From 801834e5092584416904379e81f416060e1e199b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20D=C3=A9fago?= Date: Mon, 12 Feb 2024 12:01:14 +0100 Subject: [PATCH] Improve support for long playlists (#766) Co-authored-by: Walid Kayhal <3347810+waliid@users.noreply.github.com> --- Demo/Resources/Localizable.xcstrings | 1 + .../ContentLists/ContentListView.swift | 2 +- Demo/Sources/Model/Playlist.swift | 46 ++++--- .../Showcase/Playlist/PlaylistViewModel.swift | 2 + Demo/Sources/Showcase/ShowcaseView.swift | 30 +++-- .../Showcase/Stories/StoriesView.swift | 2 +- .../CommandersAct/CommandersActService.swift | 7 +- .../Analytics/{ => Extensions}/CMTime.swift | 0 .../Analytics/{ => Extensions}/Numeric.swift | 0 .../Analytics/Extensions/ProcessInfo.swift | 13 ++ .../Analytics/{ => Extensions}/String.swift | 0 Sources/Circumspect/Similarity.swift | 2 +- Sources/Core/Publisher.swift | 12 ++ Sources/Player/Asset.swift | 47 ++++---- Sources/Player/Extensions/AVPlayerItem.swift | 4 +- Sources/Player/Interfaces/Assetable.swift | 26 ++-- Sources/Player/Internal/QueuePlayer.swift | 6 +- Sources/Player/Player+ControlCenter.swift | 3 +- Sources/Player/Player+Current.swift | 36 +++++- Sources/Player/Player+ItemUpdate.swift | 23 ---- Sources/Player/Player+Navigation.swift | 7 +- Sources/Player/Player+Replay.swift | 13 +- Sources/Player/Player.swift | 18 +-- Sources/Player/PlayerConfiguration.swift | 3 + Sources/Player/PlayerItem.swift | 41 +++++-- .../Publishers/AVPlayerPublishers.swift | 35 +++++- Sources/Player/ResourceLoading/Resource.swift | 32 +++++ Sources/Player/Types/Current.swift | 12 ++ Sources/Player/Types/ItemQueue.swift | 35 ++++++ Sources/Player/Types/ItemTransition.swift | 35 ++++++ Sources/Player/Types/ItemUpdate.swift | 21 ++++ Tests/CircumspectTests/SimilarityTests.swift | 24 ++-- Tests/CoreBusinessTests/PlayerItemTests.swift | 10 +- ...erTest.swift => SlicePublisherTests.swift} | 0 Tests/CoreTests/WaitPublisherTests.swift | 25 ++++ .../AVPlayer/AVPlayerItemTests.swift | 7 +- .../Asset/AssetCreationTests.swift | 12 +- Tests/PlayerTests/Asset/AssetableTests.swift | 26 +++- .../PlayerTests/Extensions/AVPlayerItem.swift | 13 ++ Tests/PlayerTests/Player/PlayerTests.swift | 21 +++- .../Player/ReplayChecksTests.swift | 9 ++ Tests/PlayerTests/Player/ReplayTests.swift | 12 +- .../PlayerItemAssetPublisherTests.swift | 63 ++++++++++ ...ationTests.swift => PlayerItemTests.swift} | 50 +++++--- .../Playlist/CurrentIndexTests.swift | 50 ++++---- .../Playlist/ItemNavigationForwardTests.swift | 14 +++ Tests/PlayerTests/Playlist/ItemsTests.swift | 14 +++ .../Playlist/ItemsUpdateTests.swift | 13 +- .../Playlist/NavigationBackwardTests.swift | 15 +++ ...wift => AVPlayerErrorPublisherTests.swift} | 4 +- ...AVPlayerItemTransitionPublisherTests.swift | 112 ++++++++++++++++++ ...NowPlayingInfoMetadataPublisherTests.swift | 19 ++- Tests/PlayerTests/Tools/PlayerItem.swift | 9 ++ Tests/PlayerTests/Tools/Similarity.swift | 18 ++- .../Tracking/PlayerItemTrackerTests.swift | 6 +- .../Tracking/PlayerTrackingTests.swift | 7 +- .../Types/PlayerConfigurationTests.swift | 1 + docs/KNOWN_ISSUES.md | 12 +- metadata/json/media2.json | 5 + 59 files changed, 831 insertions(+), 254 deletions(-) rename Sources/Analytics/{ => Extensions}/CMTime.swift (100%) rename Sources/Analytics/{ => Extensions}/Numeric.swift (100%) create mode 100644 Sources/Analytics/Extensions/ProcessInfo.swift rename Sources/Analytics/{ => Extensions}/String.swift (100%) delete mode 100644 Sources/Player/Player+ItemUpdate.swift create mode 100644 Sources/Player/Types/Current.swift create mode 100644 Sources/Player/Types/ItemQueue.swift create mode 100644 Sources/Player/Types/ItemTransition.swift create mode 100644 Sources/Player/Types/ItemUpdate.swift rename Tests/CoreTests/{SlicePublisherTest.swift => SlicePublisherTests.swift} (100%) create mode 100644 Tests/CoreTests/WaitPublisherTests.swift create mode 100644 Tests/PlayerTests/Extensions/AVPlayerItem.swift create mode 100644 Tests/PlayerTests/PlayerItem/PlayerItemAssetPublisherTests.swift rename Tests/PlayerTests/PlayerItem/{PlayerItemCreationTests.swift => PlayerItemTests.swift} (68%) rename Tests/PlayerTests/Publishers/{AVPlayerPublisherTests.swift => AVPlayerErrorPublisherTests.swift} (91%) create mode 100644 Tests/PlayerTests/Publishers/AVPlayerItemTransitionPublisherTests.swift create mode 100644 metadata/json/media2.json diff --git a/Demo/Resources/Localizable.xcstrings b/Demo/Resources/Localizable.xcstrings index 3832fbc08..ceb164505 100644 --- a/Demo/Resources/Localizable.xcstrings +++ b/Demo/Resources/Localizable.xcstrings @@ -30,6 +30,7 @@ } }, "%g× %@" : { + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { diff --git a/Demo/Sources/ContentLists/ContentListView.swift b/Demo/Sources/ContentLists/ContentListView.swift index 6d1456440..ea4356925 100644 --- a/Demo/Sources/ContentLists/ContentListView.swift +++ b/Demo/Sources/ContentLists/ContentListView.swift @@ -34,7 +34,7 @@ private struct LoadedView: View { } private func openPlaylist() { - router.presented = .playlist(templates: Array(templates().prefix(20))) + router.presented = .playlist(templates: templates()) } private func templates() -> [Template] { diff --git a/Demo/Sources/Model/Playlist.swift b/Demo/Sources/Model/Playlist.swift index 3b8c1e28a..4e6e8cfbb 100644 --- a/Demo/Sources/Model/Playlist.swift +++ b/Demo/Sources/Model/Playlist.swift @@ -4,8 +4,8 @@ // License information is available from the LICENSE file. // -enum URLTemplates { - static let videos: [Template] = [ +enum Playlist { + static let videoUrls: [Template] = [ Template( title: "Le R. - Légumes trop chers", description: "Playlist item 1", @@ -63,10 +63,8 @@ enum URLTemplates { ) ) ] -} -enum URNTemplates { - static let videos: [Template] = [ + static let videoUrns: [Template] = [ Template( title: "Le R. - Légumes trop chers", description: "Playlist item 1", @@ -109,7 +107,7 @@ enum URNTemplates { ) ] - static let longVideos: [Template] = [ + static let longVideoUrns: [Template] = [ Template( title: "J'ai pas l'air malade mais… (#1)", description: "Playlist item 1", @@ -157,21 +155,39 @@ enum URNTemplates { URLTemplate.appleTvMorningShowSeason2Trailer ] - static let videosWithOneError: [Template] = [ + static let audios: [Template] = [ + Template(title: "Le Journal horaire 1", type: .urn("urn:rts:audio:13605286")), + Template(title: "Forum", type: .urn("urn:rts:audio:13598743")), + Template(title: "Vertigo", type: .urn("urn:rts:audio:13579611")), + Template(title: "Le Journal horaire 2", type: .urn("urn:rts:audio:13605216")) + ] + + static let videosWithOneFailingUrl: [Template] = [ URLTemplate.shortOnDemandVideoHLS, - URNTemplate.unknown, + URLTemplate.unknown, URLTemplate.onDemandVideoHLS ] - static let videosWithErrors: [Template] = [ + static let videosWithOneFailingUrn: [Template] = [ + URNTemplate.onDemandVideo, + URNTemplate.unknown, + URNTemplate.onDemandSquareVideo + ] + + static let videosWithOnlyFailingUrns: [Template] = [ + URNTemplate.unknown, + URNTemplate.expired + ] + + static let videosWithOnlyFailingUrls: [Template] = [ URLTemplate.unknown, - URNTemplate.unknown + URLTemplate.unauthorized ] - static let audios: [Template] = [ - Template(title: "Le Journal horaire 1", type: .urn("urn:rts:audio:13605286")), - Template(title: "Forum", type: .urn("urn:rts:audio:13598743")), - Template(title: "Vertigo", type: .urn("urn:rts:audio:13579611")), - Template(title: "Le Journal horaire 2", type: .urn("urn:rts:audio:13605216")) + static let videosWithFailingUrlsAndUrns: [Template] = [ + URNTemplate.unknown, + URLTemplate.unknown, + URNTemplate.expired, + URLTemplate.unauthorized ] } diff --git a/Demo/Sources/Showcase/Playlist/PlaylistViewModel.swift b/Demo/Sources/Showcase/Playlist/PlaylistViewModel.swift index 3826e1bc4..81503bbaa 100644 --- a/Demo/Sources/Showcase/Playlist/PlaylistViewModel.swift +++ b/Demo/Sources/Showcase/Playlist/PlaylistViewModel.swift @@ -36,6 +36,8 @@ final class PlaylistViewModel: ObservableObject, PictureInPicturePersistable { URLTemplate.uhdVideoHLS, URNTemplate.gothard_360, URLTemplate.bitmovin_360, + URLTemplate.unauthorized, + URLTemplate.unknown, URNTemplate.expired, URNTemplate.unknown ] diff --git a/Demo/Sources/Showcase/ShowcaseView.swift b/Demo/Sources/Showcase/ShowcaseView.swift index 066063e56..97702cada 100644 --- a/Demo/Sources/Showcase/ShowcaseView.swift +++ b/Demo/Sources/Showcase/ShowcaseView.swift @@ -79,31 +79,43 @@ struct ShowcaseView: View { CustomSection("Playlists") { cell( title: "Video URLs", - destination: .playlist(templates: URLTemplates.videos) + destination: .playlist(templates: Playlist.videoUrls) ) cell( title: "Video URNs", - destination: .playlist(templates: URNTemplates.videos) + destination: .playlist(templates: Playlist.videoUrns) ) cell( title: "Long video URNs", - destination: .playlist(templates: URNTemplates.longVideos) + destination: .playlist(templates: Playlist.longVideoUrns) ) cell( title: "Videos with media selections", - destination: .playlist(templates: URNTemplates.videosWithMediaSelections) + destination: .playlist(templates: Playlist.videosWithMediaSelections) ) cell( title: "Audios", - destination: .playlist(templates: URNTemplates.audios) + destination: .playlist(templates: Playlist.audios) ) cell( - title: "Videos (one failed item)", - destination: .playlist(templates: URNTemplates.videosWithOneError) + title: "Videos (URLs, one failing)", + destination: .playlist(templates: Playlist.videosWithOneFailingUrl) ) cell( - title: "Videos (all failing)", - destination: .playlist(templates: URNTemplates.videosWithErrors) + title: "Videos (URNs, one failing)", + destination: .playlist(templates: Playlist.videosWithOneFailingUrn) + ) + cell( + title: "Videos (URLs, all failing)", + destination: .playlist(templates: Playlist.videosWithOnlyFailingUrls) + ) + cell( + title: "Videos (URNs, all failing)", + destination: .playlist(templates: Playlist.videosWithOnlyFailingUrns) + ) + cell( + title: "Videos (URLs and URNs, all failing)", + destination: .playlist(templates: Playlist.videosWithFailingUrlsAndUrns) ) cell( title: "Empty", diff --git a/Demo/Sources/Showcase/Stories/StoriesView.swift b/Demo/Sources/Showcase/Stories/StoriesView.swift index a2f4a684a..b3f04a23e 100644 --- a/Demo/Sources/Showcase/Stories/StoriesView.swift +++ b/Demo/Sources/Showcase/Stories/StoriesView.swift @@ -44,7 +44,7 @@ private struct TimeProgress: View { // Behavior: h-exp, v-exp struct StoriesView: View { - @StateObject private var model = StoriesViewModel(stories: Story.stories(from: URLTemplates.videos)) + @StateObject private var model = StoriesViewModel(stories: Story.stories(from: Playlist.videoUrls)) var body: some View { TabView(selection: $model.currentStory) { diff --git a/Sources/Analytics/CommandersAct/CommandersActService.swift b/Sources/Analytics/CommandersAct/CommandersActService.swift index 871bfe610..67268708f 100644 --- a/Sources/Analytics/CommandersAct/CommandersActService.swift +++ b/Sources/Analytics/CommandersAct/CommandersActService.swift @@ -7,16 +7,11 @@ import TCServerSide final class CommandersActService { - private static var isDesktopApp: Bool { - let processInfo = ProcessInfo.processInfo - return processInfo.isMacCatalystApp || processInfo.isiOSAppOnMac - } - private var serverSide: ServerSide? private var vendor: Vendor? private static func device() -> String { - guard !isDesktopApp else { return "desktop" } + guard !ProcessInfo.processInfo.isDesktopApp else { return "desktop" } switch UIDevice.current.userInterfaceIdiom { case .phone: return "phone" diff --git a/Sources/Analytics/CMTime.swift b/Sources/Analytics/Extensions/CMTime.swift similarity index 100% rename from Sources/Analytics/CMTime.swift rename to Sources/Analytics/Extensions/CMTime.swift diff --git a/Sources/Analytics/Numeric.swift b/Sources/Analytics/Extensions/Numeric.swift similarity index 100% rename from Sources/Analytics/Numeric.swift rename to Sources/Analytics/Extensions/Numeric.swift diff --git a/Sources/Analytics/Extensions/ProcessInfo.swift b/Sources/Analytics/Extensions/ProcessInfo.swift new file mode 100644 index 000000000..edbb1eab7 --- /dev/null +++ b/Sources/Analytics/Extensions/ProcessInfo.swift @@ -0,0 +1,13 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +import Foundation + +extension ProcessInfo { + var isDesktopApp: Bool { + isMacCatalystApp || isiOSAppOnMac + } +} diff --git a/Sources/Analytics/String.swift b/Sources/Analytics/Extensions/String.swift similarity index 100% rename from Sources/Analytics/String.swift rename to Sources/Analytics/Extensions/String.swift diff --git a/Sources/Circumspect/Similarity.swift b/Sources/Circumspect/Similarity.swift index e4e8e4301..284dd4104 100644 --- a/Sources/Circumspect/Similarity.swift +++ b/Sources/Circumspect/Similarity.swift @@ -23,7 +23,7 @@ public func equalDiff(_ expectedValue: T?) -> Matcher where T: Similar { } /// Matches against an expected similar value. -public func equal(_ expectedValue: T?) -> Matcher where T: Similar { +public func beSimilarTo(_ expectedValue: T?) -> Matcher where T: Similar { equal(expectedValue, by: ~~) } diff --git a/Sources/Core/Publisher.swift b/Sources/Core/Publisher.swift index 2c343c41c..0ccce9192 100644 --- a/Sources/Core/Publisher.swift +++ b/Sources/Core/Publisher.swift @@ -111,6 +111,18 @@ public extension Publisher { .removeDuplicates() .eraseToAnyPublisher() } + + /// Make the upstream publisher wait until a second signal publisher emits some value. + /// + /// - Parameter signal: The signal publisher. + /// - Returns: A publisher emitting values after the signal publisher emits a value. + func wait(untilOutputFrom signal: S) -> AnyPublisher where S: Publisher, S.Failure == Never { + prepend( + Empty(completeImmediately: false) + .prefix(untilOutputFrom: signal) + ) + .eraseToAnyPublisher() + } } // Borrowed from https://www.swiftbysundell.com/articles/combine-self-cancellable-memory-management/ diff --git a/Sources/Player/Asset.swift b/Sources/Player/Asset.swift index 29dff68c2..49c14f926 100644 --- a/Sources/Player/Asset.swift +++ b/Sources/Player/Asset.swift @@ -135,24 +135,37 @@ public struct Asset: Assetable where M: AssetMetadata { } } - func nowPlayingInfo() -> NowPlayingInfo { - var nowPlayingInfo = NowPlayingInfo() + func nowPlayingInfo() -> NowPlayingInfo? { if let metadata = metadata?.nowPlayingMetadata() { + 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 + } + else { + return nil } - return nowPlayingInfo } - func playerItem() -> AVPlayerItem { - let item = resource.playerItem().withId(id) - configuration(item) - update(item: item) - return item + 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) { @@ -227,25 +240,11 @@ public extension Asset where M == Never { extension Asset { static var loading: Self { - // Provides a playlist extension so that resource loader errors are correctly forwarded through the resource loader. - .init( - id: UUID(), - resource: .custom(url: URL(string: "pillarbox://loading.m3u8")!, delegate: LoadingResourceLoaderDelegate()), - metadata: nil, - configuration: { _ in }, - trackerAdapters: [] - ) + .init(id: UUID(), resource: .loading, metadata: nil, configuration: { _ in }, trackerAdapters: []) } static func failed(error: Error) -> Self { - // Provides a playlist extension so that resource loader errors are correctly forwarded through the resource loader. - .init( - id: UUID(), - resource: .custom(url: URL(string: "pillarbox://failing.m3u8")!, delegate: FailedResourceLoaderDelegate(error: error)), - metadata: nil, - configuration: { _ in }, - trackerAdapters: [] - ) + .init(id: UUID(), resource: .failing(error: error), metadata: nil, configuration: { _ in }, trackerAdapters: []) } } diff --git a/Sources/Player/Extensions/AVPlayerItem.swift b/Sources/Player/Extensions/AVPlayerItem.swift index f59bcd775..141a4f416 100644 --- a/Sources/Player/Extensions/AVPlayerItem.swift +++ b/Sources/Player/Extensions/AVPlayerItem.swift @@ -28,7 +28,7 @@ extension AVPlayerItem { TimeProperties.timeRange(loadedTimeRanges: loadedTimeRanges, seekableTimeRanges: seekableTimeRanges) } - static func playerItems(from items: [PlayerItem]) -> [AVPlayerItem] { - playerItems(from: items.map(\.asset)) + static func playerItems(from items: [PlayerItem], length: Int, reload: Bool) -> [AVPlayerItem] { + playerItems(from: items.prefix(length).map(\.asset), reload: reload) } } diff --git a/Sources/Player/Interfaces/Assetable.swift b/Sources/Player/Interfaces/Assetable.swift index 2307ddca3..d0e6e8d7c 100644 --- a/Sources/Player/Interfaces/Assetable.swift +++ b/Sources/Player/Interfaces/Assetable.swift @@ -15,8 +15,8 @@ protocol Assetable { func updateMetadata() func disable() - func nowPlayingInfo() -> NowPlayingInfo - func playerItem() -> AVPlayerItem + func nowPlayingInfo() -> NowPlayingInfo? + func playerItem(reload: Bool) -> AVPlayerItem func update(item: AVPlayerItem) } @@ -24,6 +24,10 @@ extension Assetable { func matches(_ playerItem: AVPlayerItem?) -> Bool { id == playerItem?.id } + + func playerItem() -> AVPlayerItem { + playerItem(reload: false) + } } extension AVPlayerItem { @@ -38,29 +42,31 @@ extension AVPlayerItem { static func playerItems( for currentAssets: [any Assetable], replacing previousAssets: [any Assetable], - currentItem: AVPlayerItem? + currentItem: AVPlayerItem?, + length: Int ) -> [AVPlayerItem] { - guard let currentItem else { return playerItems(from: currentAssets) } + assert(length > 1) + 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))) + return [currentItem] + playerItems(from: Array(currentAssets.suffix(from: currentIndex + 1).prefix(length - 1))) } else { - return playerItems(from: Array(currentAssets.suffix(from: currentIndex))) + 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))) + return playerItems(from: Array(currentAssets.suffix(from: commonIndex).prefix(length))) } else { - return playerItems(from: currentAssets) + return playerItems(from: Array(currentAssets.prefix(length))) } } - static func playerItems(from assets: [any Assetable]) -> [AVPlayerItem] { - assets.map { $0.playerItem() } + 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? { diff --git a/Sources/Player/Internal/QueuePlayer.swift b/Sources/Player/Internal/QueuePlayer.swift index 5f8157d36..13787f60b 100644 --- a/Sources/Player/Internal/QueuePlayer.swift +++ b/Sources/Player/Internal/QueuePlayer.swift @@ -158,12 +158,10 @@ extension AVQueuePlayer { if let firstItem = items.first { if firstItem !== self.items().first { - removeAll(from: 1) + remove(firstItem) replaceCurrentItem(with: firstItem) } - else { - removeAll(from: 1) - } + removeAll(from: 1) if items.count > 1 { append(Array(items.suffix(from: 1))) } diff --git a/Sources/Player/Player+ControlCenter.swift b/Sources/Player/Player+ControlCenter.swift index 61da65c0b..f29927901 100644 --- a/Sources/Player/Player+ControlCenter.swift +++ b/Sources/Player/Player+ControlCenter.swift @@ -36,7 +36,8 @@ extension Player { return Just(NowPlayingInfo()).eraseToAnyPublisher() } return current.item.$asset - .map { $0.nowPlayingInfo() } + .filter { !$0.resource.isLoading } + .compactMap { $0.nowPlayingInfo() } .eraseToAnyPublisher() } .switchToLatest() diff --git a/Sources/Player/Player+Current.swift b/Sources/Player/Player+Current.swift index 966bd66b0..b9cf85682 100644 --- a/Sources/Player/Player+Current.swift +++ b/Sources/Player/Player+Current.swift @@ -4,14 +4,11 @@ // License information is available from the LICENSE file. // +import AVFoundation import Combine +import PillarboxCore extension Player { - struct Current: Equatable { - let item: PlayerItem - let index: Int - } - func currentPublisher() -> AnyPublisher { itemUpdatePublisher .map { update in @@ -21,4 +18,33 @@ extension Player { .removeDuplicates() .eraseToAnyPublisher() } + + func queueItemsPublisher() -> AnyPublisher<[AVPlayerItem], Never> { + Publishers.Merge( + assetsPublisher() + .map { ItemQueueUpdate.assets($0) }, + queuePlayer.itemTransitionPublisher() + .map { ItemQueueUpdate.itemTransition($0) } + ) + .scan(ItemQueue.initial) { queue, update in + queue.updated(with: update) + } + .withPrevious(ItemQueue.initial) + .compactMap { [configuration] previous, current in + switch current.itemTransition { + case let .advance(item): + return AVPlayerItem.playerItems( + for: current.assets, + replacing: previous.assets, + currentItem: item, + length: configuration.preloadedItems + ) + case let .stop(item): + return [item] + case .finish: + return nil + } + } + .eraseToAnyPublisher() + } } diff --git a/Sources/Player/Player+ItemUpdate.swift b/Sources/Player/Player+ItemUpdate.swift deleted file mode 100644 index 7890b1467..000000000 --- a/Sources/Player/Player+ItemUpdate.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// Copyright (c) SRG SSR. All rights reserved. -// -// License information is available from the LICENSE file. -// - -import AVFoundation -import DequeModule - -extension Player { - struct ItemUpdate { - static var empty: Self { - .init(items: [], currentItem: nil) - } - - let items: Deque - let currentItem: AVPlayerItem? - - func currentIndex() -> Int? { - items.firstIndex { $0.matches(currentItem) } - } - } -} diff --git a/Sources/Player/Player+Navigation.swift b/Sources/Player/Player+Navigation.swift index 64266ba0d..c39c3fb8e 100644 --- a/Sources/Player/Player+Navigation.swift +++ b/Sources/Player/Player+Navigation.swift @@ -20,7 +20,7 @@ public extension Player { /// Skips failed items. func returnToPreviousItem() { guard canReturnToPreviousItem() else { return } - queuePlayer.replaceItems(with: AVPlayerItem.playerItems(from: returningItems)) + queuePlayer.replaceItems(with: AVPlayerItem.playerItems(from: returningItems, length: configuration.preloadedItems, reload: true)) } /// Checks whether moving to the next item in the queue is possible. @@ -33,16 +33,15 @@ public extension Player { /// Moves to the next item in the queue. func advanceToNextItem() { guard canAdvanceToNextItem() else { return } - queuePlayer.replaceItems(with: AVPlayerItem.playerItems(from: advancingItems)) + queuePlayer.replaceItems(with: AVPlayerItem.playerItems(from: advancingItems, length: configuration.preloadedItems, reload: true)) } /// Makes the item at the specified index become the current one. /// /// - Parameter index: The item index. func setCurrentIndex(_ index: Int) throws { - guard index != currentIndex else { return } guard (0.. Bool { guard !storedItems.isEmpty else { return false } - if let lastItem = queuePlayer.items().last { - return lastItem.error != nil + if let item = queuePlayer.items().first { + return item.error != nil } else { return true } } - /// Replays the content from the start, resuming playback automatically. + /// Replays the content, resuming playback automatically. func replay() { guard canReplay() else { return } play() - try? setCurrentIndex(0) + try? setCurrentIndex(currentIndex ?? 0) } } diff --git a/Sources/Player/Player.swift b/Sources/Player/Player.swift index 557ab2d3a..18870d741 100644 --- a/Sources/Player/Player.swift +++ b/Sources/Player/Player.swift @@ -76,7 +76,7 @@ public final class Player: ObservableObject, Equatable { }() lazy var itemUpdatePublisher: AnyPublisher = { - Publishers.CombineLatest($storedItems, queuePlayer.publisher(for: \.currentItem)) + Publishers.CombineLatest($storedItems, queuePlayer.smoothCurrentItemPublisher()) .map { ItemUpdate(items: $0, currentItem: $1) } .multicast { CurrentValueSubject(.empty) } .autoconnect() @@ -207,7 +207,7 @@ public final class Player: ObservableObject, Equatable { } private func configureQueuePlayerUpdatePublishers() { - configureQueueUpdatePublisher() + configureQueueItemsPublisher() configureRateUpdatePublisher() configureTextStyleRulesUpdatePublisher() } @@ -230,16 +230,8 @@ public final class Player: ObservableObject, Equatable { } private extension Player { - func configureQueueUpdatePublisher() { - assetsPublisher() - .withPrevious() - .map { [queuePlayer] assets in - AVPlayerItem.playerItems( - for: assets.current, - replacing: assets.previous ?? [], - currentItem: queuePlayer.currentItem - ) - } + func configureQueueItemsPublisher() { + queueItemsPublisher() .receiveOnMainThread() .sink { [queuePlayer] items in queuePlayer.replaceItems(with: items) @@ -260,7 +252,7 @@ private extension Player { func configureTextStyleRulesUpdatePublisher() { Publishers.CombineLatest( textStyleRulesPublisher, - queuePlayer.publisher(for: \.currentItem) + queuePlayer.currentItemPublisher() ) .sink { textStyleRules, item in item?.textStyleRules = textStyleRules diff --git a/Sources/Player/PlayerConfiguration.swift b/Sources/Player/PlayerConfiguration.swift index 9c875b972..eb39cce70 100644 --- a/Sources/Player/PlayerConfiguration.swift +++ b/Sources/Player/PlayerConfiguration.swift @@ -30,6 +30,9 @@ public struct PlayerConfiguration { /// The backward skip interval in seconds. public let backwardSkipInterval: TimeInterval + /// The number of items to preload. + let preloadedItems = 2 + /// Creates a player configuration. public init( allowsExternalPlayback: Bool = true, diff --git a/Sources/Player/PlayerItem.swift b/Sources/Player/PlayerItem.swift index 2fb560a7d..0633b32f6 100644 --- a/Sources/Player/PlayerItem.swift +++ b/Sources/Player/PlayerItem.swift @@ -6,6 +6,12 @@ import AVFoundation import Combine +import PillarboxCore + +private enum TriggerId: Hashable { + case load(UUID) + case reset(UUID) +} /// This class represents a playable item that can be inserted into a ``Player``. /// @@ -17,23 +23,27 @@ import Combine /// /// - Note: You can also create your own ``PlayerItem`` by extending the class. public final class PlayerItem: Equatable { + private static let trigger = Trigger() + @Published private(set) var asset: any Assetable - private let id = UUID() + 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) - publisher - .catch { error in - Just(.failed(error: error)) - } - .map { [id] asset in - asset.withId(id).withTrackerAdapters(trackerAdapters) - } - // Mitigate instabilities arising when publisher involves `URLSession` publishers, see issue #206. - .receiveOnMainThread() - .assign(to: &$asset) + Publishers.PublishAndRepeat(onOutputFrom: Self.trigger.signal(activatedBy: TriggerId.reset(id))) { + publisher + .catch { error in + Just(.failed(error: error)) + } + } + .map { [id] asset in + asset.withId(id).withTrackerAdapters(trackerAdapters) + } + .wait(untilOutputFrom: Self.trigger.signal(activatedBy: TriggerId.load(id))) + .receive(on: DispatchQueue.main) + .assign(to: &$asset) } /// Creates a player item from an ``Asset``. @@ -49,6 +59,15 @@ public final class PlayerItem: Equatable { lhs === rhs } + static func load(for id: UUID) { + Self.trigger.activate(for: TriggerId.load(id)) + } + + static func reload(for id: UUID) { + Self.trigger.activate(for: TriggerId.reset(id)) + Self.trigger.activate(for: TriggerId.load(id)) + } + func matches(_ playerItem: AVPlayerItem?) -> Bool { asset.matches(playerItem) } diff --git a/Sources/Player/Publishers/AVPlayerPublishers.swift b/Sources/Player/Publishers/AVPlayerPublishers.swift index 07b507610..314347671 100644 --- a/Sources/Player/Publishers/AVPlayerPublishers.swift +++ b/Sources/Player/Publishers/AVPlayerPublishers.swift @@ -6,10 +6,41 @@ import AVFoundation import Combine +import PillarboxCore extension AVPlayer { - func playerItemPropertiesPublisher() -> AnyPublisher { + func currentItemPublisher() -> AnyPublisher { publisher(for: \.currentItem) + .removeDuplicates() + .eraseToAnyPublisher() + } + + /// Publishes a stream of `AVPlayerItem` which preserves failed items. + func smoothCurrentItemPublisher() -> AnyPublisher { + itemTransitionPublisher() + .map { transition in + switch transition { + case let .advance(to: item): + return item + case let .stop(on: item): + return item + case .finish: + return nil + } + } + .eraseToAnyPublisher() + } + + func itemTransitionPublisher() -> AnyPublisher { + currentItemPublisher() + .withPrevious(nil) + .map { ItemTransition.transition(from: $0.previous, to: $0.current) } + .removeDuplicates() + .eraseToAnyPublisher() + } + + func playerItemPropertiesPublisher() -> AnyPublisher { + currentItemPublisher() .map { item in guard let item else { return Just(PlayerItemProperties.empty).eraseToAnyPublisher() } return item.propertiesPublisher() @@ -32,7 +63,7 @@ extension AVPlayer { } func errorPublisher() -> AnyPublisher { - publisher(for: \.currentItem) + currentItemPublisher() .compactMap { $0?.errorPublisher() } .switchToLatest() .eraseToAnyPublisher() diff --git a/Sources/Player/ResourceLoading/Resource.swift b/Sources/Player/ResourceLoading/Resource.swift index bac83ea86..6830b210b 100644 --- a/Sources/Player/ResourceLoading/Resource.swift +++ b/Sources/Player/ResourceLoading/Resource.swift @@ -17,6 +17,24 @@ 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: + true + default: + false + } + } + func playerItem() -> AVPlayerItem { switch self { case let .simple(url: url): @@ -41,6 +59,20 @@ enum Resource { } } +extension Resource { + // Provide a playlist extension so that resource loader errors are correctly forwarded through the resource loader. + static let loadingUrl = URL(string: "pillarbox://loading.m3u8")! + static let failingUrl = URL(string: "pillarbox://failing.m3u8")! + + static var loading: Self { + .custom(url: loadingUrl, delegate: LoadingResourceLoaderDelegate()) + } + + static func failing(error: Error) -> Self { + .custom(url: failingUrl, delegate: FailedResourceLoaderDelegate(error: error)) + } +} + extension Resource: Equatable { static func == (lhs: Resource, rhs: Resource) -> Bool { switch (lhs, rhs) { diff --git a/Sources/Player/Types/Current.swift b/Sources/Player/Types/Current.swift new file mode 100644 index 000000000..041e95523 --- /dev/null +++ b/Sources/Player/Types/Current.swift @@ -0,0 +1,12 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +import Foundation + +struct Current: Equatable { + let item: PlayerItem + let index: Int +} diff --git a/Sources/Player/Types/ItemQueue.swift b/Sources/Player/Types/ItemQueue.swift new file mode 100644 index 000000000..fe0bd917f --- /dev/null +++ b/Sources/Player/Types/ItemQueue.swift @@ -0,0 +1,35 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +import AVFoundation + +enum ItemQueueUpdate { + case assets([any Assetable]) + case itemTransition(ItemTransition) +} + +struct ItemQueue { + static var initial: Self { + .init(assets: [], itemTransition: .advance(to: nil)) + } + + let assets: [any Assetable] + let itemTransition: ItemTransition + + init(assets: [any Assetable], itemTransition: ItemTransition) { + self.assets = assets + self.itemTransition = !assets.isEmpty ? itemTransition : .advance(to: nil) + } + + func updated(with update: ItemQueueUpdate) -> Self { + switch update { + case let .assets(assets): + return .init(assets: assets, itemTransition: itemTransition) + case let .itemTransition(itemTransition): + return .init(assets: assets, itemTransition: itemTransition) + } + } +} diff --git a/Sources/Player/Types/ItemTransition.swift b/Sources/Player/Types/ItemTransition.swift new file mode 100644 index 000000000..6c96da854 --- /dev/null +++ b/Sources/Player/Types/ItemTransition.swift @@ -0,0 +1,35 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +import AVFoundation + +/// A transition between items in a playlist. +enum ItemTransition: Equatable { + /// Advance to the provided item (or the beginning of the playlist if `nil`). + case advance(to: AVPlayerItem?) + /// Stop on the provided item. + case stop(on: AVPlayerItem) + /// Finish playing all items. + case finish + + static func transition(from previousItem: AVPlayerItem?, to currentItem: AVPlayerItem?) -> Self { + if let previousItem, previousItem.error != nil { + return .stop(on: previousItem) + } + else if let currentItem, currentItem.error != nil { + return .stop(on: currentItem) + } + else if let currentItem { + return .advance(to: currentItem) + } + else if previousItem != nil { + return .finish + } + else { + return .advance(to: nil) + } + } +} diff --git a/Sources/Player/Types/ItemUpdate.swift b/Sources/Player/Types/ItemUpdate.swift new file mode 100644 index 000000000..8c11ff823 --- /dev/null +++ b/Sources/Player/Types/ItemUpdate.swift @@ -0,0 +1,21 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +import AVFoundation +import DequeModule + +struct ItemUpdate { + static var empty: Self { + .init(items: [], currentItem: nil) + } + + let items: Deque + let currentItem: AVPlayerItem? + + func currentIndex() -> Int? { + items.firstIndex { $0.matches(currentItem) } + } +} diff --git a/Tests/CircumspectTests/SimilarityTests.swift b/Tests/CircumspectTests/SimilarityTests.swift index 37a3609bf..b359d9e78 100644 --- a/Tests/CircumspectTests/SimilarityTests.swift +++ b/Tests/CircumspectTests/SimilarityTests.swift @@ -10,12 +10,12 @@ import Nimble import XCTest final class SimilarityTests: XCTestCase { - func testSimilarInstances() { + func testOperatorForInstances() { expect(NamedPerson(name: "Alice") ~~ NamedPerson(name: "alice")).to(beTrue()) expect(NamedPerson(name: "Alice") ~~ NamedPerson(name: "bob")).to(beFalse()) } - func testSimilarOptionals() { + func testOperatorForOptionals() { let alice1: NamedPerson? = NamedPerson(name: "Alice") let alice2: NamedPerson? = NamedPerson(name: "alice") let bob = NamedPerson(name: "Bob") @@ -23,7 +23,7 @@ final class SimilarityTests: XCTestCase { expect(alice1 ~~ bob).to(beFalse()) } - func testSimilarArrays() { + func testOperatorForArrays() { let array1 = [NamedPerson(name: "Alice"), NamedPerson(name: "Bob")] let array2 = [NamedPerson(name: "alice"), NamedPerson(name: "bob")] let array3 = [NamedPerson(name: "bob"), NamedPerson(name: "alice")] @@ -31,25 +31,25 @@ final class SimilarityTests: XCTestCase { expect(array1 ~~ array3).to(beFalse()) } - func testEqualForSimilarInstances() { - expect(NamedPerson(name: "Alice")).to(equal(NamedPerson(name: "alice"))) - expect(NamedPerson(name: "Alice")).notTo(equal(NamedPerson(name: "bob"))) + func testBeSimilarForInstances() { + expect(NamedPerson(name: "Alice")).to(beSimilarTo(NamedPerson(name: "alice"))) + expect(NamedPerson(name: "Alice")).notTo(beSimilarTo(NamedPerson(name: "bob"))) } - func testEqualForSimilarOptionals() { + func testBeSimilarForOptionals() { let alice1: NamedPerson? = NamedPerson(name: "Alice") let alice2: NamedPerson? = NamedPerson(name: "alice") let bob = NamedPerson(name: "Bob") - expect(alice1).to(equal(alice2)) + expect(alice1).to(beSimilarTo(alice2)) expect(alice1).notTo(beNil()) - expect(alice1).notTo(equal(bob)) + expect(alice1).notTo(beSimilarTo(bob)) } - func testEqualForSimilarArrays() { + func testBeSimilarForArrays() { let array1 = [NamedPerson(name: "Alice"), NamedPerson(name: "Bob")] let array2 = [NamedPerson(name: "alice"), NamedPerson(name: "bob")] let array3 = [NamedPerson(name: "bob"), NamedPerson(name: "alice")] - expect(array1).to(equal(array2)) - expect(array1).notTo(equal(array3)) + expect(array1).to(beSimilarTo(array2)) + expect(array1).notTo(beSimilarTo(array3)) } } diff --git a/Tests/CoreBusinessTests/PlayerItemTests.swift b/Tests/CoreBusinessTests/PlayerItemTests.swift index fc61a0623..c8704eb5b 100644 --- a/Tests/CoreBusinessTests/PlayerItemTests.swift +++ b/Tests/CoreBusinessTests/PlayerItemTests.swift @@ -5,10 +5,11 @@ // @testable import PillarboxCoreBusiness +@testable import PillarboxPlayer import Combine +import Nimble import PillarboxCircumspect -import PillarboxPlayer import XCTest final class PlayerItemTests: XCTestCase { @@ -55,4 +56,11 @@ final class PlayerItemTests: XCTestCase { during: .seconds(1) ) } + + func testLoadNotLooping() { + let item = PlayerItem.urn("urn:swisstxt:video:rts:1793518") + _ = Player(item: item) + let output = collectOutput(from: item.$asset, during: .seconds(1)) + expect(output.count).to(equal(2)) + } } diff --git a/Tests/CoreTests/SlicePublisherTest.swift b/Tests/CoreTests/SlicePublisherTests.swift similarity index 100% rename from Tests/CoreTests/SlicePublisherTest.swift rename to Tests/CoreTests/SlicePublisherTests.swift diff --git a/Tests/CoreTests/WaitPublisherTests.swift b/Tests/CoreTests/WaitPublisherTests.swift new file mode 100644 index 000000000..d5fe9b8b1 --- /dev/null +++ b/Tests/CoreTests/WaitPublisherTests.swift @@ -0,0 +1,25 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +@testable import PillarboxCore + +import Combine +import PillarboxCircumspect +import XCTest + +final class WaitPublisherTests: XCTestCase { + func testWait() { + let signal = PassthroughSubject() + + let publisher = Just("Received") + .wait(untilOutputFrom: signal) + expectNothingPublished(from: publisher, during: .milliseconds(100)) + + expectEqualPublished(values: ["Received"], from: publisher, during: .milliseconds(100)) { + signal.send(()) + } + } +} diff --git a/Tests/PlayerTests/AVPlayer/AVPlayerItemTests.swift b/Tests/PlayerTests/AVPlayer/AVPlayerItemTests.swift index 90e235cb7..5b0265692 100644 --- a/Tests/PlayerTests/AVPlayer/AVPlayerItemTests.swift +++ b/Tests/PlayerTests/AVPlayer/AVPlayerItemTests.swift @@ -28,11 +28,10 @@ final class AVPlayerItemTests: TestCase { PlayerItem.simple(url: Stream.shortOnDemand.url), PlayerItem.simple(url: Stream.live.url) ] - let urls = AVPlayerItem.playerItems(from: items).compactMap { item -> URL? in - guard let asset = item.asset as? AVURLAsset else { return nil } - return asset.url + expect { + AVPlayerItem.playerItems(from: items, length: 3, reload: false).compactMap(\.url) } - expect(urls).to(equal([ + .toEventually(equal([ Stream.onDemand.url, Stream.shortOnDemand.url, Stream.live.url diff --git a/Tests/PlayerTests/Asset/AssetCreationTests.swift b/Tests/PlayerTests/Asset/AssetCreationTests.swift index 5092ba241..c2a6043b3 100644 --- a/Tests/PlayerTests/Asset/AssetCreationTests.swift +++ b/Tests/PlayerTests/Asset/AssetCreationTests.swift @@ -36,14 +36,14 @@ final class AssetCreationTests: TestCase { item.preferredForwardBufferDuration = 4 } expect(asset.resource).to(equal(.simple(url: Stream.onDemand.url))) - expect(asset.nowPlayingInfo()).to(beEmpty()) + expect(asset.nowPlayingInfo()).to(beNil()) 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()).to(beEmpty()) + expect(asset.nowPlayingInfo()).to(beNil()) expect(asset.playerItem().preferredForwardBufferDuration).to(equal(0)) } @@ -79,7 +79,7 @@ final class AssetCreationTests: TestCase { item.preferredForwardBufferDuration = 4 } expect(asset.resource).to(equal(.custom(url: Stream.onDemand.url, delegate: delegate))) - expect(asset.nowPlayingInfo()).to(beEmpty()) + expect(asset.nowPlayingInfo()).to(beNil()) expect(asset.playerItem().preferredForwardBufferDuration).to(equal(4)) } @@ -87,7 +87,7 @@ final class AssetCreationTests: TestCase { 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()).to(beEmpty()) + expect(asset.nowPlayingInfo()).to(beNil()) expect(asset.playerItem().preferredForwardBufferDuration).to(equal(0)) } @@ -122,7 +122,7 @@ final class AssetCreationTests: TestCase { item.preferredForwardBufferDuration = 4 } expect(asset.resource).to(equal(.encrypted(url: Stream.onDemand.url, delegate: delegate))) - expect(asset.nowPlayingInfo()).to(beEmpty()) + expect(asset.nowPlayingInfo()).to(beNil()) expect(asset.playerItem().preferredForwardBufferDuration).to(equal(4)) } @@ -130,7 +130,7 @@ final class AssetCreationTests: TestCase { 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()).to(beEmpty()) + expect(asset.nowPlayingInfo()).to(beNil()) expect(asset.playerItem().preferredForwardBufferDuration).to(equal(0)) } } diff --git a/Tests/PlayerTests/Asset/AssetableTests.swift b/Tests/PlayerTests/Asset/AssetableTests.swift index 3804d6dde..24072e3e7 100644 --- a/Tests/PlayerTests/Asset/AssetableTests.swift +++ b/Tests/PlayerTests/Asset/AssetableTests.swift @@ -24,7 +24,7 @@ final class AssetableTests: TestCase { .loading.withId(UUID("B")), .loading.withId(UUID("C")) ] - let result = AVPlayerItem.playerItems(for: currentAssets, replacing: previousAssets, currentItem: nil) + 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) @@ -47,7 +47,7 @@ final class AssetableTests: TestCase { .loading.withId(UUID("C")) ] let currentItem = currentItemAsset.playerItem() - let result = AVPlayerItem.playerItems(for: currentAssets, replacing: previousAssets, currentItem: currentItem) + let result = AVPlayerItem.playerItems(for: currentAssets, replacing: previousAssets, currentItem: currentItem, length: currentAssets.count) let expected = [ currentItemAsset, .loading.withId(UUID("B")), @@ -76,7 +76,7 @@ final class AssetableTests: TestCase { currentItemAsset ] let currentItem = currentItemAsset.playerItem() - let result = AVPlayerItem.playerItems(for: currentAssets, replacing: previousAssets, currentItem: currentItem) + let result = AVPlayerItem.playerItems(for: currentAssets, replacing: previousAssets, currentItem: currentItem, length: currentAssets.count) let expected = [ currentItemAsset ] @@ -97,7 +97,7 @@ final class AssetableTests: TestCase { .loading.withId(UUID("B")) ] let unknownItem = EmptyAsset.loading.withId(UUID("1")).playerItem() - let result = AVPlayerItem.playerItems(for: currentAssets, replacing: previousAssets, currentItem: unknownItem) + 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) @@ -118,7 +118,7 @@ final class AssetableTests: TestCase { .loading.withId(UUID("C")) ] let currentItem = currentItemAsset.playerItem() - let result = AVPlayerItem.playerItems(for: currentAssets, replacing: previousAssets, currentItem: currentItem) + let result = AVPlayerItem.playerItems(for: currentAssets, replacing: previousAssets, currentItem: currentItem, length: currentAssets.count) let expected = [ otherAsset, .loading.withId(UUID("C")) @@ -142,11 +142,25 @@ final class AssetableTests: TestCase { .loading.withId(UUID("3")) ] let currentItem = currentItemAsset.playerItem() - let result = AVPlayerItem.playerItems(for: currentAssets, replacing: previousAssets, currentItem: currentItem) + 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/Extensions/AVPlayerItem.swift b/Tests/PlayerTests/Extensions/AVPlayerItem.swift new file mode 100644 index 000000000..31d5751d9 --- /dev/null +++ b/Tests/PlayerTests/Extensions/AVPlayerItem.swift @@ -0,0 +1,13 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +import AVFoundation + +extension AVPlayerItem { + var url: URL? { + (asset as? AVURLAsset)?.url + } +} diff --git a/Tests/PlayerTests/Player/PlayerTests.swift b/Tests/PlayerTests/Player/PlayerTests.swift index 204d5552d..382503024 100644 --- a/Tests/PlayerTests/Player/PlayerTests.swift +++ b/Tests/PlayerTests/Player/PlayerTests.swift @@ -6,6 +6,7 @@ @testable import PillarboxPlayer +import AVFoundation import CoreMedia import Nimble import PillarboxCircumspect @@ -47,7 +48,8 @@ final class PlayerTests: TestCase { func testMetadataUpdatesMustNotChangePlayerItem() { let player = Player(item: .mock(url: Stream.onDemand.url, withMetadataUpdateAfter: 1)) - expectNothingPublishedNext(from: player.queuePlayer.publisher(for: \.currentItem), during: .seconds(2)) + let publisher = player.queuePlayer.currentItemPublisher().compactMap(\.?.url) + expectEqualPublishedNext(values: [Stream.onDemand.url], from: publisher, during: .seconds(2)) } func testRetrieveCurrentValueOnSubscription() { @@ -59,4 +61,21 @@ final class PlayerTests: TestCase { during: .seconds(1) ) } + + func testPreloadedItems() { + let player = Player( + items: [ + .simple(url: Stream.onDemand.url), + .simple(url: Stream.onDemand.url), + .simple(url: Stream.onDemand.url) + ] + ) + let expectedResources: [Resource] = [ + .simple(url: Stream.onDemand.url), + .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)) + } } diff --git a/Tests/PlayerTests/Player/ReplayChecksTests.swift b/Tests/PlayerTests/Player/ReplayChecksTests.swift index 826e054f4..6a6f3f73b 100644 --- a/Tests/PlayerTests/Player/ReplayChecksTests.swift +++ b/Tests/PlayerTests/Player/ReplayChecksTests.swift @@ -64,4 +64,13 @@ final class ReplayChecksTests: TestCase { player.play() expect(player.canReplay()).toEventually(beTrue()) } + + func testWithOneLongGoodItemAndOneBadItem() { + let player = Player(items: [ + .simple(url: Stream.onDemand.url), + .simple(url: Stream.unavailable.url) + ]) + player.play() + expect(player.canReplay()).toNever(beTrue(), until: .milliseconds(500)) + } } diff --git a/Tests/PlayerTests/Player/ReplayTests.swift b/Tests/PlayerTests/Player/ReplayTests.swift index 2a4a3e182..fc8564f9e 100644 --- a/Tests/PlayerTests/Player/ReplayTests.swift +++ b/Tests/PlayerTests/Player/ReplayTests.swift @@ -26,9 +26,9 @@ final class ReplayTests: TestCase { func testWithOneBadItem() { let player = Player(item: .simple(url: Stream.unavailable.url)) - expect(player.currentIndex).toEventually(beNil()) + expect(player.currentIndex).toAlways(equal(0), until: .milliseconds(500)) player.replay() - expect(player.currentIndex).toEventually(equal(0)) + expect(player.currentIndex).toAlways(equal(0), until: .milliseconds(500)) } func testWithManyGoodItems() { @@ -48,9 +48,9 @@ final class ReplayTests: TestCase { .simple(url: Stream.unavailable.url) ]) player.play() - expect(player.currentIndex).toEventually(beNil()) + expect(player.currentIndex).toAlways(equal(0), until: .milliseconds(500)) player.replay() - expect(player.currentIndex).to(equal(0)) + expect(player.currentIndex).toAlways(equal(0), until: .milliseconds(500)) } func testWithOneGoodItemAndOneBadItem() { @@ -59,9 +59,9 @@ final class ReplayTests: TestCase { .simple(url: Stream.unavailable.url) ]) player.play() - expect(player.currentIndex).toEventually(beNil()) + expect(player.currentIndex).toEventually(equal(1)) player.replay() - expect(player.currentIndex).to(equal(0)) + expect(player.currentIndex).to(equal(1)) } func testResumePlaybackIfNeeded() { diff --git a/Tests/PlayerTests/PlayerItem/PlayerItemAssetPublisherTests.swift b/Tests/PlayerTests/PlayerItem/PlayerItemAssetPublisherTests.swift new file mode 100644 index 000000000..0df71f024 --- /dev/null +++ b/Tests/PlayerTests/PlayerItem/PlayerItemAssetPublisherTests.swift @@ -0,0 +1,63 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +@testable import PillarboxPlayer + +import AVFoundation +import PillarboxCircumspect +import PillarboxStreams + +final class PlayerItemAssetPublisherTests: TestCase { + func testNoLoad() { + let item = PlayerItem.simple(url: Stream.onDemand.url) + expectSimilarPublished( + values: [.loading], + from: item.$asset.map(\.resource), + during: .milliseconds(500) + ) + } + + func testLoad() { + 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), + during: .milliseconds(500) + ) { + PlayerItem.load(for: item.id) + } + } + + func testReload() { + 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) + } + + expectSimilarPublishedNext( + values: [.simple(url: Stream.onDemand.url)], + from: item.$asset.map(\.resource), + during: .milliseconds(500) + ) { + PlayerItem.reload(for: item.id) + } + } +} diff --git a/Tests/PlayerTests/PlayerItem/PlayerItemCreationTests.swift b/Tests/PlayerTests/PlayerItem/PlayerItemTests.swift similarity index 68% rename from Tests/PlayerTests/PlayerItem/PlayerItemCreationTests.swift rename to Tests/PlayerTests/PlayerItem/PlayerItemTests.swift index 8f7246fb4..7b733787f 100644 --- a/Tests/PlayerTests/PlayerItem/PlayerItemCreationTests.swift +++ b/Tests/PlayerTests/PlayerItem/PlayerItemTests.swift @@ -9,7 +9,7 @@ import Nimble import PillarboxStreams -final class PlayerItemCreationTests: TestCase { +final class PlayerItemTests: TestCase { func testSimpleItem() { let item = PlayerItem.simple( url: Stream.onDemand.url, @@ -17,14 +17,16 @@ final class PlayerItemCreationTests: TestCase { ) { item in item.preferredForwardBufferDuration = 4 } - expect(item.asset.resource).to(equal(.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(4)) } func testSimpleItemWithoutConfiguration() { let item = PlayerItem.simple(url: Stream.onDemand.url, metadata: AssetMetadataMock(title: "title")) - expect(item.asset.resource).to(equal(.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)) } @@ -35,15 +37,17 @@ final class PlayerItemCreationTests: TestCase { ) { item in item.preferredForwardBufferDuration = 4 } - expect(item.asset.resource).to(equal(.simple(url: Stream.onDemand.url))) - expect(item.asset.nowPlayingInfo()).to(beEmpty()) + PlayerItem.load(for: item.id) + expect(item.asset.resource).toEventually(equal(.simple(url: Stream.onDemand.url))) + expect(item.asset.nowPlayingInfo()).to(beNil()) expect(item.asset.playerItem().preferredForwardBufferDuration).to(equal(4)) } func testSimpleAssetWithoutMetadataAndConfiguration() { let item = PlayerItem.simple(url: Stream.onDemand.url) - expect(item.asset.resource).to(equal(.simple(url: Stream.onDemand.url))) - expect(item.asset.nowPlayingInfo()).to(beEmpty()) + PlayerItem.load(for: item.id) + expect(item.asset.resource).toEventually(equal(.simple(url: Stream.onDemand.url))) + expect(item.asset.nowPlayingInfo()).to(beNil()) expect(item.asset.playerItem().preferredForwardBufferDuration).to(equal(0)) } @@ -56,7 +60,8 @@ final class PlayerItemCreationTests: TestCase { ) { item in item.preferredForwardBufferDuration = 4 } - expect(item.asset.resource).to(equal(.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(4)) } @@ -64,7 +69,8 @@ final class PlayerItemCreationTests: TestCase { func testCustomAssetWithoutConfiguration() { let delegate = ResourceLoaderDelegateMock() let item = PlayerItem.custom(url: Stream.onDemand.url, delegate: delegate, metadata: AssetMetadataMock(title: "title")) - expect(item.asset.resource).to(equal(.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)) } @@ -77,16 +83,18 @@ final class PlayerItemCreationTests: TestCase { ) { item in item.preferredForwardBufferDuration = 4 } - expect(item.asset.resource).to(equal(.custom(url: Stream.onDemand.url, delegate: delegate))) - expect(item.asset.nowPlayingInfo()).to(beEmpty()) + PlayerItem.load(for: item.id) + expect(item.asset.resource).toEventually(equal(.custom(url: Stream.onDemand.url, delegate: delegate))) + expect(item.asset.nowPlayingInfo()).to(beNil()) expect(item.asset.playerItem().preferredForwardBufferDuration).to(equal(4)) } func testCustomAssetWithoutMetadataAndConfiguration() { let delegate = ResourceLoaderDelegateMock() let item = PlayerItem.custom(url: Stream.onDemand.url, delegate: delegate) - expect(item.asset.resource).to(equal(.custom(url: Stream.onDemand.url, delegate: delegate))) - expect(item.asset.nowPlayingInfo()).to(beEmpty()) + PlayerItem.load(for: item.id) + expect(item.asset.resource).toEventually(equal(.custom(url: Stream.onDemand.url, delegate: delegate))) + expect(item.asset.nowPlayingInfo()).to(beNil()) expect(item.asset.playerItem().preferredForwardBufferDuration).to(equal(0)) } @@ -99,7 +107,8 @@ final class PlayerItemCreationTests: TestCase { ) { item in item.preferredForwardBufferDuration = 4 } - expect(item.asset.resource).to(equal(.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(4)) } @@ -107,7 +116,8 @@ final class PlayerItemCreationTests: TestCase { func testEncryptedAssetWithoutConfiguration() { let delegate = ContentKeySessionDelegateMock() let item = PlayerItem.encrypted(url: Stream.onDemand.url, delegate: delegate, metadata: AssetMetadataMock(title: "title")) - expect(item.asset.resource).to(equal(.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)) } @@ -120,16 +130,18 @@ final class PlayerItemCreationTests: TestCase { ) { item in item.preferredForwardBufferDuration = 4 } - expect(item.asset.resource).to(equal(.encrypted(url: Stream.onDemand.url, delegate: delegate))) - expect(item.asset.nowPlayingInfo()).to(beEmpty()) + PlayerItem.load(for: item.id) + expect(item.asset.resource).toEventually(equal(.encrypted(url: Stream.onDemand.url, delegate: delegate))) + expect(item.asset.nowPlayingInfo()).to(beNil()) expect(item.asset.playerItem().preferredForwardBufferDuration).to(equal(4)) } func testEncryptedAssetWithoutMetadataAndConfiguration() { let delegate = ContentKeySessionDelegateMock() let item = PlayerItem.encrypted(url: Stream.onDemand.url, delegate: delegate) - expect(item.asset.resource).to(equal(.encrypted(url: Stream.onDemand.url, delegate: delegate))) - expect(item.asset.nowPlayingInfo()).to(beEmpty()) + PlayerItem.load(for: item.id) + expect(item.asset.resource).toEventually(equal(.encrypted(url: Stream.onDemand.url, delegate: delegate))) + expect(item.asset.nowPlayingInfo()).to(beNil()) expect(item.asset.playerItem().preferredForwardBufferDuration).to(equal(0)) } } diff --git a/Tests/PlayerTests/Playlist/CurrentIndexTests.swift b/Tests/PlayerTests/Playlist/CurrentIndexTests.swift index 3914ce674..5934102ca 100644 --- a/Tests/PlayerTests/Playlist/CurrentIndexTests.swift +++ b/Tests/PlayerTests/Playlist/CurrentIndexTests.swift @@ -16,7 +16,7 @@ final class CurrentIndexTests: TestCase { let item1 = PlayerItem.simple(url: Stream.shortOnDemand.url) let item2 = PlayerItem.simple(url: Stream.shortOnDemand.url) let player = Player(items: [item1, item2]) - expectAtLeastEqualPublished(values: [0, 1, nil], from: player.$currentIndex) { + expectEqualPublished(values: [0, 1, nil], from: player.$currentIndex, during: .seconds(3)) { player.play() } } @@ -25,7 +25,7 @@ final class CurrentIndexTests: TestCase { let item1 = PlayerItem.simple(url: Stream.unavailable.url) let item2 = PlayerItem.simple(url: Stream.shortOnDemand.url) let player = Player(items: [item1, item2]) - expectAtLeastEqualPublished(values: [0, 1, nil], from: player.$currentIndex) { + expectEqualPublished(values: [0], from: player.$currentIndex, during: .milliseconds(500)) { player.play() } } @@ -35,7 +35,7 @@ final class CurrentIndexTests: TestCase { let item2 = PlayerItem.simple(url: Stream.unavailable.url) let item3 = PlayerItem.simple(url: Stream.shortOnDemand.url) let player = Player(items: [item1, item2, item3]) - expectAtLeastEqualPublished(values: [0, 1, 2, nil], from: player.$currentIndex) { + expectEqualPublished(values: [0, 1], from: player.$currentIndex, during: .seconds(2)) { player.play() } } @@ -44,14 +44,14 @@ final class CurrentIndexTests: TestCase { let item1 = PlayerItem.simple(url: Stream.shortOnDemand.url) let item2 = PlayerItem.simple(url: Stream.unavailable.url) let player = Player(items: [item1, item2]) - expectAtLeastEqualPublished(values: [0, 1, nil], from: player.$currentIndex) { + expectEqualPublished(values: [0, 1], from: player.$currentIndex, during: .seconds(2)) { player.play() } } func testCurrentIndexWithFailedItem() { let player = Player(item: .simple(url: Stream.unavailable.url)) - expectAtLeastEqualPublished(values: [0, nil], from: player.$currentIndex) + expectEqualPublished(values: [0], from: player.$currentIndex, during: .milliseconds(500)) } func testCurrentIndexWithEmptyPlayer() { @@ -60,13 +60,10 @@ final class CurrentIndexTests: TestCase { } func testSlowFirstCurrentIndex() { - let item1 = PlayerItem.mock(url: Stream.shortOnDemand.url, loadedAfter: 2) + let item1 = PlayerItem.mock(url: Stream.shortOnDemand.url, loadedAfter: 1) let item2 = PlayerItem.simple(url: Stream.onDemand.url) let player = Player(items: [item1, item2]) - expectAtLeastEqualPublished( - values: [0, 1], - from: player.$currentIndex - ) { + expectEqualPublished(values: [0, 1], from: player.$currentIndex, during: .seconds(3)) { player.play() } } @@ -74,7 +71,7 @@ final class CurrentIndexTests: TestCase { func testCurrentIndexAfterPlayerEnded() { let item = PlayerItem.simple(url: Stream.shortOnDemand.url) let player = Player(items: [item]) - expectAtLeastEqualPublished(values: [0], from: player.$currentIndex) { + expectEqualPublished(values: [0, nil], from: player.$currentIndex, during: .seconds(2)) { player.play() } } @@ -83,8 +80,7 @@ final class CurrentIndexTests: TestCase { let item1 = PlayerItem.simple(url: Stream.onDemand.url) let item2 = PlayerItem.simple(url: Stream.shortOnDemand.url) let player = Player(items: [item1, item2]) - - expectAtLeastEqualPublished(values: [0, 1], from: player.$currentIndex) { + expectEqualPublished(values: [0, 1], from: player.$currentIndex, during: .milliseconds(500)) { try! player.setCurrentIndex(1) } } @@ -93,12 +89,8 @@ final class CurrentIndexTests: TestCase { let item1 = PlayerItem.simple(url: Stream.onDemand.url) let item2 = PlayerItem.simple(url: Stream.shortOnDemand.url) let player = Player(items: [item1, item2]) - let publisher = player.queuePlayer.publisher(for: \.currentItem).compactMap { item -> URL? in - guard let asset = item?.asset as? AVURLAsset else { return nil } - return asset.url - } - - expectAtLeastEqualPublished(values: [Stream.onDemand.url, Stream.shortOnDemand.url], from: publisher) { + let publisher = player.queuePlayer.currentItemPublisher().compactMap(\.?.url) + expectEqualPublishedNext(values: [Resource.loadingUrl, Stream.shortOnDemand.url], from: publisher, during: .seconds(1)) { try! player.setCurrentIndex(1) } } @@ -111,10 +103,24 @@ final class CurrentIndexTests: TestCase { func testSetCurrentIndexToSameValue() { let item = PlayerItem.simple(url: Stream.onDemand.url) let player = Player(item: item) - let publisher = player.queuePlayer.publisher(for: \.currentItem) - - expectNothingPublishedNext(from: publisher, during: .seconds(1)) { + let publisher = player.queuePlayer.currentItemPublisher().compactMap(\.?.url) + expectAtLeastEqualPublishedNext(values: [Resource.loadingUrl], from: publisher) { try! player.setCurrentIndex(0) } } + + func testPlayerPreloadedItemCount() { + let player = Player(items: [ + PlayerItem.simple(url: Stream.onDemand.url), + PlayerItem.simple(url: Stream.onDemand.url), + PlayerItem.simple(url: Stream.onDemand.url), + PlayerItem.simple(url: Stream.onDemand.url), + PlayerItem.simple(url: Stream.onDemand.url) + ]) + + try! player.setCurrentIndex(2) + + let items = player.queuePlayer.items() + expect(items.count).to(equal(player.configuration.preloadedItems)) + } } diff --git a/Tests/PlayerTests/Playlist/ItemNavigationForwardTests.swift b/Tests/PlayerTests/Playlist/ItemNavigationForwardTests.swift index d37c2fa5b..453c290ae 100644 --- a/Tests/PlayerTests/Playlist/ItemNavigationForwardTests.swift +++ b/Tests/PlayerTests/Playlist/ItemNavigationForwardTests.swift @@ -36,4 +36,18 @@ final class ItemNavigationForwardTests: TestCase { player.advanceToNextItem() expect(player.currentIndex).to(equal(2)) } + + func testPlayerPreloadedItemCount() { + let player = Player(items: [ + PlayerItem.simple(url: Stream.onDemand.url), + PlayerItem.simple(url: Stream.squareOnDemand.url), + PlayerItem.simple(url: Stream.mediumOnDemand.url), + PlayerItem.simple(url: Stream.onDemand.url), + PlayerItem.simple(url: Stream.shortOnDemand.url) + ]) + player.advanceToNextItem() + + let items = player.queuePlayer.items() + expect(items.count).to(equal(player.configuration.preloadedItems)) + } } diff --git a/Tests/PlayerTests/Playlist/ItemsTests.swift b/Tests/PlayerTests/Playlist/ItemsTests.swift index e41ae5afc..848e4bd31 100644 --- a/Tests/PlayerTests/Playlist/ItemsTests.swift +++ b/Tests/PlayerTests/Playlist/ItemsTests.swift @@ -51,4 +51,18 @@ final class ItemsTests: TestCase { expect(player.nextItems).to(beEmpty()) expect(player.previousItems).to(beEmpty()) } + + func testRemoveAll() { + let player = Player(item: .simple(url: Stream.shortOnDemand.url)) + expect(player.currentIndex).to(equal(0)) + player.removeAllItems() + expect(player.currentIndex).to(beNil()) + } + + func testAppendAfterAfterRemoveAll() { + let player = Player(item: .simple(url: Stream.shortOnDemand.url)) + player.removeAllItems() + player.append(.simple(url: Stream.onDemand.url)) + expect(player.currentIndex).to(equal(0)) + } } diff --git a/Tests/PlayerTests/Playlist/ItemsUpdateTests.swift b/Tests/PlayerTests/Playlist/ItemsUpdateTests.swift index 895f434df..9a4ecebcb 100644 --- a/Tests/PlayerTests/Playlist/ItemsUpdateTests.swift +++ b/Tests/PlayerTests/Playlist/ItemsUpdateTests.swift @@ -6,6 +6,7 @@ @testable import PillarboxPlayer +import AVFoundation import Nimble import PillarboxCircumspect import PillarboxStreams @@ -24,13 +25,13 @@ final class ItemsUpdateTests: TestCase { func testUpdateWithCurrentItemMustNotInterruptPlayback() { let item1 = PlayerItem.simple(url: Stream.onDemand.url) - let item2 = PlayerItem.simple(url: Stream.onDemand.url) - let item3 = PlayerItem.simple(url: Stream.onDemand.url) - let item4 = PlayerItem.simple(url: Stream.onDemand.url) + let item2 = PlayerItem.simple(url: Stream.onDemandWithForcedAndUnforcedLegibleOptions.url) + let item3 = PlayerItem.simple(url: Stream.onDemandWithSingleAudibleOption.url) + let item4 = PlayerItem.simple(url: Stream.shortOnDemand.url) let player = Player(items: [item1, item2, item3]) - expectNothingPublishedNext(from: player.queuePlayer.publisher(for: \.currentItem), during: .seconds(2)) { - player.items = [item4, item3, item1] - } + expect(player.queuePlayer.currentItem?.url).toEventually(equal(Stream.onDemand.url)) + player.items = [item4, item3, item1] + expect(player.queuePlayer.currentItem?.url).toAlways(equal(Stream.onDemand.url), until: .seconds(2)) } func testUpdateWithoutCurrentItem() { diff --git a/Tests/PlayerTests/Playlist/NavigationBackwardTests.swift b/Tests/PlayerTests/Playlist/NavigationBackwardTests.swift index 7c3651658..cd86cc7e3 100644 --- a/Tests/PlayerTests/Playlist/NavigationBackwardTests.swift +++ b/Tests/PlayerTests/Playlist/NavigationBackwardTests.swift @@ -119,4 +119,19 @@ final class NavigationBackwardTests: TestCase { player.returnToPreviousItem() expect(player.currentIndex).to(equal(0)) } + + func testPlayerPreloadedItemCount() { + let player = Player(items: [ + PlayerItem.simple(url: Stream.onDemand.url), + PlayerItem.simple(url: Stream.squareOnDemand.url), + PlayerItem.simple(url: Stream.mediumOnDemand.url), + PlayerItem.simple(url: Stream.onDemand.url), + PlayerItem.simple(url: Stream.shortOnDemand.url) + ]) + player.advanceToNextItem() + player.returnToPrevious() + + let items = player.queuePlayer.items() + expect(items.count).to(equal(player.configuration.preloadedItems)) + } } diff --git a/Tests/PlayerTests/Publishers/AVPlayerPublisherTests.swift b/Tests/PlayerTests/Publishers/AVPlayerErrorPublisherTests.swift similarity index 91% rename from Tests/PlayerTests/Publishers/AVPlayerPublisherTests.swift rename to Tests/PlayerTests/Publishers/AVPlayerErrorPublisherTests.swift index 482879223..de149a748 100644 --- a/Tests/PlayerTests/Publishers/AVPlayerPublisherTests.swift +++ b/Tests/PlayerTests/Publishers/AVPlayerErrorPublisherTests.swift @@ -10,14 +10,14 @@ import AVFoundation import Combine import PillarboxStreams -final class AVPlayerPublisherTests: TestCase { +final class AVPlayerErrorPublisherTests: TestCase { private static func errorPublisher(for player: AVPlayer) -> AnyPublisher { player.errorPublisher() .removeDuplicates { $0 as? NSError == $1 as? NSError } .eraseToAnyPublisher() } - func testErrorEmpty() { + func testWhenEmpty() { let player = AVQueuePlayer() expectNothingPublished(from: Self.errorPublisher(for: player), during: .milliseconds(100)) } diff --git a/Tests/PlayerTests/Publishers/AVPlayerItemTransitionPublisherTests.swift b/Tests/PlayerTests/Publishers/AVPlayerItemTransitionPublisherTests.swift new file mode 100644 index 000000000..f8d3fa645 --- /dev/null +++ b/Tests/PlayerTests/Publishers/AVPlayerItemTransitionPublisherTests.swift @@ -0,0 +1,112 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +@testable import PillarboxPlayer + +import AVFoundation +import Combine +import PillarboxStreams + +final class AVPlayerItemTransitionPublisherTests: TestCase { + func testWhenEmpty() { + let player = AVQueuePlayer() + expectEqualPublished( + values: [.advance(to: nil)], + from: player.itemTransitionPublisher(), + during: .milliseconds(100) + ) + } + + func testDuringEntirePlayback() { + let item = AVPlayerItem(url: Stream.shortOnDemand.url) + let player = AVQueuePlayer(playerItem: item) + expectEqualPublished( + values: [.advance(to: item), .finish], + from: player.itemTransitionPublisher(), + during: .seconds(2) + ) { + player.play() + } + } + + func testBetweenPlayableItems() { + let item1 = AVPlayerItem(url: Stream.shortOnDemand.url) + let item2 = AVPlayerItem(url: Stream.onDemand.url) + let player = AVQueuePlayer(items: [item1, item2]) + expectEqualPublished( + values: [.advance(to: item1), .advance(to: item2)], + from: player.itemTransitionPublisher(), + during: .seconds(2) + ) { + player.play() + } + } + + func testFailingItemFollowedByPlayableItem() { + let item1 = AVPlayerItem(url: Stream.unavailable.url) + let item2 = AVPlayerItem(url: Stream.onDemand.url) + let player = AVQueuePlayer(items: [item1, item2]) + expectEqualPublished( + values: [.advance(to: item1), .stop(on: item1)], + from: player.itemTransitionPublisher(), + during: .seconds(2) + ) { + player.play() + } + } + + func testFailingItemBetweenPlayableItems() { + let item1 = AVPlayerItem(url: Stream.shortOnDemand.url) + let item2 = AVPlayerItem(url: Stream.unavailable.url) + let item3 = AVPlayerItem(url: Stream.onDemand.url) + let player = AVQueuePlayer(items: [item1, item2, item3]) + expectEqualPublished( + values: [.advance(to: item1), .stop(on: item2)], + from: player.itemTransitionPublisher(), + during: .seconds(2) + ) { + player.play() + } + } + + func testReplaceCurrentItem() { + let item1 = AVPlayerItem(url: Stream.onDemand.url) + let item2 = AVPlayerItem(url: Stream.shortOnDemand.url) + let player = AVQueuePlayer(playerItem: item1) + expectEqualPublished( + values: [.advance(to: item1), .advance(to: item2)], + from: player.itemTransitionPublisher(), + during: .milliseconds(100) + ) { + player.replaceCurrentItem(with: item2) + } + } + + func testRemoveCurrentItemFollowedByPlayableItem() { + let item1 = AVPlayerItem(url: Stream.shortOnDemand.url) + let item2 = AVPlayerItem(url: Stream.onDemand.url) + let player = AVQueuePlayer(items: [item1, item2]) + expectEqualPublished( + values: [.advance(to: item1), .advance(to: item2)], + from: player.itemTransitionPublisher(), + during: .milliseconds(100) + ) { + player.remove(item1) + } + } + + func testRemoveAllItems() { + let item = AVPlayerItem(url: Stream.shortOnDemand.url) + let player = AVQueuePlayer(playerItem: item) + expectEqualPublished( + values: [.advance(to: item), .finish], + from: player.itemTransitionPublisher(), + during: .milliseconds(100) + ) { + player.removeAllItems() + } + } +} diff --git a/Tests/PlayerTests/Publishers/NowPlayingInfoMetadataPublisherTests.swift b/Tests/PlayerTests/Publishers/NowPlayingInfoMetadataPublisherTests.swift index dbd075303..3e1b53d4f 100644 --- a/Tests/PlayerTests/Publishers/NowPlayingInfoMetadataPublisherTests.swift +++ b/Tests/PlayerTests/Publishers/NowPlayingInfoMetadataPublisherTests.swift @@ -21,10 +21,7 @@ final class NowPlayingInfoMetadataPublisherTests: TestCase { func testImmediatelyAvailableWithoutMetadata() { let player = Player(item: .simple(url: Stream.onDemand.url)) - expectAtLeastSimilarPublished( - values: [[:]], - from: player.nowPlayingInfoMetadataPublisher() - ) + expectNothingPublished(from: player.nowPlayingInfoMetadataPublisher(), during: .seconds(1)) } func testAvailableAfterDelay() { @@ -32,7 +29,7 @@ final class NowPlayingInfoMetadataPublisherTests: TestCase { item: .mock(url: Stream.onDemand.url, loadedAfter: 0.5, withMetadata: AssetMetadataMock(title: "title")) ) expectAtLeastSimilarPublished( - values: [[:], [MPMediaItemPropertyTitle: "title"]], + values: [[MPMediaItemPropertyTitle: "title"]], from: player.nowPlayingInfoMetadataPublisher() ) } @@ -81,7 +78,6 @@ final class NowPlayingInfoMetadataPublisherTests: TestCase { let player = Player(item: .webServiceMock(media: .media1)) expectAtLeastSimilarPublished( values: [ - [:], [ MPMediaItemPropertyTitle: "Title 1", MPMediaItemPropertyArtist: "Subtitle 1", @@ -94,22 +90,21 @@ final class NowPlayingInfoMetadataPublisherTests: TestCase { values: [ [:], [ - MPMediaItemPropertyTitle: "Title 1", - MPMediaItemPropertyArtist: "Subtitle 1", - MPMediaItemPropertyComments: "Description 1" + MPMediaItemPropertyTitle: "Title 2", + MPMediaItemPropertyArtist: "Subtitle 2", + MPMediaItemPropertyComments: "Description 2" ] ], from: player.nowPlayingInfoMetadataPublisher() ) { - player.removeAllItems() - player.append(.webServiceMock(media: .media1)) + player.items = [.webServiceMock(media: .media2)] } } func testEntirePlayback() { let player = Player(item: .simple(url: Stream.shortOnDemand.url, metadata: AssetMetadataMock(title: "title"))) expectAtLeastSimilarPublished( - values: [[MPMediaItemPropertyTitle: "title"], [:]], + values: [[MPMediaItemPropertyTitle: "title"]], from: player.nowPlayingInfoMetadataPublisher() ) { player.play() diff --git a/Tests/PlayerTests/Tools/PlayerItem.swift b/Tests/PlayerTests/Tools/PlayerItem.swift index 27dac7e64..f6e0a27d0 100644 --- a/Tests/PlayerTests/Tools/PlayerItem.swift +++ b/Tests/PlayerTests/Tools/PlayerItem.swift @@ -11,6 +11,11 @@ import Foundation enum MediaMock: String { case media1 + case media2 +} + +enum MockError: Error { + case mock } extension PlayerItem { @@ -70,4 +75,8 @@ extension PlayerItem { } return .init(publisher: publisher, trackerAdapters: trackerAdapters) } + + static func failed() -> Self { + .init(publisher: Just(Asset.failed(error: MockError.mock))) + } } diff --git a/Tests/PlayerTests/Tools/Similarity.swift b/Tests/PlayerTests/Tools/Similarity.swift index bdc2b9f03..44ae66c3b 100644 --- a/Tests/PlayerTests/Tools/Similarity.swift +++ b/Tests/PlayerTests/Tools/Similarity.swift @@ -12,9 +12,8 @@ import PillarboxCircumspect extension Asset: Similar { public static func ~~ (lhs: Self, rhs: Self) -> Bool { - if let lhsUrlAsset = lhs.playerItem().asset as? AVURLAsset, - let rhsUrlAsset = rhs.playerItem().asset as? AVURLAsset { - return lhsUrlAsset.url == rhsUrlAsset.url + if let lhsUrl = lhs.playerItem().url, let rhsUrl = rhs.playerItem().url { + return lhsUrl == rhsUrl } else { return false @@ -22,6 +21,19 @@ extension Asset: Similar { } } +extension Resource: Similar { + public static func ~~ (lhs: PillarboxPlayer.Resource, rhs: PillarboxPlayer.Resource) -> Bool { + switch (lhs, rhs) { + case let (.simple(url: lhsUrl), .simple(url: rhsUrl)), + let (.custom(url: lhsUrl, delegate: _), .custom(url: rhsUrl, delegate: _)), + let (.encrypted(url: lhsUrl, delegate: _), .encrypted(url: rhsUrl, delegate: _)): + return lhsUrl == rhsUrl + default: + return false + } + } +} + extension NowPlayingInfo: Similar { public static func ~~ (lhs: Self, rhs: Self) -> Bool { // swiftlint:disable:next legacy_objc_type diff --git a/Tests/PlayerTests/Tracking/PlayerItemTrackerTests.swift b/Tests/PlayerTests/Tracking/PlayerItemTrackerTests.swift index cfa254745..542c8db73 100644 --- a/Tests/PlayerTests/Tracking/PlayerItemTrackerTests.swift +++ b/Tests/PlayerTests/Tracking/PlayerItemTrackerTests.swift @@ -60,7 +60,7 @@ final class PlayerItemTrackerTests: TestCase { func testFailedItem() { let player = Player() let publisher = NoMetadataTrackerMock.StatePublisher() - expectAtLeastEqualPublished(values: [.initialized, .enabled, .disabled], from: publisher) { + expectEqualPublished(values: [.initialized, .enabled], from: publisher, during: .milliseconds(500)) { player.append(.simple( url: Stream.unavailable.url, trackerAdapters: [NoMetadataTrackerMock.adapter(statePublisher: publisher)] @@ -72,7 +72,7 @@ final class PlayerItemTrackerTests: TestCase { func testMetadata() { let player = Player() let publisher = TrackerMock.StatePublisher() - expectAtLeastEqualPublished(values: [.initialized, .updated("title"), .enabled, .disabled], from: publisher) { + expectAtLeastEqualPublished(values: [.initialized, .enabled, .updated("title"), .disabled], from: publisher) { player.append(.simple( url: Stream.shortOnDemand.url, metadata: AssetMetadataMock(title: "title"), @@ -86,7 +86,7 @@ final class PlayerItemTrackerTests: TestCase { let player = Player() let publisher = TrackerMock.StatePublisher() expectAtLeastEqualPublished( - values: [.initialized, .updated("title0"), .enabled, .updated("title1"), .disabled], + values: [.initialized, .enabled, .updated("title0"), .updated("title1"), .disabled], from: publisher ) { player.append(.mock(url: Stream.shortOnDemand.url, withMetadataUpdateAfter: 1, trackerAdapters: [ diff --git a/Tests/PlayerTests/Tracking/PlayerTrackingTests.swift b/Tests/PlayerTests/Tracking/PlayerTrackingTests.swift index 3d1a72733..d74c02c21 100644 --- a/Tests/PlayerTests/Tracking/PlayerTrackingTests.swift +++ b/Tests/PlayerTests/Tracking/PlayerTrackingTests.swift @@ -15,9 +15,10 @@ final class PlayerTrackingTests: TestCase { player.isTrackingEnabled = false let publisher = TrackerMock.StatePublisher() - expectAtLeastEqualPublished( + expectEqualPublished( values: [.initialized, .updated("title")], - from: publisher + from: publisher, + during: .milliseconds(500) ) { player.append( .simple( @@ -70,7 +71,7 @@ final class PlayerTrackingTests: TestCase { let publisher = TrackerMock.StatePublisher() expectEqualPublished( - values: [.initialized, .updated("title"), .enabled], + values: [.initialized, .enabled, .updated("title")], from: publisher, during: .seconds(1) ) { diff --git a/Tests/PlayerTests/Types/PlayerConfigurationTests.swift b/Tests/PlayerTests/Types/PlayerConfigurationTests.swift index 1ec3afd07..a6c9445ba 100644 --- a/Tests/PlayerTests/Types/PlayerConfigurationTests.swift +++ b/Tests/PlayerTests/Types/PlayerConfigurationTests.swift @@ -18,6 +18,7 @@ final class PlayerConfigurationTests: TestCase { expect(player.configuration.isSmartNavigationEnabled).to(beTrue()) expect(player.configuration.backwardSkipInterval).to(equal(10)) expect(player.configuration.forwardSkipInterval).to(equal(10)) + expect(player.configuration.preloadedItems).to(equal(2)) } func testPlayerConfigurationInit() { diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md index 7e89b95e9..ba967edc1 100644 --- a/docs/KNOWN_ISSUES.md +++ b/docs/KNOWN_ISSUES.md @@ -26,16 +26,6 @@ Seeking near the end of a content might sometimes confuse the player (image stuc No workaround is available yet. -## Very fast playlist navigation during AirPlay playback might confuse the player - -When seeking between items very fast the receiver might get confused, not being able to cope with the number of demands and the associated network activity. As a result the receiver might get stuck. - -### Workaround - -We have mitigated AirPlay instabilities as much as possible so that fast navigation is possible in almost all practical cases. If an issue is encountered, though, closing and reopening the player should make playback possible again. - -In some extreme cases it might happen that the AirPlay receiver is unable to recover from heavy usage. If this happens restarting the receiver will make it usable again. - ## DRM playback is sometimes not possible anymore It might happen that attempting to play DRM streams always ends with an error. The reason is likely an issue with key session management. @@ -80,7 +70,7 @@ No workaround is available yet. ## Playback of a livestream in a playlist might fail if the previous item was played at a speed > 1 -When chaining an on-demand stream played at a speed > 1 to a livestream (without DVR) in a playlist, livestream playback might fail with a Core Media error. If there is an item after the livestream the livestream item will simply be skipped, otherwise the player will end in a failed state. +When chaining an on-demand stream played at a speed > 1 to a livestream (without DVR) in a playlist, livestream playback might fail with a Core Media error. If there is an item after it the livestream item will simply be skipped, otherwise the player will end in a failed state. ### Workaround diff --git a/metadata/json/media2.json b/metadata/json/media2.json new file mode 100644 index 000000000..944f457f8 --- /dev/null +++ b/metadata/json/media2.json @@ -0,0 +1,5 @@ +{ + "title": "Title 2", + "subtitle": "Subtitle 2", + "description": "Description 2" +} \ No newline at end of file