From 35631fb211c62dcd54eba575e89c682eb38841c5 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Tue, 30 Aug 2022 22:14:59 -0400 Subject: [PATCH 01/12] feat: Handle iOS background downloads --- ios/App/App/AppDelegate.swift | 7 +++++++ ios/App/App/plugins/AbsDownloader.swift | 15 ++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/ios/App/App/AppDelegate.swift b/ios/App/App/AppDelegate.swift index b37c9931..01115c80 100644 --- a/ios/App/App/AppDelegate.swift +++ b/ios/App/App/AppDelegate.swift @@ -6,6 +6,7 @@ import RealmSwift class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? + var backgroundCompletionHandler: (() -> Void)? func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { // Override point for customization after application launch. @@ -72,6 +73,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // tracking app url opens, make sure to keep this call return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler) } + + func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) { + // Stores the completion handler for background downloads + // The identifier of this method can be ignored at this time as we only have one background url session + backgroundCompletionHandler = completionHandler + } override func touchesBegan(_ touches: Set, with event: UIEvent?) { super.touchesBegan(touches, with: event) diff --git a/ios/App/App/plugins/AbsDownloader.swift b/ios/App/App/plugins/AbsDownloader.swift index d430fd5e..22c42730 100644 --- a/ios/App/App/plugins/AbsDownloader.swift +++ b/ios/App/App/plugins/AbsDownloader.swift @@ -15,9 +15,10 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { static private let downloadsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] private lazy var session: URLSession = { + let config = URLSessionConfiguration.background(withIdentifier: "AbsDownloader") let queue = OperationQueue() queue.maxConcurrentOperationCount = 5 - return URLSession(configuration: .default, delegate: self, delegateQueue: queue) + return URLSession(configuration: config, delegate: self, delegateQueue: queue) }() private let progressStatusQueue = DispatchQueue(label: "progress-status-queue", attributes: .concurrent) private var downloadItemProgress = [String: DownloadItem]() @@ -78,6 +79,18 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { } } + // Called when downloads are complete on the background thread + public func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { + DispatchQueue.main.async { + guard let appDelegate = UIApplication.shared.delegate as? AppDelegate, + let backgroundCompletionHandler = + appDelegate.backgroundCompletionHandler else { + return + } + backgroundCompletionHandler() + } + } + private func handleDownloadTaskUpdate(downloadTask: URLSessionTask, progressHandler: DownloadProgressHandler) { do { guard let downloadItemPartId = downloadTask.taskDescription else { throw LibraryItemDownloadError.noTaskDescription } From 6257c6488ba69412e7f7841f54e8d9c3ce0a4ac7 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Wed, 31 Aug 2022 23:15:25 -0400 Subject: [PATCH 02/12] Fix race condition with the player queue --- ios/App/Shared/player/AudioPlayer.swift | 66 +++++++++++++++---------- 1 file changed, 39 insertions(+), 27 deletions(-) diff --git a/ios/App/Shared/player/AudioPlayer.swift b/ios/App/Shared/player/AudioPlayer.swift index bc69f9fb..5bb5e962 100644 --- a/ios/App/Shared/player/AudioPlayer.swift +++ b/ios/App/Shared/player/AudioPlayer.swift @@ -195,36 +195,48 @@ class AudioPlayer: NSObject { private func setupQueueItemStatusObserver() { self.queueItemStatusObserver?.invalidate() - self.queueItemStatusObserver = self.audioPlayer.currentItem?.observe(\.status, options: [.new, .old], changeHandler: { (playerItem, change) in - guard let playbackSession = self.getPlaybackSession() else { - NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil) - return - } - if (playerItem.status == .readyToPlay) { - NSLog("queueStatusObserver: Current Item Ready to play. PlayWhenReady: \(self.playWhenReady)") - self.updateNowPlaying() - - // Seek the player before initializing, so a currentTime of 0 does not appear in MediaProgress / session - let firstReady = self.status < 0 - if firstReady || self.playWhenReady { - self.seek(playbackSession.currentTime, from: "queueItemStatusObserver") - } - - // Mark the player as ready - self.status = 0 - - // Start the player, if requested - if self.playWhenReady { - self.playWhenReady = false - self.play() - } - } else if (playerItem.status == .failed) { - NSLog("queueStatusObserver: FAILED \(playerItem.error?.localizedDescription ?? "")") - NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil) - } + let status = self.audioPlayer.currentItem?.status.rawValue ?? -1 + + NSLog("queueStatusObserver: Setting up status=\(status)") + // First item already loaded, we need to fire manually + if status == 1, let playerItem = self.audioPlayer.currentItem { + self.handleQueueItemStatus(playerItem: playerItem) + } + // Now listen for future updates + self.queueItemStatusObserver = self.audioPlayer.currentItem?.observe(\.status, options: [.new, .old], changeHandler: { playerItem, change in + self.handleQueueItemStatus(playerItem: playerItem) }) } + private func handleQueueItemStatus(playerItem: AVPlayerItem) { + NSLog("queueStatusObserver: Current item status changed") + guard let playbackSession = self.getPlaybackSession() else { + NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil) + return + } + if (playerItem.status == .readyToPlay) { + NSLog("queueStatusObserver: Current Item Ready to play. PlayWhenReady: \(self.playWhenReady)") + + // Seek the player before initializing, so a currentTime of 0 does not appear in MediaProgress / session + let firstReady = self.status < 0 + if firstReady || self.playWhenReady { + self.seek(playbackSession.currentTime, from: "queueItemStatusObserver") + } + + // Mark the player as ready + self.status = 0 + + // Start the player, if requested + if self.playWhenReady { + self.playWhenReady = false + self.play() + } + } else if (playerItem.status == .failed) { + NSLog("queueStatusObserver: FAILED \(playerItem.error?.localizedDescription ?? "")") + NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil) + } + } + private func startPausedTimer() { guard self.pausedTimer == nil else { return } self.queue.async { From 50f6b15d233336d992d7f6f14c9b1bc5e21106bc Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Thu, 1 Sep 2022 17:40:49 -0400 Subject: [PATCH 03/12] Refactor to ensure we aren't missing any update time --- ios/App/Shared/player/AudioPlayer.swift | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/ios/App/Shared/player/AudioPlayer.swift b/ios/App/Shared/player/AudioPlayer.swift index 5bb5e962..b20d69d7 100644 --- a/ios/App/Shared/player/AudioPlayer.swift +++ b/ios/App/Shared/player/AudioPlayer.swift @@ -194,18 +194,18 @@ class AudioPlayer: NSObject { } private func setupQueueItemStatusObserver() { + NSLog("queueStatusObserver: Setting up") + + // Listen for player item updates self.queueItemStatusObserver?.invalidate() - let status = self.audioPlayer.currentItem?.status.rawValue ?? -1 - - NSLog("queueStatusObserver: Setting up status=\(status)") - // First item already loaded, we need to fire manually - if status == 1, let playerItem = self.audioPlayer.currentItem { - self.handleQueueItemStatus(playerItem: playerItem) - } - // Now listen for future updates self.queueItemStatusObserver = self.audioPlayer.currentItem?.observe(\.status, options: [.new, .old], changeHandler: { playerItem, change in self.handleQueueItemStatus(playerItem: playerItem) }) + + // Ensure we didn't miss a player item update during initialization + if let playerItem = self.audioPlayer.currentItem { + self.handleQueueItemStatus(playerItem: playerItem) + } } private func handleQueueItemStatus(playerItem: AVPlayerItem) { From 021d63d973f2382996b1d5af14df1c4468018050 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Thu, 1 Sep 2022 18:19:45 -0400 Subject: [PATCH 04/12] Fix seek jumping --- ios/App/Shared/player/AudioPlayer.swift | 96 ++++++++++++++----------- 1 file changed, 53 insertions(+), 43 deletions(-) diff --git a/ios/App/Shared/player/AudioPlayer.swift b/ios/App/Shared/player/AudioPlayer.swift index b20d69d7..3341d4f1 100644 --- a/ios/App/Shared/player/AudioPlayer.swift +++ b/ios/App/Shared/player/AudioPlayer.swift @@ -219,7 +219,9 @@ class AudioPlayer: NSObject { // Seek the player before initializing, so a currentTime of 0 does not appear in MediaProgress / session let firstReady = self.status < 0 - if firstReady || self.playWhenReady { + if firstReady || !self.playWhenReady { + // Seek is async, and if we call this when also pressing play, we will get weird jumps in the scrub bar depending on timing + // Seeking to the correct position happens during play() self.seek(playbackSession.currentTime, from: "queueItemStatusObserver") } @@ -255,59 +257,67 @@ class AudioPlayer: NSObject { // MARK: - Methods public func play(allowSeekBack: Bool = false) { guard self.isInitialized() else { return } + guard let session = self.getPlaybackSession() else { + NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil) + return + } // Capture remaining sleep time before changing the track position let sleepSecondsRemaining = PlayerHandler.remainingSleepTime - if allowSeekBack, let session = self.getPlaybackSession() { - let lastPlayed = (session.updatedAt ?? 0)/1000 - let difference = Date.timeIntervalSinceReferenceDate - lastPlayed - var time: Int? - - if lastPlayed == 0 { - time = 5 - } else if difference < 6 { - time = 2 - } else if difference < 12 { - time = 10 - } else if difference < 30 { - time = 15 - } else if difference < 180 { - time = 20 - } else if difference < 3600 { - time = 25 - } else { - time = 29 - } - - if time != nil { - guard let currentTime = self.getCurrentTime() else { - NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil) - return - } - seek(currentTime - Double(time!), from: "play") - } - } - + // Stop the paused timer self.stopPausedTimer() - Task { - if let currentTime = self.getCurrentTime() { - await PlayerProgress.shared.syncFromPlayer(currentTime: currentTime, includesPlayProgress: self.isPlaying(), isStopping: false) + // Determine where we are starting playback + let lastPlayed = (session.updatedAt ?? 0)/1000 + let currentTime = allowSeekBack ? calculateSeekBackTimeAtCurrentTime(session.currentTime, lastPlayed: lastPlayed) : session.currentTime + + // Sync our new playback position + Task { await PlayerProgress.shared.syncFromPlayer(currentTime: currentTime, includesPlayProgress: self.isPlaying(), isStopping: false) } + + // Start playback, with a seek, for as smooth a scrub bar start as possible + let currentTrackStartOffset = session.audioTracks[self.currentTrackIndex].startOffset ?? 0.0 + let seekTime = currentTime - currentTrackStartOffset + self.audioPlayer.seek(to: CMTime(seconds: seekTime, preferredTimescale: 1000), toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] completed in + if completed, let self = self { + // Start playback + self.audioPlayer.play() + self.rate = self.tmpRate + self.audioPlayer.rate = self.tmpRate + self.status = 1 + + // If we have an active sleep timer, reschedule based on rate + self.rescheduleSleepTimerAtTime(time: currentTime, secondsRemaining: sleepSecondsRemaining) + + // Update the progress + self.updateNowPlaying() } } - - self.audioPlayer.play() - self.status = 1 - self.rate = self.tmpRate - self.audioPlayer.rate = self.tmpRate + } + + private func calculateSeekBackTimeAtCurrentTime(_ currentTime: Double, lastPlayed: Double) -> Double { + let difference = Date.timeIntervalSinceReferenceDate - lastPlayed + var time: Double = 0 - // If we have an active sleep timer, reschedule based on rate - if let currentTime = self.getCurrentTime() { - self.rescheduleSleepTimerAtTime(time: currentTime, secondsRemaining: sleepSecondsRemaining) + // Scale seek back time based on how long since last play + if lastPlayed == 0 { + time = 5 + } else if difference < 6 { + time = 2 + } else if difference < 12 { + time = 10 + } else if difference < 30 { + time = 15 + } else if difference < 180 { + time = 20 + } else if difference < 3600 { + time = 25 + } else { + time = 29 } - updateNowPlaying() + // Wind the clock back + return currentTime - time } public func pause() { From c02adfa32615ce0cf62aa4fbea4c79bff686babe Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Thu, 1 Sep 2022 18:41:21 -0400 Subject: [PATCH 05/12] Fix typo in if statement --- ios/App/Shared/player/AudioPlayer.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/App/Shared/player/AudioPlayer.swift b/ios/App/Shared/player/AudioPlayer.swift index 3341d4f1..affc56b2 100644 --- a/ios/App/Shared/player/AudioPlayer.swift +++ b/ios/App/Shared/player/AudioPlayer.swift @@ -219,7 +219,7 @@ class AudioPlayer: NSObject { // Seek the player before initializing, so a currentTime of 0 does not appear in MediaProgress / session let firstReady = self.status < 0 - if firstReady || !self.playWhenReady { + if firstReady && !self.playWhenReady { // Seek is async, and if we call this when also pressing play, we will get weird jumps in the scrub bar depending on timing // Seeking to the correct position happens during play() self.seek(playbackSession.currentTime, from: "queueItemStatusObserver") From 586405f9de3c77de0e10ee8885d66e0539f3b99d Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Fri, 2 Sep 2022 16:31:47 -0400 Subject: [PATCH 06/12] Rewrite sleep timer --- ios/App/App.xcodeproj/project.pbxproj | 4 + ios/App/App/plugins/AbsAudioPlayer.swift | 19 +- ios/App/Shared/player/AudioPlayer.swift | 178 ++++-------------- .../Shared/player/AudioPlayerSleepTimer.swift | 155 +++++++++++++++ ios/App/Shared/player/PlayerHandler.swift | 46 +---- 5 files changed, 209 insertions(+), 193 deletions(-) create mode 100644 ios/App/Shared/player/AudioPlayerSleepTimer.swift diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 7271bb36..1f223fbf 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -58,6 +58,7 @@ E9D5507128AC1EC700C746DD /* DownloadItemPart.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5507028AC1EC700C746DD /* DownloadItemPart.swift */; }; E9D5507328AC218300C746DD /* DaoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5507228AC218300C746DD /* DaoExtensions.swift */; }; E9D5507528AEF93100C746DD /* PlayerSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5507428AEF93100C746DD /* PlayerSettings.swift */; }; + E9DFCBFB28C28F4A00B36356 /* AudioPlayerSleepTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9DFCBFA28C28F4A00B36356 /* AudioPlayerSleepTimer.swift */; }; E9E985F828B02D9400957F23 /* PlayerProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9E985F728B02D9400957F23 /* PlayerProgress.swift */; }; /* End PBXBuildFile section */ @@ -117,6 +118,7 @@ E9D5507028AC1EC700C746DD /* DownloadItemPart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadItemPart.swift; sourceTree = ""; }; E9D5507228AC218300C746DD /* DaoExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DaoExtensions.swift; sourceTree = ""; }; E9D5507428AEF93100C746DD /* PlayerSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSettings.swift; sourceTree = ""; }; + E9DFCBFA28C28F4A00B36356 /* AudioPlayerSleepTimer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerSleepTimer.swift; sourceTree = ""; }; E9E985F728B02D9400957F23 /* PlayerProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerProgress.swift; sourceTree = ""; }; FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -145,6 +147,7 @@ isa = PBXGroup; children = ( 3A200C1427D64D7E00CBF02E /* AudioPlayer.swift */, + E9DFCBFA28C28F4A00B36356 /* AudioPlayerSleepTimer.swift */, 3ABF618E2804325C0070250E /* PlayerHandler.swift */, E9E985F728B02D9400957F23 /* PlayerProgress.swift */, ); @@ -438,6 +441,7 @@ E9D5506028AC1CA900C746DD /* PlaybackMetadata.swift in Sources */, E9D5504828AC1A7A00C746DD /* MediaType.swift in Sources */, E9D5504E28AC1B0700C746DD /* AudioFile.swift in Sources */, + E9DFCBFB28C28F4A00B36356 /* AudioPlayerSleepTimer.swift in Sources */, E9D5505428AC1B7900C746DD /* AudioTrack.swift in Sources */, E9D5505C28AC1C6200C746DD /* LibraryFile.swift in Sources */, 4DF74912287105C600AC7814 /* DeviceSettings.swift in Sources */, diff --git a/ios/App/App/plugins/AbsAudioPlayer.swift b/ios/App/App/plugins/AbsAudioPlayer.swift index 44b3b374..0ec2d225 100644 --- a/ios/App/App/plugins/AbsAudioPlayer.swift +++ b/ios/App/App/plugins/AbsAudioPlayer.swift @@ -169,7 +169,7 @@ public class AbsAudioPlayer: CAPPlugin { @objc func decreaseSleepTime(_ call: CAPPluginCall) { guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) } guard let time = Double(timeString) else { return call.resolve([ "success": false ]) } - guard let _ = PlayerHandler.remainingSleepTime else { return call.resolve([ "success": false ]) } + guard let _ = PlayerHandler.getSleepTimeRemaining() else { return call.resolve([ "success": false ]) } let seconds = time/1000 PlayerHandler.decreaseSleepTime(decreaseSeconds: seconds) @@ -179,7 +179,7 @@ public class AbsAudioPlayer: CAPPlugin { @objc func increaseSleepTime(_ call: CAPPluginCall) { guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) } guard let time = Double(timeString) else { return call.resolve([ "success": false ]) } - guard let _ = PlayerHandler.remainingSleepTime else { return call.resolve([ "success": false ]) } + guard let _ = PlayerHandler.getSleepTimeRemaining() else { return call.resolve([ "success": false ]) } let seconds = time/1000 PlayerHandler.increaseSleepTime(increaseSeconds: seconds) @@ -188,30 +188,29 @@ public class AbsAudioPlayer: CAPPlugin { @objc func setSleepTimer(_ call: CAPPluginCall) { guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) } - guard let time = Int(timeString) else { return call.resolve([ "success": false ]) } + guard let time = Double(timeString) else { return call.resolve([ "success": false ]) } let isChapterTime = call.getBool("isChapterTime", false) let seconds = time / 1000 NSLog("chapter time: \(isChapterTime)") if isChapterTime { - PlayerHandler.setChapterSleepTime(stopAt: Double(seconds)) + PlayerHandler.setChapterSleepTime(stopAt: seconds) return call.resolve([ "success": true ]) + } else { + PlayerHandler.setSleepTime(secondsUntilSleep: seconds) + call.resolve([ "success": true ]) } - - PlayerHandler.setSleepTime(secondsUntilSleep: Double(seconds)) - call.resolve([ "success": true ]) } @objc func cancelSleepTimer(_ call: CAPPluginCall) { PlayerHandler.cancelSleepTime() - PlayerHandler.sleepTimerChapterStopTime = nil call.resolve() } @objc func getSleepTimerTime(_ call: CAPPluginCall) { call.resolve([ - "value": PlayerHandler.remainingSleepTime + "value": PlayerHandler.getSleepTimeRemaining() ]) } @@ -223,7 +222,7 @@ public class AbsAudioPlayer: CAPPlugin { @objc func sendSleepTimerSet() { self.notifyListeners("onSleepTimerSet", data: [ - "value": PlayerHandler.remainingSleepTime + "value": PlayerHandler.getSleepTimeRemaining() ]) } diff --git a/ios/App/Shared/player/AudioPlayer.swift b/ios/App/Shared/player/AudioPlayer.swift index affc56b2..b91c66ce 100644 --- a/ios/App/Shared/player/AudioPlayer.swift +++ b/ios/App/Shared/player/AudioPlayer.swift @@ -18,7 +18,7 @@ enum PlayMethod:Int { } class AudioPlayer: NSObject { - private let queue = DispatchQueue(label: "ABSAudioPlayerQueue") + internal let queue = DispatchQueue(label: "ABSAudioPlayerQueue") // enums and @objc are not compatible @objc dynamic var status: Int @@ -32,15 +32,17 @@ class AudioPlayer: NSObject { private var playWhenReady: Bool private var initialPlaybackRate: Float - private var audioPlayer: AVQueuePlayer + internal var audioPlayer: AVQueuePlayer private var sessionId: String private var timeObserverToken: Any? private var queueObserver:NSKeyValueObservation? private var queueItemStatusObserver:NSKeyValueObservation? - private var sleepTimeStopAt: Double? - private var sleepTimeToken: Any? + // Sleep timer values + internal var sleepTimeChapterStopAt: Double? + internal var sleepTimeStopAt: Double? + internal var sleepTimeToken: Any? private var currentTrackIndex = 0 private var allPlayerItems:[AVPlayerItem] = [] @@ -263,7 +265,7 @@ class AudioPlayer: NSObject { } // Capture remaining sleep time before changing the track position - let sleepSecondsRemaining = PlayerHandler.remainingSleepTime + let sleepSecondsRemaining = self.getSleepTimeRemaining() // Stop the paused timer self.stopPausedTimer() @@ -279,19 +281,17 @@ class AudioPlayer: NSObject { let currentTrackStartOffset = session.audioTracks[self.currentTrackIndex].startOffset ?? 0.0 let seekTime = currentTime - currentTrackStartOffset self.audioPlayer.seek(to: CMTime(seconds: seekTime, preferredTimescale: 1000), toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] completed in - if completed, let self = self { - // Start playback - self.audioPlayer.play() - self.rate = self.tmpRate - self.audioPlayer.rate = self.tmpRate - self.status = 1 - - // If we have an active sleep timer, reschedule based on rate - self.rescheduleSleepTimerAtTime(time: currentTime, secondsRemaining: sleepSecondsRemaining) - - // Update the progress - self.updateNowPlaying() - } + guard completed else { return } + guard let self = self else { return } + + // Start playback + self.audioPlayer.play() + self.rate = self.tmpRate + self.audioPlayer.rate = self.tmpRate + self.status = 1 + + // Update the progress + self.updateNowPlaying() } } @@ -342,22 +342,22 @@ class AudioPlayer: NSObject { public func seek(_ to: Double, from: String) { let continuePlaying = rate > 0.0 + // Capture remaining sleep time before changing the track position or pausing + let sleepSecondsRemaining = self.getSleepTimeRemaining() + pause() - NSLog("Seek to \(to) from \(from)") + NSLog("SEEK: Seek to \(to) from \(from)") guard let playbackSession = self.getPlaybackSession() else { return } let currentTrack = playbackSession.audioTracks[self.currentTrackIndex] let ctso = currentTrack.startOffset ?? 0.0 let trackEnd = ctso + currentTrack.duration - NSLog("Seek current track END = \(trackEnd)") - - // Capture remaining sleep time before changing the track position - let sleepSecondsRemaining = PlayerHandler.remainingSleepTime + NSLog("SEEK: Seek current track END = \(trackEnd)") let indexOfSeek = getItemIndexForTime(time: to) - NSLog("Seek to index \(indexOfSeek) | Current index \(self.currentTrackIndex)") + NSLog("SEEK: Seek to index \(indexOfSeek) | Current index \(self.currentTrackIndex)") // Reconstruct queue if seeking to a different track if (self.currentTrackIndex != indexOfSeek) { @@ -378,32 +378,30 @@ class AudioPlayer: NSObject { setupQueueItemStatusObserver() } else { - NSLog("Seeking in current item \(to)") + NSLog("SEEK: Seeking in current item \(to)") let currentTrackStartOffset = playbackSession.audioTracks[self.currentTrackIndex].startOffset ?? 0.0 let seekTime = to - currentTrackStartOffset self.audioPlayer.seek(to: CMTime(seconds: seekTime, preferredTimescale: 1000)) { [weak self] completed in - if !completed { - NSLog("WARNING: seeking not completed (to \(seekTime)") + guard completed else { return NSLog("SEEK: WARNING: seeking not completed (to \(seekTime)") } + guard let self = self else { return } + + // Reschedule the sleep timer + if let currentTime = self.getCurrentTime() { + self.rescheduleSleepTimerAtTime(time: currentTime, secondsRemaining: sleepSecondsRemaining) } if continuePlaying { - self?.play() - } - self?.updateNowPlaying() - - // If we have an active sleep timer, reschedule based on seek, since seek is fuzzy - // This needs to occur after play() to capture the correct playback rate - if let currentTime = self?.getCurrentTime() { - self?.rescheduleSleepTimerAtTime(time: currentTime, secondsRemaining: sleepSecondsRemaining) + self.play() } + self.updateNowPlaying() } } } public func setPlaybackRate(_ rate: Float, observed: Bool = false) { // Capture remaining sleep time before changing the rate - let sleepSecondsRemaining = PlayerHandler.remainingSleepTime + let sleepSecondsRemaining = self.getSleepTimeRemaining() let playbackSpeedChanged = rate > 0.0 && rate != self.tmpRate && !(observed && rate == 1) if self.audioPlayer.rate != rate { @@ -427,114 +425,6 @@ class AudioPlayer: NSObject { } } - public func getSleepStopAt() -> Double? { - return self.sleepTimeStopAt - } - - // Let iOS handle the sleep timer logic by letting us know when it's time to stop - public func setSleepTime(stopAt: Double, scaleBasedOnSpeed: Bool = false) { - NSLog("SLEEP TIMER: Scheduling for \(stopAt)") - - // Reset any previous sleep timer - self.removeSleepTimer() - - guard let currentTime = getCurrentTime() else { - NSLog("Failed to get currenTime") - return - } - - // Mark the time to stop playing - if scaleBasedOnSpeed { - // Consider paused as playing at 1x - let rate = Double(self.rate > 0 ? self.rate : 1) - - // Calculate the scaled time to stop at - let timeUntilSleep = (stopAt - currentTime) * rate - self.sleepTimeStopAt = currentTime + timeUntilSleep - - NSLog("SLEEP TIMER: Adjusted based on playback speed of \(rate) to \(self.sleepTimeStopAt!)") - } else { - self.sleepTimeStopAt = stopAt - } - - guard let sleepTimeStopAt = self.sleepTimeStopAt else { return } - let sleepTime = CMTime(seconds: sleepTimeStopAt, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) - - // Schedule the observation time - var times = [NSValue]() - times.append(NSValue(time: sleepTime)) - - sleepTimeToken = self.audioPlayer.addBoundaryTimeObserver(forTimes: times, queue: queue) { [weak self] in - NSLog("SLEEP TIMER: Pausing audio") - self?.pause() - PlayerHandler.sleepTimerChapterStopTime = nil - self?.removeSleepTimer() - } - - // Update the UI - NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil) - } - - private func rescheduleSleepTimerAtTime(time: Double, secondsRemaining: Int?) { - // Not a chapter sleep timer - let hadToCancelChapterSleepTimer = decideIfChapterSleepTimerNeedsToBeCanceled(time: time) - guard !hadToCancelChapterSleepTimer else { return } - guard PlayerHandler.sleepTimerChapterStopTime == nil else { return } - - // Verify sleep timer is set - guard self.sleepTimeToken != nil else { return } - - // Update the sleep timer - if let secondsRemaining = secondsRemaining { - let newSleepTimerPosition = time + Double(secondsRemaining) - self.setSleepTime(stopAt: newSleepTimerPosition, scaleBasedOnSpeed: true) - } - } - - private func decideIfChapterSleepTimerNeedsToBeCanceled(time: Double) -> Bool { - if let chapterSleepTime = PlayerHandler.sleepTimerChapterStopTime { - let sleepIsBeforeCurrentTime = Double(chapterSleepTime) <= time - if sleepIsBeforeCurrentTime { - PlayerHandler.sleepTimerChapterStopTime = nil - self.removeSleepTimer() - return true - } - } - - return false - } - - public func increaseSleepTime(extraTimeInSeconds: Double) { - if let sleepTime = PlayerHandler.remainingSleepTime, let currentTime = getCurrentTime() { - let newSleepTimerPosition = currentTime + Double(sleepTime) + extraTimeInSeconds - if newSleepTimerPosition > currentTime { - self.setSleepTime(stopAt: newSleepTimerPosition, scaleBasedOnSpeed: true) - } - } - } - - public func decreaseSleepTime(removeTimeInSeconds: Double) { - if let sleepTime = PlayerHandler.remainingSleepTime, let currentTime = getCurrentTime() { - let newSleepTimerPosition = currentTime + Double(sleepTime) - removeTimeInSeconds - guard newSleepTimerPosition > currentTime else { return } - if newSleepTimerPosition > currentTime { - self.setSleepTime(stopAt: newSleepTimerPosition, scaleBasedOnSpeed: true) - } - } - - } - - public func removeSleepTimer() { - self.sleepTimeStopAt = nil - if let token = sleepTimeToken { - self.audioPlayer.removeTimeObserver(token) - sleepTimeToken = nil - } - - // Update the UI - NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepEnded.rawValue), object: self) - } - public func getCurrentTime() -> Double? { guard let playbackSession = self.getPlaybackSession() else { return nil } let currentTrackTime = self.audioPlayer.currentTime().seconds diff --git a/ios/App/Shared/player/AudioPlayerSleepTimer.swift b/ios/App/Shared/player/AudioPlayerSleepTimer.swift new file mode 100644 index 00000000..85839984 --- /dev/null +++ b/ios/App/Shared/player/AudioPlayerSleepTimer.swift @@ -0,0 +1,155 @@ +// +// AudioPlayerSleepTimer.swift +// App +// +// Created by Ron Heft on 9/2/22. +// + +import Foundation +import AVFoundation + +extension AudioPlayer { + + // MARK: - Public API + + public func getSleepTimeRemaining() -> Double? { + guard let currentTime = self.getCurrentTime() else { return nil } + + // Return the player time until sleep + var timeUntilSleep: Double? = nil + if let chapterStopAt = self.sleepTimeChapterStopAt { + timeUntilSleep = chapterStopAt - currentTime + } else if let stopAt = self.sleepTimeStopAt { + timeUntilSleep = stopAt - currentTime + } + + // Scale the time until sleep based on the playback rate + if let timeUntilSleep = timeUntilSleep { + let timeUntilSleepScaled = timeUntilSleep / self.getPlaybackRate() + guard timeUntilSleepScaled.isNaN == false else { return nil } + + return timeUntilSleepScaled.rounded() + } else { + return nil + } + } + + // Let iOS handle the sleep timer logic by letting us know when it's time to stop + public func setSleepTime(stopAt: Double, scaleBasedOnSpeed: Bool = false) { + NSLog("SLEEP TIMER: Scheduling for \(stopAt)") + + // Reset any previous sleep timer + let isChapterSleepTimer = !scaleBasedOnSpeed + self.removeSleepTimer(resetStopAt: !isChapterSleepTimer) + + guard let currentTime = getCurrentTime() else { + NSLog("SLEEP TIMER: Failed to get currenTime") + return + } + + // Mark the time to stop playing + let scaledStopAt = self.calculateScaledStopAt(stopAt, currentTime: currentTime, scaleBasedOnSpeed: scaleBasedOnSpeed) + self.sleepTimeStopAt = scaledStopAt + let sleepTime = CMTime(seconds: scaledStopAt, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) + + // Schedule the observation time + var times = [NSValue]() + times.append(NSValue(time: sleepTime)) + + self.sleepTimeToken = self.audioPlayer.addBoundaryTimeObserver(forTimes: times, queue: self.queue) { [weak self] in + NSLog("SLEEP TIMER: Pausing audio") + self?.pause() + self?.removeSleepTimer() + } + + // Update the UI + NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil) + } + + public func increaseSleepTime(extraTimeInSeconds: Double) { + if let sleepTime = self.getSleepTimeRemaining(), let currentTime = getCurrentTime() { + let newSleepTimerPosition = currentTime + sleepTime + extraTimeInSeconds + if newSleepTimerPosition > currentTime { + self.setSleepTime(stopAt: newSleepTimerPosition, scaleBasedOnSpeed: true) + } + } + } + + public func decreaseSleepTime(removeTimeInSeconds: Double) { + if let sleepTime = self.getSleepTimeRemaining(), let currentTime = getCurrentTime() { + let newSleepTimerPosition = currentTime + sleepTime - removeTimeInSeconds + if newSleepTimerPosition > currentTime { + self.setSleepTime(stopAt: newSleepTimerPosition, scaleBasedOnSpeed: true) + } + } + + } + + public func removeSleepTimer(resetStopAt: Bool = true) { + if resetStopAt { + self.sleepTimeStopAt = nil + self.sleepTimeChapterStopAt = nil + } + + if let token = self.sleepTimeToken { + self.audioPlayer.removeTimeObserver(token) + self.sleepTimeToken = nil + } + + // Update the UI + NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepEnded.rawValue), object: self) + } + + + // MARK: - Internal helpers + + internal func rescheduleSleepTimerAtTime(time: Double, secondsRemaining: Double?) { + guard self.isSleepTimerSet() else { return } + + // Cancel a chapter sleep timer that is no longer valid + if isChapterSleepTimerBeforeTime(time) { + return self.removeSleepTimer() + } + + // Update the sleep timer + if !isChapterSleepTimer() { + guard let secondsRemaining = secondsRemaining else { return } + let newSleepTimerPosition = time + secondsRemaining + self.setSleepTime(stopAt: newSleepTimerPosition, scaleBasedOnSpeed: true) + } + } + + private func isChapterSleepTimerBeforeTime(_ time: Double) -> Bool { + if let chapterStopAt = self.sleepTimeChapterStopAt { + return chapterStopAt <= time + } + + return false + } + + private func isSleepTimerSet() -> Bool { + return self.sleepTimeStopAt != nil + } + + private func isChapterSleepTimer() -> Bool { + return self.sleepTimeChapterStopAt != nil + } + + private func getPlaybackRate() -> Double { + // Consider paused as playing at 1x + return Double(self.rate > 0 ? self.rate : 1) + } + + private func calculateScaledStopAt(_ stopAt: Double, currentTime: Double, scaleBasedOnSpeed: Bool) -> Double { + if scaleBasedOnSpeed { + // Calculate the scaled time to stop at + let secondsUntilStopAt1x = stopAt - currentTime + let secondsUntilSleep = secondsUntilStopAt1x * self.getPlaybackRate() + NSLog("SLEEP TIMER: Adjusted based on playback speed of \(self.getPlaybackRate()) to \(secondsUntilSleep)") + return currentTime + secondsUntilSleep + } else { + return stopAt + } + } + +} diff --git a/ios/App/Shared/player/PlayerHandler.swift b/ios/App/Shared/player/PlayerHandler.swift index b2f49587..f11bf2f5 100644 --- a/ios/App/Shared/player/PlayerHandler.swift +++ b/ios/App/Shared/player/PlayerHandler.swift @@ -11,8 +11,6 @@ import RealmSwift class PlayerHandler { private static var player: AudioPlayer? - public static var sleepTimerChapterStopTime: Int? = nil - public static func startPlayback(sessionId: String, playWhenReady: Bool, playbackRate: Float) { guard let session = Database.shared.getPlaybackSession(id: sessionId) else { return } @@ -59,34 +57,6 @@ class PlayerHandler { } } - public static var remainingSleepTime: Int? { - get { - guard let player = player else { return nil } - guard let currentTime = player.getCurrentTime() else { return nil } - - // Return the player time until sleep - var timeUntilSleep: Double? = nil - if let sleepTimerChapterStopTime = sleepTimerChapterStopTime { - timeUntilSleep = Double(sleepTimerChapterStopTime) - currentTime - } else if let stopAt = player.getSleepStopAt() { - timeUntilSleep = stopAt - currentTime - } - - // Scale the time until sleep based on the playback rate - if let timeUntilSleep = timeUntilSleep { - // Consider paused as playing at 1x - let rate = Double(player.rate > 0 ? player.rate : 1) - - let timeUntilSleepScaled = timeUntilSleep / rate - guard timeUntilSleepScaled.isNaN == false else { return nil } - - return Int(timeUntilSleepScaled.rounded()) - } else { - return nil - } - } - } - public static func getCurrentTime() -> Double? { self.player?.getCurrentTime() } @@ -95,31 +65,29 @@ class PlayerHandler { self.player?.setPlaybackRate(speed) } + public static func getSleepTimeRemaining() -> Double? { + return self.player?.getSleepTimeRemaining() + } + public static func setSleepTime(secondsUntilSleep: Double) { - guard let player = player else { return } - guard let currentTime = player.getCurrentTime() else { return } + guard let currentTime = self.player?.getCurrentTime() else { return } let stopAt = secondsUntilSleep + currentTime - player.setSleepTime(stopAt: stopAt, scaleBasedOnSpeed: true) + self.player?.setSleepTime(stopAt: stopAt, scaleBasedOnSpeed: true) } public static func setChapterSleepTime(stopAt: Double) { - guard let player = player else { return } - self.sleepTimerChapterStopTime = Int(stopAt) - player.setSleepTime(stopAt: stopAt, scaleBasedOnSpeed: false) + self.player?.setSleepTime(stopAt: stopAt, scaleBasedOnSpeed: false) } public static func increaseSleepTime(increaseSeconds: Double) { - self.sleepTimerChapterStopTime = nil self.player?.increaseSleepTime(extraTimeInSeconds: increaseSeconds) } public static func decreaseSleepTime(decreaseSeconds: Double) { - self.sleepTimerChapterStopTime = nil self.player?.decreaseSleepTime(removeTimeInSeconds: decreaseSeconds) } public static func cancelSleepTime() { - PlayerHandler.sleepTimerChapterStopTime = nil self.player?.removeSleepTimer() } From dc8852eb0dd885905175ab16ee943d4c23310548 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Fri, 2 Sep 2022 16:52:08 -0400 Subject: [PATCH 07/12] Guard against seeking during initialization --- ios/App/Shared/player/PlayerHandler.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ios/App/Shared/player/PlayerHandler.swift b/ios/App/Shared/player/PlayerHandler.swift index f11bf2f5..a41414fb 100644 --- a/ios/App/Shared/player/PlayerHandler.swift +++ b/ios/App/Shared/player/PlayerHandler.swift @@ -104,6 +104,7 @@ class PlayerHandler { public static func seekForward(amount: Double) { guard let player = player else { return } + guard player.isInitialized() else { return } guard let currentTime = player.getCurrentTime() else { return } let destinationTime = currentTime + amount @@ -112,6 +113,7 @@ class PlayerHandler { public static func seekBackward(amount: Double) { guard let player = player else { return } + guard player.isInitialized() else { return } guard let currentTime = player.getCurrentTime() else { return } let destinationTime = currentTime - amount @@ -119,7 +121,10 @@ class PlayerHandler { } public static func seek(amount: Double) { - player?.seek(amount, from: "handler") + guard let player = player else { return } + guard player.isInitialized() else { return } + + player.seek(amount, from: "handler") } public static func getMetdata() -> [String: Any]? { From c14f6ec4c24f11843195ce4f07f5771a53035646 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Fri, 2 Sep 2022 18:22:42 -0400 Subject: [PATCH 08/12] Rewrite sleep timer logic again --- ios/App/Shared/player/AudioPlayer.swift | 62 +++---- .../Shared/player/AudioPlayerSleepTimer.swift | 172 +++++++++--------- ios/App/Shared/player/PlayerHandler.swift | 6 +- 3 files changed, 111 insertions(+), 129 deletions(-) diff --git a/ios/App/Shared/player/AudioPlayer.swift b/ios/App/Shared/player/AudioPlayer.swift index b91c66ce..78b66bd9 100644 --- a/ios/App/Shared/player/AudioPlayer.swift +++ b/ios/App/Shared/player/AudioPlayer.swift @@ -41,8 +41,9 @@ class AudioPlayer: NSObject { // Sleep timer values internal var sleepTimeChapterStopAt: Double? - internal var sleepTimeStopAt: Double? - internal var sleepTimeToken: Any? + internal var sleepTimeChapterToken: Any? + internal var sleepTimer: Timer? + internal var sleepTimeRemaining: Double? private var currentTrackIndex = 0 private var allPlayerItems:[AVPlayerItem] = [] @@ -168,8 +169,8 @@ class AudioPlayer: NSObject { await PlayerProgress.shared.syncFromPlayer(currentTime: currentTime, includesPlayProgress: isPlaying, isStopping: false) } - // Update the sleep time, if set - if self.sleepTimeStopAt != nil { + if self.isSleepTimerSet() { + // Update the UI NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil) } } @@ -264,12 +265,6 @@ class AudioPlayer: NSObject { return } - // Capture remaining sleep time before changing the track position - let sleepSecondsRemaining = self.getSleepTimeRemaining() - - // Stop the paused timer - self.stopPausedTimer() - // Determine where we are starting playback let lastPlayed = (session.updatedAt ?? 0)/1000 let currentTime = allowSeekBack ? calculateSeekBackTimeAtCurrentTime(session.currentTime, lastPlayed: lastPlayed) : session.currentTime @@ -282,16 +277,7 @@ class AudioPlayer: NSObject { let seekTime = currentTime - currentTrackStartOffset self.audioPlayer.seek(to: CMTime(seconds: seekTime, preferredTimescale: 1000), toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] completed in guard completed else { return } - guard let self = self else { return } - - // Start playback - self.audioPlayer.play() - self.rate = self.tmpRate - self.audioPlayer.rate = self.tmpRate - self.status = 1 - - // Update the progress - self.updateNowPlaying() + self?.resumePlayback() } } @@ -320,9 +306,24 @@ class AudioPlayer: NSObject { return currentTime - time } + private func resumePlayback() { + NSLog("PLAY: Resuming playback") + + // Stop the paused timer + self.stopPausedTimer() + + self.audioPlayer.play() + self.audioPlayer.rate = self.tmpRate + self.status = 1 + + // Update the progress + self.updateNowPlaying() + } + public func pause() { guard self.isInitialized() else { return } + NSLog("PAUSE: Pausing playback") self.audioPlayer.pause() Task { @@ -332,7 +333,6 @@ class AudioPlayer: NSObject { } self.status = 0 - self.rate = 0.0 updateNowPlaying() @@ -342,10 +342,7 @@ class AudioPlayer: NSObject { public func seek(_ to: Double, from: String) { let continuePlaying = rate > 0.0 - // Capture remaining sleep time before changing the track position or pausing - let sleepSecondsRemaining = self.getSleepTimeRemaining() - - pause() + self.pause() NSLog("SEEK: Seek to \(to) from \(from)") @@ -386,22 +383,16 @@ class AudioPlayer: NSObject { guard completed else { return NSLog("SEEK: WARNING: seeking not completed (to \(seekTime)") } guard let self = self else { return } - // Reschedule the sleep timer - if let currentTime = self.getCurrentTime() { - self.rescheduleSleepTimerAtTime(time: currentTime, secondsRemaining: sleepSecondsRemaining) + if continuePlaying { + self.resumePlayback() } - if continuePlaying { - self.play() - } self.updateNowPlaying() } } } public func setPlaybackRate(_ rate: Float, observed: Bool = false) { - // Capture remaining sleep time before changing the rate - let sleepSecondsRemaining = self.getSleepTimeRemaining() let playbackSpeedChanged = rate > 0.0 && rate != self.tmpRate && !(observed && rate == 1) if self.audioPlayer.rate != rate { @@ -415,11 +406,6 @@ class AudioPlayer: NSObject { if playbackSpeedChanged { self.tmpRate = rate - // If we have an active sleep timer, reschedule based on rate - if let currentTime = self.getCurrentTime() { - self.rescheduleSleepTimerAtTime(time: currentTime, secondsRemaining: sleepSecondsRemaining) - } - // Setup the time observer again at the new rate self.setupTimeObserver() } diff --git a/ios/App/Shared/player/AudioPlayerSleepTimer.swift b/ios/App/Shared/player/AudioPlayerSleepTimer.swift index 85839984..96801559 100644 --- a/ios/App/Shared/player/AudioPlayerSleepTimer.swift +++ b/ios/App/Shared/player/AudioPlayerSleepTimer.swift @@ -12,54 +12,59 @@ extension AudioPlayer { // MARK: - Public API + public func isSleepTimerSet() -> Bool { + return self.isCountdownSleepTimerSet() || self.isChapterSleepTimerSet() + } + public func getSleepTimeRemaining() -> Double? { guard let currentTime = self.getCurrentTime() else { return nil } // Return the player time until sleep - var timeUntilSleep: Double? = nil + var sleepTimeRemaining: Double? = nil if let chapterStopAt = self.sleepTimeChapterStopAt { - timeUntilSleep = chapterStopAt - currentTime - } else if let stopAt = self.sleepTimeStopAt { - timeUntilSleep = stopAt - currentTime + sleepTimeRemaining = chapterStopAt - currentTime + } else if self.isCountdownSleepTimerSet() { + sleepTimeRemaining = self.sleepTimeRemaining } - // Scale the time until sleep based on the playback rate - if let timeUntilSleep = timeUntilSleep { - let timeUntilSleepScaled = timeUntilSleep / self.getPlaybackRate() - guard timeUntilSleepScaled.isNaN == false else { return nil } - - return timeUntilSleepScaled.rounded() - } else { + // Guard against invalid sleep timers + if sleepTimeRemaining?.isLess(than: 0) ?? false { + self.removeSleepTimer() return nil } + + return sleepTimeRemaining } - // Let iOS handle the sleep timer logic by letting us know when it's time to stop - public func setSleepTime(stopAt: Double, scaleBasedOnSpeed: Bool = false) { - NSLog("SLEEP TIMER: Scheduling for \(stopAt)") + public func setSleepTimer(secondsUntilSleep: Double) { + NSLog("SLEEP TIMER: Sleeping in \(secondsUntilSleep) seconds") + self.removeSleepTimer() + self.sleepTimeRemaining = secondsUntilSleep - // Reset any previous sleep timer - let isChapterSleepTimer = !scaleBasedOnSpeed - self.removeSleepTimer(resetStopAt: !isChapterSleepTimer) - - guard let currentTime = getCurrentTime() else { - NSLog("SLEEP TIMER: Failed to get currenTime") - return + DispatchQueue.runOnMainQueue { + self.sleepTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in + if self.isPlaying() { + self.decrementSleepTimerIfRunning() + } + } } - // Mark the time to stop playing - let scaledStopAt = self.calculateScaledStopAt(stopAt, currentTime: currentTime, scaleBasedOnSpeed: scaleBasedOnSpeed) - self.sleepTimeStopAt = scaledStopAt - let sleepTime = CMTime(seconds: scaledStopAt, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) + // Update the UI + NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil) + } + + public func setChapterSleepTimer(stopAt: Double) { + NSLog("SLEEP TIMER: Scheduling for chapter end \(stopAt)") + self.removeSleepTimer() // Schedule the observation time + self.sleepTimeChapterStopAt = stopAt + let sleepTime = CMTime(seconds: stopAt, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) var times = [NSValue]() times.append(NSValue(time: sleepTime)) - self.sleepTimeToken = self.audioPlayer.addBoundaryTimeObserver(forTimes: times, queue: self.queue) { [weak self] in - NSLog("SLEEP TIMER: Pausing audio") - self?.pause() - self?.removeSleepTimer() + self.sleepTimeChapterToken = self.audioPlayer.addBoundaryTimeObserver(forTimes: times, queue: self.queue) { [weak self] in + self?.handleSleepEnd() } // Update the UI @@ -67,56 +72,66 @@ extension AudioPlayer { } public func increaseSleepTime(extraTimeInSeconds: Double) { - if let sleepTime = self.getSleepTimeRemaining(), let currentTime = getCurrentTime() { - let newSleepTimerPosition = currentTime + sleepTime + extraTimeInSeconds - if newSleepTimerPosition > currentTime { - self.setSleepTime(stopAt: newSleepTimerPosition, scaleBasedOnSpeed: true) - } - } + self.removeChapterSleepTimer() + guard let sleepTimeRemaining = self.sleepTimeRemaining else { return } + self.sleepTimeRemaining = sleepTimeRemaining + extraTimeInSeconds + + // Update the UI + NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil) } public func decreaseSleepTime(removeTimeInSeconds: Double) { - if let sleepTime = self.getSleepTimeRemaining(), let currentTime = getCurrentTime() { - let newSleepTimerPosition = currentTime + sleepTime - removeTimeInSeconds - if newSleepTimerPosition > currentTime { - self.setSleepTime(stopAt: newSleepTimerPosition, scaleBasedOnSpeed: true) - } - } - - } - - public func removeSleepTimer(resetStopAt: Bool = true) { - if resetStopAt { - self.sleepTimeStopAt = nil - self.sleepTimeChapterStopAt = nil - } - - if let token = self.sleepTimeToken { - self.audioPlayer.removeTimeObserver(token) - self.sleepTimeToken = nil - } + self.removeChapterSleepTimer() + guard let sleepTimeRemaining = self.sleepTimeRemaining else { return } + self.sleepTimeRemaining = sleepTimeRemaining - removeTimeInSeconds // Update the UI - NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepEnded.rawValue), object: self) + NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil) + } + + public func removeSleepTimer() { + self.sleepTimer?.invalidate() + self.sleepTimer = nil + self.removeChapterSleepTimer() + self.sleepTimeRemaining = nil + + // Update the UI after a delay, to avoid a race condition when changing chapters + DispatchQueue.runOnMainQueue { + Timer.scheduledTimer(withTimeInterval: 0.1, repeats: false) { _ in + if !self.isSleepTimerSet() { + NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepEnded.rawValue), object: self) + } + } + } } // MARK: - Internal helpers - internal func rescheduleSleepTimerAtTime(time: Double, secondsRemaining: Double?) { - guard self.isSleepTimerSet() else { return } - - // Cancel a chapter sleep timer that is no longer valid - if isChapterSleepTimerBeforeTime(time) { - return self.removeSleepTimer() + internal func decrementSleepTimerIfRunning() { + if var sleepTimeRemaining = self.sleepTimeRemaining { + sleepTimeRemaining -= 1 + self.sleepTimeRemaining = sleepTimeRemaining + + // Handle the sleep if the timer has expired + if sleepTimeRemaining <= 0 { + self.handleSleepEnd() + } } - - // Update the sleep timer - if !isChapterSleepTimer() { - guard let secondsRemaining = secondsRemaining else { return } - let newSleepTimerPosition = time + secondsRemaining - self.setSleepTime(stopAt: newSleepTimerPosition, scaleBasedOnSpeed: true) + } + + private func handleSleepEnd() { + NSLog("SLEEP TIMER: Pausing audio") + self.pause() + self.removeSleepTimer() + } + + private func removeChapterSleepTimer() { + if let token = self.sleepTimeChapterToken { + self.audioPlayer.removeTimeObserver(token) } + self.sleepTimeChapterToken = nil + self.sleepTimeChapterStopAt = nil } private func isChapterSleepTimerBeforeTime(_ time: Double) -> Bool { @@ -127,29 +142,12 @@ extension AudioPlayer { return false } - private func isSleepTimerSet() -> Bool { - return self.sleepTimeStopAt != nil + private func isCountdownSleepTimerSet() -> Bool { + return self.sleepTimeRemaining != nil } - private func isChapterSleepTimer() -> Bool { + private func isChapterSleepTimerSet() -> Bool { return self.sleepTimeChapterStopAt != nil } - private func getPlaybackRate() -> Double { - // Consider paused as playing at 1x - return Double(self.rate > 0 ? self.rate : 1) - } - - private func calculateScaledStopAt(_ stopAt: Double, currentTime: Double, scaleBasedOnSpeed: Bool) -> Double { - if scaleBasedOnSpeed { - // Calculate the scaled time to stop at - let secondsUntilStopAt1x = stopAt - currentTime - let secondsUntilSleep = secondsUntilStopAt1x * self.getPlaybackRate() - NSLog("SLEEP TIMER: Adjusted based on playback speed of \(self.getPlaybackRate()) to \(secondsUntilSleep)") - return currentTime + secondsUntilSleep - } else { - return stopAt - } - } - } diff --git a/ios/App/Shared/player/PlayerHandler.swift b/ios/App/Shared/player/PlayerHandler.swift index a41414fb..975b5331 100644 --- a/ios/App/Shared/player/PlayerHandler.swift +++ b/ios/App/Shared/player/PlayerHandler.swift @@ -70,13 +70,11 @@ class PlayerHandler { } public static func setSleepTime(secondsUntilSleep: Double) { - guard let currentTime = self.player?.getCurrentTime() else { return } - let stopAt = secondsUntilSleep + currentTime - self.player?.setSleepTime(stopAt: stopAt, scaleBasedOnSpeed: true) + self.player?.setSleepTimer(secondsUntilSleep: secondsUntilSleep) } public static func setChapterSleepTime(stopAt: Double) { - self.player?.setSleepTime(stopAt: stopAt, scaleBasedOnSpeed: false) + self.player?.setChapterSleepTimer(stopAt: stopAt) } public static func increaseSleepTime(increaseSeconds: Double) { From c8ff5a7817e32377cfdfb54674dda1af4347a0f9 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Fri, 2 Sep 2022 18:31:16 -0400 Subject: [PATCH 09/12] Handle multi-track files for sleep timer --- ios/App/Shared/models/server/AudioTrack.swift | 7 +++++++ ios/App/Shared/player/AudioPlayer.swift | 2 +- .../Shared/player/AudioPlayerSleepTimer.swift | 19 +++++++++++++++++-- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/ios/App/Shared/models/server/AudioTrack.swift b/ios/App/Shared/models/server/AudioTrack.swift index 8cd9e50e..f0e8f720 100644 --- a/ios/App/Shared/models/server/AudioTrack.swift +++ b/ios/App/Shared/models/server/AudioTrack.swift @@ -19,6 +19,13 @@ class AudioTrack: EmbeddedObject, Codable { @Persisted var localFileId: String? @Persisted var serverIndex: Int? + var endOffset: Double? { + if let startOffset = startOffset { + return startOffset + duration + } + return nil + } + private enum CodingKeys : String, CodingKey { case index, startOffset, duration, title, contentUrl, mimeType, metadata, localFileId, serverIndex } diff --git a/ios/App/Shared/player/AudioPlayer.swift b/ios/App/Shared/player/AudioPlayer.swift index 78b66bd9..62c185f6 100644 --- a/ios/App/Shared/player/AudioPlayer.swift +++ b/ios/App/Shared/player/AudioPlayer.swift @@ -45,7 +45,7 @@ class AudioPlayer: NSObject { internal var sleepTimer: Timer? internal var sleepTimeRemaining: Double? - private var currentTrackIndex = 0 + internal var currentTrackIndex = 0 private var allPlayerItems:[AVPlayerItem] = [] private var pausedTimer: Timer? diff --git a/ios/App/Shared/player/AudioPlayerSleepTimer.swift b/ios/App/Shared/player/AudioPlayerSleepTimer.swift index 96801559..18ad7aee 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 + sleepTimeRemaining = (chapterStopAt - currentTime) / Double(self.rate > 0 ? self.rate : 1.0) } else if self.isCountdownSleepTimerSet() { sleepTimeRemaining = self.sleepTimeRemaining } @@ -59,7 +59,22 @@ extension AudioPlayer { // Schedule the observation time self.sleepTimeChapterStopAt = stopAt - let sleepTime = CMTime(seconds: stopAt, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) + + // Get the current track + guard let playbackSession = self.getPlaybackSession() else { return } + let currentTrack = playbackSession.audioTracks[currentTrackIndex] + + // Set values + guard let trackStartTime = currentTrack.startOffset else { return } + guard let trackEndTime = currentTrack.endOffset else { return } + + // Verify the stop is during the current audio track + guard trackEndTime >= stopAt else { return } + + // Schedule the observation time + let trackBasedStopTime = stopAt - trackStartTime + + let sleepTime = CMTime(seconds: trackBasedStopTime, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) var times = [NSValue]() times.append(NSValue(time: sleepTime)) From 836ffddd4fb81f0b5a115f483c13aa47dc43e008 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Fri, 2 Sep 2022 18:36:57 -0400 Subject: [PATCH 10/12] Fix race condition with chapter sleep timer --- ios/App/Shared/player/AudioPlayerSleepTimer.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ios/App/Shared/player/AudioPlayerSleepTimer.swift b/ios/App/Shared/player/AudioPlayerSleepTimer.swift index 18ad7aee..646ac5a6 100644 --- a/ios/App/Shared/player/AudioPlayerSleepTimer.swift +++ b/ios/App/Shared/player/AudioPlayerSleepTimer.swift @@ -27,8 +27,8 @@ extension AudioPlayer { sleepTimeRemaining = self.sleepTimeRemaining } - // Guard against invalid sleep timers - if sleepTimeRemaining?.isLess(than: 0) ?? false { + // Guard against invalid sleep timers, but give it a chance to go 1 second negative to prevent a raise condition + if sleepTimeRemaining?.isLess(than: -1) ?? false { self.removeSleepTimer() return nil } From fb8e6408bbde945f414a4caf5dc3fbfbb19f70e2 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Sat, 3 Sep 2022 16:34:31 -0400 Subject: [PATCH 11/12] Fix memory leak --- ios/App/Shared/player/AudioPlayer.swift | 47 +++++++++++-------- .../Shared/player/AudioPlayerSleepTimer.swift | 6 +-- 2 files changed, 30 insertions(+), 23 deletions(-) diff --git a/ios/App/Shared/player/AudioPlayer.swift b/ios/App/Shared/player/AudioPlayer.swift index 62c185f6..6c22dc1c 100644 --- a/ios/App/Shared/player/AudioPlayer.swift +++ b/ios/App/Shared/player/AudioPlayer.swift @@ -100,20 +100,21 @@ class AudioPlayer: NSObject { NSLog("Audioplayer ready") } + deinit { self.stopPausedTimer() self.removeSleepTimer() self.removeTimeObserver() self.queueObserver?.invalidate() self.queueItemStatusObserver?.invalidate() - destroy() } + public func destroy() { // Pause is not synchronous causing this error on below lines: // AVAudioSession_iOS.mm:1206 Deactivating an audio session that has running I/O. All I/O should be stopped or paused prior to deactivating the audio session // It is related to L79 `AVAudioSession.sharedInstance().setActive(false)` - pause() - audioPlayer.replaceCurrentItem(with: nil) + self.pause() + self.audioPlayer.replaceCurrentItem(with: nil) do { try AVAudioSession.sharedInstance().setActive(false) @@ -125,6 +126,11 @@ class AudioPlayer: NSObject { DispatchQueue.runOnMainQueue { UIApplication.shared.endReceivingRemoteControlEvents() } + + // Remove observers + self.audioPlayer.removeObserver(self, forKeyPath: #keyPath(AVPlayer.rate), context: &playerContext) + self.audioPlayer.removeObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem), context: &playerContext) + NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.closed.rawValue), object: nil) } @@ -185,7 +191,8 @@ class AudioPlayer: NSObject { } private func setupQueueObserver() { - self.queueObserver = self.audioPlayer.observe(\.currentItem, options: [.new]) {_,_ in + self.queueObserver = self.audioPlayer.observe(\.currentItem, options: [.new]) { [weak self] _,_ in + guard let self = self else { return } let prevTrackIndex = self.currentTrackIndex self.audioPlayer.currentItem.map { item in self.currentTrackIndex = self.allPlayerItems.firstIndex(of:item) ?? 0 @@ -201,8 +208,8 @@ class AudioPlayer: NSObject { // Listen for player item updates self.queueItemStatusObserver?.invalidate() - self.queueItemStatusObserver = self.audioPlayer.currentItem?.observe(\.status, options: [.new, .old], changeHandler: { playerItem, change in - self.handleQueueItemStatus(playerItem: playerItem) + self.queueItemStatusObserver = self.audioPlayer.currentItem?.observe(\.status, options: [.new, .old], changeHandler: { [weak self] playerItem, change in + self?.handleQueueItemStatus(playerItem: playerItem) }) // Ensure we didn't miss a player item update during initialization @@ -486,59 +493,59 @@ class AudioPlayer: NSObject { let deviceSettings = Database.shared.getDeviceSettings() commandCenter.playCommand.isEnabled = true - commandCenter.playCommand.addTarget { [unowned self] event in - play(allowSeekBack: true) + commandCenter.playCommand.addTarget { [weak self] event in + self?.play(allowSeekBack: true) return .success } commandCenter.pauseCommand.isEnabled = true - commandCenter.pauseCommand.addTarget { [unowned self] event in - pause() + commandCenter.pauseCommand.addTarget { [weak self] event in + self?.pause() return .success } commandCenter.skipForwardCommand.isEnabled = true commandCenter.skipForwardCommand.preferredIntervals = [NSNumber(value: deviceSettings.jumpForwardTime)] - commandCenter.skipForwardCommand.addTarget { [unowned self] event in + commandCenter.skipForwardCommand.addTarget { [weak self] event in guard let command = event.command as? MPSkipIntervalCommand else { return .noSuchContent } - guard let currentTime = self.getCurrentTime() else { + guard let currentTime = self?.getCurrentTime() else { return .commandFailed } - seek(currentTime + command.preferredIntervals[0].doubleValue, from: "remote") + self?.seek(currentTime + command.preferredIntervals[0].doubleValue, from: "remote") return .success } commandCenter.skipBackwardCommand.isEnabled = true commandCenter.skipBackwardCommand.preferredIntervals = [NSNumber(value: deviceSettings.jumpBackwardsTime)] - commandCenter.skipBackwardCommand.addTarget { [unowned self] event in + commandCenter.skipBackwardCommand.addTarget { [weak self] event in guard let command = event.command as? MPSkipIntervalCommand else { return .noSuchContent } - guard let currentTime = self.getCurrentTime() else { + guard let currentTime = self?.getCurrentTime() else { return .commandFailed } - seek(currentTime - command.preferredIntervals[0].doubleValue, from: "remote") + self?.seek(currentTime - command.preferredIntervals[0].doubleValue, from: "remote") return .success } commandCenter.changePlaybackPositionCommand.isEnabled = true - commandCenter.changePlaybackPositionCommand.addTarget { event in + commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in guard let event = event as? MPChangePlaybackPositionCommandEvent else { return .noSuchContent } - self.seek(event.positionTime, from: "remote") + self?.seek(event.positionTime, from: "remote") return .success } commandCenter.changePlaybackRateCommand.isEnabled = true commandCenter.changePlaybackRateCommand.supportedPlaybackRates = [0.5, 0.75, 1.0, 1.25, 1.5, 2] - commandCenter.changePlaybackRateCommand.addTarget { event in + commandCenter.changePlaybackRateCommand.addTarget { [weak self] event in guard let event = event as? MPChangePlaybackRateCommandEvent else { return .noSuchContent } - self.setPlaybackRate(event.playbackRate) + self?.setPlaybackRate(event.playbackRate) return .success } } diff --git a/ios/App/Shared/player/AudioPlayerSleepTimer.swift b/ios/App/Shared/player/AudioPlayerSleepTimer.swift index 646ac5a6..59511e94 100644 --- a/ios/App/Shared/player/AudioPlayerSleepTimer.swift +++ b/ios/App/Shared/player/AudioPlayerSleepTimer.swift @@ -42,9 +42,9 @@ extension AudioPlayer { self.sleepTimeRemaining = secondsUntilSleep DispatchQueue.runOnMainQueue { - self.sleepTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in - if self.isPlaying() { - self.decrementSleepTimerIfRunning() + self.sleepTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + if self?.isPlaying() ?? false { + self?.decrementSleepTimerIfRunning() } } } From b697deac1cfaafd98826a2cec5e4aca21cc4f7a2 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Sat, 3 Sep 2022 16:44:01 -0400 Subject: [PATCH 12/12] Fix cluegy way chapter sleep timer was being reset after skipping past --- ios/App/Shared/player/AudioPlayer.swift | 5 +++++ .../Shared/player/AudioPlayerSleepTimer.swift | 22 +++++-------------- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/ios/App/Shared/player/AudioPlayer.swift b/ios/App/Shared/player/AudioPlayer.swift index 6c22dc1c..f4199465 100644 --- a/ios/App/Shared/player/AudioPlayer.swift +++ b/ios/App/Shared/player/AudioPlayer.swift @@ -178,6 +178,11 @@ class AudioPlayer: NSObject { if self.isSleepTimerSet() { // Update the UI NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil) + + // Handle a sitation where the user skips past the chapter end + if self.isChapterSleepTimerBeforeTime(currentTime) { + self.removeSleepTimer() + } } } } diff --git a/ios/App/Shared/player/AudioPlayerSleepTimer.swift b/ios/App/Shared/player/AudioPlayerSleepTimer.swift index 59511e94..b5995bc9 100644 --- a/ios/App/Shared/player/AudioPlayerSleepTimer.swift +++ b/ios/App/Shared/player/AudioPlayerSleepTimer.swift @@ -27,12 +27,6 @@ extension AudioPlayer { sleepTimeRemaining = self.sleepTimeRemaining } - // Guard against invalid sleep timers, but give it a chance to go 1 second negative to prevent a raise condition - if sleepTimeRemaining?.isLess(than: -1) ?? false { - self.removeSleepTimer() - return nil - } - return sleepTimeRemaining } @@ -110,14 +104,8 @@ extension AudioPlayer { self.removeChapterSleepTimer() self.sleepTimeRemaining = nil - // Update the UI after a delay, to avoid a race condition when changing chapters - DispatchQueue.runOnMainQueue { - Timer.scheduledTimer(withTimeInterval: 0.1, repeats: false) { _ in - if !self.isSleepTimerSet() { - NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepEnded.rawValue), object: self) - } - } - } + // Update the UI + NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepEnded.rawValue), object: self) } @@ -149,7 +137,7 @@ extension AudioPlayer { self.sleepTimeChapterStopAt = nil } - private func isChapterSleepTimerBeforeTime(_ time: Double) -> Bool { + internal func isChapterSleepTimerBeforeTime(_ time: Double) -> Bool { if let chapterStopAt = self.sleepTimeChapterStopAt { return chapterStopAt <= time } @@ -157,11 +145,11 @@ extension AudioPlayer { return false } - private func isCountdownSleepTimerSet() -> Bool { + internal func isCountdownSleepTimerSet() -> Bool { return self.sleepTimeRemaining != nil } - private func isChapterSleepTimerSet() -> Bool { + internal func isChapterSleepTimerSet() -> Bool { return self.sleepTimeChapterStopAt != nil }