Skip to content

Commit

Permalink
Mitigate playback issues when seeking near the end of a stream (#196)
Browse files Browse the repository at this point in the history
  • Loading branch information
defagos authored Jan 16, 2023
1 parent 0739ad1 commit 15d04c4
Show file tree
Hide file tree
Showing 19 changed files with 263 additions and 129 deletions.
1 change: 1 addition & 0 deletions Demo/Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
12 changes: 6 additions & 6 deletions Demo/Sources/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
18 changes: 14 additions & 4 deletions Demo/Sources/UserDefaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
])
}
}
83 changes: 36 additions & 47 deletions Sources/Player/Player.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnyCancellable>()
Expand All @@ -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

Expand All @@ -79,7 +78,7 @@ public final class Player: ObservableObject, Equatable {
configureSeekingPublisher()
configureBufferingPublisher()
configureCurrentIndexPublisher()
configureRawPlayerUpdatePublisher()
configureQueueUpdatePublisher()
configureExternalPlaybackPublisher()

configurePlayer()
Expand All @@ -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()
}
}

Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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()
}
}

Expand All @@ -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<CMTime, Never> {
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.
Expand All @@ -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<Void, Never> {
Publishers.BoundaryTimePublisher(for: rawPlayer, times: times, queue: queue)
Publishers.BoundaryTimePublisher(for: queuePlayer, times: times, queue: queue)
}
}

Expand Down Expand Up @@ -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.`
Expand All @@ -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.
Expand All @@ -432,65 +431,55 @@ public extension Player {
guard index != currentIndex else { return }
guard (0..<storedItems.count).contains(index) else { throw PlaybackError.itemOutOfBounds }
let playerItems = AVPlayerItem.playerItems(from: Array(storedItems.suffix(from: index)))
rawPlayer.replaceItems(with: playerItems)
queuePlayer.replaceItems(with: playerItems)
}
}

extension Player {
private func configurePlaybackStatePublisher() {
rawPlayer.playbackStatePublisher()
queuePlayer.playbackStatePublisher()
.receiveOnMainThread()
.lane("player_state")
.assign(to: &$playbackState)
}

private func configureCurrentItemTimeRangePublisher() {
rawPlayer.currentItemTimeRangePublisher()
queuePlayer.currentItemTimeRangePublisher()
.receiveOnMainThread()
.lane("player_time_range")
.assign(to: &$timeRange)
}

private func configureCurrentItemDurationPublisher() {
rawPlayer.currentItemDurationPublisher()
queuePlayer.currentItemDurationPublisher()
.receiveOnMainThread()
.lane("player_item_duration")
.assign(to: &$itemDuration)
}

private func configureChunkDurationPublisher() {
rawPlayer.publisher(for: \.currentItem)
.map { item -> AnyPublisher<CMTime, Never> 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.
Expand All @@ -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)
}
Expand All @@ -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
}
}
12 changes: 12 additions & 0 deletions Sources/Player/PlayerItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
13 changes: 3 additions & 10 deletions Sources/Player/PlayerItemPublishers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,11 @@ extension AVPlayerItem {
}

func timeRangePublisher() -> AnyPublisher<CMTimeRange, Never> {
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()
}
}
15 changes: 15 additions & 0 deletions Sources/Player/PlayerPublishers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,19 @@ extension AVPlayer {
.removeDuplicates()
.eraseToAnyPublisher()
}

func chunkDurationPublisher() -> AnyPublisher<CMTime, Never> {
publisher(for: \.currentItem)
.map { item -> AnyPublisher<CMTime, Never> 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()
}
}
Loading

0 comments on commit 15d04c4

Please sign in to comment.