diff --git a/ios/App/App/plugins/AbsAudioPlayer.swift b/ios/App/App/plugins/AbsAudioPlayer.swift index 7d0dfb8c..6a2a40d5 100644 --- a/ios/App/App/plugins/AbsAudioPlayer.swift +++ b/ios/App/App/plugins/AbsAudioPlayer.swift @@ -166,45 +166,47 @@ public class AbsAudioPlayer: CAPPlugin { @objc func decreaseSleepTime(_ 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 currentSleepTime = PlayerHandler.remainingSleepTime 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 ]) } - PlayerHandler.remainingSleepTime = currentSleepTime - (time / 1000) + let seconds = time/1000 + PlayerHandler.decreaseSleepTime(decreaseSeconds: seconds) call.resolve() } + @objc func increaseSleepTime(_ 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 currentSleepTime = PlayerHandler.remainingSleepTime 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 ]) } - PlayerHandler.remainingSleepTime = currentSleepTime + (time / 1000) + let seconds = time/1000 + PlayerHandler.increaseSleepTime(increaseSeconds: seconds) call.resolve() } + @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 ]) } - let timeSeconds = time / 1000 + let isChapterTime = call.getBool("isChapterTime", false) - NSLog("chapter time: \(call.getBool("isChapterTime", false))") + let seconds = time / 1000 - if call.getBool("isChapterTime", false) { - let timeToPause = timeSeconds - Int(PlayerHandler.getCurrentTime() ?? 0) - if timeToPause < 0 { return call.resolve([ "success": false ]) } - - PlayerHandler.sleepTimerChapterStopTime = timeSeconds - PlayerHandler.remainingSleepTime = timeToPause + NSLog("chapter time: \(isChapterTime)") + if isChapterTime { + PlayerHandler.setChapterSleepTime(stopAt: Double(seconds)) return call.resolve([ "success": true ]) } - PlayerHandler.sleepTimerChapterStopTime = nil - PlayerHandler.remainingSleepTime = timeSeconds + PlayerHandler.setSleepTime(secondsUntilSleep: Double(seconds)) call.resolve([ "success": true ]) } + @objc func cancelSleepTimer(_ call: CAPPluginCall) { - PlayerHandler.remainingSleepTime = nil + PlayerHandler.cancelSleepTime() PlayerHandler.sleepTimerChapterStopTime = nil call.resolve() } + @objc func getSleepTimerTime(_ call: CAPPluginCall) { call.resolve([ "value": PlayerHandler.remainingSleepTime diff --git a/ios/App/Shared/player/AudioPlayer.swift b/ios/App/Shared/player/AudioPlayer.swift index cb3bb1cf..e0ae87b5 100644 --- a/ios/App/Shared/player/AudioPlayer.swift +++ b/ios/App/Shared/player/AudioPlayer.swift @@ -38,6 +38,9 @@ class AudioPlayer: NSObject { private var queueObserver:NSKeyValueObservation? private var queueItemStatusObserver:NSKeyValueObservation? + private var sleepTimeStopAt: Double? + private var sleepTimeToken: Any? + private var currentTrackIndex = 0 private var allPlayerItems:[AVPlayerItem] = [] @@ -85,6 +88,7 @@ class AudioPlayer: NSObject { NSLog("Audioplayer ready") } deinit { + self.removeSleepTimer() self.removeTimeObserver() self.queueObserver?.invalidate() self.queueItemStatusObserver?.invalidate() @@ -129,9 +133,18 @@ class AudioPlayer: NSObject { private func setupTimeObserver() { let timeScale = CMTimeScale(NSEC_PER_SEC) - let time = CMTime(seconds: 1, preferredTimescale: timeScale) - self.timeObserverToken = self.audioPlayer.addPeriodicTimeObserver(forInterval: time, queue: .main) { [weak self] currentTime in - NSLog("currentTime: \(currentTime)") + // Observe multiple times per seconds, as rate will be different depending on playback speed + let time = CMTime(seconds: 0.25, preferredTimescale: timeScale) + self.timeObserverToken = self.audioPlayer.addPeriodicTimeObserver(forInterval: time, queue: .main) { time in + Task { + // Let the player update the current playback positions + await PlayerProgress.shared.syncFromPlayer(currentTime: time.seconds, includesPlayProgress: true, isStopping: false) + + // Update the sleep time, if set + if self.sleepTimeStopAt != nil { + NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil) + } + } } } @@ -210,6 +223,11 @@ class AudioPlayer: NSObject { } } lastPlayTime = Date.timeIntervalSinceReferenceDate + + Task { + let isPlaying = self.status > 0 + await PlayerProgress.shared.syncFromPlayer(currentTime: self.getCurrentTime(), includesPlayProgress: isPlaying, isStopping: false) + } self.audioPlayer.play() self.status = 1 @@ -224,6 +242,10 @@ class AudioPlayer: NSObject { self.status = 0 self.rate = 0.0 + Task { + await PlayerProgress.shared.syncFromPlayer(currentTime: self.getCurrentTime(), includesPlayProgress: true, isStopping: true) + } + updateNowPlaying() lastPlayTime = Date.timeIntervalSinceReferenceDate } @@ -242,6 +264,8 @@ class AudioPlayer: NSObject { let trackEnd = ctso + currentTrack.duration NSLog("Seek current track END = \(trackEnd)") + // Capture remaining sleep time before changing the track position + let sleepSecondsRemaining = PlayerHandler.remainingSleepTime let indexOfSeek = getItemIndexForTime(time: to) NSLog("Seek to index \(indexOfSeek) | Current index \(self.currentTrackIndex)") @@ -269,15 +293,21 @@ class AudioPlayer: NSObject { let currentTrackStartOffset = playbackSession.audioTracks[self.currentTrackIndex].startOffset ?? 0.0 let seekTime = to - currentTrackStartOffset - self.audioPlayer.seek(to: CMTime(seconds: seekTime, preferredTimescale: 1000)) { completed in + self.audioPlayer.seek(to: CMTime(seconds: seekTime, preferredTimescale: 1000)) { [weak self] completed in if !completed { NSLog("WARNING: seeking not completed (to \(seekTime)") } if continuePlaying { - self.play() + self?.play() + } + self?.updateNowPlaying() + + // If we have an active sleep timer, reschedule based on seek, since seek is fuzzy + // Theis needs to occur after play() to capture the correct rate + if let currentTime = self?.getCurrentTime() { + self?.rescheduleSleepTimerAtTime(time: currentTime, secondsRemaining: sleepSecondsRemaining) } - self.updateNowPlaying() } } } @@ -291,8 +321,103 @@ class AudioPlayer: NSObject { self.tmpRate = rate } + // Capture remaining sleep time before changing the rate + let sleepSecondsRemaining = PlayerHandler.remainingSleepTime + self.rate = rate self.updateNowPlaying() + + // If we have an active sleep timer, reschedule based on rate + self.rescheduleSleepTimerAtTime(time: self.getCurrentTime(), secondsRemaining: sleepSecondsRemaining) + } + + 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() + + let currentTime = getCurrentTime() + + // 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: .main) { [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) + } + + private func rescheduleSleepTimerAtTime(time: Double, secondsRemaining: Int?) { + // Not a chapter sleep timer + guard PlayerHandler.sleepTimerChapterStopTime == nil else { return } + + // Update the sleep timer + if let secondsRemaining = secondsRemaining { + let newSleepTimerPosition = time + Double(secondsRemaining) + self.setSleepTime(stopAt: newSleepTimerPosition, scaleBasedOnSpeed: true) + } + } + + 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() { + PlayerHandler.sleepTimerChapterStopTime = nil + 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 { diff --git a/ios/App/Shared/player/PlayerHandler.swift b/ios/App/Shared/player/PlayerHandler.swift index c3b8efd2..610b8f73 100644 --- a/ios/App/Shared/player/PlayerHandler.swift +++ b/ios/App/Shared/player/PlayerHandler.swift @@ -10,85 +10,8 @@ import RealmSwift class PlayerHandler { private static var player: AudioPlayer? - private static var playingTimer: Timer? - private static var pausedTimer: Timer? - private static var lastSyncTime: Double = 0.0 public static var sleepTimerChapterStopTime: Int? = nil - private static var _remainingSleepTime: Int? = nil - public static var remainingSleepTime: Int? { - get { - return _remainingSleepTime - } - set(time) { - if time != nil && time! < 0 { - _remainingSleepTime = nil - } else { - _remainingSleepTime = time - } - - if _remainingSleepTime == nil { - NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepEnded.rawValue), object: _remainingSleepTime) - } else { - NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: _remainingSleepTime) - } - } - } - private static var listeningTimePassedSinceLastSync: Double = 0.0 - - public static var paused: Bool { - get { - guard let player = player else { - return true - } - - return player.rate == 0.0 - } - set(paused) { - if paused { - self.player?.pause() - } else { - self.player?.play() - self.pausedTimer?.invalidate() - } - } - } - - public static func startTickTimer() { - DispatchQueue.runOnMainQueue { - NSLog("Starting the tick timer") - playingTimer?.invalidate() - pausedTimer?.invalidate() - playingTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in - self.tick() - } - } - } - - public static func stopTickTimer() { - NSLog("Stopping the tick timer") - playingTimer?.invalidate() - pausedTimer?.invalidate() - playingTimer = nil - } - - private static func startPausedTimer() { - guard self.paused else { return } - self.pausedTimer?.invalidate() - self.pausedTimer = Timer.scheduledTimer(timeInterval: 30, target: self, selector: #selector(syncServerProgressDuringPause), userInfo: nil, repeats: true) - } - - private static func cleanupOldSessions(currentSessionId: String?) { - let realm = try! Realm() - let oldSessions = realm.objects(PlaybackSession.self) .where({ $0.isActiveSession == true }) - try! realm.write { - for s in oldSessions { - if s.id != currentSessionId { - s.isActiveSession = false - } - } - } - } public static func startPlayback(sessionId: String, playWhenReady: Bool, playbackRate: Float) { guard let session = Database.shared.getPlaybackSession(id: sessionId) else { return } @@ -99,26 +22,21 @@ class PlayerHandler { player = nil } - // Cleanup old sessions + // Cleanup and sync old sessions cleanupOldSessions(currentSessionId: sessionId) + Task { await PlayerProgress.shared.syncToServer() } // Set now playing info NowPlayingInfo.shared.setSessionMetadata(metadata: NowPlayingMetadata(id: session.id, itemId: session.libraryItemId!, artworkUrl: session.coverPath, title: session.displayTitle ?? "Unknown title", author: session.displayAuthor, series: nil)) // Create the audio player player = AudioPlayer(sessionId: sessionId, playWhenReady: playWhenReady, playbackRate: playbackRate) - - startTickTimer() - startPausedTimer() } public static func stopPlayback() { // Pause playback first, so we can sync our current progress player?.pause() - // Stop updating progress before we destory the player, so we don't receive bad data - stopTickTimer() - player?.destroy() player = nil @@ -127,6 +45,41 @@ class PlayerHandler { NowPlayingInfo.shared.reset() } + public static var paused: Bool { + get { + guard let player = player else { return true } + return player.rate == 0.0 + } + set(paused) { + if paused { + self.player?.pause() + } else { + self.player?.play() + } + } + } + + public static var remainingSleepTime: Int? { + get { + guard let player = player else { return nil } + + // Consider paused as playing at 1x + let rate = Double(player.rate > 0 ? player.rate : 1) + + if let sleepTimerChapterStopTime = sleepTimerChapterStopTime { + let timeUntilChapterEnd = Double(sleepTimerChapterStopTime) - player.getCurrentTime() + let timeUntilChapterEndScaled = timeUntilChapterEnd / rate + return Int(timeUntilChapterEndScaled.rounded()) + } else if let stopAt = player.getSleepStopAt() { + let timeUntilSleep = stopAt - player.getCurrentTime() + let timeUntilSleepScaled = timeUntilSleep / rate + return Int(timeUntilSleepScaled.rounded()) + } else { + return nil + } + } + } + public static func getCurrentTime() -> Double? { self.player?.getCurrentTime() } @@ -135,6 +88,30 @@ class PlayerHandler { self.player?.setPlaybackRate(speed) } + public static func setSleepTime(secondsUntilSleep: Double) { + guard let player = player else { return } + let stopAt = secondsUntilSleep + player.getCurrentTime() + 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) + } + + public static func increaseSleepTime(increaseSeconds: Double) { + self.player?.increaseSleepTime(extraTimeInSeconds: increaseSeconds) + } + + public static func decreaseSleepTime(decreaseSeconds: Double) { + self.player?.decreaseSleepTime(removeTimeInSeconds: decreaseSeconds) + } + + public static func cancelSleepTime() { + self.player?.removeSleepTimer() + } + public static func getPlayMethod() -> Int? { self.player?.getPlayMethod() } @@ -142,8 +119,8 @@ class PlayerHandler { public static func getPlaybackSession() -> PlaybackSession? { guard let player = player else { return nil } guard player.isInitialized() else { return nil } - guard let session = Database.shared.getPlaybackSession(id: player.getPlaybackSessionId()) else { return nil } - return session + + return Database.shared.getPlaybackSession(id: player.getPlaybackSessionId()) } public static func seekForward(amount: Double) { @@ -168,10 +145,6 @@ class PlayerHandler { guard let player = player else { return nil } guard player.isInitialized() else { return nil } - DispatchQueue.main.async { - syncPlayerProgress() - } - return [ "duration": player.getDuration(), "currentTime": player.getCurrentTime(), @@ -180,65 +153,18 @@ class PlayerHandler { ] } - private static func tick() { - if !paused { - listeningTimePassedSinceLastSync += 1 - - if remainingSleepTime != nil { - if sleepTimerChapterStopTime != nil { - let timeUntilChapterEnd = Double(sleepTimerChapterStopTime ?? 0) - (getCurrentTime() ?? 0) - if timeUntilChapterEnd <= 0 { - paused = true - remainingSleepTime = nil - } else { - remainingSleepTime = Int(timeUntilChapterEnd.rounded()) - } - } else { - if remainingSleepTime! <= 0 { - paused = true - } - remainingSleepTime! -= 1 + // MARK: - Helper logic + + private static func cleanupOldSessions(currentSessionId: String?) { + let realm = try! Realm() + let oldSessions = realm.objects(PlaybackSession.self) .where({ $0.isActiveSession == true }) + try! realm.write { + for s in oldSessions { + if s.id != currentSessionId { + s.isActiveSession = false } } } - - if listeningTimePassedSinceLastSync >= 5 { - syncPlayerProgress() - } - } - - public static func syncPlayerProgress() { - guard let player = player else { return } - guard player.isInitialized() else { return } - guard let session = getPlaybackSession() else { return } - - NSLog("Syncing player progress") - - // Get current time - let playerCurrentTime = player.getCurrentTime() - - // Prevent multiple sync requests - let timeSinceLastSync = Date().timeIntervalSince1970 - lastSyncTime - if (lastSyncTime > 0 && timeSinceLastSync < 1) { - NSLog("syncProgress last sync time was < 1 second so not syncing") - return - } - - // Prevent a sync if we got junk data from the player (occurs when exiting out of memory - guard !playerCurrentTime.isNaN else { return } - - lastSyncTime = Date().timeIntervalSince1970 // seconds - - session.update { - session.currentTime = playerCurrentTime - session.timeListening += listeningTimePassedSinceLastSync - session.updatedAt = Date().timeIntervalSince1970 * 1000 - } - listeningTimePassedSinceLastSync = 0 - - // Persist items in the database and sync to the server - if session.isLocal { Task { await PlayerProgress.shared.syncFromPlayer() } } - Task { await PlayerProgress.shared.syncToServer() } } @objc public static func syncServerProgressDuringPause() { diff --git a/ios/App/Shared/player/PlayerProgress.swift b/ios/App/Shared/player/PlayerProgress.swift index 47251dee..1f76e0d7 100644 --- a/ios/App/Shared/player/PlayerProgress.swift +++ b/ios/App/Shared/player/PlayerProgress.swift @@ -13,11 +13,20 @@ class PlayerProgress { public static let shared = PlayerProgress() + private static let TIME_BETWEEN_SESSION_SYNC_IN_SECONDS = 10.0 + private init() {} - public func syncFromPlayer() async { + + // MARK: - SYNC HOOKS + + public func syncFromPlayer(currentTime: Double, includesPlayProgress: Bool, isStopping: Bool) async { let backgroundToken = await UIApplication.shared.beginBackgroundTask(withName: "ABS:syncFromPlayer") + let session = await updateLocalSessionFromPlayer(currentTime: currentTime, includesPlayProgress: includesPlayProgress) updateLocalMediaProgressFromLocalSession() + if let session = session { + await updateServerSessionFromLocalSession(session, rateLimitSync: !isStopping) + } await UIApplication.shared.endBackgroundTask(backgroundToken) } @@ -33,8 +42,26 @@ class PlayerProgress { await UIApplication.shared.endBackgroundTask(backgroundToken) } - private func updateLocalSessionFromPlayer() async { + + // MARK: - SYNC LOGIC + + private func updateLocalSessionFromPlayer(currentTime: Double, includesPlayProgress: Bool) async -> PlaybackSession? { + guard let session = PlayerHandler.getPlaybackSession() else { return nil } + let now = Date().timeIntervalSince1970 * 1000 + let lastUpdate = session.updatedAt ?? now + let timeSinceLastUpdate = now - lastUpdate + + session.update { + session.currentTime = currentTime + session.updatedAt = now + + if includesPlayProgress { + session.timeListening += timeSinceLastUpdate + } + } + + return session.freeze() } private func updateLocalMediaProgressFromLocalSession() { @@ -65,7 +92,19 @@ class PlayerProgress { } } - private func updateServerSessionFromLocalSession(_ session: PlaybackSession) async { + private func updateServerSessionFromLocalSession(_ session: PlaybackSession, rateLimitSync: Bool = false) async { + // If required, rate limit requests based on session last update + if rateLimitSync { + let now = Date().timeIntervalSince1970 * 1000 + let lastUpdate = session.updatedAt ?? now + let timeSinceLastSync = now - lastUpdate + let timeBetweenSessionSync = PlayerProgress.TIME_BETWEEN_SESSION_SYNC_IN_SECONDS * 1000 + guard timeSinceLastSync > timeBetweenSessionSync else { + // Skipping sync since last occurred within session sync time + return + } + } + NSLog("Sending sessionId(\(session.id)) to server") var success = false