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/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/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/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 } 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 87c9fadf..1d599d91 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,17 +32,20 @@ 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 sleepTimeChapterToken: Any? + internal var sleepTimer: Timer? + internal var sleepTimeRemaining: Double? - private var currentTrackIndex = 0 + internal var currentTrackIndex = 0 private var allPlayerItems:[AVPlayerItem] = [] private var pausedTimer: Timer? @@ -98,20 +101,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) @@ -124,6 +128,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) } @@ -178,9 +187,14 @@ 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) + + // Handle a sitation where the user skips past the chapter end + if self.isChapterSleepTimerBeforeTime(currentTime) { + self.removeSleepTimer() + } } } } @@ -194,7 +208,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 @@ -206,35 +221,49 @@ class AudioPlayer: NSObject { } private func setupQueueItemStatusObserver() { + NSLog("queueStatusObserver: Setting up") + + // Listen for player item updates 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) - } + 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 + if let playerItem = self.audioPlayer.currentItem { + 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 { + // 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") + } + + // 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() { @@ -255,65 +284,71 @@ class AudioPlayer: NSObject { // MARK: - Methods public func play(allowSeekBack: Bool = false) { guard self.isInitialized() else { 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") - } + guard let session = self.getPlaybackSession() else { + NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil) + return } + // 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 + guard completed else { return } + self?.resumePlayback() + } + } + + private func calculateSeekBackTimeAtCurrentTime(_ currentTime: Double, lastPlayed: Double) -> Double { + let difference = Date.timeIntervalSinceReferenceDate - lastPlayed + var time: Double = 0 + + // 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 + } + + // Wind the clock back + return currentTime - time + } + + private func resumePlayback() { + NSLog("PLAY: Resuming playback") + + // Stop the paused timer self.stopPausedTimer() - Task { - if let currentTime = self.getCurrentTime() { - await PlayerProgress.shared.syncFromPlayer(currentTime: currentTime, includesPlayProgress: self.isPlaying(), isStopping: false) - } - } - self.markAudioSessionAs(active: true) self.audioPlayer.play() - self.status = 1 - self.rate = self.tmpRate self.audioPlayer.rate = self.tmpRate + self.status = 1 - // If we have an active sleep timer, reschedule based on rate - if let currentTime = self.getCurrentTime() { - self.rescheduleSleepTimerAtTime(time: currentTime, secondsRemaining: sleepSecondsRemaining) - } - - updateNowPlaying() + // Update the progress + self.updateNowPlaying() } public func pause() { guard self.isInitialized() else { return } + NSLog("PAUSE: Pausing playback") self.audioPlayer.pause() self.markAudioSessionAs(active: false) @@ -324,7 +359,6 @@ class AudioPlayer: NSObject { } self.status = 0 - self.rate = 0.0 updateNowPlaying() @@ -334,22 +368,19 @@ class AudioPlayer: NSObject { public func seek(_ to: Double, from: String) { let continuePlaying = rate > 0.0 - pause() + self.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) { @@ -370,32 +401,24 @@ 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 } if continuePlaying { - self?.play() + self.resumePlayback() } - 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.updateNowPlaying() } } } public func setPlaybackRate(_ rate: Float, observed: Bool = false) { - // Capture remaining sleep time before changing the rate - let sleepSecondsRemaining = PlayerHandler.remainingSleepTime let playbackSpeedChanged = rate > 0.0 && rate != self.tmpRate && !(observed && rate == 1) if self.audioPlayer.rate != rate { @@ -409,124 +432,11 @@ 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() } } - 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 @@ -660,59 +570,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 new file mode 100644 index 00000000..b5995bc9 --- /dev/null +++ b/ios/App/Shared/player/AudioPlayerSleepTimer.swift @@ -0,0 +1,156 @@ +// +// AudioPlayerSleepTimer.swift +// App +// +// Created by Ron Heft on 9/2/22. +// + +import Foundation +import AVFoundation + +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 sleepTimeRemaining: Double? = nil + if let chapterStopAt = self.sleepTimeChapterStopAt { + sleepTimeRemaining = (chapterStopAt - currentTime) / Double(self.rate > 0 ? self.rate : 1.0) + } else if self.isCountdownSleepTimerSet() { + sleepTimeRemaining = self.sleepTimeRemaining + } + + return sleepTimeRemaining + } + + public func setSleepTimer(secondsUntilSleep: Double) { + NSLog("SLEEP TIMER: Sleeping in \(secondsUntilSleep) seconds") + self.removeSleepTimer() + self.sleepTimeRemaining = secondsUntilSleep + + DispatchQueue.runOnMainQueue { + self.sleepTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + if self?.isPlaying() ?? false { + self?.decrementSleepTimerIfRunning() + } + } + } + + // 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 + + // 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)) + + self.sleepTimeChapterToken = self.audioPlayer.addBoundaryTimeObserver(forTimes: times, queue: self.queue) { [weak self] in + self?.handleSleepEnd() + } + + // Update the UI + NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil) + } + + public func increaseSleepTime(extraTimeInSeconds: Double) { + 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) { + self.removeChapterSleepTimer() + guard let sleepTimeRemaining = self.sleepTimeRemaining else { return } + self.sleepTimeRemaining = sleepTimeRemaining - removeTimeInSeconds + + // Update the UI + 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 + NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepEnded.rawValue), object: self) + } + + + // MARK: - Internal helpers + + 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() + } + } + } + + 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 + } + + internal func isChapterSleepTimerBeforeTime(_ time: Double) -> Bool { + if let chapterStopAt = self.sleepTimeChapterStopAt { + return chapterStopAt <= time + } + + return false + } + + internal func isCountdownSleepTimerSet() -> Bool { + return self.sleepTimeRemaining != nil + } + + internal func isChapterSleepTimerSet() -> Bool { + return self.sleepTimeChapterStopAt != nil + } + +} diff --git a/ios/App/Shared/player/PlayerHandler.swift b/ios/App/Shared/player/PlayerHandler.swift index b2f49587..975b5331 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,27 @@ 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 } - let stopAt = secondsUntilSleep + currentTime - player.setSleepTime(stopAt: stopAt, scaleBasedOnSpeed: true) + self.player?.setSleepTimer(secondsUntilSleep: secondsUntilSleep) } public static func setChapterSleepTime(stopAt: Double) { - guard let player = player else { return } - self.sleepTimerChapterStopTime = Int(stopAt) - player.setSleepTime(stopAt: stopAt, scaleBasedOnSpeed: false) + self.player?.setChapterSleepTimer(stopAt: stopAt) } 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() } @@ -136,6 +102,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 @@ -144,6 +111,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 @@ -151,7 +119,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]? {