diff --git a/Sources/SwiftAudioEx/AVPlayerWrapper/AVPlayerWrapper.swift b/Sources/SwiftAudioEx/AVPlayerWrapper/AVPlayerWrapper.swift index a7b168e..1067364 100755 --- a/Sources/SwiftAudioEx/AVPlayerWrapper/AVPlayerWrapper.swift +++ b/Sources/SwiftAudioEx/AVPlayerWrapper/AVPlayerWrapper.swift @@ -29,6 +29,12 @@ class AVPlayerWrapper: AVPlayerWrapperProtocol { private let playerItemNotificationObserver = AVPlayerItemNotificationObserver() private let playerItemObserver = AVPlayerItemObserver() fileprivate var timeToSeekToAfterLoading: TimeInterval? + + /// Last known-good playback position, updated every time the + /// periodic time observer fires or a seek completes. Used to + /// preserve position across reload/recreate cycles. + fileprivate(set) var lastPosition: TimeInterval = 0 + fileprivate var asset: AVAsset? = nil fileprivate var item: AVPlayerItem? = nil fileprivate var url: URL? = nil @@ -83,7 +89,7 @@ class AVPlayerWrapper: AVPlayerWrapperProtocol { public var playWhenReady: Bool = false { didSet { if (playWhenReady == true && (state == .failed || state == .stopped)) { - reload(startFromCurrentTime: state == .failed) + reload(startFromCurrentTime: true) } applyAVPlayerRate() @@ -108,7 +114,7 @@ class AVPlayerWrapper: AVPlayerWrapperProtocol { var currentTime: TimeInterval { let seconds = avPlayer.currentTime().seconds - return seconds.isNaN ? 0 : seconds + return seconds.isNaN ? lastPosition : seconds } var duration: TimeInterval { @@ -196,9 +202,11 @@ class AVPlayerWrapper: AVPlayerWrapperProtocol { // if the player is loading then we need to defer seeking until it's ready. if (avPlayer.currentItem == nil) { timeToSeekToAfterLoading = seconds + lastPosition = seconds } else { let time = CMTimeMakeWithSeconds(seconds, preferredTimescale: 1000) avPlayer.seek(to: time, toleranceBefore: CMTime.zero, toleranceAfter: CMTime.zero) { (finished) in + self.lastPosition = seconds self.delegate?.AVWrapper(seekTo: Double(seconds), didFinish: finished) } } @@ -312,6 +320,10 @@ class AVPlayerWrapper: AVPlayerWrapperProtocol { func load(from url: URL, playWhenReady: Bool, options: [String: Any]? = nil) { self.playWhenReady = playWhenReady + // Reset lastPosition when loading a genuinely new URL + if self.url != url { + self.lastPosition = 0 + } self.url = url self.urlOptions = options self.load() @@ -352,20 +364,14 @@ class AVPlayerWrapper: AVPlayerWrapperProtocol { func unload() { clearCurrentItem() + lastPosition = 0 state = .idle } func reload(startFromCurrentTime: Bool) { - var time : Double? = nil - if (startFromCurrentTime) { - if let currentItem = currentItem { - if (!currentItem.duration.isIndefinite) { - time = currentItem.currentTime().seconds - } - } - } + let time = startFromCurrentTime ? lastPosition : nil load() - if let time = time { + if let time = time, time > 0 { seek(to: time) } } @@ -477,7 +483,11 @@ extension AVPlayerWrapper: AVPlayerTimeObserverDelegate { } func timeEvent(time: CMTime) { - delegate?.AVWrapper(secondsElapsed: time.seconds) + let seconds = time.seconds + if !seconds.isNaN { + lastPosition = seconds + } + delegate?.AVWrapper(secondsElapsed: seconds) } } @@ -493,6 +503,28 @@ extension AVPlayerWrapper: AVPlayerItemNotificationObserverDelegate { func itemPlaybackStalled() { delegate?.AVWrapperItemPlaybackStalled() } + + func itemNewErrorLogEntry() { + guard let playerItem = avPlayer.currentItem, + let errorLog = playerItem.errorLog(), + let lastEvent = errorLog.events.last else { return } + + var entry: [String: Any] = [:] + entry["errorStatusCode"] = lastEvent.errorStatusCode + entry["errorDomain"] = lastEvent.errorDomain + if let errorComment = lastEvent.errorComment { + entry["errorComment"] = errorComment + } + if let uri = lastEvent.uri { + entry["uri"] = uri + } + if let serverAddress = lastEvent.serverAddress { + entry["serverAddress"] = serverAddress + } + entry["date"] = lastEvent.date?.timeIntervalSince1970 ?? 0 + + delegate?.AVWrapperItemNewErrorLogEntry(entries: [entry]) + } func itemDidPlayToEndTime() { delegate?.AVWrapperItemDidPlayToEndTime() @@ -516,4 +548,12 @@ extension AVPlayerWrapper: AVPlayerItemObserverDelegate { func item(didReceiveTimedMetadata metadata: [AVTimedMetadataGroup]) { delegate?.AVWrapper(didReceiveTimedMetadata: metadata) } + + func item(didChangeBufferEmpty isEmpty: Bool) { + delegate?.AVWrapper(didChangeBufferEmpty: isEmpty) + } + + func item(didChangeBufferFull isFull: Bool) { + delegate?.AVWrapper(didChangeBufferFull: isFull) + } } diff --git a/Sources/SwiftAudioEx/AVPlayerWrapper/AVPlayerWrapperDelegate.swift b/Sources/SwiftAudioEx/AVPlayerWrapper/AVPlayerWrapperDelegate.swift index 9ab57ae..4d4a34d 100644 --- a/Sources/SwiftAudioEx/AVPlayerWrapper/AVPlayerWrapperDelegate.swift +++ b/Sources/SwiftAudioEx/AVPlayerWrapper/AVPlayerWrapperDelegate.swift @@ -23,5 +23,8 @@ protocol AVPlayerWrapperDelegate: AnyObject { func AVWrapperItemDidPlayToEndTime() func AVWrapperItemFailedToPlayToEndTime() func AVWrapperItemPlaybackStalled() + func AVWrapperItemNewErrorLogEntry(entries: [[String: Any]]) + func AVWrapper(didChangeBufferEmpty isEmpty: Bool) + func AVWrapper(didChangeBufferFull isFull: Bool) func AVWrapperDidRecreateAVPlayer() } diff --git a/Sources/SwiftAudioEx/AudioPlayer.swift b/Sources/SwiftAudioEx/AudioPlayer.swift index 966c295..4f92003 100755 --- a/Sources/SwiftAudioEx/AudioPlayer.swift +++ b/Sources/SwiftAudioEx/AudioPlayer.swift @@ -117,7 +117,10 @@ public class AudioPlayer: AVPlayerWrapperDelegate { } /** - The amount of seconds to be buffered by the player. Default value is 0 seconds, this means the AVPlayer will choose an appropriate level of buffering. Setting `bufferDuration` to larger than zero automatically disables `automaticallyWaitsToMinimizeStalling`. Setting it back to zero automatically enables `automaticallyWaitsToMinimizeStalling`. + The amount of seconds to be buffered by the player. Default value is 0 seconds, this means the AVPlayer will choose an appropriate level of buffering. + + Setting `bufferDuration` controls `AVPlayerItem.preferredForwardBufferDuration` + and is independent of `automaticallyWaitsToMinimizeStalling`. [Read more from Apple Documentation](https://developer.apple.com/documentation/avfoundation/avplayeritem/1643630-preferredforwardbufferduration) */ @@ -125,21 +128,21 @@ public class AudioPlayer: AVPlayerWrapperDelegate { get { wrapper.bufferDuration } set { wrapper.bufferDuration = newValue - wrapper.automaticallyWaitsToMinimizeStalling = wrapper.bufferDuration == 0 } } /** - Indicates whether the player should automatically delay playback in order to minimize stalling. Setting this to true will also set `bufferDuration` back to `0`. + Indicates whether the player should automatically delay playback in order to minimize stalling. + + Defaults to `true` (the AVPlayer default). When `true`, the player + waits until it has buffered enough data to play through without + stalling before it begins playback. [Read more from Apple Documentation](https://developer.apple.com/documentation/avfoundation/avplayer/1643482-automaticallywaitstominimizestal) */ public var automaticallyWaitsToMinimizeStalling: Bool { get { wrapper.automaticallyWaitsToMinimizeStalling } set { - if (newValue) { - wrapper.bufferDuration = 0 - } wrapper.automaticallyWaitsToMinimizeStalling = newValue } } @@ -440,9 +443,21 @@ public class AudioPlayer: AVPlayerWrapperDelegate { } func AVWrapperItemPlaybackStalled() { - + event.playbackStalled.emit(data: ()) } + func AVWrapperItemNewErrorLogEntry(entries: [[String: Any]]) { + event.newErrorLogEntry.emit(data: entries) + } + + func AVWrapper(didChangeBufferEmpty isEmpty: Bool) { + event.bufferEmpty.emit(data: isEmpty) + } + + func AVWrapper(didChangeBufferFull isFull: Bool) { + event.bufferFull.emit(data: isFull) + } + func AVWrapperDidRecreateAVPlayer() { event.didRecreateAVPlayer.emit(data: ()) } diff --git a/Sources/SwiftAudioEx/Event.swift b/Sources/SwiftAudioEx/Event.swift index 4e02a60..1912d38 100644 --- a/Sources/SwiftAudioEx/Event.swift +++ b/Sources/SwiftAudioEx/Event.swift @@ -19,6 +19,10 @@ extension AudioPlayer { public typealias UpdateDurationEventData = Double public typealias MetadataCommonEventData = [AVMetadataItem] public typealias MetadataTimedEventData = [AVTimedMetadataGroup] + public typealias PlaybackStalledEventData = () + public typealias NewErrorLogEntryEventData = [[String: Any]] + public typealias BufferEmptyEventData = Bool + public typealias BufferFullEventData = Bool public typealias DidRecreateAVPlayerEventData = () public typealias CurrentItemEventData = ( item: AudioItem?, @@ -91,6 +95,35 @@ extension AudioPlayer { */ public let receiveChapterMetadata: AudioPlayer.Event = AudioPlayer.Event() + /** + Emitted when playback has stalled due to buffer underrun. + - Important: Remember to dispatch to the main queue if any UI is updated in the event handler. + - Note: This indicates the player's buffer ran empty during playback and audio output has temporarily stopped. + */ + public let playbackStalled: AudioPlayer.Event = AudioPlayer.Event() + + /** + Emitted when a new error log entry is added by AVPlayer (iOS only). + Contains an array of dictionaries with error log entry details such as errorStatusCode, errorDomain, errorComment, and URI. + - Important: Remember to dispatch to the main queue if any UI is updated in the event handler. + - Note: These are transient streaming errors (e.g. segment download failures) that may not stop playback. + */ + public let newErrorLogEntry: AudioPlayer.Event = AudioPlayer.Event() + + /** + Emitted when the playback buffer becomes empty. + - Important: Remember to dispatch to the main queue if any UI is updated in the event handler. + - Note: When true, playback will stall or has stalled because the buffer has no more data to play. + */ + public let bufferEmpty: AudioPlayer.Event = AudioPlayer.Event() + + /** + Emitted when the playback buffer becomes full. + - Important: Remember to dispatch to the main queue if any UI is updated in the event handler. + - Note: When true, the internal buffer is full and no more data will be downloaded until some is consumed. + */ + public let bufferFull: AudioPlayer.Event = AudioPlayer.Event() + /** Emitted when the underlying AVPlayer instance is recreated. Recreation happens if the current player fails. - Important: Remember to dispatch to the main queue if any UI is updated in the event handler. diff --git a/Sources/SwiftAudioEx/Observer/AVPlayerItemNotificationObserver.swift b/Sources/SwiftAudioEx/Observer/AVPlayerItemNotificationObserver.swift index 2e62896..2bc360a 100644 --- a/Sources/SwiftAudioEx/Observer/AVPlayerItemNotificationObserver.swift +++ b/Sources/SwiftAudioEx/Observer/AVPlayerItemNotificationObserver.swift @@ -12,6 +12,7 @@ protocol AVPlayerItemNotificationObserverDelegate: AnyObject { func itemDidPlayToEndTime() func itemFailedToPlayToEndTime() func itemPlaybackStalled() + func itemNewErrorLogEntry() } /** @@ -60,6 +61,12 @@ class AVPlayerItemNotificationObserver { name: NSNotification.Name.AVPlayerItemPlaybackStalled, object: item ) + notificationCenter.addObserver( + self, + selector: #selector(itemNewErrorLogEntry), + name: NSNotification.Name.AVPlayerItemNewErrorLogEntry, + object: item + ) } /** @@ -84,6 +91,11 @@ class AVPlayerItemNotificationObserver { name: NSNotification.Name.AVPlayerItemPlaybackStalled, object: observingItem ) + notificationCenter.removeObserver( + self, + name: NSNotification.Name.AVPlayerItemNewErrorLogEntry, + object: observingItem + ) self.observingItem = nil isObserving = false } @@ -99,4 +111,8 @@ class AVPlayerItemNotificationObserver { @objc private func itemPlaybackStalled() { delegate?.itemPlaybackStalled() } + + @objc private func itemNewErrorLogEntry() { + delegate?.itemNewErrorLogEntry() + } } diff --git a/Sources/SwiftAudioEx/Observer/AVPlayerItemObserver.swift b/Sources/SwiftAudioEx/Observer/AVPlayerItemObserver.swift index f46e3d3..ac16298 100644 --- a/Sources/SwiftAudioEx/Observer/AVPlayerItemObserver.swift +++ b/Sources/SwiftAudioEx/Observer/AVPlayerItemObserver.swift @@ -23,7 +23,16 @@ protocol AVPlayerItemObserverDelegate: AnyObject { Called when the observed item receives metadata */ func item(didReceiveTimedMetadata metadata: [AVTimedMetadataGroup]) - + + /** + Called when the playback buffer empty state changes. + */ + func item(didChangeBufferEmpty isEmpty: Bool) + + /** + Called when the playback buffer full state changes. + */ + func item(didChangeBufferFull isFull: Bool) } /** @@ -38,6 +47,8 @@ class AVPlayerItemObserver: NSObject { static let duration = #keyPath(AVPlayerItem.duration) static let loadedTimeRanges = #keyPath(AVPlayerItem.loadedTimeRanges) static let playbackLikelyToKeepUp = #keyPath(AVPlayerItem.isPlaybackLikelyToKeepUp) + static let playbackBufferEmpty = #keyPath(AVPlayerItem.isPlaybackBufferEmpty) + static let playbackBufferFull = #keyPath(AVPlayerItem.isPlaybackBufferFull) } private(set) var isObserving: Bool = false @@ -66,6 +77,8 @@ class AVPlayerItemObserver: NSObject { item.addObserver(self, forKeyPath: AVPlayerItemKeyPath.duration, options: [.new], context: &AVPlayerItemObserver.context) item.addObserver(self, forKeyPath: AVPlayerItemKeyPath.loadedTimeRanges, options: [.new], context: &AVPlayerItemObserver.context) item.addObserver(self, forKeyPath: AVPlayerItemKeyPath.playbackLikelyToKeepUp, options: [.new], context: &AVPlayerItemObserver.context) + item.addObserver(self, forKeyPath: AVPlayerItemKeyPath.playbackBufferEmpty, options: [.new], context: &AVPlayerItemObserver.context) + item.addObserver(self, forKeyPath: AVPlayerItemKeyPath.playbackBufferFull, options: [.new], context: &AVPlayerItemObserver.context) // Create and add a new metadata output to the item. let metadataOutput = AVPlayerItemMetadataOutput() @@ -82,6 +95,8 @@ class AVPlayerItemObserver: NSObject { observingItem.removeObserver(self, forKeyPath: AVPlayerItemKeyPath.duration, context: &AVPlayerItemObserver.context) observingItem.removeObserver(self, forKeyPath: AVPlayerItemKeyPath.loadedTimeRanges, context: &AVPlayerItemObserver.context) observingItem.removeObserver(self, forKeyPath: AVPlayerItemKeyPath.playbackLikelyToKeepUp, context: &AVPlayerItemObserver.context) + observingItem.removeObserver(self, forKeyPath: AVPlayerItemKeyPath.playbackBufferEmpty, context: &AVPlayerItemObserver.context) + observingItem.removeObserver(self, forKeyPath: AVPlayerItemKeyPath.playbackBufferFull, context: &AVPlayerItemObserver.context) // Remove all metadata outputs from the item. observingItem.removeAllMetadataOutputs() @@ -112,7 +127,17 @@ class AVPlayerItemObserver: NSObject { if let playbackLikelyToKeepUp = change?[.newKey] as? Bool { delegate?.item(didUpdatePlaybackLikelyToKeepUp: playbackLikelyToKeepUp) } - + + case AVPlayerItemKeyPath.playbackBufferEmpty: + if let isEmpty = change?[.newKey] as? Bool { + delegate?.item(didChangeBufferEmpty: isEmpty) + } + + case AVPlayerItemKeyPath.playbackBufferFull: + if let isFull = change?[.newKey] as? Bool { + delegate?.item(didChangeBufferFull: isFull) + } + default: break }