From ba03312707cc481dbe5b13139943abb6b656a9a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20D=C3=A9fago?= Date: Thu, 20 Jun 2024 07:16:01 +0200 Subject: [PATCH] Add metrics collector (#916) --- Demo/Pillarbox-demo.xcodeproj/project.pbxproj | 40 ++++++ Demo/Resources/Localizable.xcstrings | 69 +++++++++++ Demo/Sources/Metrics/DataVolumeChart.swift | 47 ++++++++ Demo/Sources/Metrics/FrameDropsChart.swift | 46 +++++++ .../Metrics/IndicatedBitrateChart.swift | 75 ++++++++++++ Demo/Sources/Metrics/MediaRequestChart.swift | 46 +++++++ Demo/Sources/Metrics/MetricsInfoView.swift | 58 +++++++++ Demo/Sources/Metrics/MetricsView.swift | 114 ++++++++++++++++++ .../Metrics/ObservedBitrateChart.swift | 89 ++++++++++++++ Demo/Sources/Metrics/StallsChart.swift | 46 +++++++ Demo/Sources/Players/PlaybackView.swift | 84 ++++++++++++- Demo/Sources/Search/SearchView.swift | 2 +- Demo/Sources/Settings/SettingsView.swift | 2 +- Demo/Sources/Views/MessageViews.swift | 21 +++- Sources/Player/Metrics/MetricsCollector.swift | 74 ++++++++++++ .../UserInterface/ProgressTracker.swift | 14 ++- .../Metrics/MetricsCollectorTests.swift | 78 ++++++++++++ 17 files changed, 888 insertions(+), 17 deletions(-) create mode 100644 Demo/Sources/Metrics/DataVolumeChart.swift create mode 100644 Demo/Sources/Metrics/FrameDropsChart.swift create mode 100644 Demo/Sources/Metrics/IndicatedBitrateChart.swift create mode 100644 Demo/Sources/Metrics/MediaRequestChart.swift create mode 100644 Demo/Sources/Metrics/MetricsInfoView.swift create mode 100644 Demo/Sources/Metrics/MetricsView.swift create mode 100644 Demo/Sources/Metrics/ObservedBitrateChart.swift create mode 100644 Demo/Sources/Metrics/StallsChart.swift create mode 100644 Sources/Player/Metrics/MetricsCollector.swift create mode 100644 Tests/PlayerTests/Metrics/MetricsCollectorTests.swift diff --git a/Demo/Pillarbox-demo.xcodeproj/project.pbxproj b/Demo/Pillarbox-demo.xcodeproj/project.pbxproj index 678329974..53f8e962e 100644 --- a/Demo/Pillarbox-demo.xcodeproj/project.pbxproj +++ b/Demo/Pillarbox-demo.xcodeproj/project.pbxproj @@ -30,6 +30,10 @@ 6F0E5CD52B33A41F0031E313 /* MonoscopicVideoView~ios.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F0E5CD42B33A41F0031E313 /* MonoscopicVideoView~ios.swift */; }; 6F12A9522BD2B8A300AD6DDB /* IntegratingWithControlCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F12A9512BD2B8A300AD6DDB /* IntegratingWithControlCenter.swift */; }; 6F26F35E2B33B73900392ED4 /* SupportingBasicPictureInPicture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F26F35D2B33B73900392ED4 /* SupportingBasicPictureInPicture.swift */; }; + 6F56F91F2C1D85F800495E20 /* MetricsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F56F91E2C1D85F600495E20 /* MetricsView.swift */; }; + 6F56F9232C1D893400495E20 /* ObservedBitrateChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F56F9222C1D892800495E20 /* ObservedBitrateChart.swift */; }; + 6F56F9252C1D89FC00495E20 /* IndicatedBitrateChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F56F9242C1D89F700495E20 /* IndicatedBitrateChart.swift */; }; + 6F56F9272C1D8A2E00495E20 /* MediaRequestChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F56F9262C1D8A2000495E20 /* MediaRequestChart.swift */; }; 6F59E87929CF31E10093E6FB /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F59E84029CF31E10093E6FB /* SearchView.swift */; }; 6F59E87A29CF31E10093E6FB /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F59E84129CF31E10093E6FB /* SearchViewModel.swift */; }; 6F59E87B29CF31E10093E6FB /* DemoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F59E84329CF31E10093E6FB /* DemoApp.swift */; }; @@ -72,6 +76,10 @@ 6F59E8A329CF31E20093E6FB /* ExamplesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F59E87729CF31E10093E6FB /* ExamplesView.swift */; }; 6F59E8A429CF31E20093E6FB /* ExamplesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F59E87829CF31E10093E6FB /* ExamplesViewModel.swift */; }; 6F6643D92A61140600BFD644 /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F6643D82A61140600BFD644 /* URL.swift */; }; + 6F7DDB5D2C1FFF2600B48A67 /* MetricsInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F7DDB5C2C1FFF0500B48A67 /* MetricsInfoView.swift */; }; + 6F7DDB5F2C20360F00B48A67 /* StallsChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F7DDB5E2C20360B00B48A67 /* StallsChart.swift */; }; + 6F7DDB612C20388800B48A67 /* FrameDropsChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F7DDB602C20388200B48A67 /* FrameDropsChart.swift */; }; + 6F7DDB632C20395100B48A67 /* DataVolumeChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F7DDB622C20394600B48A67 /* DataVolumeChart.swift */; }; 6F7EAA552B17755C00194D03 /* TrackingProgressTutorial~ios.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F7EAA542B17755C00194D03 /* TrackingProgressTutorial~ios.swift */; }; 6F8459F22A38543400A7B5F2 /* Signal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F8459F12A38543400A7B5F2 /* Signal.swift */; }; 6F8600FC2B358BC0005CBCC5 /* SRGMedia.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F8600FB2B358BC0005CBCC5 /* SRGMedia.swift */; }; @@ -127,6 +135,10 @@ 6F45DB9D2893B773008ACCE6 /* Demo.nightly.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Demo.nightly.xcconfig; sourceTree = ""; }; 6F45DB9E2893B773008ACCE6 /* Demo.debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Demo.debug.xcconfig; sourceTree = ""; }; 6F45DB9F2893B773008ACCE6 /* Demo.release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Demo.release.xcconfig; sourceTree = ""; }; + 6F56F91E2C1D85F600495E20 /* MetricsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsView.swift; sourceTree = ""; }; + 6F56F9222C1D892800495E20 /* ObservedBitrateChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservedBitrateChart.swift; sourceTree = ""; }; + 6F56F9242C1D89F700495E20 /* IndicatedBitrateChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndicatedBitrateChart.swift; sourceTree = ""; }; + 6F56F9262C1D8A2000495E20 /* MediaRequestChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaRequestChart.swift; sourceTree = ""; }; 6F59E84029CF31E10093E6FB /* SearchView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; 6F59E84129CF31E10093E6FB /* SearchViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchViewModel.swift; sourceTree = ""; }; 6F59E84329CF31E10093E6FB /* DemoApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DemoApp.swift; sourceTree = ""; }; @@ -170,6 +182,10 @@ 6F59E87829CF31E10093E6FB /* ExamplesViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExamplesViewModel.swift; sourceTree = ""; }; 6F6643D82A61140600BFD644 /* URL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URL.swift; sourceTree = ""; }; 6F7A2B252A707708005701C7 /* pillarbox-apple */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "pillarbox-apple"; path = ..; sourceTree = ""; }; + 6F7DDB5C2C1FFF0500B48A67 /* MetricsInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsInfoView.swift; sourceTree = ""; }; + 6F7DDB5E2C20360B00B48A67 /* StallsChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StallsChart.swift; sourceTree = ""; }; + 6F7DDB602C20388200B48A67 /* FrameDropsChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameDropsChart.swift; sourceTree = ""; }; + 6F7DDB622C20394600B48A67 /* DataVolumeChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataVolumeChart.swift; sourceTree = ""; }; 6F7EAA542B17755C00194D03 /* TrackingProgressTutorial~ios.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TrackingProgressTutorial~ios.swift"; sourceTree = ""; }; 6F8459F12A38543400A7B5F2 /* Signal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Signal.swift; sourceTree = ""; }; 6F8600FB2B358BC0005CBCC5 /* SRGMedia.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SRGMedia.swift; sourceTree = ""; }; @@ -277,6 +293,21 @@ path = Application; sourceTree = ""; }; + 6F56F91D2C1D85E900495E20 /* Metrics */ = { + isa = PBXGroup; + children = ( + 6F7DDB622C20394600B48A67 /* DataVolumeChart.swift */, + 6F7DDB602C20388200B48A67 /* FrameDropsChart.swift */, + 6F56F9242C1D89F700495E20 /* IndicatedBitrateChart.swift */, + 6F56F9262C1D8A2000495E20 /* MediaRequestChart.swift */, + 6F7DDB5C2C1FFF0500B48A67 /* MetricsInfoView.swift */, + 6F56F91E2C1D85F600495E20 /* MetricsView.swift */, + 6F56F9222C1D892800495E20 /* ObservedBitrateChart.swift */, + 6F7DDB5E2C20360B00B48A67 /* StallsChart.swift */, + ); + path = Metrics; + sourceTree = ""; + }; 6F59E83F29CF31E10093E6FB /* Search */ = { isa = PBXGroup; children = ( @@ -502,6 +533,7 @@ 6F59E84D29CF31E10093E6FB /* ContentLists */, 6F59E87629CF31E10093E6FB /* Examples */, 6F6643D72A6113F300BFD644 /* Extensions */, + 6F56F91D2C1D85E900495E20 /* Metrics */, 6F59E84529CF31E10093E6FB /* Model */, 6F59E86929CF31E10093E6FB /* Players */, 6F0B2E832A40EB9600B69675 /* Router */, @@ -626,15 +658,19 @@ 0EE2A3AE2B29D6D000BAAD65 /* CustomList.swift in Sources */, 6F59E87C29CF31E10093E6FB /* AppDelegate.swift in Sources */, 0E011D1A2B2DF9BE00DAAD3D /* MediaCardView.swift in Sources */, + 6F7DDB612C20388800B48A67 /* FrameDropsChart.swift in Sources */, 6FAD51122B331A370078FE08 /* MultiViewModel.swift in Sources */, 6F59E88E29CF31E20093E6FB /* StoriesViewModel.swift in Sources */, 6F59E89729CF31E20093E6FB /* MessageViews.swift in Sources */, + 6F56F9252C1D89FC00495E20 /* IndicatedBitrateChart.swift in Sources */, 6F59E88C29CF31E20093E6FB /* TwinsView.swift in Sources */, 6FC5D6A42A6FACB20012BC89 /* CloseButton.swift in Sources */, 6F59E88529CF31E20093E6FB /* ContentListsView.swift in Sources */, 6F59E8A329CF31E20093E6FB /* ExamplesView.swift in Sources */, + 6F56F91F2C1D85F800495E20 /* MetricsView.swift in Sources */, 0E6B995C29D43E4200D0276D /* OptInView.swift in Sources */, 6F59E89C29CF31E20093E6FB /* BasicPlaybackView.swift in Sources */, + 6F7DDB5F2C20360F00B48A67 /* StallsChart.swift in Sources */, 0EDFF0DB2B0740DA005030B4 /* SourceCodeViewable.swift in Sources */, 6F59E87A29CF31E10093E6FB /* SearchViewModel.swift in Sources */, 6F59E88829CF31E20093E6FB /* UserDefaults.swift in Sources */, @@ -654,6 +690,7 @@ 6FF7C9852C0084CE00FBDADB /* PlaybackHudColor.swift in Sources */, 6F59E88229CF31E10093E6FB /* Media.swift in Sources */, 6FF7C9872C0084DD00FBDADB /* SeekBehaviorSetting.swift in Sources */, + 6F7DDB5D2C1FFF2600B48A67 /* MetricsInfoView.swift in Sources */, 6F59E87E29CF31E10093E6FB /* ServerSetting.swift in Sources */, 6F59E87929CF31E10093E6FB /* SearchView.swift in Sources */, 6F59E88029CF31E10093E6FB /* RadioChannel.swift in Sources */, @@ -682,13 +719,16 @@ 6F8600FC2B358BC0005CBCC5 /* SRGMedia.swift in Sources */, 6F59E88429CF31E20093E6FB /* ContentListViewModel.swift in Sources */, 6F59E88329CF31E20093E6FB /* MediaDescription.swift in Sources */, + 6F56F9272C1D8A2E00495E20 /* MediaRequestChart.swift in Sources */, 6F59E8A429CF31E20093E6FB /* ExamplesViewModel.swift in Sources */, 6F59E89829CF31E20093E6FB /* PlayerConfiguration.swift in Sources */, 6F8459F22A38543400A7B5F2 /* Signal.swift in Sources */, 6FDB51CB2A4042B2001F430F /* Router.swift in Sources */, 6F59E88B29CF31E20093E6FB /* PlaylistViewModel.swift in Sources */, + 6F56F9232C1D893400495E20 /* ObservedBitrateChart.swift in Sources */, 6F59E87F29CF31E10093E6FB /* Template.swift in Sources */, 6F0B2E8B2A40EE0700B69675 /* ContentList.swift in Sources */, + 6F7DDB632C20395100B48A67 /* DataVolumeChart.swift in Sources */, 6F0B2E852A40EBAC00B69675 /* RouterDestination.swift in Sources */, 6FCB9DDE29E024E900961B69 /* BlurredView.swift in Sources */, 6F59E89129CF31E20093E6FB /* WrappedView.swift in Sources */, diff --git a/Demo/Resources/Localizable.xcstrings b/Demo/Resources/Localizable.xcstrings index 71d3565c0..c4bd2639a 100644 --- a/Demo/Resources/Localizable.xcstrings +++ b/Demo/Resources/Localizable.xcstrings @@ -90,6 +90,15 @@ }, "Continues if possible" : { + }, + "Curr. %.02f Mbps" : { + + }, + "Data volume" : { + + }, + "Data volume (MB)" : { + }, "Debugging" : { @@ -114,18 +123,33 @@ }, "Font size" : { + }, + "Frame drops" : { + }, "GitHub" : { }, "Green" : { + }, + "Hide" : { + }, "Immediate" : { }, "Improves playlist navigation so that it feels more natural." : { + }, + "Index" : { + + }, + "Indicated bitrate" : { + + }, + "Indicated bitrate (Mbps)" : { + }, "Kind" : { @@ -144,12 +168,45 @@ }, "Made with " : { + }, + "Max. %.02f Mbps" : { + + }, + "MB" : { + + }, + "Mbps" : { + + }, + "Media requests" : { + + }, + "Metrics" : { + "comment" : "Playback setting menu title" + }, + "Min. %.02f Mbps" : { + }, "Mode" : { + }, + "No metrics" : { + }, "None" : { + }, + "Observed bitrate" : { + + }, + "Observed bitrate (Mbps)" : { + + }, + "Observed bitrate max" : { + + }, + "Observed bitrate min" : { + }, "Opt-in features" : { @@ -198,6 +255,9 @@ }, "Settings" : { + }, + "Show metrics" : { + }, "Showcase" : { @@ -222,6 +282,9 @@ }, "SRG SSR token protection" : { + }, + "Stalls" : { + }, "Stop" : { @@ -240,6 +303,12 @@ }, "Top" : { + }, + "Total %@" : { + + }, + "Total %lld" : { + }, "Tracking" : { diff --git a/Demo/Sources/Metrics/DataVolumeChart.swift b/Demo/Sources/Metrics/DataVolumeChart.swift new file mode 100644 index 000000000..a99ec748e --- /dev/null +++ b/Demo/Sources/Metrics/DataVolumeChart.swift @@ -0,0 +1,47 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +import Charts +import PillarboxPlayer +import SwiftUI + +struct DataVolumeChart: View { + let metrics: [Metrics] + let limit: Int + + var body: some View { + chart() + summary() + } + + private var bytesTransferred: String { + ByteCountFormatStyle().format(metrics.last?.total.numberOfBytesTransferred ?? 0) + } + + @ViewBuilder + private func chart() -> some View { + Chart(Array(metrics.suffix(limit).enumerated()), id: \.offset) { metrics in + BarMark( + x: .value("Index", metrics.offset), + y: .value("Data volume (MB)", metrics.element.increment.numberOfBytesTransferred / 1_000_000), + width: .inset(1) + ) + .foregroundStyle(.cyan) + } + .chartXAxis(.hidden) + .chartXScale(domain: 0...limit - 1) + .chartYAxisLabel("MB") + .padding(.vertical) + } + + @ViewBuilder + private func summary() -> some View { + Text("Total \(bytesTransferred)") + .font(.caption2) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + } +} diff --git a/Demo/Sources/Metrics/FrameDropsChart.swift b/Demo/Sources/Metrics/FrameDropsChart.swift new file mode 100644 index 000000000..00de77b25 --- /dev/null +++ b/Demo/Sources/Metrics/FrameDropsChart.swift @@ -0,0 +1,46 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +import Charts +import PillarboxPlayer +import SwiftUI + +struct FrameDropsChart: View { + let metrics: [Metrics] + let limit: Int + + var body: some View { + chart() + summary() + } + + private var total: Int { + metrics.last?.total.numberOfDroppedVideoFrames ?? 0 + } + + @ViewBuilder + private func chart() -> some View { + Chart(Array(metrics.suffix(limit).enumerated()), id: \.offset) { metrics in + BarMark( + x: .value("Index", metrics.offset), + y: .value("Frame drops", metrics.element.increment.numberOfDroppedVideoFrames), + width: .inset(1) + ) + .foregroundStyle(.purple) + } + .chartXAxis(.hidden) + .chartXScale(domain: 0...limit - 1) + .padding(.vertical) + } + + @ViewBuilder + private func summary() -> some View { + Text("Total \(total)") + .font(.caption2) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + } +} diff --git a/Demo/Sources/Metrics/IndicatedBitrateChart.swift b/Demo/Sources/Metrics/IndicatedBitrateChart.swift new file mode 100644 index 000000000..00c68e4fc --- /dev/null +++ b/Demo/Sources/Metrics/IndicatedBitrateChart.swift @@ -0,0 +1,75 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +import Charts +import PillarboxPlayer +import SwiftUI + +struct IndicatedBitrateChart: View { + let metrics: [Metrics] + let limit: Int + + var body: some View { + chart() + summary() + } + + private var currentIndicatedBitrateMbps: Double? { + guard let currentMetrics = metrics.last else { return nil } + return Self.indicatedBitrateMbps(from: currentMetrics) + } + + private var minIndicatedBitrateMbps: Double? { + guard let max = metrics.compactMap(\.indicatedBitrate).min() else { return nil } + return max / 1_000_000 + } + + private var maxIndicatedBitrateMbps: Double? { + guard let max = metrics.compactMap(\.indicatedBitrate).max() else { return nil } + return max / 1_000_000 + } + + private static func indicatedBitrateMbps(from metrics: Metrics) -> Double? { + guard let value = metrics.indicatedBitrate else { return nil } + return value / 1_000_000 + } + + @ViewBuilder + private func chart() -> some View { + Chart(Array(metrics.suffix(limit).enumerated()), id: \.offset) { metrics in + if let indicatedBitrate = Self.indicatedBitrateMbps(from: metrics.element) { + LineMark( + x: .value("Index", metrics.offset), + y: .value("Indicated bitrate (Mbps)", indicatedBitrate), + series: .value("Mbps", "Indicated bitrate") + ) + .foregroundStyle(.red) + } + } + .chartXAxis(.hidden) + .chartXScale(domain: 0...limit - 1) + .chartYAxisLabel("Mbps") + .padding(.vertical) + } + + @ViewBuilder + private func summary() -> some View { + HStack { + if let minIndicatedBitrateMbps { + Text("Min. \(minIndicatedBitrateMbps, specifier: "%.02f") Mbps") + } + if let currentIndicatedBitrateMbps { + Text("Curr. \(currentIndicatedBitrateMbps, specifier: "%.02f") Mbps") + } + if let maxIndicatedBitrateMbps { + Text("Max. \(maxIndicatedBitrateMbps, specifier: "%.02f") Mbps") + } + } + .font(.caption2) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + } +} diff --git a/Demo/Sources/Metrics/MediaRequestChart.swift b/Demo/Sources/Metrics/MediaRequestChart.swift new file mode 100644 index 000000000..da8b56997 --- /dev/null +++ b/Demo/Sources/Metrics/MediaRequestChart.swift @@ -0,0 +1,46 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +import Charts +import PillarboxPlayer +import SwiftUI + +struct MediaRequestChart: View { + let metrics: [Metrics] + let limit: Int + + var body: some View { + chart() + summary() + } + + private var total: Int { + metrics.last?.total.numberOfMediaRequests ?? 0 + } + + @ViewBuilder + private func chart() -> some View { + Chart(Array(metrics.suffix(limit).enumerated()), id: \.offset) { metrics in + BarMark( + x: .value("Index", metrics.offset), + y: .value("Media requests", metrics.element.increment.numberOfMediaRequests), + width: .inset(1) + ) + .foregroundStyle(.yellow) + } + .chartXAxis(.hidden) + .chartXScale(domain: 0...limit - 1) + .padding(.vertical) + } + + @ViewBuilder + private func summary() -> some View { + Text("Total \(total)") + .font(.caption2) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + } +} diff --git a/Demo/Sources/Metrics/MetricsInfoView.swift b/Demo/Sources/Metrics/MetricsInfoView.swift new file mode 100644 index 000000000..17180fe0c --- /dev/null +++ b/Demo/Sources/Metrics/MetricsInfoView.swift @@ -0,0 +1,58 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +import PillarboxPlayer +import SwiftUI + +struct MetricsInfoView: View { + private static let dateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.hour, .second, .minute] + formatter.zeroFormattingBehavior = .pad + return formatter + }() + + let metrics: Metrics + + var body: some View { + cell("URI", value: uri) +#if os(iOS) + .swipeActions { CopyButton(text: uri) } +#endif + cell("Type", value: metrics.playbackType ?? "-") + cell("Playback duration", value: playbackDuration) + cell("Data volume", value: bytesTransferred) + cell("Startup time", value: startupTime) + } + + private var uri: String { + metrics.uri ?? "-" + } + + private var playbackDuration: String { + Self.dateComponentsFormatter.string(from: metrics.total.playbackDuration) ?? "-" + } + + private var bytesTransferred: String { + ByteCountFormatStyle().format(metrics.total.numberOfBytesTransferred) + } + + private var startupTime: String { + guard let startupTime = metrics.startupTime else { return "-" } + return String(format: "%.6fs", startupTime) + } + + private func cell(_ name: String, value: String) -> some View { + HStack { + Text(name) + Spacer() + Text(value) + .monospacedDigit() + .lineLimit(1) + .foregroundColor(.secondary) + } + } +} diff --git a/Demo/Sources/Metrics/MetricsView.swift b/Demo/Sources/Metrics/MetricsView.swift new file mode 100644 index 000000000..cb33f73ad --- /dev/null +++ b/Demo/Sources/Metrics/MetricsView.swift @@ -0,0 +1,114 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +import Charts +import PillarboxPlayer +import SwiftUI + +struct MetricsView: View { + private static let limit = 90 + + @ObservedObject var metricsCollector: MetricsCollector + + var body: some View { + Group { + if !metrics.isEmpty { + List { + if let currentMetrics = metrics.last { + MetricsInfoView(metrics: currentMetrics) + } + indicatedBitrateSection() + observedBitrateSection() + dataVolumeSection() + mediaRequestsSection() + stallsSection() + frameDropsSection() + } + } + else { + MessageView(message: "No metrics", icon: .system("chart.bar")) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .navigationTitle("Metrics") +#if os(iOS) + .navigationBarTitleDisplayMode(.inline) +#endif + } + + private var metrics: [Metrics] { + metricsCollector.metrics + } + + private var currentMetrics: Metrics? { + metrics.last + } + + private func indicatedBitrateSection() -> some View { + Section { + IndicatedBitrateChart(metrics: metrics, limit: Self.limit) + } header: { + Text("Indicated bitrate") + } + } + + private func observedBitrateSection() -> some View { + Section { + ObservedBitrateChart(metrics: metrics, limit: Self.limit) + } header: { + Text("Observed bitrate") + } + } + + private func dataVolumeSection() -> some View { + Section { + DataVolumeChart(metrics: metrics, limit: Self.limit) + } header: { + Text("Data volume") + } + } + + private func mediaRequestsSection() -> some View { + Section { + MediaRequestChart(metrics: metrics, limit: Self.limit) + } header: { + Text("Media requests") + } + } + + private func stallsSection() -> some View { + Section { + StallsChart(metrics: metrics, limit: Self.limit) + } header: { + Text("Stalls") + } + } + + private func frameDropsSection() -> some View { + Section { + FrameDropsChart(metrics: metrics, limit: Self.limit) + } header: { + Text("Frame drops") + } + } +} + +struct MetricsView_Previews: PreviewProvider { + private struct MetricsPreview: View { + @State private var player = Player(item: Media(from: URLTemplate.appleAdvanced_16_9_TS_HLS).playerItem()) + @StateObject private var metricsCollector = MetricsCollector(interval: .init(value: 1, timescale: 1)) + + var body: some View { + MetricsView(metricsCollector: metricsCollector) + .bind(metricsCollector, to: player) + .onAppear(perform: player.play) + } + } + + static var previews: some View { + MetricsPreview() + } +} diff --git a/Demo/Sources/Metrics/ObservedBitrateChart.swift b/Demo/Sources/Metrics/ObservedBitrateChart.swift new file mode 100644 index 000000000..743fdb475 --- /dev/null +++ b/Demo/Sources/Metrics/ObservedBitrateChart.swift @@ -0,0 +1,89 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +import Charts +import PillarboxPlayer +import SwiftUI + +struct ObservedBitrateChart: View { + let metrics: [Metrics] + let limit: Int + + var body: some View { + chart() + summary() + } + + private var currentObservedBitrateMbps: Double? { + guard let currentMetrics = metrics.last else { return nil } + return Self.observedBitrateMbps(from: currentMetrics) + } + + private var minObservedBitrateMbps: Double? { + guard let max = metrics.compactMap(\.observedBitrate).min() else { return nil } + return max / 1_000_000 + } + + private var maxObservedBitrateMbps: Double? { + guard let max = metrics.compactMap(\.observedBitrate).max() else { return nil } + return max / 1_000_000 + } + + private static func observedBitrateMbps(from metrics: Metrics) -> Double? { + guard let value = metrics.observedBitrate else { return nil } + return value / 1_000_000 + } + + private static func observedBitrateStandardDeviationMbps(from metrics: Metrics) -> Double? { + guard let value = metrics.observedBitrateStandardDeviation else { return nil } + return value / 1_000_000 + } + + @ViewBuilder + private func chart() -> some View { + Chart(Array(metrics.suffix(limit).enumerated()), id: \.offset) { metrics in + if let observedBitrate = Self.observedBitrateMbps(from: metrics.element) { + LineMark( + x: .value("Index", metrics.offset), + y: .value("Observed bitrate (Mbps)", observedBitrate), + series: .value("Mbps", "Observed bitrate") + ) + .foregroundStyle(.blue) + + if let observedBitrateStandardDeviation = Self.observedBitrateStandardDeviationMbps(from: metrics.element) { + AreaMark( + x: .value("Index", metrics.offset), + yStart: .value("Observed bitrate min", max(observedBitrate - observedBitrateStandardDeviation, 0)), + yEnd: .value("Observed bitrate max", observedBitrate + observedBitrateStandardDeviation) + ) + .opacity(0.3) + } + } + } + .chartXAxis(.hidden) + .chartXScale(domain: 0...limit - 1) + .chartYAxisLabel("Mbps") + .padding(.vertical) + } + + @ViewBuilder + private func summary() -> some View { + HStack { + if let minObservedBitrateMbps { + Text("Min. \(minObservedBitrateMbps, specifier: "%.02f") Mbps") + } + if let currentObservedBitrateMbps { + Text("Curr. \(currentObservedBitrateMbps, specifier: "%.02f") Mbps") + } + if let maxObservedBitrateMbps { + Text("Max. \(maxObservedBitrateMbps, specifier: "%.02f") Mbps") + } + } + .font(.caption2) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + } +} diff --git a/Demo/Sources/Metrics/StallsChart.swift b/Demo/Sources/Metrics/StallsChart.swift new file mode 100644 index 000000000..aa1f512c1 --- /dev/null +++ b/Demo/Sources/Metrics/StallsChart.swift @@ -0,0 +1,46 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +import Charts +import PillarboxPlayer +import SwiftUI + +struct StallsChart: View { + let metrics: [Metrics] + let limit: Int + + var body: some View { + chart() + summary() + } + + private var total: Int { + metrics.last?.total.numberOfStalls ?? 0 + } + + @ViewBuilder + private func chart() -> some View { + Chart(Array(metrics.suffix(limit).enumerated()), id: \.offset) { metrics in + BarMark( + x: .value("Index", metrics.offset), + y: .value("Stalls", metrics.element.increment.numberOfStalls), + width: .inset(1) + ) + .foregroundStyle(.pink) + } + .chartXAxis(.hidden) + .chartXScale(domain: 0...limit - 1) + .padding(.vertical) + } + + @ViewBuilder + private func summary() -> some View { + Text("Total \(total)") + .font(.caption2) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + } +} diff --git a/Demo/Sources/Players/PlaybackView.swift b/Demo/Sources/Players/PlaybackView.swift index 28d07121c..1add07c42 100644 --- a/Demo/Sources/Players/PlaybackView.swift +++ b/Demo/Sources/Players/PlaybackView.swift @@ -12,6 +12,7 @@ import SwiftUI #if os(iOS) // Behavior: h-exp, v-exp +// swiftlint:disable:next type_body_length private struct MainView: View { @ObservedObject var player: Player @Binding var layout: PlaybackView.Layout @@ -20,8 +21,10 @@ private struct MainView: View { let progressTracker: ProgressTracker @StateObject private var visibilityTracker = VisibilityTracker() + @State private var metricsCollector = MetricsCollector(interval: .init(value: 1, timescale: 1)) @State private var layoutInfo: LayoutInfo = .none + @State private var isPresentingMetrics = false @State private var selectedGravity: AVLayerVideoGravity = .resizeAspect @State private var isInteracting = false @@ -38,14 +41,19 @@ private struct MainView: View { } var body: some View { - ZStack { - main() - bottomBar() - topBar() + AdaptiveSheetContainer(isPresenting: $isPresentingMetrics) { + ZStack { + main() + bottomBar() + topBar() + } + .animation(.defaultLinear, value: shouldHideInterface) + } sheet: { + MetricsView(metricsCollector: metricsCollector) } .statusBarHidden(isFullScreen ? isUserInterfaceHidden : false) - .animation(.defaultLinear, value: shouldHideInterface) .bind(visibilityTracker, to: player) + .bind(metricsCollector, to: player) } private var isFullScreen: Bool { @@ -210,6 +218,7 @@ private struct MainView: View { private func settingsMenu() -> some View { Menu { player.standardSettingMenu() + metricsMenu() } label: { Image(systemName: "ellipsis.circle") .font(.system(size: 20)) @@ -218,6 +227,15 @@ private struct MainView: View { .menuOrder(.fixed) } + @ViewBuilder + private func metricsMenu() -> some View { + if !isPresentingMetrics { + Button(action: showMetrics) { + Label("Show metrics", systemImage: "chart.bar") + } + } + } + @ViewBuilder private func artwork(for imageSource: ImageSource) -> some View { LazyImage(source: imageSource) { image in @@ -278,6 +296,10 @@ private struct MainView: View { .foregroundColor(.white) .padding(60) } + + private func showMetrics() { + isPresentingMetrics = true + } } private struct SkipButton: View { @@ -608,6 +630,58 @@ private struct TimeSlider: View { } } +private struct AdaptiveSheetContainer: View where Content: View, Sheet: View { + @Binding var isPresenting: Bool + + @ViewBuilder let content: () -> Content + @ViewBuilder let sheet: () -> Sheet + + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + var body: some View { + switch horizontalSizeClass { + case .compact: + compactView() + default: + defaultView() + } + } + + private func compactView() -> some View { + content() + .sheet(isPresented: $isPresenting) { + NavigationStack { + sheet() + } + .presentationDetents([.medium, .large]) + } + } + + private func defaultView() -> some View { + HStack(spacing: 0) { + content() + if isPresenting { + NavigationStack { + sheet() + .toolbar(content: toolbarContent) + } + .frame(width: 420) + } + } + .animation(.default, value: isPresenting) + } + + private func toolbarContent() -> some ToolbarContent { + ToolbarItem(placement: .topBarLeading) { + Button("Hide", action: close) + } + } + + private func close() { + isPresenting = false + } +} + #else private struct MainSystemView: View { diff --git a/Demo/Sources/Search/SearchView.swift b/Demo/Sources/Search/SearchView.swift index 426ab810e..be531355d 100644 --- a/Demo/Sources/Search/SearchView.swift +++ b/Demo/Sources/Search/SearchView.swift @@ -16,7 +16,7 @@ struct SearchView: View { ZStack { switch model.state { case .empty: - MessageView(message: "Enter something to search.", icon: .search) + MessageView(message: "Enter something to search.", icon: .system("magnifyingglass")) case .loading: ProgressView() case let .loaded(medias: medias): diff --git a/Demo/Sources/Settings/SettingsView.swift b/Demo/Sources/Settings/SettingsView.swift index 562f91650..050151811 100644 --- a/Demo/Sources/Settings/SettingsView.swift +++ b/Demo/Sources/Settings/SettingsView.swift @@ -28,7 +28,7 @@ private struct UrlCacheView: View { } private func updateCacheSize() { - urlCacheSize = ByteCountFormatter.string(fromByteCount: Int64(URLCache.shared.currentDiskUsage), countStyle: .binary) + urlCacheSize = ByteCountFormatStyle().format(Int64(URLCache.shared.currentDiskUsage)) } private func clearUrlCache() { diff --git a/Demo/Sources/Views/MessageViews.swift b/Demo/Sources/Views/MessageViews.swift index 7a7d2fe69..a0adcb22e 100644 --- a/Demo/Sources/Views/MessageViews.swift +++ b/Demo/Sources/Views/MessageViews.swift @@ -10,10 +10,21 @@ protocol Refreshable { func refresh() async } -enum MessageIcon: String { - case error = "exclamationmark.bubble" - case empty = "circle.slash" - case search = "magnifyingglass" +enum MessageIcon { + case error + case empty + case system(String) + + var systemName: String { + switch self { + case .error: + return "exclamationmark.bubble" + case .empty: + return "circle.slash" + case let .system(name): + return name + } + } } struct MessageView: View { @@ -22,7 +33,7 @@ struct MessageView: View { var body: some View { VStack(spacing: 20) { - Image(systemName: icon.rawValue) + Image(systemName: icon.systemName) .resizable() .frame(width: 90, height: 90) Text(message) diff --git a/Sources/Player/Metrics/MetricsCollector.swift b/Sources/Player/Metrics/MetricsCollector.swift new file mode 100644 index 000000000..4e88c2533 --- /dev/null +++ b/Sources/Player/Metrics/MetricsCollector.swift @@ -0,0 +1,74 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +import Combine +import CoreMedia +import SwiftUI + +/// An observable object collecting player metrics. +/// +/// A metrics collector is an [ObservableObject](https://developer.apple.com/documentation/combine/observableobject) +/// used to collect metrics associated with a ``Player`` at a regular interval. +/// +/// ## Usage +/// +/// A metrics collector is used as follows: +/// +/// 1. Instantiate a `MetricsCollector` in your view hierarchy, setting up the refresh interval you need. +/// 2. Bind the progress tracker to a ``Player`` instance by applying the ``SwiftUI/View/bind(_:to:)`` modifier. +/// 3. Current metrics can be retrieved from the ``metrics`` property and displayed in any way you want, e.g. in a +/// textual form or with charts. +public final class MetricsCollector: ObservableObject { + /// The player to attach. + /// + /// Use `View.bind(_:to:)` in SwiftUI code. + @Published public var player: Player? + + /// The available metrics history. + /// + /// Entries are sorted from the lowest to the most recent one. + @Published public private(set) var metrics: [Metrics] = [] + + /// Creates a metrics collector gathering metrics at the specified interval. + /// + /// - Parameter interval: The interval at which metrics must be gathered, according to progress of the current + /// time of the associated player timebase. + /// + /// Additional metrics will be collected when time jumps or when playback starts or stops. + public init(interval: CMTime) { + $player + .removeDuplicates() + .map { player -> AnyPublisher in + guard let player else { + return Just(nil).eraseToAnyPublisher() + } + return player.periodicMetricsPublisher(forInterval: interval) + .eraseToAnyPublisher() + } + .switchToLatest() + .compactMap { $0 } + .removeDuplicates() + .scan([]) { $0 + [$1] } + .receiveOnMainThread() + .assign(to: &$metrics) + } +} + +public extension View { + /// Binds a metrics collector to a player. + /// + /// - Parameters: + /// - metricsCollector: The metrics collector to bind. + /// - player: The player to observe. + func bind(_ metricsCollector: MetricsCollector, to player: Player?) -> some View { + onAppear { + metricsCollector.player = player + } + .onChange(of: player) { newValue in + metricsCollector.player = newValue + } + } +} diff --git a/Sources/Player/UserInterface/ProgressTracker.swift b/Sources/Player/UserInterface/ProgressTracker.swift index a82cc534f..e192116d9 100644 --- a/Sources/Player/UserInterface/ProgressTracker.swift +++ b/Sources/Player/UserInterface/ProgressTracker.swift @@ -10,12 +10,12 @@ import SwiftUI /// An observable object tracking playback progress. /// -/// A progress tracker is an [ObservableObject](https://developer.apple.com/documentation/combine/observableobject) +/// A progress tracker is an [ObservableObject](https://developer.apple.com/documentation/combine/observableobject) /// used to read and update the progress of an associated ``Player``. It automatically provides current progress /// information as well as the available range for its values. It also ensures that interactive progress updates /// do not conflict with reported progress updates, ensuring a smooth user experience. /// -/// > Warning: Progress trackers should be associated with local view scopes to avoid unnecessary view body refreshes. +/// > Warning: Progress trackers should be associated with local view scopes to avoid unnecessary view body refreshes. /// Please refer to for more information. /// /// ## Usage @@ -117,9 +117,13 @@ public final class ProgressTracker: ObservableObject { } /// Creates a progress tracker updating its progress at the specified interval. - /// - /// - Parameter interval: The interval at which progress must be updated. - /// - Parameter seekBehavior: The seek behavior to apply. + /// + /// - Parameters: + /// - interval: The interval at which progress must be updated, according to progress of the current + /// time of the timebase. + /// - seekBehavior: The seek behavior to apply. + /// + /// Additional updates will happen when time jumps or when playback starts or stops. public init(interval: CMTime, seekBehavior: SeekBehavior = .immediate) { self.seekBehavior = seekBehavior $player diff --git a/Tests/PlayerTests/Metrics/MetricsCollectorTests.swift b/Tests/PlayerTests/Metrics/MetricsCollectorTests.swift new file mode 100644 index 000000000..a747d28d7 --- /dev/null +++ b/Tests/PlayerTests/Metrics/MetricsCollectorTests.swift @@ -0,0 +1,78 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +@testable import PillarboxPlayer + +import Combine +import CoreMedia +import Nimble +import PillarboxCircumspect +import PillarboxStreams + +final class MetricsCollectorTests: TestCase { + func testUnbound() { + let metricsCollector = MetricsCollector(interval: CMTime(value: 1, timescale: 4)) + expectAtLeastEqualPublished( + values: [[]], + from: metricsCollector.$metrics + .map { $0.compactMap(\.uri) } + .removeDuplicates() + ) + } + + func testEmptyPlayer() { + let metricsCollector = MetricsCollector(interval: CMTime(value: 1, timescale: 4)) + expectAtLeastEqualPublished( + values: [[]], + from: metricsCollector.$metrics + .map { $0.compactMap(\.uri) } + .removeDuplicates() + ) { + metricsCollector.player = Player() + } + } + + func testPausedPlayer() { + let metricsCollector = MetricsCollector(interval: CMTime(value: 1, timescale: 4)) + let item = PlayerItem.simple(url: Stream.onDemand.url) + let player = Player(item: item) + expectAtLeastEqualPublished( + values: [[]], + from: metricsCollector.$metrics + .map { $0.compactMap(\.uri) } + .removeDuplicates() + ) { + metricsCollector.player = player + } + } + + func testPlayback() { + let metricsCollector = MetricsCollector(interval: CMTime(value: 1, timescale: 4)) + let item = PlayerItem.simple(url: Stream.onDemand.url) + let player = Player(item: item) + expectAtLeastEqualPublished( + values: [[], [Stream.onDemand.url.absoluteString]], + from: metricsCollector.$metrics + .map { $0.compactMap(\.uri) } + .removeDuplicates() + ) { + metricsCollector.player = player + player.play() + } + } + + func testPlayerSetToNil() { + let metricsCollector = MetricsCollector(interval: CMTime(value: 1, timescale: 4)) + let item = PlayerItem.simple(url: Stream.onDemand.url) + let player = Player(item: item) + metricsCollector.player = player + player.play() + expect(metricsCollector.metrics).toEventuallyNot(beEmpty()) + + metricsCollector.player = nil + expect(metricsCollector.metrics).notTo(beEmpty()) + } +}