From 543408aacea837284570ee3f82553b09ab6d72dd Mon Sep 17 00:00:00 2001 From: Walid Kayhal <3347810+waliid@users.noreply.github.com> Date: Fri, 30 Aug 2024 09:34:05 +0200 Subject: [PATCH] Support repeat modes (#993) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Samuel Défago --- .../xcshareddata/swiftpm/Package.resolved | 4 +- Demo/Sources/Model/MediaList.swift | 33 +++ .../Showcase/Playlist/PlaylistView.swift | 26 +++ .../Showcase/Stories/StoriesView.swift | 2 +- .../Showcase/Stories/StoriesViewModel.swift | 2 +- Sources/Monitoring/MetricsTracker.swift | 25 +-- Sources/Player/Extensions/AVPlayerItem.swift | 71 ++++++- Sources/Player/Player+ItemNavigation.swift | 98 ++++++--- Sources/Player/Player+Navigation.swift | 12 ++ Sources/Player/Player+Queue.swift | 5 +- Sources/Player/Player.swift | 8 + Sources/Player/PlayerConfiguration.swift | 2 + Sources/Player/Types/AssetContent.swift | 19 +- Sources/Player/Types/ItemSource.swift | 33 +++ Sources/Player/Types/RepeatMode.swift | 22 ++ .../MonitoringTests/MetricsTrackerTests.swift | 13 ++ .../AVPlayerItemAssetContentUpdateTests.swift | 192 ------------------ .../AVPlayerItemRepeatAllUpdateTests.swift | 176 ++++++++++++++++ .../AVPlayerItemRepeatOffUpdateTests.swift | 176 ++++++++++++++++ .../AVPlayerItemRepeatOneUpdateTests.swift | 176 ++++++++++++++++ .../AVPlayer/AVPlayerItemTests.swift | 38 +++- .../PlayerTests/Extensions/AssetContent.swift | 16 ++ Tests/PlayerTests/Extensions/UUID.swift | 21 ++ .../ItemNavigationBackwardChecksTests.swift | 6 + .../ItemNavigationBackwardTests.swift | 9 + .../ItemNavigationForwardChecksTests.swift | 6 + .../Playlist/ItemNavigationForwardTests.swift | 10 + .../Playlist/RepeatModeTests.swift | 53 +++++ 28 files changed, 992 insertions(+), 262 deletions(-) create mode 100644 Sources/Player/Types/ItemSource.swift create mode 100644 Sources/Player/Types/RepeatMode.swift delete mode 100644 Tests/PlayerTests/AVPlayer/AVPlayerItemAssetContentUpdateTests.swift create mode 100644 Tests/PlayerTests/AVPlayer/AVPlayerItemRepeatAllUpdateTests.swift create mode 100644 Tests/PlayerTests/AVPlayer/AVPlayerItemRepeatOffUpdateTests.swift create mode 100644 Tests/PlayerTests/AVPlayer/AVPlayerItemRepeatOneUpdateTests.swift create mode 100644 Tests/PlayerTests/Extensions/AssetContent.swift create mode 100644 Tests/PlayerTests/Extensions/UUID.swift create mode 100644 Tests/PlayerTests/Playlist/RepeatModeTests.swift diff --git a/Demo/Pillarbox-demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Demo/Pillarbox-demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 739fa2e85..d8a00447e 100644 --- a/Demo/Pillarbox-demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Demo/Pillarbox-demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -77,8 +77,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Quick/Nimble.git", "state" : { - "revision" : "1c49fc1243018f81a7ea99cb5e0985b00096e9f4", - "version" : "13.3.0" + "revision" : "54b4e52183f16fe806014cbfd63718a84f8ba072", + "version" : "13.4.0" } }, { diff --git a/Demo/Sources/Model/MediaList.swift b/Demo/Sources/Model/MediaList.swift index b8e0b9a23..c3338c28c 100644 --- a/Demo/Sources/Model/MediaList.swift +++ b/Demo/Sources/Model/MediaList.swift @@ -107,6 +107,39 @@ enum MediaList { ) ] + static let storyUrns = [ + Media( + title: "Mario vs Sonic", + subtitle: "Tataki 1", + type: .urn("urn:rts:video:13950405") + ), + Media( + title: "Pourquoi Beyoncé fait de la country", + subtitle: "Tataki 2", + type: .urn("urn:rts:video:14815579") + ), + Media( + title: "L'île North Sentinel", + subtitle: "Tataki 3", + type: .urn("urn:rts:video:13795051") + ), + Media( + title: "Mourir pour ressembler à une idole", + subtitle: "Tataki 4", + type: .urn("urn:rts:video:14020134") + ), + Media( + title: "Pourquoi les gens mangent des insectes ?", + subtitle: "Tataki 5", + type: .urn("urn:rts:video:12631996") + ), + Media( + title: "Le concert de Beyoncé à Dubai", + subtitle: "Tataki 6", + type: .urn("urn:rts:video:13752646") + ) + ] + static let longVideoUrns = [ Media( title: "J'ai pas l'air malade mais… (#1)", diff --git a/Demo/Sources/Showcase/Playlist/PlaylistView.swift b/Demo/Sources/Showcase/Playlist/PlaylistView.swift index 749f3eddc..804cc4a15 100644 --- a/Demo/Sources/Showcase/Playlist/PlaylistView.swift +++ b/Demo/Sources/Showcase/Playlist/PlaylistView.swift @@ -27,6 +27,17 @@ private struct Toolbar: View { @ObservedObject private var model: PlaylistViewModel @State private var isSelectionPresented = false + private var repeatModeImageName: String { + switch player.repeatMode { + case .off: + "repeat.circle" + case .one: + "repeat.1.circle.fill" + case .all: + "repeat.circle.fill" + } + } + var body: some View { HStack { previousButton() @@ -58,6 +69,10 @@ private struct Toolbar: View { @ViewBuilder private func managementButtons() -> some View { HStack(spacing: 30) { + Button(action: toggleRepeatMode) { + Image(systemName: repeatModeImageName) + } + Button(action: model.shuffle) { Image(systemName: "shuffle") } @@ -82,6 +97,17 @@ private struct Toolbar: View { .disabled(!player.canAdvanceToNext()) } + private func toggleRepeatMode() { + switch player.repeatMode { + case .off: + player.repeatMode = .all + case .one: + player.repeatMode = .off + case .all: + player.repeatMode = .one + } + } + private func add() { isSelectionPresented.toggle() } diff --git a/Demo/Sources/Showcase/Stories/StoriesView.swift b/Demo/Sources/Showcase/Stories/StoriesView.swift index 419cd2562..b42085f55 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: MediaList.videoUrls)) + @StateObject private var model = StoriesViewModel(stories: Story.stories(from: MediaList.storyUrns)) var body: some View { TabView(selection: $model.currentStory) { diff --git a/Demo/Sources/Showcase/Stories/StoriesViewModel.swift b/Demo/Sources/Showcase/Stories/StoriesViewModel.swift index a1b135be0..3758d9c43 100644 --- a/Demo/Sources/Showcase/Stories/StoriesViewModel.swift +++ b/Demo/Sources/Showcase/Stories/StoriesViewModel.swift @@ -35,7 +35,7 @@ final class StoriesViewModel: ObservableObject { private static func player(for story: Story) -> Player { let player = Player(item: story.media.item(), configuration: .externalPlaybackDisabled) - player.actionAtItemEnd = .pause + player.repeatMode = .one return player } diff --git a/Sources/Monitoring/MetricsTracker.swift b/Sources/Monitoring/MetricsTracker.swift index fe8d0cdde..44518d5cd 100644 --- a/Sources/Monitoring/MetricsTracker.swift +++ b/Sources/Monitoring/MetricsTracker.swift @@ -47,6 +47,7 @@ public final class MetricsTracker: PlayerItemTracker { public func updateMetricEvents(to events: [MetricEvent]) { switch events.last?.kind { case .asset: + reset(with: properties) session.start() sendEvent(name: .start, data: startData(from: events)) startHeartbeat() @@ -70,13 +71,7 @@ public final class MetricsTracker: PlayerItemTracker { } public func disable(with properties: PlayerProperties) { - defer { - reset() - } - stopHeartbeat() - if session.isStarted { - sendEvent(name: .stop, data: statusData(from: properties)) - } + reset(with: properties) } } @@ -185,10 +180,16 @@ private extension MetricsTracker { } } - func reset() { - stallDuration = 0 - session.reset() - stopwatch.reset() + func reset(with properties: PlayerProperties?) { + defer { + stallDuration = 0 + session.reset() + stopwatch.reset() + } + stopHeartbeat() + if let properties, session.isStarted { + sendEvent(name: .stop, data: statusData(from: properties)) + } } } @@ -200,7 +201,7 @@ private extension MetricsTracker { }() func sendEvent(name: EventName, data: some Encodable) { - guard let sessionId = session.id else { return} + guard let sessionId = session.id else { return } let payload = MetricPayload(sessionId: sessionId, eventName: name, data: data) guard let httpBody = try? Self.jsonEncoder.encode(payload) else { return } diff --git a/Sources/Player/Extensions/AVPlayerItem.swift b/Sources/Player/Extensions/AVPlayerItem.swift index df7d9164f..869eaa085 100644 --- a/Sources/Player/Extensions/AVPlayerItem.swift +++ b/Sources/Player/Extensions/AVPlayerItem.swift @@ -5,6 +5,7 @@ // import AVFoundation +import AVKit private var kIdKey: Void? @@ -28,38 +29,75 @@ extension AVPlayerItem { /// - currentContents: The current list of contents. /// - previousContents: The previous list of contents. /// - currentItem: The item currently being played by the player. + /// - repeatMode: The current repeat mode setting. + /// - length: The maximum number of items to return. /// - Returns: The list of player items to load into the player. static func playerItems( for currentContents: [AssetContent], replacing previousContents: [AssetContent], currentItem: AVPlayerItem?, + repeatMode: RepeatMode, length: Int ) -> [AVPlayerItem] { - guard let currentItem else { return playerItems(from: Array(currentContents.prefix(length))) } + let sources = itemSources(for: currentContents, replacing: previousContents, currentItem: currentItem) + let updatedSources = updatedItemSources(sources, repeatMode: repeatMode, firstContent: currentContents.first) + return playerItems(from: updatedSources, length: length, reload: false) + } + + private static func updatedItemSources(_ sources: [ItemSource], repeatMode: RepeatMode, firstContent: AssetContent?) -> [ItemSource] { + switch repeatMode { + case .off: + return sources + case .one: + guard let firstSource = sources.first else { return sources } + var updatedSources = sources + updatedSources.insert(firstSource.copy(), at: 1) + return updatedSources + case .all: + guard let firstContent else { return sources } + var updatedSources = sources + updatedSources.append(.new(content: firstContent)) + return updatedSources + } + } + + private static func itemSources( + for currentContents: [AssetContent], + replacing previousContents: [AssetContent], + currentItem: AVPlayerItem? + ) -> [ItemSource] { + guard let currentItem else { return newItemSources(from: Array(currentContents)) } 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))) + return [.reused(content: currentContent, item: currentItem)] + newItemSources(from: Array(currentContents.suffix(from: currentIndex + 1))) } else { - return playerItems(from: Array(currentContents.suffix(from: currentIndex).prefix(length))) + return newItemSources(from: Array(currentContents.suffix(from: currentIndex))) } } else if let commonIndex = firstCommonIndex(in: currentContents, matching: previousContents, after: currentItem) { - return playerItems(from: Array(currentContents.suffix(from: commonIndex).prefix(length))) + return newItemSources(from: Array(currentContents.suffix(from: commonIndex))) } else { - return playerItems(from: Array(currentContents.prefix(length))) + return newItemSources(from: Array(currentContents)) } } - static func playerItems(from items: [PlayerItem], length: Int, reload: Bool) -> [AVPlayerItem] { - playerItems(from: items.prefix(length).map(\.content), reload: reload) + static func playerItems(from items: [PlayerItem], after index: Int, repeatMode: RepeatMode, length: Int, reload: Bool) -> [AVPlayerItem] { + let afterContents = items.suffix(from: index).map(\.content) + let sources = updatedItemSources(newItemSources(from: afterContents), repeatMode: repeatMode, firstContent: items.first?.content) + return playerItems(from: sources, length: length, reload: reload) } - private static func playerItems(from contents: [AssetContent], reload: Bool = false) -> [AVPlayerItem] { - contents.map { $0.playerItem(reload: reload) } + private static func playerItems(from sources: [ItemSource], length: Int, reload: Bool) -> [AVPlayerItem] { + sources + .prefix(length) + .map { $0.playerItem(reload: reload) } + } + + private static func newItemSources(from contents: [AssetContent]) -> [ItemSource] { + contents.map { .new(content: $0) } } private static func matchingIndex(for item: AVPlayerItem, in contents: [AssetContent]) -> Int? { @@ -109,4 +147,17 @@ extension AVPlayerItem { self.id = id return self } + + func updated(with content: AssetContent) -> AVPlayerItem { + externalMetadata = content.metadata.externalMetadata +#if os(tvOS) + interstitialTimeRanges = content.metadata.blockedTimeRanges.map { timeRange in + .init(timeRange: timeRange) + } + navigationMarkerGroups = [ + AVNavigationMarkersGroup(title: "chapters", timedNavigationMarkers: content.metadata.timedNavigationMarkers) + ] +#endif + return self + } } diff --git a/Sources/Player/Player+ItemNavigation.swift b/Sources/Player/Player+ItemNavigation.swift index 654913203..37557804a 100644 --- a/Sources/Player/Player+ItemNavigation.swift +++ b/Sources/Player/Player+ItemNavigation.swift @@ -12,6 +12,9 @@ public extension Player { /// /// - Returns: `true` if possible. /// + /// The behavior of this method is adjusted to wrap around both ends of the item queue when ``Player/repeatMode`` + /// has been set to ``RepeatMode/all``. + /// /// > Important: Ignores the ``PlayerConfiguration/navigationMode`` set in the ``Player/configuration``. func canReturnToPreviousItem() -> Bool { canReturnToItem(before: currentItem, in: storedItems) @@ -19,16 +22,30 @@ public extension Player { /// Returns to the previous item in the queue. /// + /// The behavior of this method is adjusted to wrap around both ends of the item queue when ``Player/repeatMode`` + /// has been set to ``RepeatMode/all``. + /// /// > Important: Ignores the ``PlayerConfiguration/navigationMode`` set in the ``Player/configuration``. func returnToPreviousItem() { - guard canReturnToPreviousItem() else { return } - queuePlayer.replaceItems(with: AVPlayerItem.playerItems(from: returningItems, length: configuration.preloadedItems, reload: true)) + guard let index = index(before: currentItem, in: storedItems) else { return } + queuePlayer.replaceItems( + with: AVPlayerItem.playerItems( + from: Array(storedItems), + after: index, + repeatMode: repeatMode, + length: configuration.preloadedItems, + reload: true + ) + ) } /// Checks whether moving to the next item in the queue is possible. /// /// - Returns: `true` if possible. /// + /// The behavior of this method is adjusted to wrap around both ends of the item queue when ``Player/repeatMode`` + /// has been set to ``RepeatMode/all``. + /// /// > Important: Ignores the ``PlayerConfiguration/navigationMode`` set in the ``Player/configuration``. func canAdvanceToNextItem() -> Bool { canAdvanceToItem(after: currentItem, in: storedItems) @@ -36,56 +53,91 @@ public extension Player { /// Moves to the next item in the queue. /// + /// The behavior of this method is adjusted to wrap around both ends of the item queue when ``Player/repeatMode`` + /// has been set to ``RepeatMode/all``. + /// /// > Important: Ignores the ``PlayerConfiguration/navigationMode`` set in the ``Player/configuration``. func advanceToNextItem() { - guard canAdvanceToNextItem() else { return } - queuePlayer.replaceItems(with: AVPlayerItem.playerItems(from: advancingItems, length: configuration.preloadedItems, reload: true)) + guard let index = index(after: currentItem, in: storedItems) else { return } + queuePlayer.replaceItems( + with: AVPlayerItem.playerItems( + from: Array(storedItems), + after: index, + repeatMode: repeatMode, + length: configuration.preloadedItems, + reload: true + ) + ) } } extension Player { func canReturnToItem(before item: PlayerItem?, in items: Deque) -> Bool { - !Self.items(before: item, in: items).isEmpty + index(before: item, in: items) != nil } func canAdvanceToItem(after item: PlayerItem?, in items: Deque) -> Bool { - !Self.items(after: item, in: items).isEmpty + index(after: item, in: items) != nil } func replaceCurrentItemWithItem(_ item: PlayerItem?) { if let item { guard storedItems.contains(item), let index = storedItems.firstIndex(of: item) else { return } - let playerItems = AVPlayerItem.playerItems(from: Array(storedItems.suffix(from: index)), length: configuration.preloadedItems, reload: true) + let playerItems = AVPlayerItem.playerItems( + from: Array(storedItems), + after: index, + repeatMode: repeatMode, + length: configuration.preloadedItems, + reload: true + ) queuePlayer.replaceItems(with: playerItems) } else { queuePlayer.removeAllItems() } } + + func reloadItems() { + let contents = Array(storedItems).map(\.content) + let items = AVPlayerItem.playerItems( + for: contents, + replacing: contents, + currentItem: queuePlayer.currentItem, + repeatMode: repeatMode, + length: configuration.preloadedItems + ) + queuePlayer.replaceItems(with: items) + } } private extension Player { - /// Returns the list of items to be loaded to return to the previous (playable) item. - var returningItems: [PlayerItem] { - Self.items(before: currentItem, in: storedItems) + func index(before item: PlayerItem?, in items: Deque) -> Int? { + guard let item, let index = items.firstIndex(of: item) else { return nil } + let previousIndex = items.index(before: index) + return previousIndex >= items.startIndex ? previousIndex : beforeIndex() } - /// Returns the list of items to be loaded to advance to the next (playable) item. - var advancingItems: [PlayerItem] { - Self.items(after: currentItem, in: storedItems) + func index(after item: PlayerItem?, in items: Deque) -> Int? { + guard let item, let index = items.firstIndex(of: item) else { return nil } + let nextIndex = items.index(after: index) + return nextIndex < items.endIndex ? nextIndex : afterIndex() } - static func items(before item: PlayerItem?, in items: Deque) -> [PlayerItem] { - guard let item, let index = items.firstIndex(of: item) else { return [] } - let previousIndex = items.index(before: index) - guard previousIndex >= 0 else { return [] } - return Array(items.suffix(from: previousIndex)) + func afterIndex() -> Int? { + switch repeatMode { + case .off, .one: + return nil + case .all: + return items.startIndex + } } - static func items(after item: PlayerItem?, in items: Deque) -> [PlayerItem] { - guard let item, let index = items.firstIndex(of: item) else { return [] } - let nextIndex = items.index(after: index) - guard nextIndex < items.count else { return [] } - return Array(items.suffix(from: nextIndex)) + func beforeIndex() -> Int? { + switch repeatMode { + case .off, .one: + return nil + case .all: + return items.index(before: items.endIndex) + } } } diff --git a/Sources/Player/Player+Navigation.swift b/Sources/Player/Player+Navigation.swift index 74e09c5f7..47700204d 100644 --- a/Sources/Player/Player+Navigation.swift +++ b/Sources/Player/Player+Navigation.swift @@ -13,6 +13,9 @@ public extension Player { /// /// - Returns: `true` if possible. /// + /// The behavior of this method is adjusted to wrap around both ends of the item queue when ``Player/repeatMode`` + /// has been set to ``RepeatMode/all``. + /// /// > Important: Observes the ``PlayerConfiguration/navigationMode`` set in the ``Player/configuration``. func canReturnToPrevious() -> Bool { canReturn(before: currentItem, in: storedItems, streamType: streamType) @@ -20,6 +23,9 @@ public extension Player { /// Returns to the previous content. /// + /// The behavior of this method is adjusted to wrap around both ends of the item queue when ``Player/repeatMode`` + /// has been set to ``RepeatMode/all``. + /// /// > Important: Observes the ``PlayerConfiguration/navigationMode`` set in the ``Player/configuration``. func returnToPrevious() { if shouldSeekToStartTime() { @@ -34,6 +40,9 @@ public extension Player { /// /// - Returns: `true` if possible. /// + /// The behavior of this method is adjusted to wrap around both ends of the item queue when ``Player/repeatMode`` + /// has been set to ``RepeatMode/all``. + /// /// > Important: Observes the ``PlayerConfiguration/navigationMode`` set in the ``Player/configuration``. func canAdvanceToNext() -> Bool { canAdvanceToNextItem() @@ -41,6 +50,9 @@ public extension Player { /// Moves to the next content. /// + /// The behavior of this method is adjusted to wrap around both ends of the item queue when ``Player/repeatMode`` + /// has been set to ``RepeatMode/all``. + /// /// > Important: Observes the ``PlayerConfiguration/navigationMode`` set in the ``Player/configuration``. func advanceToNext() { advanceToNextItem() diff --git a/Sources/Player/Player+Queue.swift b/Sources/Player/Player+Queue.swift index 0f39cbe20..31c7d0f1e 100644 --- a/Sources/Player/Player+Queue.swift +++ b/Sources/Player/Player+Queue.swift @@ -31,14 +31,15 @@ extension Player { func queuePlayerItemsPublisher() -> AnyPublisher<[AVPlayerItem], Never> { queuePublisher .withPrevious(.empty) - .compactMap { [configuration] previous, current in - guard let buffer = Queue.buffer(from: previous, to: current, length: configuration.preloadedItems) else { + .compactMap { [weak self, configuration] previous, current in + guard let self, let buffer = Queue.buffer(from: previous, to: current, length: configuration.preloadedItems) else { return nil } return AVPlayerItem.playerItems( for: current.elements.map(\.content), replacing: previous.elements.map(\.content), currentItem: buffer.item, + repeatMode: repeatMode, length: buffer.length ) } diff --git a/Sources/Player/Player.swift b/Sources/Player/Player.swift index 5a166f694..e8ab2fdca 100644 --- a/Sources/Player/Player.swift +++ b/Sources/Player/Player.swift @@ -33,6 +33,14 @@ public final class Player: ObservableObject, Equatable { /// The metadata related to the item being played. @Published public private(set) var metadata: PlayerMetadata = .empty + /// The mode with which the player repeats playback of items in its queue. + @Published public var repeatMode: RepeatMode = .off { + didSet { + guard !canReplay() else { return } + reloadItems() + } + } + @Published var storedItems: Deque @Published var _playbackSpeed: PlaybackSpeed = .indefinite diff --git a/Sources/Player/PlayerConfiguration.swift b/Sources/Player/PlayerConfiguration.swift index 5a92aea80..0695be60d 100644 --- a/Sources/Player/PlayerConfiguration.swift +++ b/Sources/Player/PlayerConfiguration.swift @@ -7,6 +7,8 @@ import Foundation /// A player configuration. +/// +/// The configuration controls behaviors set at player creation time and that cannot be changed afterwards. public struct PlayerConfiguration { /// The default configuration. public static let `default` = Self() diff --git a/Sources/Player/Types/AssetContent.swift b/Sources/Player/Types/AssetContent.swift index e9040ef45..84aee62e0 100644 --- a/Sources/Player/Types/AssetContent.swift +++ b/Sources/Player/Types/AssetContent.swift @@ -5,7 +5,6 @@ // import AVFoundation -import AVKit struct AssetContent { let id: UUID @@ -22,30 +21,16 @@ struct AssetContent { .init(id: id, resource: .failing(error: error), metadata: .empty, configuration: .default, dateInterval: nil) } - func update(item: AVPlayerItem) { - item.externalMetadata = metadata.externalMetadata -#if os(tvOS) - item.interstitialTimeRanges = metadata.blockedTimeRanges.map { timeRange in - .init(timeRange: timeRange) - } - item.navigationMarkerGroups = [ - AVNavigationMarkersGroup(title: "chapters", timedNavigationMarkers: metadata.timedNavigationMarkers) - ] -#endif - } - func playerItem(reload: Bool = false) -> AVPlayerItem { if reload, resource.isFailing { - let item = Resource.loading.playerItem().withId(id) + let item = Resource.loading.playerItem().withId(id).updated(with: self) configure(item: item) - update(item: item) PlayerItem.reload(for: id) return item } else { - let item = resource.playerItem().withId(id) + let item = resource.playerItem().withId(id).updated(with: self) configure(item: item) - update(item: item) PlayerItem.load(for: id) return item } diff --git a/Sources/Player/Types/ItemSource.swift b/Sources/Player/Types/ItemSource.swift new file mode 100644 index 000000000..1bae7afe1 --- /dev/null +++ b/Sources/Player/Types/ItemSource.swift @@ -0,0 +1,33 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +import AVFoundation + +struct ItemSource { + private let content: AssetContent + private let item: AVPlayerItem? + + static func new(content: AssetContent) -> Self { + .init(content: content, item: nil) + } + + static func reused(content: AssetContent, item: AVPlayerItem) -> Self { + .init(content: content, item: item) + } + + func copy() -> Self { + .init(content: content, item: nil) + } + + func playerItem(reload: Bool) -> AVPlayerItem { + if let item { + return item.updated(with: content) + } + else { + return content.playerItem(reload: reload) + } + } +} diff --git a/Sources/Player/Types/RepeatMode.swift b/Sources/Player/Types/RepeatMode.swift new file mode 100644 index 000000000..42a99c18c --- /dev/null +++ b/Sources/Player/Types/RepeatMode.swift @@ -0,0 +1,22 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +import Foundation + +/// A mode setting how a player repeats playback of items in its queue. +public enum RepeatMode { + /// Disabled. + case off + + /// Repeat the current item. + case one + + /// Repeat all items. + /// + /// The behavior of player advance and return navigation methods is adjusted to wrap around both ends of the item + /// queue. + case all +} diff --git a/Tests/MonitoringTests/MetricsTrackerTests.swift b/Tests/MonitoringTests/MetricsTrackerTests.swift index 132c4f22b..bf9a04424 100644 --- a/Tests/MonitoringTests/MetricsTrackerTests.swift +++ b/Tests/MonitoringTests/MetricsTrackerTests.swift @@ -210,4 +210,17 @@ final class MetricsTrackerTests: MonitoringTestCase { player.play() } } + + func testRepeatOne() { + let player = Player(item: .simple( + url: Stream.shortOnDemand.url, + trackerAdapters: [ + MetricsTracker.adapter(configuration: .test) { _ in .test } + ] + )) + player.repeatMode = .one + expectAtLeastHits(start(), heartbeat(), stop(), start(), heartbeat(), stop()) { + player.play() + } + } } diff --git a/Tests/PlayerTests/AVPlayer/AVPlayerItemAssetContentUpdateTests.swift b/Tests/PlayerTests/AVPlayer/AVPlayerItemAssetContentUpdateTests.swift deleted file mode 100644 index c489a7706..000000000 --- a/Tests/PlayerTests/AVPlayer/AVPlayerItemAssetContentUpdateTests.swift +++ /dev/null @@ -1,192 +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 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, - configuration: .default, - dateInterval: nil - ) - } -} - -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/AVPlayer/AVPlayerItemRepeatAllUpdateTests.swift b/Tests/PlayerTests/AVPlayer/AVPlayerItemRepeatAllUpdateTests.swift new file mode 100644 index 000000000..904653a6e --- /dev/null +++ b/Tests/PlayerTests/AVPlayer/AVPlayerItemRepeatAllUpdateTests.swift @@ -0,0 +1,176 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +@testable import PillarboxPlayer + +import AVFoundation +import Nimble +import PillarboxCircumspect +import PillarboxStreams + +final class AVPlayerItemRepeatAllUpdateTests: 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 items = AVPlayerItem.playerItems( + for: currentContents, + replacing: previousContents, + currentItem: nil, + repeatMode: .all, + length: .max + ) + expect(items.map(\.id)).to(equalDiff([UUID("A"), UUID("B"), UUID("C"), UUID("A")])) + } + + 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 items = AVPlayerItem.playerItems( + for: currentContents, + replacing: previousContents, + currentItem: currentItem, + repeatMode: .all, + length: .max + ) + expect(items.map(\.id)).to(equalDiff([UUID("3"), UUID("B"), UUID("C"), UUID("A")])) + expect(items.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 items = AVPlayerItem.playerItems( + for: currentContents, + replacing: previousContents, + currentItem: currentItem, + repeatMode: .all, + length: .max + ) + expect(items.map(\.id)).to(equalDiff([UUID("3"), UUID("A")])) + expect(items.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 items = AVPlayerItem.playerItems( + for: currentContents, + replacing: previousContents, + currentItem: unknownItem, + repeatMode: .all, + length: .max + ) + expect(items.map(\.id)).to(equalDiff([UUID("A"), UUID("B"), UUID("A")])) + } + + 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 items = AVPlayerItem.playerItems( + for: currentContents, + replacing: previousContents, + currentItem: currentItem, + repeatMode: .all, + length: .max + ) + expect(items.map(\.id)).to(equalDiff([UUID("2"), UUID("C"), UUID("3")])) + } + + 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 items = AVPlayerItem.playerItems( + for: currentContents, + replacing: previousContents, + currentItem: currentItem, + repeatMode: .all, + length: .max + ) + expect(items.map(\.id)).to(equalDiff([UUID("1"), UUID("2"), UUID("3"), UUID("1")])) + expect(items.first).to(equal(currentItem)) + } + + func testPlayerItemsLength() { + let currentContents: [AssetContent] = [ + .test(id: "A"), + .test(id: "B"), + .test(id: "C"), + .test(id: "D") + ] + let items = AVPlayerItem.playerItems( + for: currentContents, + replacing: [], + currentItem: nil, + repeatMode: .all, + length: 2 + ) + expect(items.map(\.id)).to(equalDiff([UUID("A"), UUID("B")])) + } +} diff --git a/Tests/PlayerTests/AVPlayer/AVPlayerItemRepeatOffUpdateTests.swift b/Tests/PlayerTests/AVPlayer/AVPlayerItemRepeatOffUpdateTests.swift new file mode 100644 index 000000000..9cb26eba7 --- /dev/null +++ b/Tests/PlayerTests/AVPlayer/AVPlayerItemRepeatOffUpdateTests.swift @@ -0,0 +1,176 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +@testable import PillarboxPlayer + +import AVFoundation +import Nimble +import PillarboxCircumspect +import PillarboxStreams + +final class AVPlayerItemRepeatOffUpdateTests: 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 items = AVPlayerItem.playerItems( + for: currentContents, + replacing: previousContents, + currentItem: nil, + repeatMode: .off, + length: .max + ) + expect(items.map(\.id)).to(equalDiff([UUID("A"), UUID("B"), UUID("C")])) + } + + 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 items = AVPlayerItem.playerItems( + for: currentContents, + replacing: previousContents, + currentItem: currentItem, + repeatMode: .off, + length: .max + ) + expect(items.map(\.id)).to(equalDiff([UUID("3"), UUID("B"), UUID("C")])) + expect(items.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 items = AVPlayerItem.playerItems( + for: currentContents, + replacing: previousContents, + currentItem: currentItem, + repeatMode: .off, + length: .max + ) + expect(items.map(\.id)).to(equalDiff([UUID("3")])) + expect(items.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 items = AVPlayerItem.playerItems( + for: currentContents, + replacing: previousContents, + currentItem: unknownItem, + repeatMode: .off, + length: .max + ) + expect(items.map(\.id)).to(equalDiff([UUID("A"), UUID("B")])) + } + + 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 items = AVPlayerItem.playerItems( + for: currentContents, + replacing: previousContents, + currentItem: currentItem, + repeatMode: .off, + length: .max + ) + expect(items.map(\.id)).to(equalDiff([UUID("2"), UUID("C")])) + } + + 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 items = AVPlayerItem.playerItems( + for: currentContents, + replacing: previousContents, + currentItem: currentItem, + repeatMode: .off, + length: .max + ) + expect(items.map(\.id)).to(equalDiff([UUID("1"), UUID("2"), UUID("3")])) + expect(items.first).to(equal(currentItem)) + } + + func testPlayerItemsLength() { + let currentContents: [AssetContent] = [ + .test(id: "A"), + .test(id: "B"), + .test(id: "C"), + .test(id: "D") + ] + let items = AVPlayerItem.playerItems( + for: currentContents, + replacing: [], + currentItem: nil, + repeatMode: .off, + length: 2 + ) + expect(items.map(\.id)).to(equalDiff([UUID("A"), UUID("B")])) + } +} diff --git a/Tests/PlayerTests/AVPlayer/AVPlayerItemRepeatOneUpdateTests.swift b/Tests/PlayerTests/AVPlayer/AVPlayerItemRepeatOneUpdateTests.swift new file mode 100644 index 000000000..cf9d4f186 --- /dev/null +++ b/Tests/PlayerTests/AVPlayer/AVPlayerItemRepeatOneUpdateTests.swift @@ -0,0 +1,176 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +@testable import PillarboxPlayer + +import AVFoundation +import Nimble +import PillarboxCircumspect +import PillarboxStreams + +final class AVPlayerItemRepeatOneUpdateTests: 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 items = AVPlayerItem.playerItems( + for: currentContents, + replacing: previousContents, + currentItem: nil, + repeatMode: .one, + length: .max + ) + expect(items.map(\.id)).to(equalDiff([UUID("A"), UUID("A"), UUID("B"), UUID("C")])) + } + + 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 items = AVPlayerItem.playerItems( + for: currentContents, + replacing: previousContents, + currentItem: currentItem, + repeatMode: .one, + length: .max + ) + expect(items.map(\.id)).to(equalDiff([UUID("3"), UUID("3"), UUID("B"), UUID("C")])) + expect(items.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 items = AVPlayerItem.playerItems( + for: currentContents, + replacing: previousContents, + currentItem: currentItem, + repeatMode: .one, + length: .max + ) + expect(items.map(\.id)).to(equalDiff([UUID("3"), UUID("3")])) + expect(items.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 items = AVPlayerItem.playerItems( + for: currentContents, + replacing: previousContents, + currentItem: unknownItem, + repeatMode: .one, + length: .max + ) + expect(items.map(\.id)).to(equalDiff([UUID("A"), UUID("A"), UUID("B")])) + } + + 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 items = AVPlayerItem.playerItems( + for: currentContents, + replacing: previousContents, + currentItem: currentItem, + repeatMode: .one, + length: .max + ) + expect(items.map(\.id)).to(equalDiff([UUID("2"), UUID("2"), UUID("C")])) + } + + 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 items = AVPlayerItem.playerItems( + for: currentContents, + replacing: previousContents, + currentItem: currentItem, + repeatMode: .one, + length: .max + ) + expect(items.map(\.id)).to(equalDiff([UUID("1"), UUID("1"), UUID("2"), UUID("3")])) + expect(items.first).to(equal(currentItem)) + } + + func testPlayerItemsLength() { + let currentContents: [AssetContent] = [ + .test(id: "A"), + .test(id: "B"), + .test(id: "C"), + .test(id: "D") + ] + let items = AVPlayerItem.playerItems( + for: currentContents, + replacing: [], + currentItem: nil, + repeatMode: .one, + length: 2 + ) + expect(items.map(\.id)).to(equalDiff([UUID("A"), UUID("A")])) + } +} diff --git a/Tests/PlayerTests/AVPlayer/AVPlayerItemTests.swift b/Tests/PlayerTests/AVPlayer/AVPlayerItemTests.swift index 5b0265692..4510ba165 100644 --- a/Tests/PlayerTests/AVPlayer/AVPlayerItemTests.swift +++ b/Tests/PlayerTests/AVPlayer/AVPlayerItemTests.swift @@ -22,14 +22,14 @@ final class AVPlayerItemTests: TestCase { expect(item.timeRange).toEventuallyNot(equal(.invalid)) } - func testPlayerItems() { + func testPlayerItemsWithRepeatOff() { let items = [ PlayerItem.simple(url: Stream.onDemand.url), PlayerItem.simple(url: Stream.shortOnDemand.url), PlayerItem.simple(url: Stream.live.url) ] expect { - AVPlayerItem.playerItems(from: items, length: 3, reload: false).compactMap(\.url) + AVPlayerItem.playerItems(from: items, after: 0, repeatMode: .off, length: .max, reload: false).compactMap(\.url) } .toEventually(equal([ Stream.onDemand.url, @@ -37,4 +37,38 @@ final class AVPlayerItemTests: TestCase { Stream.live.url ])) } + + func testPlayerItemsWithRepeatOne() { + let items = [ + PlayerItem.simple(url: Stream.onDemand.url), + PlayerItem.simple(url: Stream.shortOnDemand.url), + PlayerItem.simple(url: Stream.live.url) + ] + expect { + AVPlayerItem.playerItems(from: items, after: 0, repeatMode: .one, length: .max, reload: false).compactMap(\.url) + } + .toEventually(equal([ + Stream.onDemand.url, + Stream.onDemand.url, + Stream.shortOnDemand.url, + Stream.live.url + ])) + } + + func testPlayerItemsWithRepeatAll() { + let items = [ + PlayerItem.simple(url: Stream.onDemand.url), + PlayerItem.simple(url: Stream.shortOnDemand.url), + PlayerItem.simple(url: Stream.live.url) + ] + expect { + AVPlayerItem.playerItems(from: items, after: 0, repeatMode: .all, length: .max, reload: false).compactMap(\.url) + } + .toEventually(equal([ + Stream.onDemand.url, + Stream.shortOnDemand.url, + Stream.live.url, + Stream.onDemand.url + ])) + } } diff --git a/Tests/PlayerTests/Extensions/AssetContent.swift b/Tests/PlayerTests/Extensions/AssetContent.swift new file mode 100644 index 000000000..51d39a873 --- /dev/null +++ b/Tests/PlayerTests/Extensions/AssetContent.swift @@ -0,0 +1,16 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +@testable import PillarboxPlayer + +import Foundation +import PillarboxStreams + +extension AssetContent { + static func test(id: Character) -> Self { + AssetContent(id: UUID(id), resource: .simple(url: Stream.onDemand.url), metadata: .empty, configuration: .default, dateInterval: nil) + } +} diff --git a/Tests/PlayerTests/Extensions/UUID.swift b/Tests/PlayerTests/Extensions/UUID.swift new file mode 100644 index 000000000..5c30bf11f --- /dev/null +++ b/Tests/PlayerTests/Extensions/UUID.swift @@ -0,0 +1,21 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +import Foundation + +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/Playlist/ItemNavigationBackwardChecksTests.swift b/Tests/PlayerTests/Playlist/ItemNavigationBackwardChecksTests.swift index 1dcd733d7..6ff6bc0bf 100644 --- a/Tests/PlayerTests/Playlist/ItemNavigationBackwardChecksTests.swift +++ b/Tests/PlayerTests/Playlist/ItemNavigationBackwardChecksTests.swift @@ -29,4 +29,10 @@ final class ItemNavigationBackwardChecksTests: TestCase { let player = Player() expect(player.canReturnToPreviousItem()).to(beFalse()) } + + func testWrapAtFrontWithRepeatAll() { + let player = Player(item: .simple(url: Stream.onDemand.url)) + player.repeatMode = .all + expect(player.canReturnToPreviousItem()).to(beTrue()) + } } diff --git a/Tests/PlayerTests/Playlist/ItemNavigationBackwardTests.swift b/Tests/PlayerTests/Playlist/ItemNavigationBackwardTests.swift index 4dac94630..174fc0fce 100644 --- a/Tests/PlayerTests/Playlist/ItemNavigationBackwardTests.swift +++ b/Tests/PlayerTests/Playlist/ItemNavigationBackwardTests.swift @@ -36,4 +36,13 @@ final class ItemNavigationBackwardTests: TestCase { player.returnToPreviousItem() expect(player.currentItem).to(equal(item1)) } + + func testWrapAtFrontWithRepeatAll() { + let item1 = PlayerItem.simple(url: Stream.shortOnDemand.url) + let item2 = PlayerItem.simple(url: Stream.onDemand.url) + let player = Player(items: [item1, item2]) + player.repeatMode = .all + player.returnToPreviousItem() + expect(player.currentItem).to(equal(item2)) + } } diff --git a/Tests/PlayerTests/Playlist/ItemNavigationForwardChecksTests.swift b/Tests/PlayerTests/Playlist/ItemNavigationForwardChecksTests.swift index 50e706ff1..98a6d21c5 100644 --- a/Tests/PlayerTests/Playlist/ItemNavigationForwardChecksTests.swift +++ b/Tests/PlayerTests/Playlist/ItemNavigationForwardChecksTests.swift @@ -29,4 +29,10 @@ final class ItemNavigationForwardChecksTests: TestCase { let player = Player() expect(player.canAdvanceToNextItem()).to(beFalse()) } + + func testWrapAtBackWithRepeatAll() { + let player = Player(item: .simple(url: Stream.onDemand.url)) + player.repeatMode = .all + expect(player.canAdvanceToNextItem()).to(beTrue()) + } } diff --git a/Tests/PlayerTests/Playlist/ItemNavigationForwardTests.swift b/Tests/PlayerTests/Playlist/ItemNavigationForwardTests.swift index ccd0c0fa7..116bff7d2 100644 --- a/Tests/PlayerTests/Playlist/ItemNavigationForwardTests.swift +++ b/Tests/PlayerTests/Playlist/ItemNavigationForwardTests.swift @@ -50,4 +50,14 @@ final class ItemNavigationForwardTests: TestCase { let items = player.queuePlayer.items() expect(items.count).to(equal(player.configuration.preloadedItems)) } + + func testWrapAtBackWithRepeatAll() { + let item1 = PlayerItem.simple(url: Stream.shortOnDemand.url) + let item2 = PlayerItem.simple(url: Stream.onDemand.url) + let player = Player(items: [item1, item2]) + player.repeatMode = .all + player.advanceToNextItem() + player.advanceToNextItem() + expect(player.currentItem).to(equal(item1)) + } } diff --git a/Tests/PlayerTests/Playlist/RepeatModeTests.swift b/Tests/PlayerTests/Playlist/RepeatModeTests.swift new file mode 100644 index 000000000..298cc51b6 --- /dev/null +++ b/Tests/PlayerTests/Playlist/RepeatModeTests.swift @@ -0,0 +1,53 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +@testable import PillarboxPlayer + +import Nimble +import PillarboxStreams + +final class RepeatModeTests: TestCase { + func testRepeatOne() { + let item1 = PlayerItem.simple(url: Stream.shortOnDemand.url) + let item2 = PlayerItem.simple(url: Stream.onDemand.url) + let player = Player(items: [item1, item2]) + player.repeatMode = .one + player.play() + expect(player.currentItem).toAlways(equal(item1), until: .seconds(2)) + player.repeatMode = .off + expect(player.currentItem).toEventually(equal(item2)) + } + + func testRepeatAll() { + let item1 = PlayerItem.simple(url: Stream.shortOnDemand.url) + let item2 = PlayerItem.simple(url: Stream.shortOnDemand.url) + let player = Player(items: [item1, item2]) + player.repeatMode = .all + player.play() + expect(player.currentItem).toEventually(equal(item1)) + expect(player.currentItem).toEventually(equal(item2)) + expect(player.currentItem).toEventually(equal(item1)) + player.repeatMode = .off + expect(player.currentItem).toEventually(equal(item2)) + expect(player.currentItem).toEventually(beNil()) + } + + func testRepeatModeUpdateDoesNotRestartPlayback() { + let player = Player(item: .simple(url: Stream.onDemand.url)) + player.play() + expect(player.streamType).toEventually(equal(.onDemand)) + player.repeatMode = .one + expect(player.streamType).toNever(equal(.unknown), until: .milliseconds(100)) + } + + func testRepeatModeUpdateDoesNotReplay() { + let player = Player(item: .simple(url: Stream.shortOnDemand.url)) + player.play() + expect(player.currentItem).toEventually(beNil()) + player.repeatMode = .one + expect(player.currentItem).toAlways(beNil(), until: .milliseconds(100)) + } +}