diff --git a/Demo/Pillarbox-demo.xcodeproj/project.pbxproj b/Demo/Pillarbox-demo.xcodeproj/project.pbxproj index 16dbab688..2aab9a578 100644 --- a/Demo/Pillarbox-demo.xcodeproj/project.pbxproj +++ b/Demo/Pillarbox-demo.xcodeproj/project.pbxproj @@ -89,6 +89,7 @@ 6F9D879A2BE35A6100DEE9EB /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 6F9D87992BE3587700DEE9EB /* PrivacyInfo.xcprivacy */; }; 6FAD51122B331A370078FE08 /* MultiViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FAD51112B331A370078FE08 /* MultiViewModel.swift */; }; 6FAD51142B331AAD0078FE08 /* SingleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FAD51132B331AAD0078FE08 /* SingleView.swift */; }; + 6FB8ED742C417E7E0049171B /* MetricEventCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FB8ED732C417E780049171B /* MetricEventCell.swift */; }; 6FBCC1F92B2A090E009EA3E3 /* MotionManager~ios.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FBCC1F82B2A090E009EA3E3 /* MotionManager~ios.swift */; }; 6FBF198F2B1A2E1C00B16BD5 /* OptimizingCustomLayouts2~ios.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FBF198D2B1A2E1C00B16BD5 /* OptimizingCustomLayouts2~ios.swift */; }; 6FBF19902B1A2E1C00B16BD5 /* OptimizingCustomLayouts1~ios.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FBF198E2B1A2E1C00B16BD5 /* OptimizingCustomLayouts1~ios.swift */; }; @@ -193,6 +194,7 @@ 6F9D87992BE3587700DEE9EB /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 6FAD51112B331A370078FE08 /* MultiViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiViewModel.swift; sourceTree = ""; }; 6FAD51132B331AAD0078FE08 /* SingleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleView.swift; sourceTree = ""; }; + 6FB8ED732C417E780049171B /* MetricEventCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricEventCell.swift; sourceTree = ""; }; 6FBCC1F82B2A090E009EA3E3 /* MotionManager~ios.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MotionManager~ios.swift"; sourceTree = ""; }; 6FBF198D2B1A2E1C00B16BD5 /* OptimizingCustomLayouts2~ios.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "OptimizingCustomLayouts2~ios.swift"; sourceTree = ""; }; 6FBF198E2B1A2E1C00B16BD5 /* OptimizingCustomLayouts1~ios.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "OptimizingCustomLayouts1~ios.swift"; sourceTree = ""; }; @@ -294,6 +296,7 @@ 6F56F91D2C1D85E900495E20 /* Metrics */ = { isa = PBXGroup; children = ( + 6FB8ED732C417E780049171B /* MetricEventCell.swift */, 6F7DDB622C20394600B48A67 /* DataVolumeChart.swift */, 6F7DDB602C20388200B48A67 /* FrameDropsChart.swift */, 6F56F9242C1D89F700495E20 /* IndicatedBitrateChart.swift */, @@ -719,6 +722,7 @@ 6F59E8A429CF31E20093E6FB /* ExamplesViewModel.swift in Sources */, 6F59E89829CF31E20093E6FB /* PlayerConfiguration.swift in Sources */, 6F8459F22A38543400A7B5F2 /* Signal.swift in Sources */, + 6FB8ED742C417E7E0049171B /* MetricEventCell.swift in Sources */, 6FDB51CB2A4042B2001F430F /* Router.swift in Sources */, 6F59E88B29CF31E20093E6FB /* PlaylistViewModel.swift in Sources */, 6F56F9232C1D893400495E20 /* ObservedBitrateChart.swift in Sources */, diff --git a/Demo/Resources/Localizable.xcstrings b/Demo/Resources/Localizable.xcstrings index 697d870ad..38bc7d8e6 100644 --- a/Demo/Resources/Localizable.xcstrings +++ b/Demo/Resources/Localizable.xcstrings @@ -123,6 +123,9 @@ }, "Enabled" : { + }, + "Event log" : { + }, "Examples" : { diff --git a/Demo/Sources/Metrics/MetricEventCell.swift b/Demo/Sources/Metrics/MetricEventCell.swift new file mode 100644 index 000000000..a059e2e79 --- /dev/null +++ b/Demo/Sources/Metrics/MetricEventCell.swift @@ -0,0 +1,61 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +import CoreMedia +import PillarboxPlayer +import SwiftUI + +struct MetricEventCell: View { + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .short + return formatter + }() + + private static let durationFormatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.hour, .minute, .second] + formatter.zeroFormattingBehavior = .pad + return formatter + }() + + let event: MetricEvent + + private var time: String { + if let duration = Self.duration(from: event.time) { + return "[\(duration)] \(formattedDate)" + } + else { + return formattedDate + } + } + + private var description: String { + event.kind.description + } + + private var formattedDate: String { + Self.dateFormatter.string(from: event.date) + } + + var body: some View { + VStack(alignment: .leading) { + Text(time) + .font(.subheadline) + .foregroundColor(.secondary) + .lineLimit(1) + Text(description) + .lineLimit(3) + .multilineTextAlignment(.leading) + } + } + + private static func duration(from time: CMTime) -> String? { + guard time.isValid else { return nil } + return durationFormatter.string(from: time.seconds) + } +} diff --git a/Demo/Sources/Metrics/MetricsView.swift b/Demo/Sources/Metrics/MetricsView.swift index 1306b9535..d999915ce 100644 --- a/Demo/Sources/Metrics/MetricsView.swift +++ b/Demo/Sources/Metrics/MetricsView.swift @@ -153,6 +153,7 @@ struct MetricsView: View { stallsChartSection() frameDropsChartSection() } + eventLogSection() } } @@ -229,6 +230,16 @@ struct MetricsView: View { Text("Frame drops") } } + + private func eventLogSection() -> some View { + Section { + ForEach(metricsCollector.metricEvents, id: \.self) { event in + MetricEventCell(event: event) + } + } header: { + Text("Event log") + } + } } struct MetricsView_Previews: PreviewProvider { diff --git a/Sources/Player/Metrics/MetricEvent.swift b/Sources/Player/Metrics/MetricEvent.swift index ba1d7b8ee..237c2c1a6 100644 --- a/Sources/Player/Metrics/MetricEvent.swift +++ b/Sources/Player/Metrics/MetricEvent.swift @@ -5,6 +5,7 @@ // import CoreMedia +import Foundation /// A metric event. public struct MetricEvent: Hashable { @@ -75,21 +76,59 @@ public struct MetricEvent: Hashable { } } -extension MetricEvent: CustomStringConvertible { +extension MetricEvent.Kind: CustomStringConvertible { public var description: String { - switch kind { + switch self { case let .assetLoading(dateInterval): - return "assetLoading(\(dateInterval.duration))" + return "Asset loaded in \(Self.duration(from: dateInterval.duration))" case let .resourceLoading(dateInterval): - return "resourceLoading(\(dateInterval.duration))" + return "Resource loaded in \(Self.duration(from: dateInterval.duration))" case let .failure(error): - return "failure(\(error.localizedDescription))" + return "[FAILURE] \(error.localizedDescription)" case let .warning(error): - return "warning(\(error.localizedDescription))" + return "[WARNING] \(error.localizedDescription)" case .stall: - return "stall" + return "Stall" case let .resumeAfterStall(dateInterval): - return "resumeAfterStall(\(dateInterval.duration))" + return "Resume after stall for \(Self.duration(from: dateInterval.duration))" } } + + private static func duration(from interval: TimeInterval) -> String { + String(format: "%.3fs", interval) + } +} + +extension MetricEvent: CustomStringConvertible { + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .short + return formatter + }() + + private static let durationFormatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.hour, .minute, .second] + formatter.zeroFormattingBehavior = .pad + return formatter + }() + + private var formattedDate: String { + Self.dateFormatter.string(from: date) + } + + public var description: String { + if let duration = Self.duration(from: time) { + "[\(duration)] \(formattedDate) - \(kind.description)" + } + else { + "\(formattedDate) - \(kind.description)" + } + } + + private static func duration(from time: CMTime) -> String? { + guard time.isValid else { return nil } + return durationFormatter.string(from: time.seconds) + } } diff --git a/Sources/Player/Publishers/AVPlayerPublishers.swift b/Sources/Player/Publishers/AVPlayerPublishers.swift index c8f59905d..505d26844 100644 --- a/Sources/Player/Publishers/AVPlayerPublishers.swift +++ b/Sources/Player/Publishers/AVPlayerPublishers.swift @@ -13,26 +13,22 @@ extension AVPlayer { publisher(for: \.currentItem) .removeDuplicates() .map { item -> AnyPublisher in - if let item { - if let error = item.error { - let event = MetricEvent(kind: .failure(error), time: item.currentTime()) - item.metricLog.appendEvent(event) - return Just(.init(item: item, error: error)).eraseToAnyPublisher() - } - else { - return item.errorPublisher() - .handleEvents(receiveOutput: { error in - // swiftlint:disable:previous trailing_closure - let event = MetricEvent(kind: .failure(error), time: item.currentTime()) - item.metricLog.appendEvent(event) - }) - .map { .init(item: item, error: $0) } - .prepend(.init(item: item, error: nil)) - .eraseToAnyPublisher() - } + guard let item else { return Just(.empty).eraseToAnyPublisher() } + if let error = item.error { + let event = MetricEvent(kind: .failure(error), time: item.currentTime()) + item.metricLog.appendEvent(event) + return Just(.init(item: item, error: error)).eraseToAnyPublisher() } else { - return Just(.empty).eraseToAnyPublisher() + return item.errorPublisher() + .handleEvents(receiveOutput: { error in + // swiftlint:disable:previous trailing_closure + let event = MetricEvent(kind: .failure(error), time: item.currentTime()) + item.metricLog.appendEvent(event) + }) + .map { .init(item: item, error: $0) } + .prepend(.init(item: item, error: nil)) + .eraseToAnyPublisher() } } .switchToLatest()