diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 651672e1..9662823d 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -65,6 +65,9 @@ E9E8814D28DA6B9000D750C1 /* PlayerTimeUtilsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9E8814C28DA6B9000D750C1 /* PlayerTimeUtilsTests.swift */; }; E9E985F828B02D9400957F23 /* PlayerProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9E985F728B02D9400957F23 /* PlayerProgress.swift */; }; E9FA07E328C82848005520B0 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9FA07E228C82848005520B0 /* Logger.swift */; }; + EACB38122BCCA1330060DA4A /* AudioPlayerRateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EACB38112BCCA1330060DA4A /* AudioPlayerRateManager.swift */; }; + EACB38142BCCA1410060DA4A /* LegacyAudioPlayerRateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EACB38132BCCA1410060DA4A /* LegacyAudioPlayerRateManager.swift */; }; + EACB38162BCCA1500060DA4A /* DefaultedAudioPlayerRateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EACB38152BCCA1500060DA4A /* DefaultedAudioPlayerRateManager.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -142,6 +145,9 @@ E9E8814C28DA6B9000D750C1 /* PlayerTimeUtilsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerTimeUtilsTests.swift; sourceTree = ""; }; E9E985F728B02D9400957F23 /* PlayerProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerProgress.swift; sourceTree = ""; }; E9FA07E228C82848005520B0 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; + EACB38112BCCA1330060DA4A /* AudioPlayerRateManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AudioPlayerRateManager.swift; sourceTree = ""; }; + EACB38132BCCA1410060DA4A /* LegacyAudioPlayerRateManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LegacyAudioPlayerRateManager.swift; sourceTree = ""; }; + EACB38152BCCA1500060DA4A /* DefaultedAudioPlayerRateManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DefaultedAudioPlayerRateManager.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -183,6 +189,9 @@ 3ABF6190280432610070250E /* player */ = { isa = PBXGroup; children = ( + EACB38152BCCA1500060DA4A /* DefaultedAudioPlayerRateManager.swift */, + EACB38132BCCA1410060DA4A /* LegacyAudioPlayerRateManager.swift */, + EACB38112BCCA1330060DA4A /* AudioPlayerRateManager.swift */, E9E8814828DA641B00D750C1 /* util */, 3A200C1427D64D7E00CBF02E /* AudioPlayer.swift */, E9DFCBFA28C28F4A00B36356 /* AudioPlayerSleepTimer.swift */, @@ -521,6 +530,7 @@ E9D5506228AC1CC900C746DD /* PlayerState.swift in Sources */, 3AD4FCE728043E72006DB301 /* AbsDatabase.m in Sources */, 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, + EACB38122BCCA1330060DA4A /* AudioPlayerRateManager.swift in Sources */, E9FA07E328C82848005520B0 /* Logger.swift in Sources */, 3A90295F280968E700E1D427 /* PlaybackReport.swift in Sources */, E9D5505A28AC1C4500C746DD /* Folder.swift in Sources */, @@ -540,6 +550,7 @@ 3A200C1527D64D7E00CBF02E /* AudioPlayer.swift in Sources */, E9D5507128AC1EC700C746DD /* DownloadItemPart.swift in Sources */, 4D66B956282EE951008272D4 /* AbsFileSystem.m in Sources */, + EACB38142BCCA1410060DA4A /* LegacyAudioPlayerRateManager.swift in Sources */, 3AFCB5E827EA240D00ECCC05 /* NowPlayingInfo.swift in Sources */, 3AB34053280829BF0039308B /* Extensions.swift in Sources */, E9D5505828AC1C1A00C746DD /* Library.swift in Sources */, @@ -548,6 +559,7 @@ 4DABC04F2B0139CA000F6264 /* User.swift in Sources */, 4D66B952282EE822008272D4 /* AbsDownloader.m in Sources */, E9D5506828AC1DC300C746DD /* LocalPodcastEpisode.swift in Sources */, + EACB38162BCCA1500060DA4A /* DefaultedAudioPlayerRateManager.swift in Sources */, E9D5505228AC1B5D00C746DD /* Chapter.swift in Sources */, E9D5506028AC1CA900C746DD /* PlaybackMetadata.swift in Sources */, E9D5504828AC1A7A00C746DD /* MediaType.swift in Sources */, diff --git a/ios/App/Shared/player/AudioPlayer.swift b/ios/App/Shared/player/AudioPlayer.swift index e9086775..a2e99634 100644 --- a/ios/App/Shared/player/AudioPlayer.swift +++ b/ios/App/Shared/player/AudioPlayer.swift @@ -29,9 +29,7 @@ class AudioPlayer: NSObject { internal let logger = AppLogger(category: "AudioPlayer") private var status: PlayerStatus - internal var rate: Float - - private var tmpRate: Float = 1.0 + internal var rateManager: AudioPlayerRateManager private var playerContext = 0 private var playerItemContext = 0 @@ -64,11 +62,18 @@ class AudioPlayer: NSObject { self.audioPlayer.automaticallyWaitsToMinimizeStalling = true self.sessionId = sessionId self.status = .uninitialized - self.rate = 0.0 - self.tmpRate = playbackRate + + if #available(iOS 16.0, *) { + self.rateManager = DefaultedAudioPlayerRateManager(audioPlayer: self.audioPlayer, defaultRate: playbackRate) + } else { + self.rateManager = LegacyAudioPlayerRateManager(audioPlayer: self.audioPlayer, defaultRate: playbackRate) + } super.init() + self.rateManager.rateChangedCompletion = self.updateNowPlaying + self.rateManager.defaultRateChangedCompletion = self.setupTimeObservers + initAudioSession() setupRemoteTransportControls() @@ -81,7 +86,6 @@ class AudioPlayer: NSObject { // Listen to player events self.setupAudioSessionNotifications() - self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.rate), options: .new, context: &playerContext) self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem), options: .new, context: &playerContext) for track in playbackSession.audioTracks { @@ -150,7 +154,6 @@ class AudioPlayer: NSObject { } // Remove observers - self.audioPlayer.removeObserver(self, forKeyPath: #keyPath(AVPlayer.rate), context: &playerContext) self.audioPlayer.removeObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem), context: &playerContext) self.removeTimeObservers() @@ -332,7 +335,7 @@ class AudioPlayer: NSObject { self.markAudioSessionAs(active: true) DispatchQueue.runOnMainQueue { self.audioPlayer.play() - self.audioPlayer.rate = self.tmpRate + self.rateManager.handlePlayEvent() } self.status = .playing @@ -424,25 +427,8 @@ class AudioPlayer: NSObject { } } - public func setPlaybackRate(_ rate: Float, observed: Bool = false) { - let playbackSpeedChanged = rate > 0.0 && rate != self.tmpRate && !(observed && rate == 1) - - if self.audioPlayer.rate != rate { - logger.log("setPlaybakRate rate changed from \(self.audioPlayer.rate) to \(rate)") - DispatchQueue.runOnMainQueue { - self.audioPlayer.rate = rate - } - } - - self.rate = rate - self.updateNowPlaying() - - if playbackSpeedChanged { - self.tmpRate = rate - - // Setup the time observer again at the new rate - self.setupTimeObservers() - } + public func setPlaybackRate(_ rate: Float) { + self.rateManager.setPlaybackRate(rate) } public func setChapterTrack() { @@ -706,24 +692,21 @@ class AudioPlayer: NSObject { NowPlayingInfo.shared.update( duration: currentChapter.getRelativeChapterEndTime(), currentTime: currentChapter.getRelativeChapterCurrentTime(sessionCurrentTime: session.currentTime), - rate: rate, - defaultRate: tmpRate, + rate: self.rateManager.rate, + defaultRate: self.rateManager.defaultRate, chapterName: currentChapter.title, chapterNumber: (session.chapters.firstIndex(of: currentChapter) ?? 0) + 1, chapterCount: session.chapters.count ) } else if let duration = self.getDuration(), let currentTime = self.getCurrentTime() { - NowPlayingInfo.shared.update(duration: duration, currentTime: currentTime, rate: rate, defaultRate: tmpRate) + NowPlayingInfo.shared.update(duration: duration, currentTime: currentTime, rate: self.rateManager.rate, defaultRate: self.rateManager.defaultRate) } } // MARK: - Observer public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { if context == &playerContext { - if keyPath == #keyPath(AVPlayer.rate) { - logger.log("playerContext observer player rate") - self.setPlaybackRate(change?[.newKey] as? Float ?? 1.0, observed: true) - } else if keyPath == #keyPath(AVPlayer.currentItem) { + if keyPath == #keyPath(AVPlayer.currentItem) { NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil) logger.log("WARNING: Item ended") } diff --git a/ios/App/Shared/player/AudioPlayerRateManager.swift b/ios/App/Shared/player/AudioPlayerRateManager.swift new file mode 100644 index 00000000..db4d43ed --- /dev/null +++ b/ios/App/Shared/player/AudioPlayerRateManager.swift @@ -0,0 +1,23 @@ +// +// AudioPlayerRateManager.swift +// Audiobookshelf +// +// Created by Marke Hallowell on 4/14/24. +// + +import Foundation +import AVFoundation + +protocol AudioPlayerRateManager { + var rate: Float { get } + var defaultRate: Float { get } + var rateChangedCompletion: () -> Void { get set } + var defaultRateChangedCompletion: () -> Void { get set } + + init(audioPlayer: AVPlayer, defaultRate: Float) + + func setPlaybackRate(_ rate: Float) + + // Callback for play events (e.g. LegacyAudioPlayerRateManager uses this set rate immediately after playback resumes) + func handlePlayEvent() -> Void +} diff --git a/ios/App/Shared/player/AudioPlayerSleepTimer.swift b/ios/App/Shared/player/AudioPlayerSleepTimer.swift index a84a7023..28cfdf06 100644 --- a/ios/App/Shared/player/AudioPlayerSleepTimer.swift +++ b/ios/App/Shared/player/AudioPlayerSleepTimer.swift @@ -22,7 +22,7 @@ extension AudioPlayer { // Return the player time until sleep var sleepTimeRemaining: Double? = nil if let chapterStopAt = self.sleepTimeChapterStopAt { - sleepTimeRemaining = (chapterStopAt - currentTime) / Double(self.rate > 0 ? self.rate : 1.0) + sleepTimeRemaining = (chapterStopAt - currentTime) / Double(self.rateManager.rate > 0 ? self.rateManager.rate : 1.0) } else if self.isCountdownSleepTimerSet() { sleepTimeRemaining = self.sleepTimeRemaining } diff --git a/ios/App/Shared/player/DefaultedAudioPlayerRateManager.swift b/ios/App/Shared/player/DefaultedAudioPlayerRateManager.swift new file mode 100644 index 00000000..1e45906a --- /dev/null +++ b/ios/App/Shared/player/DefaultedAudioPlayerRateManager.swift @@ -0,0 +1,94 @@ +// +// DefaultedAudioPlayerRateManager.swift +// Audiobookshelf +// +// Created by Marke Hallowell on 4/14/24. +// + +import Foundation +import AVFoundation + +@available(iOS 16.0, *) +class DefaultedAudioPlayerRateManager: NSObject, AudioPlayerRateManager { + internal let logger = AppLogger(category: "DefaultedAudioPlayerRateManager") + + internal var audioPlayer: AVPlayer + + // MARK: - AudioPlayerRateManager + public private(set) var defaultRate: Float + public private(set) var rate: Float + public var rateChangedCompletion: () -> Void + public var defaultRateChangedCompletion: () -> Void + + required init(audioPlayer: AVPlayer, defaultRate: Float) { + self.audioPlayer = audioPlayer + self.rateChangedCompletion = {} + self.defaultRateChangedCompletion = {} + self.rate = self.audioPlayer.rate + self.defaultRate = defaultRate + self.audioPlayer.defaultRate = defaultRate + + super.init() + + NotificationCenter.default.addObserver(self, selector: #selector(handleObservedRateChange), name: AVPlayer.rateDidChangeNotification, object: self.audioPlayer) + } + + public func setPlaybackRate(_ rate: Float) { + self.handlePlaybackRateChange(rate, observed: false) + } + + // No-op (player automatically resumes at last-known defaultRate) + public func handlePlayEvent() { } + + // MARK: - Destructor + public func destroy() { + NotificationCenter.default.removeObserver(self, name: AVPlayer.rateDidChangeNotification, object: self.audioPlayer) + } + + // MARK: - Internal + internal func handlePlaybackRateChange(_ rate: Float, observed: Bool = false) { + let playbackSpeedChanged = rate > 0.0 && rate != self.defaultRate + + if playbackSpeedChanged { + self.defaultRate = rate + self.audioPlayer.defaultRate = rate + self.defaultRateChangedCompletion() + + // Check to see if we also need to make a temporary rate change to player + if self.audioPlayer.rate > 0.0 { + if self.audioPlayer.rate != rate { + self.rate = rate + self.audioPlayer.rate = rate + self.rateChangedCompletion() + } + } + } else { + self.rate = rate + self.rateChangedCompletion() + } + } + + // MARK: - iOS rate change notification handler + @objc internal func handleObservedRateChange(notification: Notification) { + // TODO: Consider handling cases individually (e.g. overall session impact?) + /* + guard let reason = notification.userInfo?[AVPlayer.rateDidChangeReasonKey] as? AVPlayer.RateDidChangeReason else { + return + } + + switch reason { + case .appBackgrounded: + // App transitioned to the background. + case .audioSessionInterrupted: + // The system interrupts the app's audio session. + case .setRateCalled: + // The app set the player's rate. + case .setRateFailed: + // An attempt to change the player's rate failed. + default: + break + } + */ + self.handlePlaybackRateChange(self.audioPlayer.rate, observed: true) + } +} diff --git a/ios/App/Shared/player/LegacyAudioPlayerRateManager.swift b/ios/App/Shared/player/LegacyAudioPlayerRateManager.swift new file mode 100644 index 00000000..3912d8c7 --- /dev/null +++ b/ios/App/Shared/player/LegacyAudioPlayerRateManager.swift @@ -0,0 +1,84 @@ +// +// LegacyAudioPlayerRateManager.swift +// Audiobookshelf +// +// Created by Marke Hallowell on 4/14/24. +// + +import Foundation +import AVFoundation + +class LegacyAudioPlayerRateManager: NSObject, AudioPlayerRateManager { + internal let logger = AppLogger(category: "AudioPlayer") + + internal var audioPlayer: AVPlayer + + internal var managerContext = 0 + + // MARK: - AudioPlayerRateManager + public private(set) var defaultRate: Float + public private(set) var rate: Float + public var rateChangedCompletion: () -> Void + public var defaultRateChangedCompletion: () -> Void + + required init(audioPlayer: AVPlayer, defaultRate: Float) { + self.rate = 0.0 + self.defaultRate = defaultRate + self.audioPlayer = audioPlayer + self.rateChangedCompletion = {} + self.defaultRateChangedCompletion = {} + + super.init() + + self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.rate), options: .new, context: &managerContext) + } + + public func setPlaybackRate(_ rate: Float) { + self.handlePlaybackRateChange(rate, observed: false) + } + + public func handlePlayEvent() { + DispatchQueue.runOnMainQueue { + self.audioPlayer.rate = self.defaultRate + } + } + + // MARK: - Destructor + public func destroy() { + // Remove Observer + self.audioPlayer.removeObserver(self, forKeyPath: #keyPath(AVPlayer.rate), context: &managerContext) + } + + // MARK: - Internal + internal func handlePlaybackRateChange(_ rate: Float, observed: Bool = false) { + let playbackSpeedChanged = rate > 0.0 && rate != self.defaultRate && !(observed && rate == 1) + + if self.audioPlayer.rate != rate { + logger.log("setPlaybakRate rate changed from \(self.audioPlayer.rate) to \(rate)") + DispatchQueue.runOnMainQueue { + self.audioPlayer.rate = rate + } + } + + self.rate = rate + self.rateChangedCompletion() + + if playbackSpeedChanged { + self.defaultRate = rate + self.defaultRateChangedCompletion() + } + } + + // MARK: - Observer + public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { + if context == &managerContext { + if keyPath == #keyPath(AVPlayer.rate) { + logger.log("playerContext observer player rate") + self.handlePlaybackRateChange(change?[.newKey] as? Float ?? 1.0, observed: true) + } + } else { + super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context) + return + } + } +} diff --git a/ios/App/Shared/player/PlayerHandler.swift b/ios/App/Shared/player/PlayerHandler.swift index c895a2c3..4cc9ccb1 100644 --- a/ios/App/Shared/player/PlayerHandler.swift +++ b/ios/App/Shared/player/PlayerHandler.swift @@ -38,7 +38,7 @@ class PlayerHandler { public static var paused: Bool { get { guard let player = player else { return true } - return player.rate == 0.0 + return player.rateManager.rate == 0.0 } set(paused) { if paused {