diff --git a/Demo/Sources/AppDelegate.swift b/Demo/Sources/AppDelegate.swift index ca7b6ed6b..747c22e8d 100644 --- a/Demo/Sources/AppDelegate.swift +++ b/Demo/Sources/AppDelegate.swift @@ -16,6 +16,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { // swiftlint:disable:next discouraged_optional_collection func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { try? AVAudioSession.sharedInstance().setCategory(.playback) + UserDefaults.standard.registerDefaults() configureShowTime() return true } diff --git a/Demo/Sources/SettingsView.swift b/Demo/Sources/SettingsView.swift index ba46e843e..d03ff0272 100644 --- a/Demo/Sources/SettingsView.swift +++ b/Demo/Sources/SettingsView.swift @@ -17,16 +17,16 @@ struct SettingsView: View { @AppStorage(UserDefaults.seekBehaviorSettingKey) private var seekBehaviorSetting: SeekBehaviorSetting = .immediate - @AppStorage(UserDefaults.allowsExternalPlaybackSettingKey) - private var allowsExternalPlaybackSetting = true + @AppStorage(UserDefaults.allowsExternalPlaybackKey) + private var allowsExternalPlayback = true - @AppStorage(UserDefaults.audiovisualBackgroundPlaybackPolicySettingKey) - private var audiovisualBackgroundPlaybackPolicySettingKey: AVPlayerAudiovisualBackgroundPlaybackPolicy = .automatic + @AppStorage(UserDefaults.audiovisualBackgroundPlaybackPolicyKey) + private var audiovisualBackgroundPlaybackPolicyKey: AVPlayerAudiovisualBackgroundPlaybackPolicy = .automatic var body: some View { List { Toggle("Presenter mode", isOn: $isPresentedModeEnabled) - Toggle("Allows external playback", isOn: $allowsExternalPlaybackSetting) + Toggle("Allows external playback", isOn: $allowsExternalPlayback) seekBehaviorPicker() audiovisualBackgroundPlaybackPolicyPicker() Toggle("Body counters", isOn: $areBodyCountersEnabled) @@ -49,7 +49,7 @@ struct SettingsView: View { @ViewBuilder private func audiovisualBackgroundPlaybackPolicyPicker() -> some View { - Picker("Audiovisual background policy", selection: $audiovisualBackgroundPlaybackPolicySettingKey) { + Picker("Audiovisual background policy", selection: $audiovisualBackgroundPlaybackPolicyKey) { Text("Automatic").tag(AVPlayerAudiovisualBackgroundPlaybackPolicy.automatic) Text("Continues if possible").tag(AVPlayerAudiovisualBackgroundPlaybackPolicy.continuesIfPossible) Text("Pauses").tag(AVPlayerAudiovisualBackgroundPlaybackPolicy.pauses) diff --git a/Demo/Sources/UserDefaults.swift b/Demo/Sources/UserDefaults.swift index 96a705891..7e7136e73 100644 --- a/Demo/Sources/UserDefaults.swift +++ b/Demo/Sources/UserDefaults.swift @@ -22,8 +22,8 @@ extension UserDefaults { static let presenterModeEnabledKey = "presenterModeEnabled" static let bodyCountersEnabledKey = "bodyCountersEnabled" static let seekBehaviorSettingKey = "seekBehaviorSetting" - static let allowsExternalPlaybackSettingKey = "allowsExternalPlaybackSetting" - static let audiovisualBackgroundPlaybackPolicySettingKey = "audiovisualBackgroundPlaybackPolicySetting" + static let allowsExternalPlaybackKey = "allowsExternalPlayback" + static let audiovisualBackgroundPlaybackPolicyKey = "audiovisualBackgroundPlaybackPolicy" @objc dynamic var presenterModeEnabled: Bool { bool(forKey: Self.presenterModeEnabledKey) @@ -47,10 +47,20 @@ extension UserDefaults { } @objc dynamic var allowsExternalPlaybackEnabled: Bool { - bool(forKey: Self.allowsExternalPlaybackSettingKey) + bool(forKey: Self.allowsExternalPlaybackKey) } @objc dynamic var audiovisualBackgroundPlaybackPolicy: AVPlayerAudiovisualBackgroundPlaybackPolicy { - .init(rawValue: integer(forKey: Self.audiovisualBackgroundPlaybackPolicySettingKey)) ?? .automatic + .init(rawValue: integer(forKey: Self.audiovisualBackgroundPlaybackPolicyKey)) ?? .automatic + } + + func registerDefaults() { + register(defaults: [ + Self.presenterModeEnabledKey: false, + Self.bodyCountersEnabledKey: false, + Self.seekBehaviorSettingKey: SeekBehaviorSetting.immediate.rawValue, + Self.allowsExternalPlaybackKey: true, + Self.audiovisualBackgroundPlaybackPolicyKey: AVPlayerAudiovisualBackgroundPlaybackPolicy.automatic.rawValue + ]) } } diff --git a/Sources/Player/Player.swift b/Sources/Player/Player.swift index 296dd60cc..3cb902a6a 100644 --- a/Sources/Player/Player.swift +++ b/Sources/Player/Player.swift @@ -38,11 +38,11 @@ public final class Player: ObservableObject, Equatable { /// Current time. public var time: CMTime { - rawPlayer.currentTime() + queuePlayer.currentTime() } - /// Raw player used for playback. - let rawPlayer: RawPlayer + /// Low-level player used for playback. + let queuePlayer = QueuePlayer() public let configuration: PlayerConfiguration private var cancellables = Set() @@ -68,7 +68,6 @@ public final class Player: ObservableObject, Equatable { /// - items: The items to be queued initially. /// - configuration: The configuration to apply to the player. public init(items: [PlayerItem] = [], configuration: PlayerConfiguration = .init()) { - rawPlayer = RawPlayer() storedItems = Deque(items) self.configuration = configuration @@ -79,7 +78,7 @@ public final class Player: ObservableObject, Equatable { configureSeekingPublisher() configureBufferingPublisher() configureCurrentIndexPublisher() - configureRawPlayerUpdatePublisher() + configureQueueUpdatePublisher() configureExternalPlaybackPublisher() configurePlayer() @@ -98,28 +97,28 @@ public final class Player: ObservableObject, Equatable { } deinit { - rawPlayer.cancelPendingReplacements() + queuePlayer.cancelPendingReplacements() } } public extension Player { /// Resume playback. func play() { - rawPlayer.play() + queuePlayer.play() } /// Pause playback. func pause() { - rawPlayer.pause() + queuePlayer.pause() } /// Toggle playback between play and pause. func togglePlayPause() { - if rawPlayer.rate != 0 { - rawPlayer.pause() + if queuePlayer.rate != 0 { + queuePlayer.pause() } else { - rawPlayer.play() + queuePlayer.play() } } @@ -135,7 +134,7 @@ public extension Player { toleranceAfter: CMTime = .positiveInfinity, completionHandler: @escaping (Bool) -> Void = { _ in } ) { - rawPlayer.seek(to: time, toleranceBefore: toleranceBefore, toleranceAfter: toleranceAfter, completionHandler: completionHandler) + queuePlayer.seek(to: time, toleranceBefore: toleranceBefore, toleranceAfter: toleranceAfter, completionHandler: completionHandler) } /// Seek to a given location. @@ -150,7 +149,7 @@ public extension Player { toleranceBefore: CMTime = .positiveInfinity, toleranceAfter: CMTime = .positiveInfinity ) async -> Bool { - await rawPlayer.seek(to: time, toleranceBefore: toleranceBefore, toleranceAfter: toleranceAfter) + await queuePlayer.seek(to: time, toleranceBefore: toleranceBefore, toleranceAfter: toleranceAfter) } /// Return whether the current player item player can be returned to live conditions. @@ -173,7 +172,7 @@ public extension Player { /// - Parameter completionHandler: A completion handler called when skipping ends. func skipToLive(completionHandler: @escaping (Bool) -> Void = { _ in }) { guard canSkipToLive(), timeRange.isValid else { return } - rawPlayer.seek( + queuePlayer.seek( to: timeRange.end, toleranceBefore: .positiveInfinity, toleranceAfter: .positiveInfinity @@ -187,12 +186,12 @@ public extension Player { /// not a livestream or does not support DVR. func skipToLive() async { guard canSkipToLive(), timeRange.isValid else { return } - await rawPlayer.seek( + await queuePlayer.seek( to: timeRange.end, toleranceBefore: .positiveInfinity, toleranceAfter: .positiveInfinity ) - rawPlayer.play() + queuePlayer.play() } } @@ -204,7 +203,7 @@ public extension Player { /// - queue: The queue on which values are published. /// - Returns: The publisher. func periodicTimePublisher(forInterval interval: CMTime, queue: DispatchQueue = .main) -> AnyPublisher { - Publishers.PeriodicTimePublisher(for: rawPlayer, interval: interval, queue: queue) + Publishers.PeriodicTimePublisher(for: queuePlayer, interval: interval, queue: queue) } /// Return a publisher emitting when traversing the specified times during normal playback. @@ -213,7 +212,7 @@ public extension Player { /// - queue: The queue on which values are published. /// - Returns: The publisher. func boundaryTimePublisher(for times: [CMTime], queue: DispatchQueue = .main) -> AnyPublisher { - Publishers.BoundaryTimePublisher(for: rawPlayer, times: times, queue: queue) + Publishers.BoundaryTimePublisher(for: queuePlayer, times: times, queue: queue) } } @@ -411,7 +410,7 @@ public extension Player { /// Return to the previous item in the deque. Skips failed items. func returnToPreviousItem() { guard canReturnToPreviousItem() else { return } - rawPlayer.replaceItems(with: AVPlayerItem.playerItems(from: returningItems)) + queuePlayer.replaceItems(with: AVPlayerItem.playerItems(from: returningItems)) } /// Check whether moving to the next item in the deque is possible.` @@ -423,7 +422,7 @@ public extension Player { /// Move to the next item in the deque. func advanceToNextItem() { guard canAdvanceToNextItem() else { return } - rawPlayer.replaceItems(with: AVPlayerItem.playerItems(from: advancingItems)) + queuePlayer.replaceItems(with: AVPlayerItem.playerItems(from: advancingItems)) } /// Set the index of the current item. @@ -432,65 +431,55 @@ public extension Player { guard index != currentIndex else { return } guard (0.. AnyPublisher in - guard let item else { return Just(.invalid).eraseToAnyPublisher() } - return item.asset.propertyPublisher(.minimumTimeOffsetFromLive) - .map { CMTimeMultiplyByRatio($0, multiplier: 1, divisor: 3) } // The minimum offset represents 3 chunks - .replaceError(with: .invalid) - .prepend(.invalid) - .eraseToAnyPublisher() - } - .switchToLatest() - .removeDuplicates() + queuePlayer.chunkDurationPublisher() .receiveOnMainThread() .lane("player_chunk_duration") .assign(to: &$chunkDuration) } private func configureSeekingPublisher() { - rawPlayer.seekingPublisher() + queuePlayer.seekingPublisher() .receiveOnMainThread() .lane("player_seeking") .assign(to: &$isSeeking) } private func configureBufferingPublisher() { - rawPlayer.bufferingPublisher() + queuePlayer.bufferingPublisher() .receiveOnMainThread() .lane("player_buffering") .assign(to: &$isBuffering) } private func configureCurrentIndexPublisher() { - Publishers.CombineLatest($storedItems, rawPlayer.publisher(for: \.currentItem)) + Publishers.CombineLatest($storedItems, queuePlayer.publisher(for: \.currentItem)) .filter { storedItems, currentItem in // The current item is automatically set to `nil` when a failure is encountered. If this is the case // preserve the previous value, provided the player is loaded with items. @@ -505,15 +494,15 @@ extension Player { .assign(to: &$currentIndex) } - private func configureRawPlayerUpdatePublisher() { + private func configureQueueUpdatePublisher() { sourcesPublisher() .withPrevious() - .map { [rawPlayer] sources in - AVPlayerItem.playerItems(for: sources.current, replacing: sources.previous ?? [], currentItem: rawPlayer.currentItem) + .map { [queuePlayer] sources in + AVPlayerItem.playerItems(for: sources.current, replacing: sources.previous ?? [], currentItem: queuePlayer.currentItem) } .receiveOnMainThread() - .sink { [rawPlayer] items in - rawPlayer.replaceItems(with: items) + .sink { [queuePlayer] items in + queuePlayer.replaceItems(with: items) } .store(in: &cancellables) } @@ -530,14 +519,14 @@ extension Player { } private func configureExternalPlaybackPublisher() { - rawPlayer.publisher(for: \.isExternalPlaybackActive) + queuePlayer.publisher(for: \.isExternalPlaybackActive) .receiveOnMainThread() .assign(to: &$isExternalPlaybackActive) } private func configurePlayer() { - rawPlayer.allowsExternalPlayback = configuration.allowsExternalPlayback - rawPlayer.usesExternalPlaybackWhileExternalScreenIsActive = configuration.usesExternalPlaybackWhileMirroring - rawPlayer.audiovisualBackgroundPlaybackPolicy = configuration.audiovisualBackgroundPlaybackPolicy + queuePlayer.allowsExternalPlayback = configuration.allowsExternalPlayback + queuePlayer.usesExternalPlaybackWhileExternalScreenIsActive = configuration.usesExternalPlaybackWhileMirroring + queuePlayer.audiovisualBackgroundPlaybackPolicy = configuration.audiovisualBackgroundPlaybackPolicy } } diff --git a/Sources/Player/PlayerItem.swift b/Sources/Player/PlayerItem.swift index 41ac31730..c137c0ab1 100644 --- a/Sources/Player/PlayerItem.swift +++ b/Sources/Player/PlayerItem.swift @@ -69,9 +69,21 @@ extension Source { } extension AVPlayerItem { + var timeRange: CMTimeRange? { + Self.timeRange(loadedTimeRanges: loadedTimeRanges, seekableTimeRanges: seekableTimeRanges) + } + static func playerItems(from items: [PlayerItem]) -> [AVPlayerItem] { playerItems(from: items.map(\.source)) } + + static func timeRange(loadedTimeRanges: [NSValue], seekableTimeRanges: [NSValue]) -> CMTimeRange? { + guard let firstRange = seekableTimeRanges.first?.timeRangeValue, !firstRange.isIndefinite, + let lastRange = seekableTimeRanges.last?.timeRangeValue, !lastRange.isIndefinite else { + return !loadedTimeRanges.isEmpty ? .zero : nil + } + return CMTimeRangeFromTimeToTime(start: firstRange.start, end: lastRange.end) + } } private extension AVPlayerItem { diff --git a/Sources/Player/PlayerItemPublishers.swift b/Sources/Player/PlayerItemPublishers.swift index 5a550ad23..47f58dd23 100644 --- a/Sources/Player/PlayerItemPublishers.swift +++ b/Sources/Player/PlayerItemPublishers.swift @@ -22,18 +22,11 @@ extension AVPlayerItem { } func timeRangePublisher() -> AnyPublisher { - Publishers.CombineLatest3( + Publishers.CombineLatest( publisher(for: \.loadedTimeRanges), - publisher(for: \.seekableTimeRanges), - publisher(for: \.duration) + publisher(for: \.seekableTimeRanges) ) - .compactMap { loadedTimeRanges, seekableTimeRanges, _ in - guard let firstRange = seekableTimeRanges.first?.timeRangeValue, !firstRange.isIndefinite, - let lastRange = seekableTimeRanges.last?.timeRangeValue, !lastRange.isIndefinite else { - return !loadedTimeRanges.isEmpty ? .zero : nil - } - return CMTimeRangeFromTimeToTime(start: firstRange.start, end: lastRange.end) - } + .compactMap { Self.timeRange(loadedTimeRanges: $0, seekableTimeRanges: $1) } .eraseToAnyPublisher() } } diff --git a/Sources/Player/PlayerPublishers.swift b/Sources/Player/PlayerPublishers.swift index f9773f58d..7cdd54187 100644 --- a/Sources/Player/PlayerPublishers.swift +++ b/Sources/Player/PlayerPublishers.swift @@ -96,4 +96,19 @@ extension AVPlayer { .removeDuplicates() .eraseToAnyPublisher() } + + func chunkDurationPublisher() -> AnyPublisher { + publisher(for: \.currentItem) + .map { item -> AnyPublisher in + guard let item else { return Just(.invalid).eraseToAnyPublisher() } + return item.asset.propertyPublisher(.minimumTimeOffsetFromLive) + .map { CMTimeMultiplyByRatio($0, multiplier: 1, divisor: 3) } // The minimum offset represents 3 chunks + .replaceError(with: .invalid) + .prepend(.invalid) + .eraseToAnyPublisher() + } + .switchToLatest() + .removeDuplicates() + .eraseToAnyPublisher() + } } diff --git a/Sources/Player/RawPlayer.swift b/Sources/Player/QueuePlayer.swift similarity index 67% rename from Sources/Player/RawPlayer.swift rename to Sources/Player/QueuePlayer.swift index 01810192a..619358bf8 100644 --- a/Sources/Player/RawPlayer.swift +++ b/Sources/Player/QueuePlayer.swift @@ -5,16 +5,30 @@ // import AVFoundation +import Combine -final class RawPlayer: AVQueuePlayer { +final class QueuePlayer: AVQueuePlayer { + private static let offset = CMTime(value: 12, timescale: 1) private var seekCount = 0 + private static func safeSeekTime(_ time: CMTime, for item: AVPlayerItem?) -> CMTime { + guard let item, let timeRange = item.timeRange, !item.duration.isIndefinite /* DVR stream */ else { + return time + } + return CMTimeMinimum(time, CMTimeMaximum(timeRange.end - offset, .zero)) + } + override func seek(to time: CMTime, toleranceBefore: CMTime, toleranceAfter: CMTime, completionHandler: @escaping (Bool) -> Void) { if seekCount == 0 { NotificationCenter.default.post(name: .willSeek, object: self) } seekCount += 1 - super.seek(to: time, toleranceBefore: toleranceBefore, toleranceAfter: toleranceAfter) { finished in + super.seek( + to: Self.safeSeekTime(time, for: currentItem), + toleranceBefore: toleranceBefore, + toleranceAfter: toleranceAfter + ) { [weak self] finished in + guard let self else { return } self.seekCount -= 1 if self.seekCount == 0 { NotificationCenter.default.post(name: .didSeek, object: self) @@ -60,6 +74,6 @@ final class RawPlayer: AVQueuePlayer { } extension Notification.Name { - static let willSeek = Notification.Name("RawPlayerWillSeekNotification") - static let didSeek = Notification.Name("RawPlayerDidSeekNotification") + static let willSeek = Notification.Name("QueuePlayerWillSeekNotification") + static let didSeek = Notification.Name("QueuePlayerDidSeekNotification") } diff --git a/Sources/Player/SystemVideoView.swift b/Sources/Player/SystemVideoView.swift index 8fb3b43ee..6d82966cc 100644 --- a/Sources/Player/SystemVideoView.swift +++ b/Sources/Player/SystemVideoView.swift @@ -15,7 +15,7 @@ public struct SystemVideoView: View { @ObservedObject private var player: Player public var body: some View { - VideoPlayer(player: player.rawPlayer) + VideoPlayer(player: player.queuePlayer) } public init(player: Player) { diff --git a/Sources/Player/VideoView.swift b/Sources/Player/VideoView.swift index 916f85c4c..39e6dbb8f 100644 --- a/Sources/Player/VideoView.swift +++ b/Sources/Player/VideoView.swift @@ -37,12 +37,12 @@ public struct VideoView: UIViewRepresentable { public func makeUIView(context: Context) -> VideoLayerView { let view = VideoLayerView() view.backgroundColor = .clear - view.player = player.rawPlayer + view.player = player.queuePlayer view.playerLayer.videoGravity = gravity return view } public func updateUIView(_ uiView: VideoLayerView, context: Context) { - uiView.player = player.rawPlayer + uiView.player = player.queuePlayer } } diff --git a/Tests/PlayerTests/ChunkDurationPublisherTests.swift b/Tests/PlayerTests/ChunkDurationPublisherTests.swift new file mode 100644 index 000000000..96d72d8d8 --- /dev/null +++ b/Tests/PlayerTests/ChunkDurationPublisherTests.swift @@ -0,0 +1,76 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +@testable import Player + +import AVFoundation +import Circumspect +import XCTest + +final class ChunkDurationPublisherTests: XCTestCase { + func testChunkDuration() { + let item = AVPlayerItem(url: Stream.shortOnDemand.url) + let player = AVPlayer(playerItem: item) + expectEqualPublished( + values: [.invalid, CMTime(value: 1, timescale: 1)], + from: player.chunkDurationPublisher(), + during: 3 + ) + } + + func testChunkDurationDuringEntirePlayback() { + let item = AVPlayerItem(url: Stream.shortOnDemand.url) + let player = AVPlayer(playerItem: item) + expectAtLeastEqualPublished( + values: [.invalid, CMTime(value: 1, timescale: 1)], + from: player.chunkDurationPublisher() + ) { + player.play() + } + } + + func testChunkDurationDuringEntirePlaybackInQueuePlayerAdvancingAtItemEnd() { + let item = AVPlayerItem(url: Stream.shortOnDemand.url) + let player = AVQueuePlayer(playerItem: item) + player.actionAtItemEnd = .advance + expectAtLeastEqualPublished( + values: [.invalid, CMTime(value: 1, timescale: 1), .invalid], + from: player.chunkDurationPublisher() + ) { + player.play() + } + } + + func testChunkDurationDuringEntirePlaybackInQueuePlayerPausingAtItemEnd() { + let item = AVPlayerItem(url: Stream.shortOnDemand.url) + let player = AVQueuePlayer(playerItem: item) + player.actionAtItemEnd = .pause + expectAtLeastEqualPublished( + values: [.invalid, CMTime(value: 1, timescale: 1)], + from: player.chunkDurationPublisher() + ) { + player.play() + } + } + + func testCheckDurationsDuringItemChange() { + let item1 = AVPlayerItem(url: Stream.shortOnDemand.url) + let item2 = AVPlayerItem(url: Stream.onDemand.url) + let player = AVQueuePlayer(items: [item1, item2]) + expectEqualPublished( + values: [ + .invalid, + CMTime(value: 1, timescale: 1), + .invalid, + CMTime(value: 4, timescale: 1) + ], + from: player.chunkDurationPublisher(), + during: 3 + ) { + player.play() + } + } +} diff --git a/Tests/PlayerTests/ItemUpdateTests.swift b/Tests/PlayerTests/ItemUpdateTests.swift index 13e433bdb..37a139265 100644 --- a/Tests/PlayerTests/ItemUpdateTests.swift +++ b/Tests/PlayerTests/ItemUpdateTests.swift @@ -28,7 +28,7 @@ final class ItemUpdateTests: XCTestCase { let item3 = PlayerItem(url: Stream.item(numbered: 3).url) let item4 = PlayerItem(url: Stream.item(numbered: 4).url) let player = Player(items: [item1, item2, item3]) - expectNothingPublishedNext(from: player.rawPlayer.publisher(for: \.currentItem), during: 2) { + expectNothingPublishedNext(from: player.queuePlayer.publisher(for: \.currentItem), during: 2) { player.items = [item4, item3, item1] } } diff --git a/Tests/PlayerTests/PlayerCurrentIndexTests.swift b/Tests/PlayerTests/PlayerCurrentIndexTests.swift index 5d27bb8f5..370031818 100644 --- a/Tests/PlayerTests/PlayerCurrentIndexTests.swift +++ b/Tests/PlayerTests/PlayerCurrentIndexTests.swift @@ -61,7 +61,7 @@ final class PlayerCurrentIndexTests: XCTestCase { let item1 = PlayerItem(url: Stream.onDemand.url) let item2 = PlayerItem(url: Stream.shortOnDemand.url) let player = Player(items: [item1, item2]) - let publisher = player.rawPlayer.publisher(for: \.currentItem).compactMap { item -> URL? in + let publisher = player.queuePlayer.publisher(for: \.currentItem).compactMap { item -> URL? in guard let asset = item?.asset as? AVURLAsset else { return nil } return asset.url } @@ -79,7 +79,7 @@ final class PlayerCurrentIndexTests: XCTestCase { func testSetCurrentIndexToSameValue() { let item = PlayerItem(url: Stream.onDemand.url) let player = Player(item: item) - let publisher = player.rawPlayer.publisher(for: \.currentItem) + let publisher = player.queuePlayer.publisher(for: \.currentItem) expectNothingPublishedNext(from: publisher, during: 1) { try! player.setCurrentIndex(0) diff --git a/Tests/PlayerTests/PlayerTests.swift b/Tests/PlayerTests/PlayerTests.swift index 17d0f2141..e74bcdf43 100644 --- a/Tests/PlayerTests/PlayerTests.swift +++ b/Tests/PlayerTests/PlayerTests.swift @@ -12,45 +12,6 @@ import Nimble import XCTest final class PlayerTests: XCTestCase { - func testChunkDuration() { - let item = PlayerItem(url: Stream.shortOnDemand.url) - let player = Player(item: item) - expectEqualPublished( - values: [.invalid, CMTime(value: 1, timescale: 1)], - from: player.$chunkDuration, - during: 3 - ) - } - - func testChunkDurationDuringEntirePlayback() { - let item = PlayerItem(url: Stream.shortOnDemand.url) - let player = Player(item: item) - expectAtLeastEqualPublished( - values: [.invalid, CMTime(value: 1, timescale: 1), .invalid], - from: player.$chunkDuration - ) { - player.play() - } - } - - func testCheckDurationsDuringItemChange() { - let item1 = PlayerItem(url: Stream.shortOnDemand.url) - let item2 = PlayerItem(url: Stream.onDemand.url) - let player = Player(items: [item1, item2]) - expectEqualPublished( - values: [ - .invalid, - CMTime(value: 1, timescale: 1), - .invalid, - CMTime(value: 4, timescale: 1) - ], - from: player.$chunkDuration, - during: 3 - ) { - player.play() - } - } - func testDeallocation() { let item = PlayerItem(url: Stream.onDemand.url) var player: Player? = Player(item: item) diff --git a/Tests/PlayerTests/RawPlayerTests.swift b/Tests/PlayerTests/QueuePlayerTests.swift similarity index 88% rename from Tests/PlayerTests/RawPlayerTests.swift rename to Tests/PlayerTests/QueuePlayerTests.swift index 81ef2a41c..7a7092872 100644 --- a/Tests/PlayerTests/RawPlayerTests.swift +++ b/Tests/PlayerTests/QueuePlayerTests.swift @@ -11,18 +11,18 @@ import Circumspect import Nimble import XCTest -final class RawPlayerTests: XCTestCase { +final class QueuePlayerTests: XCTestCase { func testReplaceItemsWithEmptyList() { let item1 = AVPlayerItem(url: Stream.item(numbered: 1).url) let item2 = AVPlayerItem(url: Stream.item(numbered: 2).url) let item3 = AVPlayerItem(url: Stream.item(numbered: 3).url) - let player = RawPlayer(items: [item1, item2, item3]) + let player = QueuePlayer(items: [item1, item2, item3]) player.replaceItems(with: []) expect(player.items()).to(beEmpty()) } func testReplaceItemsWhenEmpty() { - let player = RawPlayer() + let player = QueuePlayer() let item1 = AVPlayerItem(url: Stream.item(numbered: 1).url) let item2 = AVPlayerItem(url: Stream.item(numbered: 2).url) let item3 = AVPlayerItem(url: Stream.item(numbered: 3).url) @@ -35,7 +35,7 @@ final class RawPlayerTests: XCTestCase { let item1 = AVPlayerItem(url: Stream.item(numbered: 1).url) let item2 = AVPlayerItem(url: Stream.item(numbered: 2).url) let item3 = AVPlayerItem(url: Stream.item(numbered: 3).url) - let player = RawPlayer(items: [item1, item2, item3]) + let player = QueuePlayer(items: [item1, item2, item3]) let item4 = AVPlayerItem(url: Stream.item(numbered: 4).url) let item5 = AVPlayerItem(url: Stream.item(numbered: 5).url) player.replaceItems(with: [item4, item5]) @@ -47,7 +47,7 @@ final class RawPlayerTests: XCTestCase { let item1 = AVPlayerItem(url: Stream.item(numbered: 1).url) let item2 = AVPlayerItem(url: Stream.item(numbered: 2).url) let item3 = AVPlayerItem(url: Stream.item(numbered: 3).url) - let player = RawPlayer(items: [item1, item2, item3]) + let player = QueuePlayer(items: [item1, item2, item3]) let item4 = AVPlayerItem(url: Stream.item(numbered: 4).url) player.replaceItems(with: [item1, item4]) expect(player.items()).to(equalDiff([item1])) @@ -57,7 +57,7 @@ final class RawPlayerTests: XCTestCase { func testReplaceItemsWithIdenticalItems() { let item1 = AVPlayerItem(url: Stream.item(numbered: 1).url) let item2 = AVPlayerItem(url: Stream.item(numbered: 2).url) - let player = RawPlayer(items: [item1, item2]) + let player = QueuePlayer(items: [item1, item2]) player.replaceItems(with: [item1, item2]) expect(player.items()).to(equalDiff([item1, item2])) } @@ -66,7 +66,7 @@ final class RawPlayerTests: XCTestCase { let item1 = AVPlayerItem(url: Stream.item(numbered: 1).url) let item2 = AVPlayerItem(url: Stream.item(numbered: 2).url) let item3 = AVPlayerItem(url: Stream.item(numbered: 3).url) - let player = RawPlayer(items: [item1, item2, item3]) + let player = QueuePlayer(items: [item1, item2, item3]) player.replaceItems(with: [item2, item3]) expect(player.items()).to(equalDiff([item2])) expect(player.items()).toEventually(equalDiff([item2, item3]), timeout: .seconds(2)) @@ -75,7 +75,7 @@ final class RawPlayerTests: XCTestCase { func testReplaceItemsWithPreviousItems() { let item2 = AVPlayerItem(url: Stream.item(numbered: 2).url) let item3 = AVPlayerItem(url: Stream.item(numbered: 3).url) - let player = RawPlayer(items: [item2, item3]) + let player = QueuePlayer(items: [item2, item3]) let item1 = AVPlayerItem(url: Stream.item(numbered: 1).url) player.replaceItems(with: [item1, item2, item3]) expect(player.items()).to(equalDiff([item1])) @@ -83,7 +83,7 @@ final class RawPlayerTests: XCTestCase { } func testReplaceItemsLastReplacementWins() { - let player = RawPlayer() + let player = QueuePlayer() let item1 = AVPlayerItem(url: Stream.item(numbered: 1).url) let item2 = AVPlayerItem(url: Stream.item(numbered: 2).url) player.replaceItems(with: [item1, item2]) diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 01958b2b6..f079266dc 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -55,9 +55,6 @@ struct PlayerView: View { The system default playback user experience is provided as well. Just use `SystemVideoView` instead of `VideoView`. -> **Warning** -> A bug in AVKit currently makes `SystemVideoView` leak resources after having interacted with the playback button. This issue has been reported to Apple as FB11934227. - ## Advanced view layouts Pillarbox currently does not provide any standard playback view you can use but you can build one yourself. Since `Player` is an `ObservableObject`, though, implementation of a playback view can be achieved in the same was as for any usual SwiftUI view. diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md new file mode 100644 index 000000000..57b0e563b --- /dev/null +++ b/docs/KNOWN_ISSUES.md @@ -0,0 +1,64 @@ +# Known issues + +The following document lists known Pillarbox issues. Entries with a feedback number (FBxxxxxxxx) have been reported to Apple and will hopefully be fixed in upcoming iOS and tvOS releases. + +## Video view leak (FB11934227) + +A bug in AVKit currently makes `SystemVideoView` leak resources after having interacted with the playback button. + +### Workaround + +No workaround is available yet. + +## Stuck periodic time observers with audio playlists played over AirPlay + +A bug with AVKit and AirPlay prevents time observers from working properly in playlists. After transitioning to another audio item in a playlist no more periodic time observer updates will be received. Reported times remain stuck at zero, which means: + +- `ProgressTracker` does not report progress anymore. +- Sliders found in user interface components are not updated anymore. + +### Workaround + +No workaround is available yet. + +## DRM-protected streams do not play in the simulator + +DRM-protected streams do not play in the simulator. This is expected behavior as the required hardware features are not available in the simulator environment. + +### Workaround + +Use a physical device. + +## Seeking to the end of an on-demand stream is limited + +Seeks are currently prevented in the last 12 seconds of an on-demand stream to mitigate known player instabilities. If seeking is made within this window playback will resume at the nearest safely reachable location. + +### Workaround + +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. + +### Workaround + +Kill and restart the application. + +## Token-protected content is not playable on Apple TV 3rd generation devices + +Token-protected content cannot be played on old Apple TV 3rd generation devices. An error is returned when attempting to play such content. + +### Workaround + +No workaround is available yet. \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index 04805ebf0..2b5464f4f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -24,8 +24,9 @@ To learn more how integration of Pillarbox into your project please have a look ## Documentation -Follow the links below for further technical documentation: +Follow the links below for further documentation: +- [Known issues](KNOWN_ISSUES.md) - [Development setup](DEVELOPMENT_SETUP.md) - [Continuous integration](CONTINUOUS_INTEGRATION.md) - [Test streams](TEST_STREAMS.md) diff --git a/markdown_style.rb b/markdown_style.rb index 964a8080f..9a9dfac22 100644 --- a/markdown_style.rb +++ b/markdown_style.rb @@ -5,4 +5,5 @@ exclude_rule 'MD013' exclude_rule 'MD041' +rule 'MD024', allow_different_nesting: true rule 'MD029', style: :ordered