From b82fd13ef4352ce78f6e79a557fb95f93e1f38e5 Mon Sep 17 00:00:00 2001 From: Walid Kayhal <3347810+waliid@users.noreply.github.com> Date: Tue, 26 Mar 2024 16:18:29 +0100 Subject: [PATCH] Deliver player metadata (#808) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Samuel Défago --- Demo/Sources/Analytics/DemoTracker.swift | 1 - Demo/Sources/Model/Media.swift | 13 +- Demo/Sources/Settings/SettingsView.swift | 1 - Demo/Sources/Settings/UserDefaults.swift | 1 - Demo/Sources/Tools/MotionManager~ios.swift | 1 - Demo/Sources/Views/CloseButton.swift | 1 - Sources/Core/Publisher.swift | 35 +++- Sources/Core/ReplaySubscription.swift | 1 - .../Extensions/MetadataAdapter.swift | 19 ++ .../CoreBusiness/Model/MediaMetadata.swift | 6 +- Sources/CoreBusiness/PlayerItem.swift | 4 +- Sources/Player/Asset.swift | 181 ++--------------- Sources/Player/ControlCenter/NowPlaying.swift | 35 ---- .../Player/ControlCenter/NowPlayingInfo.swift | 11 ++ Sources/Player/Extensions/AVPlayerItem.swift | 91 ++++++++- Sources/Player/Interfaces/AssetMetadata.swift | 23 --- Sources/Player/Interfaces/Assetable.swift | 101 ---------- .../Interfaces/PlayerItemMetadata.swift | 88 +++++++++ Sources/Player/Player+ControlCenter.swift | 93 ++++----- Sources/Player/Player+Queue.swift | 8 +- Sources/Player/Player+Tracking.swift | 25 +++ .../integrating-with-control-center-2-1.swift | 2 +- .../integrating-with-control-center-2-2.swift | 2 +- Sources/Player/Player.swift | 39 +++- Sources/Player/PlayerItem.swift | 128 +++++++++--- Sources/Player/ResourceLoading/Resource.swift | 9 - Sources/Player/Tracking/CurrentTracker.swift | 48 +---- .../Player/Tracking/PlayerItemTracker.swift | 41 ++-- Sources/Player/Tracking/TrackerAdapter.swift | 25 ++- .../Player/Tracking/TrackerLifeCycle.swift | 11 ++ Sources/Player/Types/AssetContent.swift | 43 ++++ Sources/Player/Types/EmptyMetadata.swift | 19 ++ Sources/Player/Types/MetadataAdapter.swift | 38 ++++ Sources/Player/Types/PlayerMetadata.swift | 19 ++ Sources/Player/Types/QueueElement.swift | 8 +- Sources/Player/Types/StandardMetadata.swift | 57 ++++++ .../ComScoreTrackerDvrPropertiesTests.swift | 2 +- .../ComScoreTrackerMetadataTests.swift | 6 +- .../ComScoreTrackerPlaybackSpeedTests.swift | 2 +- .../ComScore/ComScoreTrackerRateTests.swift | 2 +- .../ComScore/ComScoreTrackerSeekTests.swift | 2 +- .../ComScore/ComScoreTrackerTests.swift | 2 +- ...mmandersActTrackerDvrPropertiesTests.swift | 2 +- .../CommandersActTrackerMetadataTests.swift | 2 +- .../CommandersActTrackerPositionTests.swift | 2 +- .../CommandersActTrackerSeekTests.swift | 2 +- .../CommandersActTrackerTests.swift | 22 ++- Tests/CoreBusinessTests/PlayerItemTests.swift | 2 +- Tests/CoreTests/DispatchPublisherTests.swift | 28 +++ .../AVPlayerItemAssetContentUpdateTests.swift | 186 ++++++++++++++++++ .../Asset/AssetCreationTests.swift | 105 ---------- .../PlayerTests/Asset/AssetMetadataMock.swift | 8 - Tests/PlayerTests/Asset/AssetableTests.swift | 166 ---------------- ...temTests.swift => ResourceItemTests.swift} | 8 +- Tests/PlayerTests/Player/PlayerTests.swift | 4 +- .../PlayerItemAssetPublisherTests.swift | 19 +- .../PlayerItem/PlayerItemTests.swift | 121 ++---------- ...NowPlayingInfoMetadataPublisherTests.swift | 134 ------------- .../NowPlayingInfoPublisherTests.swift | 130 +++++++++++- Tests/PlayerTests/Tools/PlayerItem.swift | 40 ++-- Tests/PlayerTests/Tools/Similarity.swift | 11 -- Tests/PlayerTests/Tools/Tools.swift | 18 -- .../Tools/TrackerLifeCycleMock.swift | 3 +- .../PlayerTests/Tools/TrackerUpdateMock.swift | 7 +- .../PlayerItemTrackerUpdateTests.swift | 13 +- Tests/PlayerTests/Types/ItemErrorTests.swift | 1 - 66 files changed, 1128 insertions(+), 1150 deletions(-) create mode 100644 Sources/CoreBusiness/Extensions/MetadataAdapter.swift delete mode 100644 Sources/Player/ControlCenter/NowPlaying.swift create mode 100644 Sources/Player/ControlCenter/NowPlayingInfo.swift delete mode 100644 Sources/Player/Interfaces/AssetMetadata.swift delete mode 100644 Sources/Player/Interfaces/Assetable.swift create mode 100644 Sources/Player/Interfaces/PlayerItemMetadata.swift create mode 100644 Sources/Player/Player+Tracking.swift create mode 100644 Sources/Player/Tracking/TrackerLifeCycle.swift create mode 100644 Sources/Player/Types/AssetContent.swift create mode 100644 Sources/Player/Types/EmptyMetadata.swift create mode 100644 Sources/Player/Types/MetadataAdapter.swift create mode 100644 Sources/Player/Types/PlayerMetadata.swift create mode 100644 Sources/Player/Types/StandardMetadata.swift create mode 100644 Tests/PlayerTests/AVPlayer/AVPlayerItemAssetContentUpdateTests.swift delete mode 100644 Tests/PlayerTests/Asset/AssetableTests.swift rename Tests/PlayerTests/Asset/{AssetPlayerItemTests.swift => ResourceItemTests.swift} (79%) delete mode 100644 Tests/PlayerTests/Publishers/NowPlayingInfoMetadataPublisherTests.swift diff --git a/Demo/Sources/Analytics/DemoTracker.swift b/Demo/Sources/Analytics/DemoTracker.swift index 06536722e..85af8c203 100644 --- a/Demo/Sources/Analytics/DemoTracker.swift +++ b/Demo/Sources/Analytics/DemoTracker.swift @@ -4,7 +4,6 @@ // License information is available from the LICENSE file. // -import Combine import Foundation import os import PillarboxPlayer diff --git a/Demo/Sources/Model/Media.swift b/Demo/Sources/Model/Media.swift index ccdd158dd..8b1675394 100644 --- a/Demo/Sources/Model/Media.swift +++ b/Demo/Sources/Model/Media.swift @@ -84,11 +84,7 @@ struct Media: Hashable { } } -extension Media: AssetMetadata { - func nowPlayingMetadata() -> NowPlayingMetadata { - .init(title: title, subtitle: description, image: image) - } - +extension Media { private func playerItem(for url: URL, configuration: @escaping (AVPlayerItem) -> Void = { _ in }) -> PlayerItem { .init( publisher: imagePublisher() @@ -99,6 +95,13 @@ extension Media: AssetMetadata { configuration: configuration ) }, + metadataAdapter: StandardMetadata.adapter { metadata in + .init( + title: metadata.title, + subtitle: metadata.description, + image: metadata.image + ) + }, trackerAdapters: [ DemoTracker.adapter { media in DemoTracker.Metadata(title: media.title) diff --git a/Demo/Sources/Settings/SettingsView.swift b/Demo/Sources/Settings/SettingsView.swift index fd9c27e4d..158a56c49 100644 --- a/Demo/Sources/Settings/SettingsView.swift +++ b/Demo/Sources/Settings/SettingsView.swift @@ -4,7 +4,6 @@ // License information is available from the LICENSE file. // -import AVFoundation import PillarboxPlayer import SwiftUI diff --git a/Demo/Sources/Settings/UserDefaults.swift b/Demo/Sources/Settings/UserDefaults.swift index 6257a92eb..19317ff6b 100644 --- a/Demo/Sources/Settings/UserDefaults.swift +++ b/Demo/Sources/Settings/UserDefaults.swift @@ -4,7 +4,6 @@ // License information is available from the LICENSE file. // -import AVFoundation import Foundation import PillarboxPlayer diff --git a/Demo/Sources/Tools/MotionManager~ios.swift b/Demo/Sources/Tools/MotionManager~ios.swift index 464db01e7..aae96e757 100644 --- a/Demo/Sources/Tools/MotionManager~ios.swift +++ b/Demo/Sources/Tools/MotionManager~ios.swift @@ -6,7 +6,6 @@ import Combine import CoreMotion -import PillarboxPlayer final class MotionManager: ObservableObject { @Published private(set) var attitude: CMAttitude? diff --git a/Demo/Sources/Views/CloseButton.swift b/Demo/Sources/Views/CloseButton.swift index 8efbd96d2..e03e901f8 100644 --- a/Demo/Sources/Views/CloseButton.swift +++ b/Demo/Sources/Views/CloseButton.swift @@ -5,7 +5,6 @@ // import SwiftUI -import UIKit struct CloseButton: View { @Environment(\.dismiss) private var dismiss diff --git a/Sources/Core/Publisher.swift b/Sources/Core/Publisher.swift index b8060622d..488196947 100644 --- a/Sources/Core/Publisher.swift +++ b/Sources/Core/Publisher.swift @@ -77,12 +77,14 @@ public extension Publisher { func weakCapture(_ other: T?) -> AnyPublisher<(Output, T), Failure> where T: AnyObject { weakCapture(other, at: \T.self) } +} +public extension Publisher { /// Safely receives elements from the upstream on the main thread. /// /// - Returns: A publisher delivering elements on the main thread. func receiveOnMainThread() -> AnyPublisher { - map { output in + flatMap { output in // `receive(on: DispatchQueue.main)` defers execution if already on the main thread. Do nothing in this case. if Thread.isMainThread { return Just(output) @@ -94,7 +96,36 @@ public extension Publisher { .eraseToAnyPublisher() } } - .switchToLatest() + .eraseToAnyPublisher() + } + + /// Delays delivery of all output to the downstream receiver by a specified amount of time on a particular scheduler. + /// + /// - Parameters: + /// - interval: The amount of time to delay. + /// - tolerance: The allowed tolerance in delivering delayed events. The `Delay` publisher may deliver elements + /// this much sooner or later than the interval specifies. + /// - scheduler: The scheduler to deliver the delayed events. + /// - options: Options relevant to the scheduler’s behavior. + /// - Returns: A publisher that delays delivery of elements and completion to the downstream receiver. + /// + /// If the `interval` is zero the value is published synchronously, not on the specified scheduler. + func delayIfNeeded( + for interval: S.SchedulerTimeType.Stride, + tolerance: S.SchedulerTimeType.Stride? = nil, + scheduler: S, + options: S.SchedulerOptions? = nil + ) -> AnyPublisher where S: Scheduler { + flatMap { output in + if interval != 0 { + return Just(output) + .delay(for: interval, tolerance: tolerance, scheduler: scheduler, options: options) + .eraseToAnyPublisher() + } + else { + return Just(output).eraseToAnyPublisher() + } + } .eraseToAnyPublisher() } } diff --git a/Sources/Core/ReplaySubscription.swift b/Sources/Core/ReplaySubscription.swift index cac9f6e9b..1fedf1b99 100644 --- a/Sources/Core/ReplaySubscription.swift +++ b/Sources/Core/ReplaySubscription.swift @@ -5,7 +5,6 @@ // import Combine -import Foundation final class ReplaySubscription: Subscription where Failure: Error { var onCancel: (() -> Void)? diff --git a/Sources/CoreBusiness/Extensions/MetadataAdapter.swift b/Sources/CoreBusiness/Extensions/MetadataAdapter.swift new file mode 100644 index 000000000..99b1e672a --- /dev/null +++ b/Sources/CoreBusiness/Extensions/MetadataAdapter.swift @@ -0,0 +1,19 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +import PillarboxPlayer + +public extension MetadataAdapter where M == MediaMetadata { + /// A metadata adapter displaying standard media metadata. + static let standard: Self = StandardMetadata.adapter { metadata in + .init( + title: metadata.title, + subtitle: metadata.subtitle, + description: metadata.description, + image: metadata.image + ) + } +} diff --git a/Sources/CoreBusiness/Model/MediaMetadata.swift b/Sources/CoreBusiness/Model/MediaMetadata.swift index 2d693da14..ee33aaad7 100644 --- a/Sources/CoreBusiness/Model/MediaMetadata.swift +++ b/Sources/CoreBusiness/Model/MediaMetadata.swift @@ -8,7 +8,7 @@ import PillarboxPlayer import UIKit /// Metadata associated with content loaded from a URN. -public struct MediaMetadata: AssetMetadata { +public struct MediaMetadata { private static let dateFormatter: DateFormatter = { let dateFormatter = DateFormatter() dateFormatter.timeZone = TimeZone(identifier: "Europe/Zurich") @@ -82,8 +82,4 @@ public struct MediaMetadata: AssetMetadata { private static func areRedundant(chapter: Chapter, show: Show) -> Bool { chapter.title.lowercased() == show.title.lowercased() } - - public func nowPlayingMetadata() -> NowPlayingMetadata { - .init(title: title, subtitle: subtitle, description: description, image: image) - } } diff --git a/Sources/CoreBusiness/PlayerItem.swift b/Sources/CoreBusiness/PlayerItem.swift index a55a2fb6c..59ab60aea 100644 --- a/Sources/CoreBusiness/PlayerItem.swift +++ b/Sources/CoreBusiness/PlayerItem.swift @@ -15,6 +15,7 @@ public extension PlayerItem { /// - Parameters: /// - urn: The URN to play. /// - server: The server which the URN is played from. + /// - metadataAdapter: A `MetadataAdapter` converting item metadata into player metadata. /// - trackerAdapters: An array of `TrackerAdapter` instances to use for tracking playback events. /// - configuration: A closure to configure player items created from the receiver. /// @@ -22,6 +23,7 @@ public extension PlayerItem { static func urn( _ urn: String, server: Server = .production, + metadataAdapter: MetadataAdapter = .standard, trackerAdapters: [TrackerAdapter] = [], configuration: @escaping (AVPlayerItem) -> Void = { _ in } ) -> Self { @@ -43,7 +45,7 @@ public extension PlayerItem { } .switchToLatest() .eraseToAnyPublisher() - return .init(publisher: publisher, trackerAdapters: [ + return .init(publisher: publisher, metadataAdapter: metadataAdapter, trackerAdapters: [ ComScoreTracker.adapter { $0.analyticsData }, CommandersActTracker.adapter { $0.analyticsMetadata } ] + trackerAdapters) diff --git a/Sources/Player/Asset.swift b/Sources/Player/Asset.swift index 4c88757b3..853b6808b 100644 --- a/Sources/Player/Asset.swift +++ b/Sources/Player/Asset.swift @@ -5,9 +5,6 @@ // import AVFoundation -import MediaPlayer - -private var kIdKey: Void? private let kResourceLoaderQueue = DispatchQueue(label: "ch.srgssr.player.resource_loader") @@ -32,25 +29,15 @@ final class ResourceLoadedPlayerItem: AVPlayerItem { /// - Simple assets which can be played directly. /// - Custom assets which require a custom resource loader delegate. /// - Encrypted assets which require a FairPlay content key session delegate. -public struct Asset: Assetable where M: AssetMetadata { - let id: UUID +public struct Asset { let resource: Resource - private let metadata: M? - private let configuration: (AVPlayerItem) -> Void - private let trackerAdapters: [TrackerAdapter] + let metadata: M + let configuration: (AVPlayerItem) -> Void - init( - id: UUID, - resource: Resource, - metadata: M?, - configuration: @escaping (AVPlayerItem) -> Void, - trackerAdapters: [TrackerAdapter] - ) { - self.id = id + init(resource: Resource, metadata: M, configuration: @escaping (AVPlayerItem) -> Void) { self.resource = resource self.metadata = metadata self.configuration = configuration - self.trackerAdapters = trackerAdapters.map { $0.withId(id) } } /// Returns a simple asset playable from a URL. @@ -60,18 +47,8 @@ public struct Asset: Assetable where M: AssetMetadata { /// - metadata: The metadata associated with the asset. /// - configuration: A closure to configure player items created from the receiver. /// - Returns: The asset. - public static func simple( - url: URL, - metadata: M, - configuration: @escaping (AVPlayerItem) -> Void = { _ in } - ) -> Self { - .init( - id: UUID(), - resource: .simple(url: url), - metadata: metadata, - configuration: configuration, - trackerAdapters: [] - ) + public static func simple(url: URL, metadata: M, configuration: @escaping (AVPlayerItem) -> Void = { _ in }) -> Self { + .init(resource: .simple(url: url), metadata: metadata, configuration: configuration) } /// Returns an asset loaded with custom resource loading. @@ -91,11 +68,9 @@ public struct Asset: Assetable where M: AssetMetadata { configuration: @escaping (AVPlayerItem) -> Void = { _ in } ) -> Self { .init( - id: UUID(), resource: .custom(url: url, delegate: delegate), metadata: metadata, - configuration: configuration, - trackerAdapters: [] + configuration: configuration ) } @@ -114,82 +89,14 @@ public struct Asset: Assetable where M: AssetMetadata { configuration: @escaping (AVPlayerItem) -> Void = { _ in } ) -> Self { .init( - id: UUID(), resource: .encrypted(url: url, delegate: delegate), metadata: metadata, - configuration: configuration, - trackerAdapters: [] + configuration: configuration ) } - - func withTrackerAdapters(_ trackerAdapters: [TrackerAdapter]) -> Self { - .init(id: id, resource: resource, metadata: metadata, configuration: configuration, trackerAdapters: trackerAdapters) - } - - func withId(_ id: UUID) -> Self { - .init(id: id, resource: resource, metadata: metadata, configuration: configuration, trackerAdapters: trackerAdapters) - } - - func enable(for player: Player) { - trackerAdapters.forEach { adapter in - adapter.enable(for: player) - } - } - - func updateMetadata() { - guard let metadata else { return } - trackerAdapters.forEach { adapter in - adapter.update(metadata: metadata) - } - } - - func disable() { - trackerAdapters.forEach { adapter in - adapter.disable() - } - } - - func nowPlayingInfo(with error: Error?) -> NowPlayingInfo { - var nowPlayingInfo = NowPlayingInfo() - if let metadata = metadata?.nowPlayingMetadata() { - nowPlayingInfo[MPMediaItemPropertyTitle] = metadata.title - nowPlayingInfo[MPMediaItemPropertyArtist] = error?.localizedDescription ?? metadata.subtitle - nowPlayingInfo[MPMediaItemPropertyComments] = metadata.description - if let image = metadata.image { - nowPlayingInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size) { _ in image } - } - } - else { - // Fill the title so that the Control Center can be enabled for the asset, even if it has no associated - // metadata. - nowPlayingInfo[MPMediaItemPropertyTitle] = error?.localizedDescription ?? "" - } - return nowPlayingInfo - } - - func playerItem(reload: Bool) -> AVPlayerItem { - if reload, resource.isFailing { - let item = Resource.loading.playerItem().withId(id) - configuration(item) - update(item: item) - PlayerItem.reload(for: id) - return item - } - else { - let item = resource.playerItem().withId(id) - configuration(item) - update(item: item) - PlayerItem.load(for: id) - return item - } - } - - func update(item: AVPlayerItem) { - item.externalMetadata = Self.externalMetadata(from: metadata?.nowPlayingMetadata()) - } } -public extension Asset where M == Never { +public extension Asset where M == Void { /// Returns a simple asset playable from a URL. /// /// - Parameters: @@ -201,11 +108,9 @@ public extension Asset where M == Never { configuration: @escaping (AVPlayerItem) -> Void = { _ in } ) -> Self { .init( - id: UUID(), resource: .simple(url: url), - metadata: nil, - configuration: configuration, - trackerAdapters: [] + metadata: (), + configuration: configuration ) } @@ -224,11 +129,9 @@ public extension Asset where M == Never { configuration: @escaping (AVPlayerItem) -> Void = { _ in } ) -> Self { .init( - id: UUID(), resource: .custom(url: url, delegate: delegate), - metadata: nil, - configuration: configuration, - trackerAdapters: [] + metadata: (), + configuration: configuration ) } @@ -245,63 +148,9 @@ public extension Asset where M == Never { configuration: @escaping (AVPlayerItem) -> Void = { _ in } ) -> Self { .init( - id: UUID(), resource: .encrypted(url: url, delegate: delegate), - metadata: nil, - configuration: configuration, - trackerAdapters: [] + metadata: (), + configuration: configuration ) } } - -extension Asset { - static var loading: Self { - .init(id: UUID(), resource: .loading, metadata: nil, configuration: { _ in }, trackerAdapters: []) - } - - static func failed(error: Error) -> Self { - .init(id: UUID(), resource: .failing(error: error), metadata: nil, configuration: { _ in }, trackerAdapters: []) - } -} - -private extension Asset { - static func externalMetadata(from metadata: NowPlayingMetadata?) -> [AVMetadataItem] { - [ - metadataItem(for: .commonIdentifierTitle, value: metadata?.title), - metadataItem(for: .iTunesMetadataTrackSubTitle, value: metadata?.subtitle), - metadataItem(for: .commonIdentifierArtwork, value: metadata?.image?.pngData()), - metadataItem(for: .commonIdentifierDescription, value: metadata?.description) - ] - .compactMap { $0 } - } - - private static func metadataItem(for identifier: AVMetadataIdentifier, value: T?) -> AVMetadataItem? { - guard let value else { return nil } - let item = AVMutableMetadataItem() - item.identifier = identifier - item.value = value as? NSCopying & NSObjectProtocol - item.extendedLanguageTag = "und" - return item.copy() as? AVMetadataItem - } -} - -extension AVPlayerItem { - /// An identifier for player items delivered by the same data source. - var id: UUID? { - get { - objc_getAssociatedObject(self, &kIdKey) as? UUID - } - set { - objc_setAssociatedObject(self, &kIdKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - } - } - - /// Assigns an identifier for player items delivered by the same data source. - /// - /// - Parameter id: The id to assign. - /// - Returns: The receiver with the id assigned to it. - fileprivate func withId(_ id: UUID) -> AVPlayerItem { - self.id = id - return self - } -} diff --git a/Sources/Player/ControlCenter/NowPlaying.swift b/Sources/Player/ControlCenter/NowPlaying.swift deleted file mode 100644 index 957e6f2d7..000000000 --- a/Sources/Player/ControlCenter/NowPlaying.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// Copyright (c) SRG SSR. All rights reserved. -// -// License information is available from the LICENSE file. -// - -import UIKit - -/// Metadata describing what is currently being played. -public typealias NowPlayingInfo = [String: Any] - -/// Metadata providing information about what is currently being played. -/// -/// This metadata is most notably displayed in the Control Center. -public struct NowPlayingMetadata { - /// The title. - let title: String? - - /// The subtitle. - let subtitle: String? - - /// The description. - let description: String? - - /// The image suitable for artwork display. - let image: UIImage? - - /// Creates now playing metadata. - public init(title: String? = nil, subtitle: String? = nil, description: String? = nil, image: UIImage? = nil) { - self.title = title - self.subtitle = subtitle - self.description = description - self.image = image - } -} diff --git a/Sources/Player/ControlCenter/NowPlayingInfo.swift b/Sources/Player/ControlCenter/NowPlayingInfo.swift new file mode 100644 index 000000000..b554e9c77 --- /dev/null +++ b/Sources/Player/ControlCenter/NowPlayingInfo.swift @@ -0,0 +1,11 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +/// Metadata describing what is currently being played. +/// +/// Refer to the [official documentation](https://developer.apple.com/documentation/mediaplayer/mpnowplayinginfocenter#1674387) +/// for a list of available keys. +public typealias NowPlayingInfo = [String: Any] diff --git a/Sources/Player/Extensions/AVPlayerItem.swift b/Sources/Player/Extensions/AVPlayerItem.swift index 141a4f416..f0da5440e 100644 --- a/Sources/Player/Extensions/AVPlayerItem.swift +++ b/Sources/Player/Extensions/AVPlayerItem.swift @@ -6,6 +6,8 @@ import AVFoundation +private var kIdKey: Void? + public extension AVPlayerItem { /// Seeks to a given position. /// @@ -27,8 +29,95 @@ extension AVPlayerItem { var timeRange: CMTimeRange { TimeProperties.timeRange(loadedTimeRanges: loadedTimeRanges, seekableTimeRanges: seekableTimeRanges) } +} + +extension AVPlayerItem { + /// Returns the list of `AVPlayerItems` to load into an `AVQueuePlayer` when a list of contents replaces a previous + /// one. + /// + /// - Parameters: + /// - currentContents: The current list of contents. + /// - previousContents: The previous list of contents. + /// - currentItem: The item currently being played by the player. + /// - Returns: The list of player items to load into the player. + static func playerItems( + for currentContents: [AssetContent], + replacing previousContents: [AssetContent], + currentItem: AVPlayerItem?, + length: Int + ) -> [AVPlayerItem] { + guard let currentItem else { return playerItems(from: Array(currentContents.prefix(length))) } + if let currentIndex = matchingIndex(for: currentItem, in: currentContents) { + let currentContent = currentContents[currentIndex] + if findContent(currentContent, in: previousContents) { + currentContent.update(item: currentItem) + return [currentItem] + playerItems(from: Array(currentContents.suffix(from: currentIndex + 1).prefix(length - 1))) + } + else { + return playerItems(from: Array(currentContents.suffix(from: currentIndex).prefix(length))) + } + } + else if let commonIndex = firstCommonIndex(in: currentContents, matching: previousContents, after: currentItem) { + return playerItems(from: Array(currentContents.suffix(from: commonIndex).prefix(length))) + } + else { + return playerItems(from: Array(currentContents.prefix(length))) + } + } static func playerItems(from items: [PlayerItem], length: Int, reload: Bool) -> [AVPlayerItem] { - playerItems(from: items.prefix(length).map(\.asset), reload: reload) + playerItems(from: items.prefix(length).map(\.content), reload: reload) + } + + static func playerItems(from contents: [AssetContent], reload: Bool = false) -> [AVPlayerItem] { + contents.map { $0.playerItem(reload: reload) } + } + + private static func matchingIndex(for item: AVPlayerItem, in contents: [AssetContent]) -> Int? { + contents.firstIndex { $0.id == item.id } + } + + private static func firstMatchingIndex(for contents: [AssetContent], in other: [AssetContent]) -> Int? { + guard let match = contents.first(where: { content in + other.contains(where: { $0.id == content.id }) + }) else { + return nil + } + return matchingIndex(for: match, in: other) + } + + private static func matchingIndex(for content: AssetContent, in contents: [AssetContent]) -> Int? { + contents.firstIndex { $0.id == content.id } + } + + private static func findContent(_ content: AssetContent, in contents: [AssetContent]) -> Bool { + guard let match = contents.first(where: { $0.id == content.id }) else { return false } + return match.resource == content.resource + } + + private static func firstCommonIndex(in contents: [AssetContent], matching other: [AssetContent], after item: AVPlayerItem) -> Int? { + guard let matchIndex = matchingIndex(for: item, in: other) else { return nil } + return firstMatchingIndex(for: Array(other.suffix(from: matchIndex + 1)), in: contents) + } +} + +extension AVPlayerItem { + /// An identifier for player items delivered by the same data source. + private(set) var id: UUID? { + get { + objc_getAssociatedObject(self, &kIdKey) as? UUID + } + set { + objc_setAssociatedObject(self, &kIdKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + /// Assigns an identifier for player items delivered by the same data source. + /// + /// - Parameter id: The id to assign. + /// - Returns: The receiver with the id assigned to it. + func withId(_ id: UUID) -> AVPlayerItem { + self.id = id + return self } } diff --git a/Sources/Player/Interfaces/AssetMetadata.swift b/Sources/Player/Interfaces/AssetMetadata.swift deleted file mode 100644 index 14c657d02..000000000 --- a/Sources/Player/Interfaces/AssetMetadata.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// Copyright (c) SRG SSR. All rights reserved. -// -// License information is available from the LICENSE file. -// - -/// A protocol representing metadata associated with an ``Asset``. -public protocol AssetMetadata { - /// Returns metadata used to display what is currently being played. - /// - /// This metadata is most notably displayed in the Control Center. - func nowPlayingMetadata() -> NowPlayingMetadata -} - -/// An extension providing a default implementation for the `nowPlayingMetadata()` method. -public extension AssetMetadata { - /// Returns a `NowPlayingMetadata` instance with default values. - func nowPlayingMetadata() -> NowPlayingMetadata { - .init() - } -} - -extension Never: AssetMetadata {} diff --git a/Sources/Player/Interfaces/Assetable.swift b/Sources/Player/Interfaces/Assetable.swift deleted file mode 100644 index 48124c692..000000000 --- a/Sources/Player/Interfaces/Assetable.swift +++ /dev/null @@ -1,101 +0,0 @@ -// -// Copyright (c) SRG SSR. All rights reserved. -// -// License information is available from the LICENSE file. -// - -import AVFoundation - -/// A protocol describing an asset. -protocol Assetable { - var id: UUID { get } - var resource: Resource { get } - - func enable(for player: Player) - func updateMetadata() - func disable() - - func nowPlayingInfo(with error: Error?) -> NowPlayingInfo - func playerItem(reload: Bool) -> AVPlayerItem - func update(item: AVPlayerItem) -} - -extension Assetable { - func matches(_ playerItem: AVPlayerItem?) -> Bool { - id == playerItem?.id - } - - func playerItem() -> AVPlayerItem { - playerItem(reload: false) - } - - func nowPlayingInfo() -> NowPlayingInfo { - nowPlayingInfo(with: nil) - } -} - -extension AVPlayerItem { - /// Returns the list of `AVPlayerItems` to load into an `AVQueuePlayer` when a list of assets replaces a previous - /// one. - /// - /// - Parameters: - /// - currentAssets: The current list of assets. - /// - previousAssets: The previous list of assets. - /// - currentItem: The item currently being played by the player. - /// - Returns: The list of player items to load into the player. - static func playerItems( - for currentAssets: [any Assetable], - replacing previousAssets: [any Assetable], - currentItem: AVPlayerItem?, - length: Int - ) -> [AVPlayerItem] { - guard let currentItem else { return playerItems(from: Array(currentAssets.prefix(length))) } - if let currentIndex = matchingIndex(for: currentItem, in: currentAssets) { - let currentAsset = currentAssets[currentIndex] - if findAsset(currentAsset, in: previousAssets) { - currentAsset.update(item: currentItem) - return [currentItem] + playerItems(from: Array(currentAssets.suffix(from: currentIndex + 1).prefix(length - 1))) - } - else { - return playerItems(from: Array(currentAssets.suffix(from: currentIndex).prefix(length))) - } - } - else if let commonIndex = firstCommonIndex(in: currentAssets, matching: previousAssets, after: currentItem) { - return playerItems(from: Array(currentAssets.suffix(from: commonIndex).prefix(length))) - } - else { - return playerItems(from: Array(currentAssets.prefix(length))) - } - } - - static func playerItems(from assets: [any Assetable], reload: Bool = false) -> [AVPlayerItem] { - assets.map { $0.playerItem(reload: reload) } - } - - private static func matchingIndex(for item: AVPlayerItem, in assets: [any Assetable]) -> Int? { - assets.firstIndex { $0.matches(item) } - } - - private static func firstMatchingIndex(for assets: [any Assetable], in other: [any Assetable]) -> Int? { - guard let match = assets.first(where: { asset in - other.contains(where: { $0.id == asset.id }) - }) else { - return nil - } - return matchingIndex(for: match, in: other) - } - - private static func matchingIndex(for asset: any Assetable, in assets: [any Assetable]) -> Int? { - assets.firstIndex { $0.id == asset.id } - } - - private static func findAsset(_ asset: any Assetable, in assets: [any Assetable]) -> Bool { - guard let match = assets.first(where: { $0.id == asset.id }) else { return false } - return match.resource == asset.resource - } - - private static func firstCommonIndex(in assets: [any Assetable], matching other: [any Assetable], after item: AVPlayerItem) -> Int? { - guard let matchIndex = matchingIndex(for: item, in: other) else { return nil } - return firstMatchingIndex(for: Array(other.suffix(from: matchIndex + 1)), in: assets) - } -} diff --git a/Sources/Player/Interfaces/PlayerItemMetadata.swift b/Sources/Player/Interfaces/PlayerItemMetadata.swift new file mode 100644 index 000000000..a6ae2265f --- /dev/null +++ b/Sources/Player/Interfaces/PlayerItemMetadata.swift @@ -0,0 +1,88 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +import AVFoundation + +/// A protocol for custom player item metadata integration. +public protocol PlayerItemMetadata { + /// A type describing the configuration offered for metadata display. + /// + /// Use `Void` if no configuration is offered. + associatedtype Configuration + + /// A type describing how metadata is stored internally. + /// + /// Use `Void` if no metadata is required. In such cases metadata can still be displayed by the player but in + /// a way that is not associated with any item. + associatedtype Metadata + + /// Creates an instance for holding metadata and formatting it for display by the player. + /// + /// - Parameter configuration: The metadata configuration. + init(configuration: Configuration) + + /// A method formatting metadata for Control Center display. + /// + /// - Returns: A dictionary suitable for display. + /// + /// Refer to the [official documentation](https://developer.apple.com/documentation/mediaplayer/mpnowplayinginfocenter#1674387) + /// for more information. + func nowPlayingInfo(from metadata: Metadata) -> NowPlayingInfo + + /// A method formatting metadata for display in the standard system player user interface. + /// + /// - Returns: An array of metadata items. + /// + /// Refer to the [official documentation](https://developer.apple.com/documentation/avkit/customizing_the_tvos_playback_experience) + /// for more information. + func metadataItems(from metadata: Metadata) -> [AVMetadataItem] +} + +public extension PlayerItemMetadata { + /// Creates an adapter for the receiver with the provided mapping to its metadata format. + /// + /// - Parameters: + /// - configuration: The metadata configuration. + /// - mapper: A closure that maps an item metadata to player metadata. + /// - Returns: The metadata adapter. + static func adapter(configuration: Configuration, mapper: @escaping (M) -> Metadata) -> MetadataAdapter { + .init(metadataType: Self.self, configuration: configuration, mapper: mapper) + } +} + +public extension PlayerItemMetadata where Configuration == Void { + /// Creates an adapter for the receiver with the provided mapping to its metadata format. + /// + /// - Parameter mapper: A closure that maps an item metadata to player metadata. + /// - Returns: The metadata adapter. + static func adapter(mapper: @escaping (M) -> Metadata) -> MetadataAdapter { + .init(metadataType: Self.self, configuration: (), mapper: mapper) + } +} + +public extension PlayerItemMetadata where Metadata == Void { + /// Creates an adapter for the receiver. + /// + /// - Returns: The metadata adapter. + /// + /// This adapter is useful when no metadata is delivered by the item but you still want to implement player + /// metadata display (mostly static and not related to the item itself). + static func adapter(configuration: Configuration) -> MetadataAdapter { + .init(metadataType: Self.self, configuration: configuration) { _ in } + } +} + +public extension PlayerItemMetadata where Configuration == Void, Metadata == Void { + /// Creates an adapter for the receiver. + /// + /// - Returns: The metadata adapter. + /// + /// This adapter is useful when no metadata is delivered by the item but you still want to implement player + /// metadata display (mostly static and not related to the item itself). + static func adapter() -> MetadataAdapter { + .init(metadataType: Self.self, configuration: ()) { _ in } + } +} diff --git a/Sources/Player/Player+ControlCenter.swift b/Sources/Player/Player+ControlCenter.swift index 9d1ee28e4..836afd496 100644 --- a/Sources/Player/Player+ControlCenter.swift +++ b/Sources/Player/Player+ControlCenter.swift @@ -28,56 +28,6 @@ extension Player { } commandRegistrations = [] } - - func nowPlayingInfoMetadataPublisher() -> AnyPublisher { - queuePublisher - .compactMap { queue in - guard let index = queue.index else { - return NowPlayingInfo() - } - let asset = queue.elements[index].asset - return !asset.resource.isLoading ? asset.nowPlayingInfo(with: queue.error) : nil - } - .removeDuplicates { lhs, rhs in - // swiftlint:disable:next legacy_objc_type - NSDictionary(dictionary: lhs).isEqual(to: rhs) - } - .eraseToAnyPublisher() - } - - func nowPlayingInfoPlaybackPublisher() -> AnyPublisher { - propertiesPublisher - .map { [weak queuePlayer] properties in - var nowPlayingInfo = NowPlayingInfo() - if properties.streamType != .unknown { - nowPlayingInfo[MPNowPlayingInfoPropertyIsLiveStream] = (properties.streamType == .live) - nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = properties.isBuffering ? 0 : properties.rate - if let time = properties.seekTime ?? queuePlayer?.currentTime(), time.isValid { - nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = (time - properties.seekableTimeRange.start).seconds - } - nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = properties.seekableTimeRange.duration.seconds - } - return nowPlayingInfo - } - .eraseToAnyPublisher() - } - - func nowPlayingInfoPublisher() -> AnyPublisher { - $isActive - .map { [weak self] isActive in - guard let self, isActive else { return Just(NowPlayingInfo()).eraseToAnyPublisher() } - return Publishers.CombineLatest( - nowPlayingInfoMetadataPublisher(), - nowPlayingInfoPlaybackPublisher() - ) - .map { nowPlayingInfoMetadata, nowPlayingInfoPlayback in - nowPlayingInfoMetadata.merging(nowPlayingInfoPlayback) { _, new in new } - } - .eraseToAnyPublisher() - } - .switchToLatest() - .eraseToAnyPublisher() - } } private extension Player { @@ -164,3 +114,46 @@ private extension Player { } } } + +extension Player { + func nowPlayingInfoMetadataPublisher() -> AnyPublisher { + metadataPublisher + .map(\.nowPlayingInfo) + .eraseToAnyPublisher() + } + + private func nowPlayingInfoPlaybackPublisher() -> AnyPublisher { + propertiesPublisher + .map { [weak queuePlayer] properties in + var nowPlayingInfo = NowPlayingInfo() + // Always fill a key so that the Control Center can be enabled for the item, even if it has no associated metadata. + nowPlayingInfo[MPNowPlayingInfoPropertyIsLiveStream] = (properties.streamType == .live) + if properties.streamType != .unknown { + nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = properties.isBuffering ? 0 : properties.rate + if let time = properties.seekTime ?? queuePlayer?.currentTime(), time.isValid { + nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = (time - properties.seekableTimeRange.start).seconds + } + nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = properties.seekableTimeRange.duration.seconds + } + return nowPlayingInfo + } + .eraseToAnyPublisher() + } + + func nowPlayingInfoPublisher() -> AnyPublisher { + $isActive + .map { [weak self] isActive in + guard let self, isActive else { return Just(NowPlayingInfo()).eraseToAnyPublisher() } + return Publishers.CombineLatest( + nowPlayingInfoMetadataPublisher(), + nowPlayingInfoPlaybackPublisher() + ) + .map { nowPlayingInfoMetadata, nowPlayingInfoPlayback in + nowPlayingInfoMetadata.merging(nowPlayingInfoPlayback) { _, new in new } + } + .eraseToAnyPublisher() + } + .switchToLatest() + .eraseToAnyPublisher() + } +} diff --git a/Sources/Player/Player+Queue.swift b/Sources/Player/Player+Queue.swift index 05a5c84c0..0f39cbe20 100644 --- a/Sources/Player/Player+Queue.swift +++ b/Sources/Player/Player+Queue.swift @@ -13,8 +13,8 @@ extension Player { $storedItems .map { items in Publishers.AccumulateLatestMany(items.map { item in - item.$asset - .map { QueueElement(item: item, asset: $0) } + item.$content + .map { QueueElement(item: item, content: $0) } }) } .switchToLatest() @@ -36,8 +36,8 @@ extension Player { return nil } return AVPlayerItem.playerItems( - for: current.elements.map(\.asset), - replacing: previous.elements.map(\.asset), + for: current.elements.map(\.content), + replacing: previous.elements.map(\.content), currentItem: buffer.item, length: buffer.length ) diff --git a/Sources/Player/Player+Tracking.swift b/Sources/Player/Player+Tracking.swift new file mode 100644 index 000000000..aa1ce70b2 --- /dev/null +++ b/Sources/Player/Player+Tracking.swift @@ -0,0 +1,25 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +import Combine +import PillarboxCore + +extension Player { + func currentTrackerPublisher() -> AnyPublisher { + Publishers.CombineLatest(queuePublisher.slice(at: \.item), $isTrackingEnabled) + .map { (item: $0, isTrackingEnabled: $1) } + .scan(nil) { [weak self] tracker, next in + if let self, next.isTrackingEnabled, let item = next.item { + guard tracker?.item !== item else { return tracker } + return CurrentTracker(item: item, player: self) + } + else { + return nil + } + } + .eraseToAnyPublisher() + } +} diff --git a/Sources/Player/Player.docc/Tutorials/integrating-with-control-center/integrating-with-control-center-2-1.swift b/Sources/Player/Player.docc/Tutorials/integrating-with-control-center/integrating-with-control-center-2-1.swift index 9a1c4848c..889416bd4 100644 --- a/Sources/Player/Player.docc/Tutorials/integrating-with-control-center/integrating-with-control-center-2-1.swift +++ b/Sources/Player/Player.docc/Tutorials/integrating-with-control-center/integrating-with-control-center-2-1.swift @@ -1,7 +1,7 @@ import PillarboxPlayer import SwiftUI -private struct Metadata: AssetMetadata {} +private struct Metadata {} struct ContentView: View { @StateObject private var player = Player(item: .simple( diff --git a/Sources/Player/Player.docc/Tutorials/integrating-with-control-center/integrating-with-control-center-2-2.swift b/Sources/Player/Player.docc/Tutorials/integrating-with-control-center/integrating-with-control-center-2-2.swift index 5329265ef..7a31d86e4 100644 --- a/Sources/Player/Player.docc/Tutorials/integrating-with-control-center/integrating-with-control-center-2-2.swift +++ b/Sources/Player/Player.docc/Tutorials/integrating-with-control-center/integrating-with-control-center-2-2.swift @@ -1,7 +1,7 @@ import PillarboxPlayer import SwiftUI -private struct Metadata: AssetMetadata { +private struct Metadata { func nowPlayingMetadata() -> NowPlayingMetadata { .init(title: "🍎", subtitle: "🍏", image: .apple) } diff --git a/Sources/Player/Player.swift b/Sources/Player/Player.swift index 7cc56bb3e..cedb76dcb 100644 --- a/Sources/Player/Player.swift +++ b/Sources/Player/Player.swift @@ -27,6 +27,9 @@ public final class Player: ObservableObject, Equatable { /// A Boolean setting whether trackers must be enabled or not. @Published public var isTrackingEnabled = true + /// The metadata related to the item being played. + @Published public private(set) var metadata: PlayerMetadata = .empty + @Published var storedItems: Deque @Published var _playbackSpeed: PlaybackSpeed = .indefinite @@ -42,7 +45,7 @@ public final class Player: ObservableObject, Equatable { } } - @Published private var currentTracker: CurrentTracker? + private var currentTracker: CurrentTracker? var properties: PlayerProperties = .empty { willSet { @@ -84,6 +87,19 @@ public final class Player: ObservableObject, Equatable { .eraseToAnyPublisher() }() + lazy var metadataPublisher: AnyPublisher = { + queuePublisher + .slice(at: \.item) + .map { item in + guard let item else { return Just(PlayerMetadata.empty).eraseToAnyPublisher() } + return item.$content.map(\.metadata).eraseToAnyPublisher() + } + .switchToLatest() + .removeDuplicates() + .share(replay: 1) + .eraseToAnyPublisher() + }() + /// A Boolean setting whether the audio output of the player must be muted. public var isMuted: Bool { get { @@ -172,6 +188,8 @@ public final class Player: ObservableObject, Equatable { configurePublishedPropertyPublishers() configureQueuePlayerUpdatePublishers() configureControlCenterPublishers() + configureCurrentTrackerPublishers() + configureMetadataPublisher() } /// Creates a player with a single item in its queue. @@ -229,7 +247,6 @@ public final class Player: ObservableObject, Equatable { configurePropertiesPublisher() configureErrorPublisher() configureCurrentIndexPublisher() - configureCurrentTrackerPublisher() configurePlaybackSpeedPublisher() } @@ -298,15 +315,16 @@ private extension Player { .assign(to: &$currentIndex) } - func configureCurrentTrackerPublisher() { - queuePublisher - .slice(at: \.item) - .map { [weak self] item in - guard let self, let item else { return nil } - return CurrentTracker(item: item, player: self) - } + func configureMetadataPublisher() { + metadataPublisher .receiveOnMainThread() - .assign(to: &$currentTracker) + .assign(to: &$metadata) + } + + func configureCurrentTrackerPublishers() { + currentTrackerPublisher() + .weakAssign(to: \.currentTracker, on: self) + .store(in: &cancellables) } func configurePlaybackSpeedPublisher() { @@ -332,6 +350,7 @@ private extension Player { propertiesPublisher, $isActive ) + .receiveOnMainThread() .sink { [weak self] queue, properties, _ in guard let self else { return } let areSkipsEnabled = queue.elements.count <= 1 && properties.streamType != .live diff --git a/Sources/Player/PlayerItem.swift b/Sources/Player/PlayerItem.swift index 4d3a0f08e..46cbab578 100644 --- a/Sources/Player/PlayerItem.swift +++ b/Sources/Player/PlayerItem.swift @@ -23,34 +23,55 @@ private enum TriggerId: Hashable { public final class PlayerItem: Equatable { private static let trigger = Trigger() - @Published private(set) var asset: any Assetable + @Published private(set) var content: AssetContent + private let trackerAdapters: [any TrackerLifeCycle] let id = UUID() /// Creates the item from an ``Asset`` publisher data source. - public init(publisher: P, trackerAdapters: [TrackerAdapter] = []) where P: Publisher, M: AssetMetadata, P.Output == Asset { - asset = Asset.loading.withId(id).withTrackerAdapters(trackerAdapters) - Publishers.PublishAndRepeat(onOutputFrom: Self.trigger.signal(activatedBy: TriggerId.reset(id))) { - publisher - .catch { error in - Just(.failed(error: error)) - } + public init( + publisher: P, + metadataAdapter: MetadataAdapter = .none, + trackerAdapters: [TrackerAdapter] = [] + ) where P: Publisher, P.Output == Asset { + let trackerAdapters = trackerAdapters.map { [id] adapter in + adapter.withId(id) } - .map { [id] asset in - asset.withId(id).withTrackerAdapters(trackerAdapters) + self.trackerAdapters = trackerAdapters + content = .loading(id: id) + Publishers.PublishAndRepeat(onOutputFrom: Self.trigger.signal(activatedBy: TriggerId.reset(id))) { [id] in + Publishers.CombineLatest( + publisher, + Just(trackerAdapters).setFailureType(to: P.Failure.self) + ) + .map { asset, trackerAdapters in + trackerAdapters.forEach { adapter in + adapter.updateMetadata(with: asset.metadata) + } + return AssetContent( + id: id, + resource: asset.resource, + metadata: metadataAdapter.metadata(from: asset.metadata), + configuration: asset.configuration + ) + } + .catch { error in + Just(.failing(id: id, error: error)) + } } .wait(untilOutputFrom: Self.trigger.signal(activatedBy: TriggerId.load(id))) .receive(on: DispatchQueue.main) - .assign(to: &$asset) + .assign(to: &$content) } /// Creates a player item from an ``Asset``. /// /// - Parameters: /// - asset: The asset to play. + /// - metadataAdapter: A `MetadataAdapter` converting item metadata into player metadata. /// - trackerAdapters: An array of `TrackerAdapter` instances to use for tracking playback events. - public convenience init(asset: Asset, trackerAdapters: [TrackerAdapter] = []) where M: AssetMetadata { - self.init(publisher: Just(asset), trackerAdapters: trackerAdapters) + public convenience init(asset: Asset, metadataAdapter: MetadataAdapter = .none, trackerAdapters: [TrackerAdapter] = []) { + self.init(publisher: Just(asset), metadataAdapter: metadataAdapter, trackerAdapters: trackerAdapters) } public static func == (lhs: PlayerItem, rhs: PlayerItem) -> Bool { @@ -67,7 +88,27 @@ public final class PlayerItem: Equatable { } func matches(_ playerItem: AVPlayerItem?) -> Bool { - asset.matches(playerItem) + playerItem?.id == id + } +} + +extension PlayerItem { + func enableTrackers(for player: Player) { + trackerAdapters.forEach { adapter in + adapter.enable(for: player) + } + } + + func updateTrackerProperties(_ properties: PlayerProperties) { + trackerAdapters.forEach { adapter in + adapter.updateProperties(with: properties) + } + } + + func disableTrackers() { + trackerAdapters.forEach { adapter in + adapter.disable() + } } } @@ -77,16 +118,22 @@ public extension PlayerItem { /// - Parameters: /// - url: The URL to be played. /// - metadata: The metadata associated with the item. + /// - metadataAdapter: A `MetadataAdapter` converting item metadata into player metadata. /// - trackerAdapters: An array of `TrackerAdapter` instances to use for tracking playback events. /// - configuration: A closure to configure player items created from the receiver. /// - Returns: The item. static func simple( url: URL, metadata: M, + metadataAdapter: MetadataAdapter = .none, trackerAdapters: [TrackerAdapter] = [], configuration: @escaping (AVPlayerItem) -> Void = { _ in } - ) -> Self where M: AssetMetadata { - .init(asset: .simple(url: url, metadata: metadata, configuration: configuration), trackerAdapters: trackerAdapters) + ) -> Self { + .init( + asset: .simple(url: url, metadata: metadata, configuration: configuration), + metadataAdapter: metadataAdapter, + trackerAdapters: trackerAdapters + ) } /// Returns an item loaded with custom resource loading. @@ -95,6 +142,7 @@ public extension PlayerItem { /// - url: The URL to be played. /// - delegate: The custom resource loader to use. /// - metadata: The metadata associated with the item. + /// - metadataAdapter: A `MetadataAdapter` converting item metadata into player metadata. /// - trackerAdapters: An array of `TrackerAdapter` instances to use for tracking playback events. /// - configuration: A closure to configure player items created from the receiver. /// - Returns: The item. @@ -104,10 +152,15 @@ public extension PlayerItem { url: URL, delegate: AVAssetResourceLoaderDelegate, metadata: M, + metadataAdapter: MetadataAdapter = .none, trackerAdapters: [TrackerAdapter] = [], configuration: @escaping (AVPlayerItem) -> Void = { _ in } - ) -> Self where M: AssetMetadata { - .init(asset: .custom(url: url, delegate: delegate, metadata: metadata, configuration: configuration), trackerAdapters: trackerAdapters) + ) -> Self { + .init( + asset: .custom(url: url, delegate: delegate, metadata: metadata, configuration: configuration), + metadataAdapter: metadataAdapter, + trackerAdapters: trackerAdapters + ) } /// Returns an encrypted item loaded with a content key session. @@ -116,6 +169,7 @@ public extension PlayerItem { /// - url: The URL to be played. /// - delegate: The content key session delegate to use. /// - metadata: The metadata associated with the item. + /// - metadataAdapter: A `MetadataAdapter` converting item metadata into player metadata. /// - trackerAdapters: An array of `TrackerAdapter` instances to use for tracking playback events. /// - configuration: A closure to configure player items created from the receiver. /// - Returns: The item. @@ -123,10 +177,15 @@ public extension PlayerItem { url: URL, delegate: AVContentKeySessionDelegate, metadata: M, + metadataAdapter: MetadataAdapter = .none, trackerAdapters: [TrackerAdapter] = [], configuration: @escaping (AVPlayerItem) -> Void = { _ in } - ) -> Self where M: AssetMetadata { - .init(asset: .encrypted(url: url, delegate: delegate, metadata: metadata, configuration: configuration), trackerAdapters: trackerAdapters) + ) -> Self { + .init( + asset: .encrypted(url: url, delegate: delegate, metadata: metadata, configuration: configuration), + metadataAdapter: metadataAdapter, + trackerAdapters: trackerAdapters + ) } } @@ -140,10 +199,15 @@ public extension PlayerItem { /// - Returns: The item. static func simple( url: URL, - trackerAdapters: [TrackerAdapter] = [], + metadataAdapter: MetadataAdapter = .none, + trackerAdapters: [TrackerAdapter] = [], configuration: @escaping (AVPlayerItem) -> Void = { _ in } ) -> Self { - .init(asset: .simple(url: url, configuration: configuration), trackerAdapters: trackerAdapters) + .init( + asset: .simple(url: url, configuration: configuration), + metadataAdapter: metadataAdapter, + trackerAdapters: trackerAdapters + ) } /// Returns an item loaded with custom resource loading. @@ -159,10 +223,15 @@ public extension PlayerItem { static func custom( url: URL, delegate: AVAssetResourceLoaderDelegate, - trackerAdapters: [TrackerAdapter] = [], + metadataAdapter: MetadataAdapter = .none, + trackerAdapters: [TrackerAdapter] = [], configuration: @escaping (AVPlayerItem) -> Void = { _ in } ) -> Self { - .init(asset: .custom(url: url, delegate: delegate, configuration: configuration), trackerAdapters: trackerAdapters) + .init( + asset: .custom(url: url, delegate: delegate, configuration: configuration), + metadataAdapter: metadataAdapter, + trackerAdapters: trackerAdapters + ) } /// Returns an encrypted item loaded with a content key session. @@ -176,15 +245,20 @@ public extension PlayerItem { static func encrypted( url: URL, delegate: AVContentKeySessionDelegate, - trackerAdapters: [TrackerAdapter] = [], + metadataAdapter: MetadataAdapter = .none, + trackerAdapters: [TrackerAdapter] = [], configuration: @escaping (AVPlayerItem) -> Void = { _ in } ) -> Self { - .init(asset: .encrypted(url: url, delegate: delegate, configuration: configuration), trackerAdapters: trackerAdapters) + .init( + asset: .encrypted(url: url, delegate: delegate, configuration: configuration), + metadataAdapter: metadataAdapter, + trackerAdapters: trackerAdapters + ) } } extension PlayerItem: CustomDebugStringConvertible { public var debugDescription: String { - "\(asset)" + "\(content)" } } diff --git a/Sources/Player/ResourceLoading/Resource.swift b/Sources/Player/ResourceLoading/Resource.swift index 54880e93a..b83b1f813 100644 --- a/Sources/Player/ResourceLoading/Resource.swift +++ b/Sources/Player/ResourceLoading/Resource.swift @@ -17,15 +17,6 @@ enum Resource { private static let logger = Logger(category: "Resource") - var isLoading: Bool { - switch self { - case let .custom(url: url, _) where url == Self.loadingUrl: - true - default: - false - } - } - var isFailing: Bool { switch self { case let .custom(url: url, _) where url == Self.failingUrl: diff --git a/Sources/Player/Tracking/CurrentTracker.swift b/Sources/Player/Tracking/CurrentTracker.swift index 5b822cccc..1d90e2bf2 100644 --- a/Sources/Player/Tracking/CurrentTracker.swift +++ b/Sources/Player/Tracking/CurrentTracker.swift @@ -6,54 +6,26 @@ import Combine +/// Tracks the provided item during its lifecycle. +/// +/// This class implements a Resource Acquisition Is Initialization (RAII) approach to ensure lifecycle events are +/// properly balanced. final class CurrentTracker { - private let item: PlayerItem - private var isEnabled = false + let item: PlayerItem private var cancellables = Set() init(item: PlayerItem, player: Player) { self.item = item - configureAssetPublisher(for: item) - configureTrackingPublisher(player: player) - } - - private func configureTrackingPublisher(player: Player) { - player.$isTrackingEnabled - .sink { [weak self, weak player] enabled in - guard let self, let player, isEnabled != enabled else { return } - isEnabled = enabled - if enabled { - enableAsset(for: player) - } - else { - disableAsset() - } - } - .store(in: &cancellables) - } + item.enableTrackers(for: player) - private func configureAssetPublisher(for item: PlayerItem) { - item.$asset - .sink { asset in - asset.updateMetadata() + player.propertiesPublisher + .sink { properties in + item.updateTrackerProperties(properties) } .store(in: &cancellables) } - private func enableAsset(for player: Player) { - item.asset.enable(for: player) - } - - private func disableAsset() { - item.asset.disable() - } - - private func disableAssetIfNeeded() { - guard isEnabled else { return } - disableAsset() - } - deinit { - disableAssetIfNeeded() + item.disableTrackers() } } diff --git a/Sources/Player/Tracking/PlayerItemTracker.swift b/Sources/Player/Tracking/PlayerItemTracker.swift index 1416b6ba8..d8aa625b7 100644 --- a/Sources/Player/Tracking/PlayerItemTracker.swift +++ b/Sources/Player/Tracking/PlayerItemTracker.swift @@ -28,7 +28,7 @@ public protocol PlayerItemTracker: AnyObject { /// - Parameter player: The player for which the tracker must be enabled. func enable(for player: Player) - /// A method called when tracker metadata is updated. + /// A method called when metadata is updated. /// /// - Parameter metadata: The updated metadata. func updateMetadata(with metadata: Metadata) @@ -49,53 +49,38 @@ public extension PlayerItemTracker { /// /// - Parameters: /// - configuration: The tracker configuration. - /// - mapper: A closure that maps the tracker's metadata to another metadata. + /// - mapper: A closure that maps an item metadata to tracker metadata. /// - Returns: The tracker adapter. - static func adapter(configuration: Configuration, mapper: @escaping (M) -> Metadata) -> TrackerAdapter where M: AssetMetadata { - TrackerAdapter(trackerType: Self.self, configuration: configuration, mapper: mapper) - } - - /// Creates an adapter for the receiver. - /// - /// - Parameter configuration: The tracker configuration. - /// - Returns: The tracker adapter. - static func adapter(configuration: Configuration) -> TrackerAdapter { - TrackerAdapter(trackerType: Self.self, configuration: configuration, mapper: nil) + static func adapter(configuration: Configuration, mapper: @escaping (M) -> Metadata) -> TrackerAdapter { + .init(trackerType: Self.self, configuration: configuration, mapper: mapper) } } -public extension PlayerItemTracker where Metadata == Void { +public extension PlayerItemTracker where Configuration == Void { /// Creates an adapter for the receiver. /// /// - Parameter configuration: The tracker configuration. /// - Returns: The tracker adapter. - static func adapter(configuration: Configuration) -> TrackerAdapter where M: AssetMetadata { - TrackerAdapter(trackerType: Self.self, configuration: configuration) { _ in } + static func adapter(mapper: @escaping (M) -> Metadata) -> TrackerAdapter { + .init(trackerType: Self.self, configuration: (), mapper: mapper) } } -public extension PlayerItemTracker where Configuration == Void { +public extension PlayerItemTracker where Metadata == Void { /// Creates an adapter for the receiver. /// /// - Parameter configuration: The tracker configuration. /// - Returns: The tracker adapter. - static func adapter(mapper: @escaping (M) -> Metadata) -> TrackerAdapter where M: AssetMetadata { - TrackerAdapter(trackerType: Self.self, configuration: (), mapper: mapper) - } - - /// Creates an adapter for the receiver. - /// - /// - Returns: The tracker adapter. - static func adapter() -> TrackerAdapter { - TrackerAdapter(trackerType: Self.self, configuration: (), mapper: nil) + static func adapter(configuration: Configuration) -> TrackerAdapter { + .init(trackerType: Self.self, configuration: configuration) { _ in } } } -public extension PlayerItemTracker where Metadata == Void, Configuration == Void { +public extension PlayerItemTracker where Configuration == Void, Metadata == Void { /// Creates an adapter for the receiver. /// /// - Returns: The tracker adapter. - static func adapter() -> TrackerAdapter where M: AssetMetadata { - TrackerAdapter(trackerType: Self.self, configuration: ()) { _ in } + static func adapter() -> TrackerAdapter { + .init(trackerType: Self.self, configuration: ()) { _ in } } } diff --git a/Sources/Player/Tracking/TrackerAdapter.swift b/Sources/Player/Tracking/TrackerAdapter.swift index 8a9a93eaa..803dd8ed6 100644 --- a/Sources/Player/Tracking/TrackerAdapter.swift +++ b/Sources/Player/Tracking/TrackerAdapter.swift @@ -4,19 +4,17 @@ // License information is available from the LICENSE file. // -import Combine import Foundation /// An adapter which instantiates and manages a tracker of a specified type. /// /// An adapter transforms metadata delivered by a player item into the metadata format required by the tracker. -public class TrackerAdapter { +public class TrackerAdapter { private let tracker: any PlayerItemTracker private let update: (M) -> Void - private var cancellables = Set() private var id = UUID() - /// Creates an adapter for a type of tracker with the provided mapping to its metadata format. + /// Creates an adapter for a type of tracker with the provided mapper. /// /// - Parameters: /// - trackerType: The type of the tracker to instantiate and manage. @@ -37,23 +35,22 @@ public class TrackerAdapter { return self } + func updateMetadata(with metadata: M) { + update(metadata) + } +} + +extension TrackerAdapter: TrackerLifeCycle { func enable(for player: Player) { tracker.enable(for: player) - - player.propertiesPublisher - .sink { [weak self] properties in - guard let self, properties.id == id else { return } - tracker.updateProperties(with: properties) - } - .store(in: &cancellables) } - func update(metadata: M) { - update(metadata) + func updateProperties(with properties: PlayerProperties) { + guard properties.id == id else { return } + tracker.updateProperties(with: properties) } func disable() { - cancellables = [] tracker.disable() } } diff --git a/Sources/Player/Tracking/TrackerLifeCycle.swift b/Sources/Player/Tracking/TrackerLifeCycle.swift new file mode 100644 index 000000000..bd05b7d45 --- /dev/null +++ b/Sources/Player/Tracking/TrackerLifeCycle.swift @@ -0,0 +1,11 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +protocol TrackerLifeCycle { + func enable(for player: Player) + func updateProperties(with properties: PlayerProperties) + func disable() +} diff --git a/Sources/Player/Types/AssetContent.swift b/Sources/Player/Types/AssetContent.swift new file mode 100644 index 000000000..418e79da6 --- /dev/null +++ b/Sources/Player/Types/AssetContent.swift @@ -0,0 +1,43 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +import AVFoundation + +struct AssetContent { + let id: UUID + let resource: Resource + let metadata: PlayerMetadata + let configuration: (AVPlayerItem) -> Void + + static func loading(id: UUID) -> Self { + .init(id: id, resource: .loading, metadata: .empty) { _ in } + } + + static func failing(id: UUID, error: Error) -> Self { + .init(id: id, resource: .failing(error: error), metadata: .empty) { _ in } + } + + func update(item: AVPlayerItem) { + item.externalMetadata = metadata.metadataItems + } + + func playerItem(reload: Bool = false) -> AVPlayerItem { + if reload, resource.isFailing { + let item = Resource.loading.playerItem().withId(id) + configuration(item) + update(item: item) + PlayerItem.reload(for: id) + return item + } + else { + let item = resource.playerItem().withId(id) + configuration(item) + update(item: item) + PlayerItem.load(for: id) + return item + } + } +} diff --git a/Sources/Player/Types/EmptyMetadata.swift b/Sources/Player/Types/EmptyMetadata.swift new file mode 100644 index 000000000..b2a1ed0a7 --- /dev/null +++ b/Sources/Player/Types/EmptyMetadata.swift @@ -0,0 +1,19 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +import AVFoundation + +struct EmptyMetadata: PlayerItemMetadata { + init(configuration: Void) {} + + func nowPlayingInfo(from metadata: M) -> NowPlayingInfo { + .init() + } + + func metadataItems(from metadata: M) -> [AVMetadataItem] { + [] + } +} diff --git a/Sources/Player/Types/MetadataAdapter.swift b/Sources/Player/Types/MetadataAdapter.swift new file mode 100644 index 000000000..27d084a5a --- /dev/null +++ b/Sources/Player/Types/MetadataAdapter.swift @@ -0,0 +1,38 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +/// An adapter which instantiates and manages metadata associated with a player. +/// +/// An adapter transforms metadata delivered by a player item into a metadata format suitable for the player. +public struct MetadataAdapter { + /// A special adapter which provides no metadata to the player. + public static var none: Self { + EmptyMetadata.adapter() + } + + private let map: (M) -> PlayerMetadata + + /// Creates an adapter for a type of metadata with the provided mapper. + /// + /// - Parameters: + /// - metadataType: The type of metadata to instantiate and manage. + /// - mapper: The metadata mapper. + public init(metadataType: T.Type, configuration: T.Configuration, mapper: ((M) -> T.Metadata)?) where T: PlayerItemMetadata { + map = { metadata in + guard let mapper else { return .empty } + let playerMetadata = metadataType.init(configuration: configuration) + let mappedMetadata = mapper(metadata) + return .init( + nowPlayingInfo: playerMetadata.nowPlayingInfo(from: mappedMetadata), + metadataItems: playerMetadata.metadataItems(from: mappedMetadata) + ) + } + } + + func metadata(from metadata: M) -> PlayerMetadata { + map(metadata) + } +} diff --git a/Sources/Player/Types/PlayerMetadata.swift b/Sources/Player/Types/PlayerMetadata.swift new file mode 100644 index 000000000..96a30295b --- /dev/null +++ b/Sources/Player/Types/PlayerMetadata.swift @@ -0,0 +1,19 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +import AVFoundation + +public struct PlayerMetadata: Equatable { + static let empty = Self(nowPlayingInfo: .init(), metadataItems: []) + + public let nowPlayingInfo: NowPlayingInfo + public let metadataItems: [AVMetadataItem] + + public static func == (lhs: Self, rhs: Self) -> Bool { + // swiftlint:disable:next legacy_objc_type + NSDictionary(dictionary: lhs.nowPlayingInfo).isEqual(to: rhs.nowPlayingInfo) && lhs.metadataItems == rhs.metadataItems + } +} diff --git a/Sources/Player/Types/QueueElement.swift b/Sources/Player/Types/QueueElement.swift index 5cacb4007..481e087c7 100644 --- a/Sources/Player/Types/QueueElement.swift +++ b/Sources/Player/Types/QueueElement.swift @@ -8,12 +8,12 @@ import AVFoundation struct QueueElement { let item: PlayerItem - let asset: any Assetable + let content: AssetContent - init(item: PlayerItem, asset: any Assetable) { - assert(item.id == asset.id) + init(item: PlayerItem, content: AssetContent) { + assert(item.id == content.id) self.item = item - self.asset = asset + self.content = content } func matches(_ playerItem: AVPlayerItem?) -> Bool { diff --git a/Sources/Player/Types/StandardMetadata.swift b/Sources/Player/Types/StandardMetadata.swift new file mode 100644 index 000000000..1db07aa53 --- /dev/null +++ b/Sources/Player/Types/StandardMetadata.swift @@ -0,0 +1,57 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +import AVFoundation +import MediaPlayer +import UIKit + +public struct StandardMetadata: PlayerItemMetadata { + public struct Metadata { + public let title: String? + public let subtitle: String? + public let description: String? + public let image: UIImage? + + public init(title: String? = nil, subtitle: String? = nil, description: String? = nil, image: UIImage? = nil) { + self.title = title + self.subtitle = subtitle + self.description = description + self.image = image + } + } + + public init(configuration: Void) {} + + public func nowPlayingInfo(from metadata: Metadata) -> NowPlayingInfo { + var nowPlayingInfo = NowPlayingInfo() + nowPlayingInfo[MPMediaItemPropertyTitle] = metadata.title + nowPlayingInfo[MPMediaItemPropertyArtist] = metadata.subtitle + nowPlayingInfo[MPMediaItemPropertyComments] = metadata.description + if let image = metadata.image { + nowPlayingInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size) { _ in image } + } + return nowPlayingInfo + } + + public func metadataItems(from metadata: Metadata) -> [AVMetadataItem] { + [ + metadataItem(for: .commonIdentifierTitle, value: metadata.title), + metadataItem(for: .iTunesMetadataTrackSubTitle, value: metadata.subtitle), + metadataItem(for: .commonIdentifierArtwork, value: metadata.image?.pngData()), + metadataItem(for: .commonIdentifierDescription, value: metadata.description) + ] + .compactMap { $0 } + } + + private func metadataItem(for identifier: AVMetadataIdentifier, value: T?) -> AVMetadataItem? { + guard let value else { return nil } + let item = AVMutableMetadataItem() + item.identifier = identifier + item.value = value as? NSCopying & NSObjectProtocol + item.extendedLanguageTag = "und" + return item.copy() as? AVMetadataItem + } +} diff --git a/Tests/AnalyticsTests/ComScore/ComScoreTrackerDvrPropertiesTests.swift b/Tests/AnalyticsTests/ComScore/ComScoreTrackerDvrPropertiesTests.swift index 478a8d69a..a583dd468 100644 --- a/Tests/AnalyticsTests/ComScore/ComScoreTrackerDvrPropertiesTests.swift +++ b/Tests/AnalyticsTests/ComScore/ComScoreTrackerDvrPropertiesTests.swift @@ -11,7 +11,7 @@ import Nimble import PillarboxPlayer import PillarboxStreams -private struct AssetMetadataMock: AssetMetadata {} +private struct AssetMetadataMock {} final class ComScoreTrackerDvrPropertiesTests: ComScoreTestCase { func testOnDemand() { diff --git a/Tests/AnalyticsTests/ComScore/ComScoreTrackerMetadataTests.swift b/Tests/AnalyticsTests/ComScore/ComScoreTrackerMetadataTests.swift index e37da81c0..7fec5c536 100644 --- a/Tests/AnalyticsTests/ComScore/ComScoreTrackerMetadataTests.swift +++ b/Tests/AnalyticsTests/ComScore/ComScoreTrackerMetadataTests.swift @@ -10,7 +10,7 @@ import Nimble import PillarboxPlayer import PillarboxStreams -private struct AssetMetadataMock: AssetMetadata {} +private struct AssetMetadataMock {} final class ComScoreTrackerMetadataTests: ComScoreTestCase { func testMetadata() { @@ -53,7 +53,9 @@ final class ComScoreTrackerMetadataTests: ComScoreTestCase { let player = Player(item: .simple( url: Stream.onDemand.url, trackerAdapters: [ - ComScoreTracker.adapter() + ComScoreTracker.adapter { _ in + [:] + } ] )) diff --git a/Tests/AnalyticsTests/ComScore/ComScoreTrackerPlaybackSpeedTests.swift b/Tests/AnalyticsTests/ComScore/ComScoreTrackerPlaybackSpeedTests.swift index 6d2fed25d..3aa5da1ab 100644 --- a/Tests/AnalyticsTests/ComScore/ComScoreTrackerPlaybackSpeedTests.swift +++ b/Tests/AnalyticsTests/ComScore/ComScoreTrackerPlaybackSpeedTests.swift @@ -10,7 +10,7 @@ import Nimble import PillarboxPlayer import PillarboxStreams -private struct AssetMetadataMock: AssetMetadata {} +private struct AssetMetadataMock {} final class ComScoreTrackerPlaybackSpeedTests: ComScoreTestCase { func testRateAtStart() { diff --git a/Tests/AnalyticsTests/ComScore/ComScoreTrackerRateTests.swift b/Tests/AnalyticsTests/ComScore/ComScoreTrackerRateTests.swift index d5b9aa48e..9d76a8d98 100644 --- a/Tests/AnalyticsTests/ComScore/ComScoreTrackerRateTests.swift +++ b/Tests/AnalyticsTests/ComScore/ComScoreTrackerRateTests.swift @@ -10,7 +10,7 @@ import Nimble import PillarboxPlayer import PillarboxStreams -private struct AssetMetadataMock: AssetMetadata {} +private struct AssetMetadataMock {} final class ComScoreTrackerRateTests: ComScoreTestCase { func testInitialRate() { diff --git a/Tests/AnalyticsTests/ComScore/ComScoreTrackerSeekTests.swift b/Tests/AnalyticsTests/ComScore/ComScoreTrackerSeekTests.swift index 136339006..d11913457 100644 --- a/Tests/AnalyticsTests/ComScore/ComScoreTrackerSeekTests.swift +++ b/Tests/AnalyticsTests/ComScore/ComScoreTrackerSeekTests.swift @@ -11,7 +11,7 @@ import Nimble import PillarboxPlayer import PillarboxStreams -private struct AssetMetadataMock: AssetMetadata {} +private struct AssetMetadataMock {} final class ComScoreTrackerSeekTests: ComScoreTestCase { func testSeekWhilePlaying() { diff --git a/Tests/AnalyticsTests/ComScore/ComScoreTrackerTests.swift b/Tests/AnalyticsTests/ComScore/ComScoreTrackerTests.swift index 9a25c09bd..263f8f040 100644 --- a/Tests/AnalyticsTests/ComScore/ComScoreTrackerTests.swift +++ b/Tests/AnalyticsTests/ComScore/ComScoreTrackerTests.swift @@ -11,7 +11,7 @@ import Nimble import PillarboxPlayer import PillarboxStreams -private struct AssetMetadataMock: AssetMetadata {} +private struct AssetMetadataMock {} // Testing comScore end events is a bit tricky: // 1. Apparently comScore will never emit events if a play event is followed by an end event within ~5 seconds. For diff --git a/Tests/AnalyticsTests/CommandersAct/CommandersActTrackerDvrPropertiesTests.swift b/Tests/AnalyticsTests/CommandersAct/CommandersActTrackerDvrPropertiesTests.swift index ec16f0aa2..9fb9970c8 100644 --- a/Tests/AnalyticsTests/CommandersAct/CommandersActTrackerDvrPropertiesTests.swift +++ b/Tests/AnalyticsTests/CommandersAct/CommandersActTrackerDvrPropertiesTests.swift @@ -12,7 +12,7 @@ import PillarboxCircumspect import PillarboxPlayer import PillarboxStreams -private struct AssetMetadataMock: AssetMetadata {} +private struct AssetMetadataMock {} final class CommandersActTrackerDvrPropertiesTests: CommandersActTestCase { func testOnDemand() { diff --git a/Tests/AnalyticsTests/CommandersAct/CommandersActTrackerMetadataTests.swift b/Tests/AnalyticsTests/CommandersAct/CommandersActTrackerMetadataTests.swift index dcba18f76..7496849d8 100644 --- a/Tests/AnalyticsTests/CommandersAct/CommandersActTrackerMetadataTests.swift +++ b/Tests/AnalyticsTests/CommandersAct/CommandersActTrackerMetadataTests.swift @@ -10,7 +10,7 @@ import Nimble import PillarboxPlayer import PillarboxStreams -private struct AssetMetadataMock: AssetMetadata {} +private struct AssetMetadataMock {} final class CommandersActTrackerMetadataTests: CommandersActTestCase { func testWhenInitialized() { diff --git a/Tests/AnalyticsTests/CommandersAct/CommandersActTrackerPositionTests.swift b/Tests/AnalyticsTests/CommandersAct/CommandersActTrackerPositionTests.swift index 7b62aaf27..be69bf16f 100644 --- a/Tests/AnalyticsTests/CommandersAct/CommandersActTrackerPositionTests.swift +++ b/Tests/AnalyticsTests/CommandersAct/CommandersActTrackerPositionTests.swift @@ -12,7 +12,7 @@ import PillarboxCircumspect import PillarboxPlayer import PillarboxStreams -private struct AssetMetadataMock: AssetMetadata {} +private struct AssetMetadataMock {} final class CommandersActTrackerPositionTests: CommandersActTestCase { func testLivePlayback() { diff --git a/Tests/AnalyticsTests/CommandersAct/CommandersActTrackerSeekTests.swift b/Tests/AnalyticsTests/CommandersAct/CommandersActTrackerSeekTests.swift index 32fed29ed..87ef8afb4 100644 --- a/Tests/AnalyticsTests/CommandersAct/CommandersActTrackerSeekTests.swift +++ b/Tests/AnalyticsTests/CommandersAct/CommandersActTrackerSeekTests.swift @@ -10,7 +10,7 @@ import Nimble import PillarboxPlayer import PillarboxStreams -private struct AssetMetadataMock: AssetMetadata {} +private struct AssetMetadataMock {} final class CommandersActTrackerSeekTests: CommandersActTestCase { func testSeekWhilePlaying() { diff --git a/Tests/AnalyticsTests/CommandersAct/CommandersActTrackerTests.swift b/Tests/AnalyticsTests/CommandersAct/CommandersActTrackerTests.swift index d6ed3e274..e3f932196 100644 --- a/Tests/AnalyticsTests/CommandersAct/CommandersActTrackerTests.swift +++ b/Tests/AnalyticsTests/CommandersAct/CommandersActTrackerTests.swift @@ -10,7 +10,7 @@ import Nimble import PillarboxPlayer import PillarboxStreams -private struct AssetMetadataMock: AssetMetadata {} +private struct AssetMetadataMock {} final class CommandersActTrackerTests: CommandersActTestCase { func testInitiallyPlaying() { @@ -35,7 +35,9 @@ final class CommandersActTrackerTests: CommandersActTestCase { let player = Player(item: .simple( url: Stream.onDemand.url, trackerAdapters: [ - CommandersActTracker.adapter() + CommandersActTracker.adapter { _ in + [:] + } ] )) @@ -131,7 +133,9 @@ final class CommandersActTrackerTests: CommandersActTestCase { var player: Player? = Player(item: .simple( url: Stream.shortOnDemand.url, trackerAdapters: [ - CommandersActTracker.adapter() + CommandersActTracker.adapter { _ in + [:] + } ] )) @@ -148,7 +152,9 @@ final class CommandersActTrackerTests: CommandersActTestCase { let player = Player(item: .simple( url: Stream.unavailable.url, trackerAdapters: [ - CommandersActTracker.adapter() + CommandersActTracker.adapter { _ in + [:] + } ] )) @@ -161,7 +167,9 @@ final class CommandersActTrackerTests: CommandersActTestCase { let player = Player(item: .simple( url: Stream.onDemand.url, trackerAdapters: [ - CommandersActTracker.adapter() + CommandersActTracker.adapter { _ in + [:] + } ] )) @@ -177,7 +185,9 @@ final class CommandersActTrackerTests: CommandersActTestCase { let player = Player(item: .simple( url: Stream.onDemand.url, trackerAdapters: [ - CommandersActTracker.adapter() + CommandersActTracker.adapter { _ in + [:] + } ] )) diff --git a/Tests/CoreBusinessTests/PlayerItemTests.swift b/Tests/CoreBusinessTests/PlayerItemTests.swift index c8704eb5b..43bca7686 100644 --- a/Tests/CoreBusinessTests/PlayerItemTests.swift +++ b/Tests/CoreBusinessTests/PlayerItemTests.swift @@ -60,7 +60,7 @@ final class PlayerItemTests: XCTestCase { func testLoadNotLooping() { let item = PlayerItem.urn("urn:swisstxt:video:rts:1793518") _ = Player(item: item) - let output = collectOutput(from: item.$asset, during: .seconds(1)) + let output = collectOutput(from: item.$content, during: .seconds(1)) expect(output.count).to(equal(2)) } } diff --git a/Tests/CoreTests/DispatchPublisherTests.swift b/Tests/CoreTests/DispatchPublisherTests.swift index 2967bd73e..0cfd45a69 100644 --- a/Tests/CoreTests/DispatchPublisherTests.swift +++ b/Tests/CoreTests/DispatchPublisherTests.swift @@ -8,6 +8,7 @@ import Combine import Nimble +import PillarboxCircumspect import XCTest final class DispatchPublisherTests: XCTestCase { @@ -62,4 +63,31 @@ final class DispatchPublisherTests: XCTestCase { .store(in: &cancellables) expect(value).to(equal(0)) } + + func testReceiveOnMainThreadReceivesAllOutputFromMainThread() { + let publisher = [1, 2, 3].publisher + .receiveOnMainThread() + expectOnlyEqualPublished(values: [1, 2, 3], from: publisher) + } + + func testReceiveOnMainThreadReceivesAllOutputFromBackgroundThreads() { + let publisher = [1, 2, 3].publisher + .receive(on: DispatchQueue(label: "com.srgssr.pillarbox-tests")) + .receiveOnMainThread() + expectOnlyEqualPublished(values: [1, 2, 3], from: publisher) + } + + func testDelayIfNeededOutputOrderingWithNonZeroDelay() { + let delayedPublisher = [1, 2, 3].publisher + .delayIfNeeded(for: 0.1, scheduler: DispatchQueue.main) + let subject = CurrentValueSubject(0) + expectEqualPublished(values: [0, 1, 2, 3], from: Publishers.Merge(delayedPublisher, subject), during: .milliseconds(100)) + } + + func testDelayIfNeededOutputOrderingWithZeroDelay() { + let delayedPublisher = [1, 2, 3].publisher + .delayIfNeeded(for: 0, scheduler: DispatchQueue.main) + let subject = CurrentValueSubject(0) + expectEqualPublished(values: [1, 2, 3, 0], from: Publishers.Merge(delayedPublisher, subject), during: .milliseconds(100)) + } } diff --git a/Tests/PlayerTests/AVPlayer/AVPlayerItemAssetContentUpdateTests.swift b/Tests/PlayerTests/AVPlayer/AVPlayerItemAssetContentUpdateTests.swift new file mode 100644 index 000000000..11843cc8e --- /dev/null +++ b/Tests/PlayerTests/AVPlayer/AVPlayerItemAssetContentUpdateTests.swift @@ -0,0 +1,186 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +@testable import PillarboxPlayer + +import AVFoundation +import Nimble +import PillarboxStreams + +final class AVPlayerItemAssetContentUpdateTests: TestCase { + func testPlayerItemsWithoutCurrentItem() { + let previousContents: [AssetContent] = [ + .test(id: "1"), + .test(id: "2"), + .test(id: "3"), + .test(id: "4"), + .test(id: "5") + ] + let currentContents: [AssetContent] = [ + .test(id: "A"), + .test(id: "B"), + .test(id: "C") + ] + let result = AVPlayerItem.playerItems(for: currentContents, replacing: previousContents, currentItem: nil, length: currentContents.count) + expect(result.count).to(equal(currentContents.count)) + expect(zip(result, currentContents)).to(allPass { item, content in + content.id == item.id + }) + } + + func testPlayerItemsWithPreservedCurrentItem() { + let currentItemContent = AssetContent.test(id: "3") + let previousContents: [AssetContent] = [ + .test(id: "1"), + .test(id: "2"), + currentItemContent, + .test(id: "4"), + .test(id: "5") + ] + let currentContents = [ + .test(id: "A"), + currentItemContent, + .test(id: "B"), + .test(id: "C") + ] + let currentItem = currentItemContent.playerItem() + let result = AVPlayerItem.playerItems(for: currentContents, replacing: previousContents, currentItem: currentItem, length: currentContents.count) + let expected = [ + currentItemContent, + .test(id: "B"), + .test(id: "C") + ] + expect(result.count).to(equal(expected.count)) + expect(zip(result, expected)).to(allPass { item, content in + content.id == item.id + }) + expect(result.first).to(equal(currentItem)) + } + + func testPlayerItemsWithPreservedCurrentItemAtEnd() { + let currentItemContent = AssetContent.test(id: "3") + let previousContents = [ + .test(id: "1"), + .test(id: "2"), + currentItemContent, + .test(id: "4"), + .test(id: "5") + ] + let currentContents = [ + .test(id: "A"), + .test(id: "B"), + .test(id: "C"), + currentItemContent + ] + let currentItem = currentItemContent.playerItem() + let result = AVPlayerItem.playerItems(for: currentContents, replacing: previousContents, currentItem: currentItem, length: currentContents.count) + let expected = [ + currentItemContent + ] + expect(result.count).to(equal(expected.count)) + expect(zip(result, expected)).to(allPass { item, content in + content.id == item.id + }) + expect(result.first).to(equal(currentItem)) + } + + func testPlayerItemsWithUnknownCurrentItem() { + let previousContents: [AssetContent] = [ + .test(id: "1"), + .test(id: "2") + ] + let currentContents: [AssetContent] = [ + .test(id: "A"), + .test(id: "B") + ] + let unknownItem = AssetContent.test(id: "1").playerItem() + let result = AVPlayerItem.playerItems(for: currentContents, replacing: previousContents, currentItem: unknownItem, length: currentContents.count) + expect(result.count).to(equal(currentContents.count)) + expect(zip(result, currentContents)).to(allPass { item, content in + content.id == item.id + }) + } + + func testPlayerItemsWithCurrentItemReplacedByAnotherItem() { + let currentItemContent = AssetContent.test(id: "1") + let otherContent = AssetContent.test(id: "2") + let previousContents = [ + currentItemContent, + otherContent, + .test(id: "3") + ] + let currentContents = [ + .test(id: "3"), + otherContent, + .test(id: "C") + ] + let currentItem = currentItemContent.playerItem() + let result = AVPlayerItem.playerItems(for: currentContents, replacing: previousContents, currentItem: currentItem, length: currentContents.count) + let expected = [ + otherContent, + .test(id: "C") + ] + expect(result.count).to(equal(expected.count)) + expect(zip(result, expected)).to(allPass { item, content in + content.id == item.id + }) + } + + func testPlayerItemsWithUpdatedCurrentItem() { + let currentItemContent = AssetContent.test(id: "1") + let previousContents: [AssetContent] = [ + .test(id: "1"), + .test(id: "2"), + .test(id: "3") + ] + let currentContents = [ + currentItemContent, + .test(id: "2"), + .test(id: "3") + ] + let currentItem = currentItemContent.playerItem() + let result = AVPlayerItem.playerItems(for: currentContents, replacing: previousContents, currentItem: currentItem, length: currentContents.count) + expect(result.count).to(equal(currentContents.count)) + expect(zip(result, currentContents)).to(allPass { item, content in + content.id == item.id + }) + expect(result.first).to(equal(currentItem)) + } + + func testPlayerItemsLength() { + let previousContents: [AssetContent] = [ + .test(id: "1"), + .test(id: "2"), + .test(id: "3") + ] + let currentContents: [AssetContent] = [ + .test(id: "A"), + .test(id: "B") + ] + let result = AVPlayerItem.playerItems(for: currentContents, replacing: previousContents, currentItem: nil, length: 2) + expect(result.count).to(equal(2)) + } +} + +private extension AssetContent { + static func test(id: Character) -> Self { + AssetContent(id: UUID(id), resource: .simple(url: Stream.onDemand.url), metadata: .empty) { _ in } + } +} + +private extension UUID { + init(_ char: Character) { + self.init( + uuidString: """ + \(String(repeating: char, count: 8))\ + -\(String(repeating: char, count: 4))\ + -\(String(repeating: char, count: 4))\ + -\(String(repeating: char, count: 4))\ + -\(String(repeating: char, count: 12)) + """ + )! + } +} diff --git a/Tests/PlayerTests/Asset/AssetCreationTests.swift b/Tests/PlayerTests/Asset/AssetCreationTests.swift index e119cc130..6e6def6bd 100644 --- a/Tests/PlayerTests/Asset/AssetCreationTests.swift +++ b/Tests/PlayerTests/Asset/AssetCreationTests.swift @@ -11,124 +11,19 @@ import PillarboxStreams final class AssetCreationTests: TestCase { func testSimpleAsset() { - let asset = Asset.simple( - url: Stream.onDemand.url, - metadata: AssetMetadataMock(title: "title") - ) { item in - item.preferredForwardBufferDuration = 4 - } - expect(asset.resource).to(equal(.simple(url: Stream.onDemand.url))) - expect(asset.nowPlayingInfo()).notTo(beEmpty()) - expect(asset.playerItem().preferredForwardBufferDuration).to(equal(4)) - } - - func testSimpleAssetWithoutConfiguration() { - let asset = Asset.simple(url: Stream.onDemand.url, metadata: AssetMetadataMock(title: "title")) - expect(asset.resource).to(equal(.simple(url: Stream.onDemand.url))) - expect(asset.nowPlayingInfo()).notTo(beEmpty()) - expect(asset.playerItem().preferredForwardBufferDuration).to(equal(0)) - } - - func testSimpleAssetWithoutMetadata() { - let asset = Asset.simple(url: Stream.onDemand.url) { item in - item.preferredForwardBufferDuration = 4 - } - expect(asset.resource).to(equal(.simple(url: Stream.onDemand.url))) - expect(asset.nowPlayingInfo()).notTo(beEmpty()) - expect(asset.playerItem().preferredForwardBufferDuration).to(equal(4)) - } - - func testSimpleAssetWithoutMetadataAndConfiguration() { let asset = Asset.simple(url: Stream.onDemand.url) expect(asset.resource).to(equal(.simple(url: Stream.onDemand.url))) - expect(asset.nowPlayingInfo()).notTo(beEmpty()) - expect(asset.playerItem().preferredForwardBufferDuration).to(equal(0)) } func testCustomAsset() { - let delegate = ResourceLoaderDelegateMock() - let asset = Asset.custom( - url: Stream.onDemand.url, - delegate: delegate, - metadata: AssetMetadataMock(title: "title") - ) { item in - item.preferredForwardBufferDuration = 4 - } - expect(asset.resource).to(equal(.custom(url: Stream.onDemand.url, delegate: delegate))) - expect(asset.nowPlayingInfo()).notTo(beEmpty()) - expect(asset.playerItem().preferredForwardBufferDuration).to(equal(4)) - expect(asset.playerItem().externalMetadata).notTo(beEmpty()) - } - - func testCustomAssetWithoutConfiguration() { - let delegate = ResourceLoaderDelegateMock() - let asset = Asset.custom(url: Stream.onDemand.url, delegate: delegate, metadata: AssetMetadataMock(title: "title")) - expect(asset.resource).to(equal(.custom(url: Stream.onDemand.url, delegate: delegate))) - expect(asset.nowPlayingInfo()).notTo(beEmpty()) - expect(asset.playerItem().preferredForwardBufferDuration).to(equal(0)) - } - - func testCustomAssetWithoutMetadata() { - let delegate = ResourceLoaderDelegateMock() - let asset = Asset.custom( - url: Stream.onDemand.url, - delegate: delegate - ) { item in - item.preferredForwardBufferDuration = 4 - } - expect(asset.resource).to(equal(.custom(url: Stream.onDemand.url, delegate: delegate))) - expect(asset.nowPlayingInfo()).notTo(beEmpty()) - expect(asset.playerItem().preferredForwardBufferDuration).to(equal(4)) - } - - func testCustomAssetWithoutMetadataAndConfiguration() { let delegate = ResourceLoaderDelegateMock() let asset = Asset.custom(url: Stream.onDemand.url, delegate: delegate) expect(asset.resource).to(equal(.custom(url: Stream.onDemand.url, delegate: delegate))) - expect(asset.nowPlayingInfo()).notTo(beEmpty()) - expect(asset.playerItem().preferredForwardBufferDuration).to(equal(0)) } func testEncryptedAsset() { - let delegate = ContentKeySessionDelegateMock() - let asset = Asset.encrypted( - url: Stream.onDemand.url, - delegate: delegate, - metadata: AssetMetadataMock(title: "title") - ) { item in - item.preferredForwardBufferDuration = 4 - } - expect(asset.resource).to(equal(.encrypted(url: Stream.onDemand.url, delegate: delegate))) - expect(asset.nowPlayingInfo()).notTo(beEmpty()) - expect(asset.playerItem().preferredForwardBufferDuration).to(equal(4)) - } - - func testEncryptedAssetWithoutConfiguration() { - let delegate = ContentKeySessionDelegateMock() - let asset = Asset.encrypted(url: Stream.onDemand.url, delegate: delegate, metadata: AssetMetadataMock(title: "title")) - expect(asset.resource).to(equal(.encrypted(url: Stream.onDemand.url, delegate: delegate))) - expect(asset.nowPlayingInfo()).notTo(beEmpty()) - expect(asset.playerItem().preferredForwardBufferDuration).to(equal(0)) - } - - func testEncryptedAssetWithoutMetadata() { - let delegate = ContentKeySessionDelegateMock() - let asset = Asset.encrypted( - url: Stream.onDemand.url, - delegate: delegate - ) { item in - item.preferredForwardBufferDuration = 4 - } - expect(asset.resource).to(equal(.encrypted(url: Stream.onDemand.url, delegate: delegate))) - expect(asset.nowPlayingInfo()).notTo(beEmpty()) - expect(asset.playerItem().preferredForwardBufferDuration).to(equal(4)) - } - - func testEncryptedAssetWithoutMetadataAndConfiguration() { let delegate = ContentKeySessionDelegateMock() let asset = Asset.encrypted(url: Stream.onDemand.url, delegate: delegate) expect(asset.resource).to(equal(.encrypted(url: Stream.onDemand.url, delegate: delegate))) - expect(asset.nowPlayingInfo()).notTo(beEmpty()) - expect(asset.playerItem().preferredForwardBufferDuration).to(equal(0)) } } diff --git a/Tests/PlayerTests/Asset/AssetMetadataMock.swift b/Tests/PlayerTests/Asset/AssetMetadataMock.swift index 6e8c378f1..f8fc3a099 100644 --- a/Tests/PlayerTests/Asset/AssetMetadataMock.swift +++ b/Tests/PlayerTests/Asset/AssetMetadataMock.swift @@ -4,8 +4,6 @@ // License information is available from the LICENSE file. // -import PillarboxPlayer - struct AssetMetadataMock: Decodable { let title: String let subtitle: String? @@ -17,9 +15,3 @@ struct AssetMetadataMock: Decodable { self.description = description } } - -extension AssetMetadataMock: AssetMetadata { - func nowPlayingMetadata() -> NowPlayingMetadata { - .init(title: title, subtitle: subtitle, description: description) - } -} diff --git a/Tests/PlayerTests/Asset/AssetableTests.swift b/Tests/PlayerTests/Asset/AssetableTests.swift deleted file mode 100644 index 24072e3e7..000000000 --- a/Tests/PlayerTests/Asset/AssetableTests.swift +++ /dev/null @@ -1,166 +0,0 @@ -// -// Copyright (c) SRG SSR. All rights reserved. -// -// License information is available from the LICENSE file. -// - -@testable import PillarboxPlayer - -import AVFoundation -import Nimble -import PillarboxStreams - -final class AssetableTests: TestCase { - func testPlayerItemsWithoutCurrentItem() { - let previousAssets: [EmptyAsset] = [ - .loading.withId(UUID("1")), - .loading.withId(UUID("2")), - .loading.withId(UUID("3")), - .loading.withId(UUID("4")), - .loading.withId(UUID("5")) - ] - let currentAssets: [EmptyAsset] = [ - .loading.withId(UUID("A")), - .loading.withId(UUID("B")), - .loading.withId(UUID("C")) - ] - let result = AVPlayerItem.playerItems(for: currentAssets, replacing: previousAssets, currentItem: nil, length: currentAssets.count) - expect(result.count).to(equal(currentAssets.count)) - expect(zip(result, currentAssets)).to(allPass { item, asset in - asset.matches(item) - }) - } - - func testPlayerItemsWithPreservedCurrentItem() { - let currentItemAsset = EmptyAsset.loading.withId(UUID("3")) - let previousAssets = [ - .loading.withId(UUID("1")), - .loading.withId(UUID("2")), - currentItemAsset, - .loading.withId(UUID("4")), - .loading.withId(UUID("5")) - ] - let currentAssets = [ - .loading.withId(UUID("A")), - currentItemAsset, - .loading.withId(UUID("B")), - .loading.withId(UUID("C")) - ] - let currentItem = currentItemAsset.playerItem() - let result = AVPlayerItem.playerItems(for: currentAssets, replacing: previousAssets, currentItem: currentItem, length: currentAssets.count) - let expected = [ - currentItemAsset, - .loading.withId(UUID("B")), - .loading.withId(UUID("C")) - ] - expect(result.count).to(equal(expected.count)) - expect(zip(result, expected)).to(allPass { item, asset in - asset.matches(item) - }) - expect(result.first).to(equal(currentItem)) - } - - func testPlayerItemsWithPreservedCurrentItemAtEnd() { - let currentItemAsset = EmptyAsset.loading.withId(UUID("3")) - let previousAssets = [ - .loading.withId(UUID("1")), - .loading.withId(UUID("2")), - currentItemAsset, - .loading.withId(UUID("4")), - .loading.withId(UUID("5")) - ] - let currentAssets = [ - .loading.withId(UUID("A")), - .loading.withId(UUID("B")), - .loading.withId(UUID("C")), - currentItemAsset - ] - let currentItem = currentItemAsset.playerItem() - let result = AVPlayerItem.playerItems(for: currentAssets, replacing: previousAssets, currentItem: currentItem, length: currentAssets.count) - let expected = [ - currentItemAsset - ] - expect(result.count).to(equal(expected.count)) - expect(zip(result, expected)).to(allPass { item, asset in - asset.matches(item) - }) - expect(result.first).to(equal(currentItem)) - } - - func testPlayerItemsWithUnknownCurrentItem() { - let previousAssets: [EmptyAsset] = [ - .loading.withId(UUID("1")), - .loading.withId(UUID("2")) - ] - let currentAssets: [EmptyAsset] = [ - .loading.withId(UUID("A")), - .loading.withId(UUID("B")) - ] - let unknownItem = EmptyAsset.loading.withId(UUID("1")).playerItem() - let result = AVPlayerItem.playerItems(for: currentAssets, replacing: previousAssets, currentItem: unknownItem, length: currentAssets.count) - expect(result.count).to(equal(currentAssets.count)) - expect(zip(result, currentAssets)).to(allPass { item, asset in - asset.matches(item) - }) - } - - func testPlayerItemsWithCurrentItemReplacedByAnotherItem() { - let currentItemAsset = EmptyAsset.loading.withId(UUID("1")) - let otherAsset = EmptyAsset.loading.withId(UUID("2")) - let previousAssets = [ - currentItemAsset, - otherAsset, - .loading.withId(UUID("3")) - ] - let currentAssets = [ - .loading.withId(UUID("3")), - otherAsset, - .loading.withId(UUID("C")) - ] - let currentItem = currentItemAsset.playerItem() - let result = AVPlayerItem.playerItems(for: currentAssets, replacing: previousAssets, currentItem: currentItem, length: currentAssets.count) - let expected = [ - otherAsset, - .loading.withId(UUID("C")) - ] - expect(result.count).to(equal(expected.count)) - expect(zip(result, expected)).to(allPass { item, asset in - asset.matches(item) - }) - } - - func testPlayerItemsWithUpdatedCurrentItem() { - let currentItemAsset = EmptyAsset.simple(url: Stream.onDemand.url).withId(UUID("1")) - let previousAssets: [EmptyAsset] = [ - .loading.withId(UUID("1")), - .loading.withId(UUID("2")), - .loading.withId(UUID("3")) - ] - let currentAssets = [ - currentItemAsset, - .loading.withId(UUID("2")), - .loading.withId(UUID("3")) - ] - let currentItem = currentItemAsset.playerItem() - let result = AVPlayerItem.playerItems(for: currentAssets, replacing: previousAssets, currentItem: currentItem, length: currentAssets.count) - expect(result.count).to(equal(currentAssets.count)) - expect(zip(result, currentAssets)).to(allPass { item, asset in - asset.matches(item) - }) - expect(result.first).notTo(equal(currentItem)) - } - - func testPlayerItemsLength() { - let previousAssets: [EmptyAsset] = [ - .loading.withId(UUID("1")), - .loading.withId(UUID("2")), - .loading.withId(UUID("3")) - ] - let currentAssets: [EmptyAsset] = [ - .loading.withId(UUID("A")), - .loading.withId(UUID("B")) - ] - let result = AVPlayerItem.playerItems(for: currentAssets, replacing: previousAssets, currentItem: nil, length: 2) - expect(result.count).to(equal(2)) - } -} diff --git a/Tests/PlayerTests/Asset/AssetPlayerItemTests.swift b/Tests/PlayerTests/Asset/ResourceItemTests.swift similarity index 79% rename from Tests/PlayerTests/Asset/AssetPlayerItemTests.swift rename to Tests/PlayerTests/Asset/ResourceItemTests.swift index 53ab37519..263ba5196 100644 --- a/Tests/PlayerTests/Asset/AssetPlayerItemTests.swift +++ b/Tests/PlayerTests/Asset/ResourceItemTests.swift @@ -10,9 +10,9 @@ import AVFoundation import PillarboxCircumspect import PillarboxStreams -final class AssetPlayerItemTests: TestCase { +final class ResourceItemTests: TestCase { func testNativePlayerItem() { - let item = Asset.simple(url: Stream.onDemand.url).playerItem() + let item = Resource.simple(url: Stream.onDemand.url).playerItem() _ = AVPlayer(playerItem: item) expectAtLeastEqualPublished( values: [false, true], @@ -21,7 +21,7 @@ final class AssetPlayerItemTests: TestCase { } func testLoadingPlayerItem() { - let item = EmptyAsset.loading.playerItem() + let item = Resource.loading.playerItem() _ = AVPlayer(playerItem: item) expectAtLeastEqualPublished( values: [false], @@ -30,7 +30,7 @@ final class AssetPlayerItemTests: TestCase { } func testFailingPlayerItem() { - let item = EmptyAsset.failed(error: StructError()).playerItem() + let item = Resource.failing(error: StructError()).playerItem() _ = AVPlayer(playerItem: item) expectEqualPublished( values: [.unknown], diff --git a/Tests/PlayerTests/Player/PlayerTests.swift b/Tests/PlayerTests/Player/PlayerTests.swift index 066a427d5..f11250ae4 100644 --- a/Tests/PlayerTests/Player/PlayerTests.swift +++ b/Tests/PlayerTests/Player/PlayerTests.swift @@ -74,7 +74,7 @@ final class PlayerTests: TestCase { .simple(url: Stream.onDemand.url), .loading ] - expect(player.items.map(\.asset.resource)).toEventually(beSimilarTo(expectedResources)) - expect(player.items.map(\.asset.resource)).toAlways(beSimilarTo(expectedResources), until: .seconds(1)) + expect(player.items.map(\.content.resource)).toEventually(beSimilarTo(expectedResources)) + expect(player.items.map(\.content.resource)).toAlways(beSimilarTo(expectedResources), until: .seconds(1)) } } diff --git a/Tests/PlayerTests/PlayerItem/PlayerItemAssetPublisherTests.swift b/Tests/PlayerTests/PlayerItem/PlayerItemAssetPublisherTests.swift index fb3ea2eef..f56e3c92e 100644 --- a/Tests/PlayerTests/PlayerItem/PlayerItemAssetPublisherTests.swift +++ b/Tests/PlayerTests/PlayerItem/PlayerItemAssetPublisherTests.swift @@ -14,7 +14,7 @@ final class PlayerItemAssetPublisherTests: TestCase { let item = PlayerItem.simple(url: Stream.onDemand.url) expectSimilarPublished( values: [.loading], - from: item.$asset.map(\.resource), + from: item.$content.map(\.resource), during: .milliseconds(500) ) } @@ -23,18 +23,7 @@ final class PlayerItemAssetPublisherTests: TestCase { let item = PlayerItem.simple(url: Stream.onDemand.url) expectSimilarPublished( values: [.loading, .simple(url: Stream.onDemand.url)], - from: item.$asset.map(\.resource), - during: .milliseconds(500) - ) { - PlayerItem.load(for: item.id) - } - } - - func testFailure() { - let item = PlayerItem.failed() - expectSimilarPublished( - values: [.loading, .failing(error: MockError.mock)], - from: item.$asset.map(\.resource), + from: item.$content.map(\.resource), during: .milliseconds(500) ) { PlayerItem.load(for: item.id) @@ -45,7 +34,7 @@ final class PlayerItemAssetPublisherTests: TestCase { let item = PlayerItem.simple(url: Stream.onDemand.url) expectSimilarPublished( values: [.loading, .simple(url: Stream.onDemand.url)], - from: item.$asset.map(\.resource), + from: item.$content.map(\.resource), during: .milliseconds(500) ) { PlayerItem.load(for: item.id) @@ -53,7 +42,7 @@ final class PlayerItemAssetPublisherTests: TestCase { expectSimilarPublishedNext( values: [.simple(url: Stream.onDemand.url)], - from: item.$asset.map(\.resource), + from: item.$content.map(\.resource), during: .milliseconds(500) ) { PlayerItem.reload(for: item.id) diff --git a/Tests/PlayerTests/PlayerItem/PlayerItemTests.swift b/Tests/PlayerTests/PlayerItem/PlayerItemTests.swift index fc0e7c21b..26648f56b 100644 --- a/Tests/PlayerTests/PlayerItem/PlayerItemTests.swift +++ b/Tests/PlayerTests/PlayerItem/PlayerItemTests.swift @@ -11,137 +11,54 @@ import PillarboxStreams final class PlayerItemTests: TestCase { func testSimpleItem() { - let item = PlayerItem.simple( - url: Stream.onDemand.url, - metadata: AssetMetadataMock(title: "title") - ) { item in + let item = PlayerItem.simple(url: Stream.onDemand.url) { item in item.preferredForwardBufferDuration = 4 } PlayerItem.load(for: item.id) - expect(item.asset.resource).toEventually(equal(.simple(url: Stream.onDemand.url))) - expect(item.asset.nowPlayingInfo()).notTo(beEmpty()) - expect(item.asset.playerItem().preferredForwardBufferDuration).to(equal(4)) + expect(item.content.resource).toEventually(equal(.simple(url: Stream.onDemand.url))) + expect(item.content.playerItem().preferredForwardBufferDuration).to(equal(4)) } func testSimpleItemWithoutConfiguration() { - let item = PlayerItem.simple(url: Stream.onDemand.url, metadata: AssetMetadataMock(title: "title")) - PlayerItem.load(for: item.id) - expect(item.asset.resource).toEventually(equal(.simple(url: Stream.onDemand.url))) - expect(item.asset.nowPlayingInfo()).notTo(beEmpty()) - expect(item.asset.playerItem().preferredForwardBufferDuration).to(equal(0)) - } - - func testSimpleItemWithoutMetadata() { - let item = PlayerItem.simple( - url: Stream.onDemand.url - ) { item in - item.preferredForwardBufferDuration = 4 - } - PlayerItem.load(for: item.id) - expect(item.asset.resource).toEventually(equal(.simple(url: Stream.onDemand.url))) - expect(item.asset.nowPlayingInfo()).notTo(beEmpty()) - expect(item.asset.playerItem().preferredForwardBufferDuration).to(equal(4)) - } - - func testSimpleAssetWithoutMetadataAndConfiguration() { let item = PlayerItem.simple(url: Stream.onDemand.url) PlayerItem.load(for: item.id) - expect(item.asset.resource).toEventually(equal(.simple(url: Stream.onDemand.url))) - expect(item.asset.nowPlayingInfo()).notTo(beEmpty()) - expect(item.asset.playerItem().preferredForwardBufferDuration).to(equal(0)) - } - - func testCustomAsset() { - let delegate = ResourceLoaderDelegateMock() - let item = PlayerItem.custom( - url: Stream.onDemand.url, - delegate: delegate, - metadata: AssetMetadataMock(title: "title") - ) { item in - item.preferredForwardBufferDuration = 4 - } - PlayerItem.load(for: item.id) - expect(item.asset.resource).toEventually(equal(.custom(url: Stream.onDemand.url, delegate: delegate))) - expect(item.asset.nowPlayingInfo()).notTo(beEmpty()) - expect(item.asset.playerItem().preferredForwardBufferDuration).to(equal(4)) + expect(item.content.resource).toEventually(equal(.simple(url: Stream.onDemand.url))) + expect(item.content.playerItem().preferredForwardBufferDuration).to(equal(0)) } - func testCustomAssetWithoutConfiguration() { + func testCustomItem() { let delegate = ResourceLoaderDelegateMock() - let item = PlayerItem.custom(url: Stream.onDemand.url, delegate: delegate, metadata: AssetMetadataMock(title: "title")) - PlayerItem.load(for: item.id) - expect(item.asset.resource).toEventually(equal(.custom(url: Stream.onDemand.url, delegate: delegate))) - expect(item.asset.nowPlayingInfo()).notTo(beEmpty()) - expect(item.asset.playerItem().preferredForwardBufferDuration).to(equal(0)) - } - - func testCustomAssetWithoutMetadata() { - let delegate = ResourceLoaderDelegateMock() - let item = PlayerItem.custom( - url: Stream.onDemand.url, - delegate: delegate - ) { item in + let item = PlayerItem.custom(url: Stream.onDemand.url, delegate: delegate) { item in item.preferredForwardBufferDuration = 4 } PlayerItem.load(for: item.id) - expect(item.asset.resource).toEventually(equal(.custom(url: Stream.onDemand.url, delegate: delegate))) - expect(item.asset.nowPlayingInfo()).notTo(beEmpty()) - expect(item.asset.playerItem().preferredForwardBufferDuration).to(equal(4)) + expect(item.content.resource).toEventually(equal(.custom(url: Stream.onDemand.url, delegate: delegate))) + expect(item.content.playerItem().preferredForwardBufferDuration).to(equal(4)) } - func testCustomAssetWithoutMetadataAndConfiguration() { + func testCustomItemWithoutConfiguration() { let delegate = ResourceLoaderDelegateMock() let item = PlayerItem.custom(url: Stream.onDemand.url, delegate: delegate) PlayerItem.load(for: item.id) - expect(item.asset.resource).toEventually(equal(.custom(url: Stream.onDemand.url, delegate: delegate))) - expect(item.asset.nowPlayingInfo()).notTo(beEmpty()) - expect(item.asset.playerItem().preferredForwardBufferDuration).to(equal(0)) - } - - func testEncryptedAsset() { - let delegate = ContentKeySessionDelegateMock() - let item = PlayerItem.encrypted( - url: Stream.onDemand.url, - delegate: delegate, - metadata: AssetMetadataMock(title: "title") - ) { item in - item.preferredForwardBufferDuration = 4 - } - PlayerItem.load(for: item.id) - expect(item.asset.resource).toEventually(equal(.encrypted(url: Stream.onDemand.url, delegate: delegate))) - expect(item.asset.nowPlayingInfo()).notTo(beEmpty()) - expect(item.asset.playerItem().preferredForwardBufferDuration).to(equal(4)) - } - - func testEncryptedAssetWithoutConfiguration() { - let delegate = ContentKeySessionDelegateMock() - let item = PlayerItem.encrypted(url: Stream.onDemand.url, delegate: delegate, metadata: AssetMetadataMock(title: "title")) - PlayerItem.load(for: item.id) - expect(item.asset.resource).toEventually(equal(.encrypted(url: Stream.onDemand.url, delegate: delegate))) - expect(item.asset.nowPlayingInfo()).notTo(beEmpty()) - expect(item.asset.playerItem().preferredForwardBufferDuration).to(equal(0)) + expect(item.content.resource).toEventually(equal(.custom(url: Stream.onDemand.url, delegate: delegate))) + expect(item.content.playerItem().preferredForwardBufferDuration).to(equal(0)) } - func testEncryptedAssetWithoutMetadata() { + func testEncryptedItem() { let delegate = ContentKeySessionDelegateMock() - let item = PlayerItem.encrypted( - url: Stream.onDemand.url, - delegate: delegate - ) { item in + let item = PlayerItem.encrypted(url: Stream.onDemand.url, delegate: delegate) { item in item.preferredForwardBufferDuration = 4 } PlayerItem.load(for: item.id) - expect(item.asset.resource).toEventually(equal(.encrypted(url: Stream.onDemand.url, delegate: delegate))) - expect(item.asset.nowPlayingInfo()).notTo(beEmpty()) - expect(item.asset.playerItem().preferredForwardBufferDuration).to(equal(4)) + expect(item.content.resource).toEventually(equal(.encrypted(url: Stream.onDemand.url, delegate: delegate))) + expect(item.content.playerItem().preferredForwardBufferDuration).to(equal(4)) } - func testEncryptedAssetWithoutMetadataAndConfiguration() { + func testEncryptedItemWithoutConfiguration() { let delegate = ContentKeySessionDelegateMock() let item = PlayerItem.encrypted(url: Stream.onDemand.url, delegate: delegate) PlayerItem.load(for: item.id) - expect(item.asset.resource).toEventually(equal(.encrypted(url: Stream.onDemand.url, delegate: delegate))) - expect(item.asset.nowPlayingInfo()).notTo(beEmpty()) - expect(item.asset.playerItem().preferredForwardBufferDuration).to(equal(0)) + expect(item.content.resource).toEventually(equal(.encrypted(url: Stream.onDemand.url, delegate: delegate))) + expect(item.content.playerItem().preferredForwardBufferDuration).to(equal(0)) } } diff --git a/Tests/PlayerTests/Publishers/NowPlayingInfoMetadataPublisherTests.swift b/Tests/PlayerTests/Publishers/NowPlayingInfoMetadataPublisherTests.swift deleted file mode 100644 index a4e44ad2e..000000000 --- a/Tests/PlayerTests/Publishers/NowPlayingInfoMetadataPublisherTests.swift +++ /dev/null @@ -1,134 +0,0 @@ -// -// Copyright (c) SRG SSR. All rights reserved. -// -// License information is available from the LICENSE file. -// - -@testable import PillarboxPlayer - -import MediaPlayer -import PillarboxCircumspect -import PillarboxStreams - -final class NowPlayingInfoMetadataPublisherTests: TestCase { - func testEmpty() { - let player = Player() - expectAtLeastSimilarPublished( - values: [[:]], - from: player.nowPlayingInfoMetadataPublisher() - ) - } - - func testImmediatelyAvailableWithoutMetadata() { - let player = Player(item: .simple(url: Stream.onDemand.url)) - expectAtLeastSimilarPublished( - values: [[MPMediaItemPropertyTitle: ""]], - from: player.nowPlayingInfoMetadataPublisher() - ) - } - - func testAvailableAfterDelay() { - let player = Player( - item: .mock(url: Stream.onDemand.url, loadedAfter: 0.5, withMetadata: AssetMetadataMock(title: "title")) - ) - expectAtLeastSimilarPublished( - values: [[MPMediaItemPropertyTitle: "title"]], - from: player.nowPlayingInfoMetadataPublisher() - ) - } - - func testImmediatelyAvailableWithMetadata() { - let player = Player(item: .simple( - url: Stream.onDemand.url, - metadata: AssetMetadataMock( - title: "title", - subtitle: "subtitle", - description: "description" - ) - )) - expectAtLeastSimilarPublished( - values: [ - [ - MPMediaItemPropertyTitle: "title", - MPMediaItemPropertyArtist: "subtitle", - MPMediaItemPropertyComments: "description" - ] - ], - from: player.nowPlayingInfoMetadataPublisher() - ) - } - - func testUpdate() { - let player = Player(item: .mock(url: Stream.onDemand.url, withMetadataUpdateAfter: 1)) - expectAtLeastSimilarPublished( - values: [ - [ - MPMediaItemPropertyTitle: "title0", - MPMediaItemPropertyArtist: "subtitle0", - MPMediaItemPropertyComments: "description0" - ], - [ - MPMediaItemPropertyTitle: "title1", - MPMediaItemPropertyArtist: "subtitle1", - MPMediaItemPropertyComments: "description1" - ] - ], - from: player.nowPlayingInfoMetadataPublisher() - ) - } - - func testNetworkItemReloading() { - let player = Player(item: .webServiceMock(media: .media1)) - expectAtLeastSimilarPublished( - values: [ - [ - MPMediaItemPropertyTitle: "Title 1", - MPMediaItemPropertyArtist: "Subtitle 1", - MPMediaItemPropertyComments: "Description 1" - ] - ], - from: player.nowPlayingInfoMetadataPublisher() - ) - expectAtLeastSimilarPublishedNext( - values: [ - [:], - [ - MPMediaItemPropertyTitle: "Title 2", - MPMediaItemPropertyArtist: "Subtitle 2", - MPMediaItemPropertyComments: "Description 2" - ] - ], - from: player.nowPlayingInfoMetadataPublisher() - ) { - player.items = [.webServiceMock(media: .media2)] - } - } - - func testEntirePlayback() { - let player = Player(item: .simple(url: Stream.shortOnDemand.url, metadata: AssetMetadataMock(title: "title"))) - expectAtLeastSimilarPublished( - values: [[MPMediaItemPropertyTitle: "title"], [:]], - from: player.nowPlayingInfoMetadataPublisher() - ) { - player.play() - } - } - - func testError() { - let player = Player(item: .simple(url: Stream.unavailable.url, metadata: AssetMetadataMock(title: "title"))) - expectAtLeastSimilarPublished( - values: [ - [ - MPMediaItemPropertyTitle: "title" - ], - [ - MPMediaItemPropertyTitle: "title", - MPMediaItemPropertyArtist: "The requested URL was not found on this server." - ] - ], - from: player.nowPlayingInfoMetadataPublisher() - ) { - player.play() - } - } -} diff --git a/Tests/PlayerTests/Publishers/NowPlayingInfoPublisherTests.swift b/Tests/PlayerTests/Publishers/NowPlayingInfoPublisherTests.swift index 75aa09457..6f77c70bd 100644 --- a/Tests/PlayerTests/Publishers/NowPlayingInfoPublisherTests.swift +++ b/Tests/PlayerTests/Publishers/NowPlayingInfoPublisherTests.swift @@ -13,24 +13,140 @@ import PillarboxStreams final class NowPlayingInfoPublisherTests: TestCase { func testEmpty() { let player = Player() + expectSimilarPublished( + values: [[:]], + from: player.nowPlayingInfoMetadataPublisher(), + during: .milliseconds(100) + ) + } + + func testImmediatelyAvailableWithoutMetadata() { + let player = Player(item: .simple(url: Stream.onDemand.url)) + expectSimilarPublished( + values: [[:]], + from: player.nowPlayingInfoMetadataPublisher(), + during: .milliseconds(100) + ) + } + + func testAvailableAfterDelay() { + let player = Player( + item: .mock(url: Stream.onDemand.url, loadedAfter: 0.1, withMetadata: AssetMetadataMock(title: "title")) + ) + expectSimilarPublished( + values: [[:], [MPMediaItemPropertyTitle: "title"]], + from: player.nowPlayingInfoMetadataPublisher(), + during: .milliseconds(200) + ) + } + + func testImmediatelyAvailableWithMetadata() { + let player = Player(item: .mock( + url: Stream.onDemand.url, + loadedAfter: 0, + withMetadata: AssetMetadataMock( + title: "title", + subtitle: "subtitle", + description: "description" + ) + )) + expectSimilarPublished( + values: [ + [:], + [ + MPMediaItemPropertyTitle: "title", + MPMediaItemPropertyArtist: "subtitle", + MPMediaItemPropertyComments: "description" + ] + ], + from: player.nowPlayingInfoMetadataPublisher(), + during: .milliseconds(100) + ) + } + + func testUpdate() { + let player = Player(item: .mock(url: Stream.onDemand.url, withMetadataUpdateAfter: 0.1)) + expectSimilarPublished( + values: [ + [:], + [ + MPMediaItemPropertyTitle: "title0", + MPMediaItemPropertyArtist: "subtitle0", + MPMediaItemPropertyComments: "description0" + ], + [ + MPMediaItemPropertyTitle: "title1", + MPMediaItemPropertyArtist: "subtitle1", + MPMediaItemPropertyComments: "description1" + ] + ], + from: player.nowPlayingInfoMetadataPublisher(), + during: .milliseconds(200) + ) + } + + func testNetworkItemReloading() { + let player = Player(item: .webServiceMock(media: .media1)) expectAtLeastSimilarPublished( + values: [ + [:], + [ + MPMediaItemPropertyTitle: "Title 1", + MPMediaItemPropertyArtist: "Subtitle 1", + MPMediaItemPropertyComments: "Description 1" + ] + ], + from: player.nowPlayingInfoMetadataPublisher() + ) + expectSimilarPublishedNext( + values: [ + [:], + [ + MPMediaItemPropertyTitle: "Title 2", + MPMediaItemPropertyArtist: "Subtitle 2", + MPMediaItemPropertyComments: "Description 2" + ] + ], + from: player.nowPlayingInfoMetadataPublisher(), + during: .milliseconds(100) + ) { + player.items = [.webServiceMock(media: .media2)] + } + } + + func testEntirePlayback() { + let player = Player(item: .mock(url: Stream.shortOnDemand.url, loadedAfter: 0, withMetadata: AssetMetadataMock(title: "title"))) + expectSimilarPublished( + values: [[:], [MPMediaItemPropertyTitle: "title"], [:]], + from: player.nowPlayingInfoMetadataPublisher(), + during: .seconds(2) + ) { + player.play() + } + } + + func testInactive() { + let player = Player(item: .mock(url: Stream.onDemand.url, loadedAfter: 0, withMetadata: AssetMetadataMock(title: "title"))) + expectSimilarPublished( values: [[:]], - from: player.nowPlayingInfoPublisher() + from: player.nowPlayingInfoPublisher(), + during: .milliseconds(100) ) } - func testActive() { - let player = Player(item: .simple(url: Stream.onDemand.url, metadata: AssetMetadataMock(title: "title"))) + func testToggleActive() { + let player = Player(item: .mock(url: Stream.onDemand.url, loadedAfter: 0, withMetadata: AssetMetadataMock(title: "title"))) expectAtLeastSimilarPublished( - values: [[:], [MPMediaItemPropertyTitle: "title"]], + values: [[:], [MPNowPlayingInfoPropertyIsLiveStream: false]], from: player.nowPlayingInfoPublisher() ) { player.isActive = true } - expectAtLeastSimilarPublished( - values: [[MPMediaItemPropertyTitle: "title"], [:]], - from: player.nowPlayingInfoPublisher() + expectSimilarPublishedNext( + values: [[:]], + from: player.nowPlayingInfoPublisher(), + during: .milliseconds(100) ) { player.isActive = false } diff --git a/Tests/PlayerTests/Tools/PlayerItem.swift b/Tests/PlayerTests/Tools/PlayerItem.swift index f6e0a27d0..ba886d562 100644 --- a/Tests/PlayerTests/Tools/PlayerItem.swift +++ b/Tests/PlayerTests/Tools/PlayerItem.swift @@ -14,18 +14,14 @@ enum MediaMock: String { case media2 } -enum MockError: Error { - case mock -} - extension PlayerItem { static func mock( url: URL, loadedAfter delay: TimeInterval, - trackerAdapters: [TrackerAdapter] = [] + trackerAdapters: [TrackerAdapter] = [] ) -> Self { let publisher = Just(Asset.simple(url: url)) - .delay(for: .seconds(delay), scheduler: DispatchQueue.main) + .delayIfNeeded(for: .seconds(delay), scheduler: DispatchQueue.main) return .init(publisher: publisher, trackerAdapters: trackerAdapters) } @@ -36,8 +32,14 @@ extension PlayerItem { trackerAdapters: [TrackerAdapter] = [] ) -> Self { let publisher = Just(Asset.simple(url: url, metadata: withMetadata)) - .delay(for: .seconds(delay), scheduler: DispatchQueue.main) - return .init(publisher: publisher, trackerAdapters: trackerAdapters) + .delayIfNeeded(for: .seconds(delay), scheduler: DispatchQueue.main) + return .init( + publisher: publisher, + metadataAdapter: StandardMetadata.adapter { metadata in + .init(title: metadata.title, subtitle: metadata.subtitle, description: metadata.description) + }, + trackerAdapters: trackerAdapters + ) } static func mock( @@ -53,7 +55,7 @@ extension PlayerItem { description: "description1" ) )) - .delay(for: .seconds(delay), scheduler: DispatchQueue.main) + .delayIfNeeded(for: .seconds(delay), scheduler: DispatchQueue.main) .prepend(Asset.simple( url: url, metadata: AssetMetadataMock( @@ -62,7 +64,13 @@ extension PlayerItem { description: "description0" ) )) - return .init(publisher: publisher, trackerAdapters: trackerAdapters) + return .init( + publisher: publisher, + metadataAdapter: StandardMetadata.adapter { metadata in + .init(title: metadata.title, subtitle: metadata.subtitle, description: metadata.description) + }, + trackerAdapters: trackerAdapters + ) } static func webServiceMock(media: MediaMock, trackerAdapters: [TrackerAdapter] = []) -> Self { @@ -73,10 +81,12 @@ extension PlayerItem { .map { metadata in Asset.simple(url: url, metadata: metadata) } - return .init(publisher: publisher, trackerAdapters: trackerAdapters) - } - - static func failed() -> Self { - .init(publisher: Just(Asset.failed(error: MockError.mock))) + return .init( + publisher: publisher, + metadataAdapter: StandardMetadata.adapter { metadata in + .init(title: metadata.title, subtitle: metadata.subtitle, description: metadata.description) + }, + trackerAdapters: trackerAdapters + ) } } diff --git a/Tests/PlayerTests/Tools/Similarity.swift b/Tests/PlayerTests/Tools/Similarity.swift index 89a7e1698..7616d6aac 100644 --- a/Tests/PlayerTests/Tools/Similarity.swift +++ b/Tests/PlayerTests/Tools/Similarity.swift @@ -9,17 +9,6 @@ import CoreMedia import PillarboxCircumspect -extension Asset: Similar { - public static func ~~ (lhs: Self, rhs: Self) -> Bool { - if let lhsUrl = lhs.playerItem().url, let rhsUrl = rhs.playerItem().url { - return lhsUrl == rhsUrl - } - else { - return false - } - } -} - extension Resource: Similar { public static func ~~ (lhs: PillarboxPlayer.Resource, rhs: PillarboxPlayer.Resource) -> Bool { switch (lhs, rhs) { diff --git a/Tests/PlayerTests/Tools/Tools.swift b/Tests/PlayerTests/Tools/Tools.swift index a745e1400..338e6804f 100644 --- a/Tests/PlayerTests/Tools/Tools.swift +++ b/Tests/PlayerTests/Tools/Tools.swift @@ -4,28 +4,10 @@ // License information is available from the LICENSE file. // -@testable import PillarboxPlayer - import Foundation -typealias EmptyAsset = Asset - struct StructError: LocalizedError { var errorDescription: String? { "Struct error description" } } - -extension UUID { - init(_ char: Character) { - self.init( - uuidString: """ - \(String(repeating: char, count: 8))\ - -\(String(repeating: char, count: 4))\ - -\(String(repeating: char, count: 4))\ - -\(String(repeating: char, count: 4))\ - -\(String(repeating: char, count: 12)) - """ - )! - } -} diff --git a/Tests/PlayerTests/Tools/TrackerLifeCycleMock.swift b/Tests/PlayerTests/Tools/TrackerLifeCycleMock.swift index ce58ad873..b6a63f19c 100644 --- a/Tests/PlayerTests/Tools/TrackerLifeCycleMock.swift +++ b/Tests/PlayerTests/Tools/TrackerLifeCycleMock.swift @@ -23,7 +23,6 @@ final class TrackerLifeCycleMock: PlayerItemTracker { } private let configuration: Configuration - private var cancellables = Set() init(configuration: Configuration) { self.configuration = configuration @@ -48,7 +47,7 @@ final class TrackerLifeCycleMock: PlayerItemTracker { } extension TrackerLifeCycleMock { - static func adapter(statePublisher: StatePublisher) -> TrackerAdapter { + static func adapter(statePublisher: StatePublisher) -> TrackerAdapter { adapter(configuration: Configuration(statePublisher: statePublisher)) } } diff --git a/Tests/PlayerTests/Tools/TrackerUpdateMock.swift b/Tests/PlayerTests/Tools/TrackerUpdateMock.swift index eec6739a4..58a883e92 100644 --- a/Tests/PlayerTests/Tools/TrackerUpdateMock.swift +++ b/Tests/PlayerTests/Tools/TrackerUpdateMock.swift @@ -24,7 +24,6 @@ final class TrackerUpdateMock: PlayerItemTracker where Metadata: Equat } private let configuration: Configuration - private var cancellables = Set() init(configuration: Configuration) { self.configuration = configuration @@ -48,11 +47,7 @@ final class TrackerUpdateMock: PlayerItemTracker where Metadata: Equat } extension TrackerUpdateMock { - static func adapter(statePublisher: StatePublisher, mapper: @escaping (M) -> Metadata) -> TrackerAdapter where M: AssetMetadata { + static func adapter(statePublisher: StatePublisher, mapper: @escaping (M) -> Metadata) -> TrackerAdapter { adapter(configuration: Configuration(statePublisher: statePublisher), mapper: mapper) } - - static func adapter(statePublisher: StatePublisher) -> TrackerAdapter { - adapter(configuration: Configuration(statePublisher: statePublisher)) - } } diff --git a/Tests/PlayerTests/Tracking/PlayerItemTrackerUpdateTests.swift b/Tests/PlayerTests/Tracking/PlayerItemTrackerUpdateTests.swift index 5858bc1a4..5217b338b 100644 --- a/Tests/PlayerTests/Tracking/PlayerItemTrackerUpdateTests.swift +++ b/Tests/PlayerTests/Tracking/PlayerItemTrackerUpdateTests.swift @@ -16,13 +16,17 @@ final class PlayerItemTrackerUpdateTests: TestCase { let item = PlayerItem.simple( url: Stream.shortOnDemand.url, metadata: AssetMetadataMock(title: "title"), - trackerAdapters: [TrackerUpdateMock.adapter(statePublisher: publisher) { $0.title }] + metadataAdapter: StandardMetadata.adapter { metadata in + .init(title: metadata.title) + }, + trackerAdapters: [ + TrackerUpdateMock.adapter(statePublisher: publisher) { $0.title } + ] ) expectAtLeastEqualPublished( values: [ - .enabled, - .updatedProperties(for: item.id), .updatedMetadata("title"), + .enabled, .updatedProperties(for: item.id), .disabled ], @@ -41,9 +45,8 @@ final class PlayerItemTrackerUpdateTests: TestCase { ]) expectAtLeastEqualPublished( values: [ - .enabled, - .updatedProperties(for: item.id), .updatedMetadata("title0"), + .enabled, .updatedProperties(for: item.id), .updatedMetadata("title1"), .updatedProperties(for: item.id), diff --git a/Tests/PlayerTests/Types/ItemErrorTests.swift b/Tests/PlayerTests/Types/ItemErrorTests.swift index 5cc2c58b9..865cbddf8 100644 --- a/Tests/PlayerTests/Types/ItemErrorTests.swift +++ b/Tests/PlayerTests/Types/ItemErrorTests.swift @@ -6,7 +6,6 @@ @testable import PillarboxPlayer -import Foundation import Nimble final class ItemErrorTests: TestCase {