diff --git a/ios/App/App/AppDelegate.swift b/ios/App/App/AppDelegate.swift index 99d44fe4..b37c9931 100644 --- a/ios/App/App/AppDelegate.swift +++ b/ios/App/App/AppDelegate.swift @@ -11,7 +11,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Override point for customization after application launch. let configuration = Realm.Configuration( - schemaVersion: 2, + schemaVersion: 4, migrationBlock: { migration, oldSchemaVersion in if (oldSchemaVersion < 1) { NSLog("Realm schema version was \(oldSchemaVersion)") @@ -19,6 +19,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate { newObject?["enableAltView"] = false } } + if (oldSchemaVersion < 4) { + NSLog("Realm schema version was \(oldSchemaVersion)... Reindexing server configs") + var indexCounter = 1 + migration.enumerateObjects(ofType: ServerConnectionConfig.className()) { oldObject, newObject in + newObject?["index"] = indexCounter + indexCounter += 1 + } + } } ) Realm.Configuration.defaultConfiguration = configuration diff --git a/ios/App/App/plugins/AbsAudioPlayer.swift b/ios/App/App/plugins/AbsAudioPlayer.swift index 34e00a77..44b3b374 100644 --- a/ios/App/App/plugins/AbsAudioPlayer.swift +++ b/ios/App/App/plugins/AbsAudioPlayer.swift @@ -38,9 +38,11 @@ public class AbsAudioPlayer: CAPPlugin { do { // Fetch the most recent active session - let activeSession = try await Realm().objects(PlaybackSession.self).where({ $0.isActiveSession == true }).last?.freeze() + let activeSession = try await Realm().objects(PlaybackSession.self).where({ + $0.isActiveSession == true && $0.serverConnectionConfigId == Store.serverConfig?.id + }).last?.freeze() if let activeSession = activeSession { - await PlayerProgress.syncFromServer() + await PlayerProgress.shared.syncFromServer() try self.startPlaybackSession(activeSession, playWhenReady: false, playbackRate: PlayerSettings.main().playbackRate) } } catch { @@ -79,9 +81,9 @@ public class AbsAudioPlayer: CAPPlugin { NSLog("Failed to get local playback session") return call.resolve([:]) } - playbackSession.save() do { + try playbackSession.save() try self.startPlaybackSession(playbackSession, playWhenReady: playWhenReady, playbackRate: playbackRate) call.resolve(try playbackSession.asDictionary()) } catch(let exception) { @@ -91,8 +93,8 @@ public class AbsAudioPlayer: CAPPlugin { } } else { // Playing from the server ApiClient.startPlaybackSession(libraryItemId: libraryItemId!, episodeId: episodeId, forceTranscode: false) { session in - session.save() do { + try session.save() try self.startPlaybackSession(session, playWhenReady: playWhenReady, playbackRate: playbackRate) call.resolve(try session.asDictionary()) } catch(let exception) { @@ -120,7 +122,7 @@ public class AbsAudioPlayer: CAPPlugin { @objc func setPlaybackSpeed(_ call: CAPPluginCall) { let playbackRate = call.getFloat("value", 1.0) let settings = PlayerSettings.main() - settings.update { + try? settings.update { settings.playbackRate = playbackRate } PlayerHandler.setPlaybackSpeed(speed: settings.playbackRate) @@ -166,45 +168,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 @@ -240,17 +244,15 @@ public class AbsAudioPlayer: CAPPlugin { // If direct playing then fallback to transcode ApiClient.startPlaybackSession(libraryItemId: libraryItemId, episodeId: episodeId, forceTranscode: true) { session in - session.save() - PlayerHandler.startPlayback(sessionId: session.id, playWhenReady: self.initialPlayWhenReady, playbackRate: PlayerSettings.main().playbackRate) - do { + try session.save() + PlayerHandler.startPlayback(sessionId: session.id, playWhenReady: self.initialPlayWhenReady, playbackRate: PlayerSettings.main().playbackRate) self.sendPlaybackSession(session: try session.asDictionary()) + self.sendMetadata() } catch(let exception) { - NSLog("failed to convert session to json") + NSLog("Failed to start transcoded session") debugPrint(exception) } - - self.sendMetadata() } } else { self.notifyListeners("onPlaybackFailed", data: [ diff --git a/ios/App/App/plugins/AbsDatabase.swift b/ios/App/App/plugins/AbsDatabase.swift index a78619df..7f3d9f54 100644 --- a/ios/App/App/plugins/AbsDatabase.swift +++ b/ios/App/App/plugins/AbsDatabase.swift @@ -43,7 +43,7 @@ public class AbsDatabase: CAPPlugin { let config = ServerConnectionConfig() config.id = id ?? "" - config.index = 1 + config.index = 0 config.name = name config.address = address config.userId = userId @@ -51,7 +51,8 @@ public class AbsDatabase: CAPPlugin { config.token = token Store.serverConfig = config - call.resolve(convertServerConnectionConfigToJSON(config: config)) + let savedConfig = Store.serverConfig // Fetch the latest value + call.resolve(convertServerConnectionConfigToJSON(config: savedConfig!)) } @objc func removeServerConnectionConfig(_ call: CAPPluginCall) { let id = call.getString("serverConnectionConfigId", "") @@ -139,7 +140,7 @@ public class AbsDatabase: CAPPlugin { call.reject("localMediaProgressId not specificed") return } - Database.shared.removeLocalMediaProgress(localMediaProgressId: localMediaProgressId) + try? Database.shared.removeLocalMediaProgress(localMediaProgressId: localMediaProgressId) call.resolve() } @@ -171,15 +172,15 @@ public class AbsDatabase: CAPPlugin { return call.reject("localLibraryItemId or localMediaProgressId must be specified") } - let localMediaProgress = LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localMediaProgressId, localLibraryItemId: localLibraryItemId, localEpisodeId: localEpisodeId) + let localMediaProgress = try LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localMediaProgressId, localLibraryItemId: localLibraryItemId, localEpisodeId: localEpisodeId) guard let localMediaProgress = localMediaProgress else { call.reject("Local media progress not found or created") return } - localMediaProgress.updateFromServerMediaProgress(serverMediaProgress) NSLog("syncServerMediaProgressWithLocalMediaProgress: Saving local media progress") - Database.shared.saveLocalMediaProgress(localMediaProgress) + try localMediaProgress.updateFromServerMediaProgress(serverMediaProgress) + call.resolve(try localMediaProgress.asDictionary()) } catch { call.reject("Failed to sync media progress") @@ -195,31 +196,36 @@ public class AbsDatabase: CAPPlugin { NSLog("updateLocalMediaProgressFinished \(localMediaProgressId ?? "Unknown") | Is Finished: \(isFinished)") - let localMediaProgress = LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localMediaProgressId, localLibraryItemId: localLibraryItemId, localEpisodeId: localEpisodeId) - guard let localMediaProgress = localMediaProgress else { - call.resolve(["error": "Library Item not found"]) - return - } + do { + let localMediaProgress = try LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localMediaProgressId, localLibraryItemId: localLibraryItemId, localEpisodeId: localEpisodeId) + guard let localMediaProgress = localMediaProgress else { + call.resolve(["error": "Library Item not found"]) + return + } - // Update finished status - localMediaProgress.updateIsFinished(isFinished) - Database.shared.saveLocalMediaProgress(localMediaProgress) - - // Build API response - let progressDictionary = try? localMediaProgress.asDictionary() - var response: [String: Any] = ["local": true, "server": false, "localMediaProgress": progressDictionary ?? ""] - - // Send update to the server if logged in - let hasLinkedServer = localMediaProgress.serverConnectionConfigId != nil - let loggedIntoServer = Store.serverConfig?.id == localMediaProgress.serverConnectionConfigId - if hasLinkedServer && loggedIntoServer { - response["server"] = true - let payload = ["isFinished": isFinished] - ApiClient.updateMediaProgress(libraryItemId: localMediaProgress.libraryItemId!, episodeId: localEpisodeId, payload: payload) { + // Update finished status + try localMediaProgress.updateIsFinished(isFinished) + + // Build API response + let progressDictionary = try? localMediaProgress.asDictionary() + var response: [String: Any] = ["local": true, "server": false, "localMediaProgress": progressDictionary ?? ""] + + // Send update to the server if logged in + let hasLinkedServer = localMediaProgress.serverConnectionConfigId != nil + let loggedIntoServer = Store.serverConfig?.id == localMediaProgress.serverConnectionConfigId + if hasLinkedServer && loggedIntoServer { + response["server"] = true + let payload = ["isFinished": isFinished] + ApiClient.updateMediaProgress(libraryItemId: localMediaProgress.libraryItemId!, episodeId: localEpisodeId, payload: payload) { + call.resolve(response) + } + } else { call.resolve(response) } - } else { - call.resolve(response) + } catch { + debugPrint(error) + call.resolve(["error": "Failed to mark as complete"]) + return } } diff --git a/ios/App/App/plugins/AbsDownloader.swift b/ios/App/App/plugins/AbsDownloader.swift index a5dbffa2..d430fd5e 100644 --- a/ios/App/App/plugins/AbsDownloader.swift +++ b/ios/App/App/plugins/AbsDownloader.swift @@ -28,7 +28,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { handleDownloadTaskUpdate(downloadTask: downloadTask) { downloadItem, downloadItemPart in - let realm = try! Realm() + let realm = try Realm() try realm.write { downloadItemPart.progress = 100 downloadItemPart.completed = true @@ -139,7 +139,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { } self.handleDownloadTaskCompleteFromDownloadItem(item) if let item = Database.shared.getDownloadItem(downloadItemId: item.id!) { - item.delete() + try? item.delete() } } @@ -181,7 +181,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { } } else { localLibraryItem = LocalLibraryItem(libraryItem, localUrl: localDirectory, server: Store.serverConfig!, files: files, coverPath: coverFile) - Database.shared.saveLocalLibraryItem(localLibraryItem: localLibraryItem!) + try? Database.shared.saveLocalLibraryItem(localLibraryItem: localLibraryItem!) } statusNotification["localLibraryItem"] = try? localLibraryItem.asDictionary() @@ -189,7 +189,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { if let progress = libraryItem.userMediaProgress { let episode = downloadItem.media?.episodes.first(where: { $0.id == downloadItem.episodeId }) let localMediaProgress = LocalMediaProgress(localLibraryItem: localLibraryItem!, episode: episode, progress: progress) - Database.shared.saveLocalMediaProgress(localMediaProgress) + try? localMediaProgress.save() statusNotification["localMediaProgress"] = try? localMediaProgress.asDictionary() } @@ -276,7 +276,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { } // Persist in the database before status start coming in - Database.shared.saveDownloadItem(downloadItem) + try Database.shared.saveDownloadItem(downloadItem) // Start all the downloads for task in tasks { diff --git a/ios/App/App/plugins/AbsFileSystem.swift b/ios/App/App/plugins/AbsFileSystem.swift index 928eed64..a96e53c6 100644 --- a/ios/App/App/plugins/AbsFileSystem.swift +++ b/ios/App/App/plugins/AbsFileSystem.swift @@ -70,7 +70,7 @@ public class AbsFileSystem: CAPPlugin { do { if let localLibraryItemId = localLibraryItemId, let item = Database.shared.getLocalLibraryItem(localLibraryItemId: localLibraryItemId) { try FileManager.default.removeItem(at: item.contentDirectory!) - item.delete() + try item.delete() success = true } } catch { @@ -89,24 +89,29 @@ public class AbsFileSystem: CAPPlugin { var success = false if let localLibraryItemId = localLibraryItemId, let trackLocalFileId = trackLocalFileId, let item = Database.shared.getLocalLibraryItem(localLibraryItemId: localLibraryItemId) { - item.update { - do { - if let fileIndex = item.localFiles.firstIndex(where: { $0.id == trackLocalFileId }) { - try FileManager.default.removeItem(at: item.localFiles[fileIndex].contentPath) - item.realm?.delete(item.localFiles[fileIndex]) - if item.isPodcast, let media = item.media { - if let episodeIndex = media.episodes.firstIndex(where: { $0.audioTrack?.localFileId == trackLocalFileId }) { - media.episodes.remove(at: episodeIndex) + do { + try item.update { + do { + if let fileIndex = item.localFiles.firstIndex(where: { $0.id == trackLocalFileId }) { + try FileManager.default.removeItem(at: item.localFiles[fileIndex].contentPath) + item.realm?.delete(item.localFiles[fileIndex]) + if item.isPodcast, let media = item.media { + if let episodeIndex = media.episodes.firstIndex(where: { $0.audioTrack?.localFileId == trackLocalFileId }) { + media.episodes.remove(at: episodeIndex) + } + item.media = media } - item.media = media + call.resolve(try item.asDictionary()) + success = true } - call.resolve(try item.asDictionary()) - success = true + } catch { + NSLog("Failed to delete \(error)") + success = false } - } catch { - NSLog("Failed to delete \(error)") - success = false } + } catch { + NSLog("Failed to delete \(error)") + success = false } } diff --git a/ios/App/Shared/models/PlaybackSession.swift b/ios/App/Shared/models/PlaybackSession.swift index fc562c4e..62db00cf 100644 --- a/ios/App/Shared/models/PlaybackSession.swift +++ b/ios/App/Shared/models/PlaybackSession.swift @@ -31,6 +31,7 @@ class PlaybackSession: Object, Codable, Deletable { @Persisted var serverConnectionConfigId: String? @Persisted var serverAddress: String? @Persisted var isActiveSession = true + @Persisted var serverUpdatedAt: Double = 0 var isLocal: Bool { self.localLibraryItem != nil } var mediaPlayer: String { "AVPlayer" } diff --git a/ios/App/Shared/models/download/DownloadItem.swift b/ios/App/Shared/models/download/DownloadItem.swift index 446cadcc..ee30213a 100644 --- a/ios/App/Shared/models/download/DownloadItem.swift +++ b/ios/App/Shared/models/download/DownloadItem.swift @@ -88,8 +88,8 @@ extension DownloadItem { self.downloadItemParts.allSatisfy({ $0.failed == false }) } - func delete() { - try! self.realm?.write { + func delete() throws { + try self.realm?.write { self.realm?.delete(self.downloadItemParts) self.realm?.delete(self) } diff --git a/ios/App/Shared/models/local/LocalLibraryItem.swift b/ios/App/Shared/models/local/LocalLibraryItem.swift index d2ef503c..51d6a8bb 100644 --- a/ios/App/Shared/models/local/LocalLibraryItem.swift +++ b/ios/App/Shared/models/local/LocalLibraryItem.swift @@ -198,8 +198,8 @@ extension LocalLibraryItem { ) } - func delete() { - try! self.realm?.write { + func delete() throws { + try self.realm?.write { self.realm?.delete(self.localFiles) self.realm?.delete(self) } diff --git a/ios/App/Shared/models/local/LocalMediaProgress.swift b/ios/App/Shared/models/local/LocalMediaProgress.swift index 8962673d..c034ae8d 100644 --- a/ios/App/Shared/models/local/LocalMediaProgress.swift +++ b/ios/App/Shared/models/local/LocalMediaProgress.swift @@ -63,8 +63,12 @@ class LocalMediaProgress: Object, Codable { try container.encode(localLibraryItemId, forKey: .localLibraryItemId) try container.encode(localEpisodeId, forKey: .localEpisodeId) try container.encode(duration, forKey: .duration) - try container.encode(progress, forKey: .progress) - try container.encode(currentTime, forKey: .currentTime) + if progress.isNaN == false { + try container.encode(progress, forKey: .progress) + } + if currentTime.isNaN == false { + try container.encode(currentTime, forKey: .currentTime) + } try container.encode(isFinished, forKey: .isFinished) try container.encode(lastUpdate, forKey: .lastUpdate) try container.encode(startedAt, forKey: .startedAt) @@ -115,8 +119,8 @@ extension LocalMediaProgress { self.finishedAt = progress.finishedAt } - func updateIsFinished(_ finished: Bool) { - try! Realm().write { + func updateIsFinished(_ finished: Bool) throws { + try self.realm?.write { if self.isFinished != finished { self.progress = finished ? 1.0 : 0.0 } @@ -131,8 +135,8 @@ extension LocalMediaProgress { } } - func updateFromPlaybackSession(_ playbackSession: PlaybackSession) { - try! Realm().write { + func updateFromPlaybackSession(_ playbackSession: PlaybackSession) throws { + try self.realm?.write { self.currentTime = playbackSession.currentTime self.progress = playbackSession.progress self.lastUpdate = Date().timeIntervalSince1970 * 1000 @@ -141,8 +145,8 @@ extension LocalMediaProgress { } } - func updateFromServerMediaProgress(_ serverMediaProgress: MediaProgress) { - try! Realm().write { + func updateFromServerMediaProgress(_ serverMediaProgress: MediaProgress) throws { + try self.realm?.write { self.isFinished = serverMediaProgress.isFinished self.progress = serverMediaProgress.progress self.currentTime = serverMediaProgress.currentTime @@ -153,20 +157,25 @@ extension LocalMediaProgress { } } - static func fetchOrCreateLocalMediaProgress(localMediaProgressId: String?, localLibraryItemId: String?, localEpisodeId: String?) -> LocalMediaProgress? { - if let localMediaProgressId = localMediaProgressId { - // Check if it existing in the database, if not, we need to create it - if let progress = Database.shared.getLocalMediaProgress(localMediaProgressId: localMediaProgressId) { - return progress + static func fetchOrCreateLocalMediaProgress(localMediaProgressId: String?, localLibraryItemId: String?, localEpisodeId: String?) throws -> LocalMediaProgress? { + let realm = try Realm() + return try realm.write { () -> LocalMediaProgress? in + if let localMediaProgressId = localMediaProgressId { + // Check if it existing in the database, if not, we need to create it + if let progress = Database.shared.getLocalMediaProgress(localMediaProgressId: localMediaProgressId) { + return progress + } + } + + if let localLibraryItemId = localLibraryItemId { + guard let localLibraryItem = Database.shared.getLocalLibraryItem(localLibraryItemId: localLibraryItemId) else { return nil } + let episode = localLibraryItem.getPodcastEpisode(episodeId: localEpisodeId) + let progress = LocalMediaProgress(localLibraryItem: localLibraryItem, episode: episode) + realm.add(progress) + return progress + } else { + return nil } - } - - if let localLibraryItemId = localLibraryItemId { - guard let localLibraryItem = Database.shared.getLocalLibraryItem(localLibraryItemId: localLibraryItemId) else { return nil } - let episode = localLibraryItem.getPodcastEpisode(episodeId: localEpisodeId) - return LocalMediaProgress(localLibraryItem: localLibraryItem, episode: episode) - } else { - return nil } } } diff --git a/ios/App/Shared/models/server/AudioTrack.swift b/ios/App/Shared/models/server/AudioTrack.swift index c0d0ab88..8cd9e50e 100644 --- a/ios/App/Shared/models/server/AudioTrack.swift +++ b/ios/App/Shared/models/server/AudioTrack.swift @@ -37,7 +37,7 @@ class AudioTrack: EmbeddedObject, Codable { contentUrl = try? values.decode(String.self, forKey: .contentUrl) mimeType = try values.decode(String.self, forKey: .mimeType) metadata = try? values.decode(FileMetadata.self, forKey: .metadata) - localFileId = try! values.decodeIfPresent(String.self, forKey: .localFileId) + localFileId = try? values.decodeIfPresent(String.self, forKey: .localFileId) serverIndex = try? values.decode(Int.self, forKey: .serverIndex) } diff --git a/ios/App/Shared/player/AudioPlayer.swift b/ios/App/Shared/player/AudioPlayer.swift index 6a16c494..65d6ed2a 100644 --- a/ios/App/Shared/player/AudioPlayer.swift +++ b/ios/App/Shared/player/AudioPlayer.swift @@ -18,12 +18,13 @@ enum PlayMethod:Int { } class AudioPlayer: NSObject { + private let queue = DispatchQueue(label: "ABSAudioPlayerQueue") + // enums and @objc are not compatible @objc dynamic var status: Int @objc dynamic var rate: Float private var tmpRate: Float = 1.0 - private var lastPlayTime: Double = 0.0 private var playerContext = 0 private var playerItemContext = 0 @@ -34,12 +35,18 @@ class AudioPlayer: NSObject { private 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? + private var currentTrackIndex = 0 private var allPlayerItems:[AVPlayerItem] = [] + private var pausedTimer: Timer? + // MARK: - Constructor init(sessionId: String, playWhenReady: Bool = false, playbackRate: Float = 1) { self.playWhenReady = playWhenReady @@ -77,12 +84,16 @@ class AudioPlayer: NSObject { self.audioPlayer.insert(item, after:self.audioPlayer.items().last) } + setupTimeObserver() setupQueueObserver() setupQueueItemStatusObserver() NSLog("Audioplayer ready") } deinit { + self.stopPausedTimer() + self.removeSleepTimer() + self.removeTimeObserver() self.queueObserver?.invalidate() self.queueItemStatusObserver?.invalidate() destroy() @@ -124,6 +135,36 @@ class AudioPlayer: NSObject { return 0 } + private func setupTimeObserver() { + // Time observer should be configured on the main queue + DispatchQueue.runOnMainQueue { + self.removeTimeObserver() + + let timeScale = CMTimeScale(NSEC_PER_SEC) + // Rate will be different depending on playback speed, aim for 2 observations/sec + let seconds = 0.5 * (self.rate > 0 ? self.rate : 1.0) + let time = CMTime(seconds: Double(seconds), preferredTimescale: timeScale) + self.timeObserverToken = self.audioPlayer.addPeriodicTimeObserver(forInterval: time, queue: self.queue) { [weak self] 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) + } + } + } + } + + private func removeTimeObserver() { + if let timeObserverToken = timeObserverToken { + self.audioPlayer.removeTimeObserver(timeObserverToken) + self.timeObserverToken = nil + } + } + func setupQueueObserver() { self.queueObserver = self.audioPlayer.observe(\.currentItem, options: [.new]) {_,_ in let prevTrackIndex = self.currentTrackIndex @@ -165,25 +206,44 @@ class AudioPlayer: NSObject { }) } + private func startPausedTimer() { + guard self.pausedTimer == nil else { return } + self.queue.async { + self.pausedTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { timer in + NSLog("PAUSE TIMER: Syncing from server") + Task { await PlayerProgress.shared.syncFromServer() } + } + } + } + + private func stopPausedTimer() { + self.pausedTimer?.invalidate() + self.pausedTimer = nil + } + // MARK: - Methods public func play(allowSeekBack: Bool = false) { guard self.isInitialized() else { return } - if allowSeekBack { - let diffrence = Date.timeIntervalSinceReferenceDate - lastPlayTime + // Capture remaining sleep time before changing the track position + let sleepSecondsRemaining = PlayerHandler.remainingSleepTime + + if allowSeekBack, let session = Database.shared.getPlaybackSession(id: self.sessionId) { + let lastPlayed = (session.updatedAt ?? 0)/1000 + let difference = Date.timeIntervalSinceReferenceDate - lastPlayed var time: Int? - if lastPlayTime == 0 { + if lastPlayed == 0 { time = 5 - } else if diffrence < 6 { + } else if difference < 6 { time = 2 - } else if diffrence < 12 { + } else if difference < 12 { time = 10 - } else if diffrence < 30 { + } else if difference < 30 { time = 15 - } else if diffrence < 180 { + } else if difference < 180 { time = 20 - } else if diffrence < 3600 { + } else if difference < 3600 { time = 25 } else { time = 29 @@ -193,13 +253,22 @@ class AudioPlayer: NSObject { seek(getCurrentTime() - Double(time!), from: "play") } } - lastPlayTime = Date.timeIntervalSinceReferenceDate + + self.stopPausedTimer() + + Task { + let isPlaying = self.status > 0 + await PlayerProgress.shared.syncFromPlayer(currentTime: self.getCurrentTime(), includesPlayProgress: isPlaying, isStopping: false) + } self.audioPlayer.play() self.status = 1 self.rate = self.tmpRate self.audioPlayer.rate = self.tmpRate + // If we have an active sleep timer, reschedule based on rate + self.rescheduleSleepTimerAtTime(time: self.getCurrentTime(), secondsRemaining: sleepSecondsRemaining) + updateNowPlaying() } @@ -207,11 +276,18 @@ class AudioPlayer: NSObject { guard self.isInitialized() else { return } self.audioPlayer.pause() + + Task { + let wasPlaying = self.status > 0 + await PlayerProgress.shared.syncFromPlayer(currentTime: self.getCurrentTime(), includesPlayProgress: wasPlaying, isStopping: true) + } + self.status = 0 self.rate = 0.0 updateNowPlaying() - lastPlayTime = Date.timeIntervalSinceReferenceDate + + self.startPausedTimer() } public func seek(_ to: Double, from: String) { @@ -228,6 +304,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)") @@ -236,7 +314,7 @@ class AudioPlayer: NSObject { if (self.currentTrackIndex != indexOfSeek) { self.currentTrackIndex = indexOfSeek - playbackSession.update { + try? playbackSession.update { playbackSession.currentTime = to } @@ -255,30 +333,154 @@ 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 + // 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 { NSLog("setPlaybakRate rate changed from \(self.audioPlayer.rate) to \(rate)") self.audioPlayer.rate = rate } - if rate > 0.0 && !(observed && rate == 1) { - self.tmpRate = rate - } self.rate = rate self.updateNowPlaying() + + if playbackSpeedChanged { + self.tmpRate = rate + + // If we have an active sleep timer, reschedule based on rate + self.rescheduleSleepTimerAtTime(time: self.getCurrentTime(), 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() + + 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: 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 { @@ -332,7 +534,7 @@ class AudioPlayer: NSObject { private func initAudioSession() { do { - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio, options: [.allowAirPlay]) + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio) try AVAudioSession.sharedInstance().setActive(true) } catch { NSLog("Failed to set AVAudioSession category") @@ -346,6 +548,7 @@ class AudioPlayer: NSObject { UIApplication.shared.beginReceivingRemoteControlEvents() } let commandCenter = MPRemoteCommandCenter.shared() + let deviceSettings = Database.shared.getDeviceSettings() commandCenter.playCommand.isEnabled = true commandCenter.playCommand.addTarget { [unowned self] event in @@ -359,7 +562,7 @@ class AudioPlayer: NSObject { } commandCenter.skipForwardCommand.isEnabled = true - commandCenter.skipForwardCommand.preferredIntervals = [30] + commandCenter.skipForwardCommand.preferredIntervals = [NSNumber(value: deviceSettings.jumpForwardTime)] commandCenter.skipForwardCommand.addTarget { [unowned self] event in guard let command = event.command as? MPSkipIntervalCommand else { return .noSuchContent @@ -369,7 +572,7 @@ class AudioPlayer: NSObject { return .success } commandCenter.skipBackwardCommand.isEnabled = true - commandCenter.skipBackwardCommand.preferredIntervals = [30] + commandCenter.skipBackwardCommand.preferredIntervals = [NSNumber(value: deviceSettings.jumpBackwardsTime)] commandCenter.skipBackwardCommand.addTarget { [unowned self] event in guard let command = event.command as? MPSkipIntervalCommand else { return .noSuchContent diff --git a/ios/App/Shared/player/PlayerHandler.swift b/ios/App/Shared/player/PlayerHandler.swift index 6d840d54..c565690d 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,47 @@ 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 } + + // Return the player time until sleep + var timeUntilSleep: Double? = nil + if let sleepTimerChapterStopTime = sleepTimerChapterStopTime { + timeUntilSleep = Double(sleepTimerChapterStopTime) - player.getCurrentTime() + } else if let stopAt = player.getSleepStopAt() { + timeUntilSleep = stopAt - player.getCurrentTime() + } + + // 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() } @@ -135,29 +94,53 @@ 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.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() + } + public static func getPlayMethod() -> Int? { self.player?.getPlayMethod() } public static func getPlaybackSession() -> PlaybackSession? { guard let player = player else { return nil } - guard let session = Database.shared.getPlaybackSession(id: player.getPlaybackSessionId()) else { return nil } - return session + guard player.isInitialized() else { return nil } + + return Database.shared.getPlaybackSession(id: player.getPlaybackSessionId()) } public static func seekForward(amount: Double) { - guard let player = player else { - return - } + guard let player = player else { return } let destinationTime = player.getCurrentTime() + amount player.seek(destinationTime, from: "handler") } public static func seekBackward(amount: Double) { - guard let player = player else { - return - } + guard let player = player else { return } let destinationTime = player.getCurrentTime() - amount player.seek(destinationTime, from: "handler") @@ -171,10 +154,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(), @@ -183,68 +162,24 @@ 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()) + // MARK: - Helper logic + + private static func cleanupOldSessions(currentSessionId: String?) { + do { + let realm = try Realm() + let oldSessions = realm.objects(PlaybackSession.self) .where({ + $0.isActiveSession == true && $0.serverConnectionConfigId == Store.serverConfig?.id + }) + try realm.write { + for s in oldSessions { + if s.id != currentSessionId { + s.isActiveSession = false } - } else { - if remainingSleepTime! <= 0 { - paused = true - } - remainingSleepTime! -= 1 } } + } catch { + debugPrint("Failed to cleanup sessions") + debugPrint(error) } - - 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 { PlayerProgress.syncFromPlayer() } - Task { await PlayerProgress.syncToServer() } - } - - @objc public static func syncServerProgressDuringPause() { - Task { await PlayerProgress.syncFromServer() } } } diff --git a/ios/App/Shared/player/PlayerProgress.swift b/ios/App/Shared/player/PlayerProgress.swift index 1b50f2a4..c39cce8d 100644 --- a/ios/App/Shared/player/PlayerProgress.swift +++ b/ios/App/Shared/player/PlayerProgress.swift @@ -10,38 +10,91 @@ import UIKit import RealmSwift class PlayerProgress { + public static let shared = PlayerProgress() + + private static let TIME_BETWEEN_SESSION_SYNC_IN_SECONDS = 10.0 private init() {} - public static func syncFromPlayer() { - updateLocalMediaProgressFromLocalSession() + + // MARK: - SYNC HOOKS + + public func syncFromPlayer(currentTime: Double, includesPlayProgress: Bool, isStopping: Bool) async { + let backgroundToken = await UIApplication.shared.beginBackgroundTask(withName: "ABS:syncFromPlayer") + do { + let session = try updateLocalSessionFromPlayer(currentTime: currentTime, includesPlayProgress: includesPlayProgress) + try updateLocalMediaProgressFromLocalSession() + if let session = session { + try await updateServerSessionFromLocalSession(session, rateLimitSync: !isStopping) + } + } catch { + debugPrint("Failed to syncFromPlayer") + debugPrint(error) + } + await UIApplication.shared.endBackgroundTask(backgroundToken) } - public static func syncToServer() async { + public func syncToServer() async { let backgroundToken = await UIApplication.shared.beginBackgroundTask(withName: "ABS:syncToServer") - updateAllServerSessionFromLocalSession() + do { + try await updateAllServerSessionFromLocalSession() + } catch { + debugPrint("Failed to syncToServer") + debugPrint(error) + } await UIApplication.shared.endBackgroundTask(backgroundToken) } - public static func syncFromServer() async { + public func syncFromServer() async { let backgroundToken = await UIApplication.shared.beginBackgroundTask(withName: "ABS:syncFromServer") - await updateLocalSessionFromServerMediaProgress() + do { + try await updateLocalSessionFromServerMediaProgress() + } catch { + debugPrint("Failed to syncFromServer") + debugPrint(error) + } await UIApplication.shared.endBackgroundTask(backgroundToken) } - private static func updateLocalMediaProgressFromLocalSession() { + + // MARK: - SYNC LOGIC + + private func updateLocalSessionFromPlayer(currentTime: Double, includesPlayProgress: Bool) throws -> PlaybackSession? { + guard let session = PlayerHandler.getPlaybackSession() else { return nil } + guard !currentTime.isNaN else { return nil } // Prevent bad data on player stop + + try session.update { + session.realm?.refresh() + + let nowInSeconds = Date().timeIntervalSince1970 + let nowInMilliseconds = nowInSeconds * 1000 + let lastUpdateInMilliseconds = session.updatedAt ?? nowInMilliseconds + let lastUpdateInSeconds = lastUpdateInMilliseconds / 1000 + let secondsSinceLastUpdate = nowInSeconds - lastUpdateInSeconds + + session.currentTime = currentTime + session.updatedAt = nowInMilliseconds + + if includesPlayProgress { + session.timeListening += secondsSinceLastUpdate + } + } + + return session.freeze() + } + + private func updateLocalMediaProgressFromLocalSession() throws { guard let session = PlayerHandler.getPlaybackSession() else { return } guard session.isLocal else { return } - let localMediaProgress = LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: session.localMediaProgressId, localLibraryItemId: session.localLibraryItem?.id, localEpisodeId: session.episodeId) + let localMediaProgress = try LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: session.localMediaProgressId, localLibraryItemId: session.localLibraryItem?.id, localEpisodeId: session.episodeId) guard let localMediaProgress = localMediaProgress else { // Local media progress should have been created // If we're here, it means a library id is invalid return } - localMediaProgress.updateFromPlaybackSession(session) - Database.shared.saveLocalMediaProgress(localMediaProgress) + try localMediaProgress.updateFromPlaybackSession(session) NSLog("Local progress saved to the database") @@ -49,16 +102,46 @@ class PlayerProgress { NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.localProgress.rawValue), object: nil) } - private static func updateAllServerSessionFromLocalSession() { - let sessions = try! Realm().objects(PlaybackSession.self).where({ $0.serverConnectionConfigId == Store.serverConfig?.id }) - for session in sessions { - let session = session.freeze() - Task { await updateServerSessionFromLocalSession(session) } + private func updateAllServerSessionFromLocalSession() async throws { + try await withThrowingTaskGroup(of: Void.self) { [self] group in + for session in try await Realm().objects(PlaybackSession.self).where({ $0.serverConnectionConfigId == Store.serverConfig?.id }) { + let session = session.freeze() + group.addTask { + try await self.updateServerSessionFromLocalSession(session) + } + } + try await group.waitForAll() } } - private static func updateServerSessionFromLocalSession(_ session: PlaybackSession) async { - NSLog("Sending sessionId(\(session.id)) to server") + private func updateServerSessionFromLocalSession(_ session: PlaybackSession, rateLimitSync: Bool = false) async throws { + var safeToSync = true + + guard var session = session.thaw() else { return } + + // We need to update and check the server time in a transaction for thread-safety + try session.update { + session.realm?.refresh() + + let nowInMilliseconds = Date().timeIntervalSince1970 * 1000 + let lastUpdateInMilliseconds = session.serverUpdatedAt + + // If required, rate limit requests based on session last update + if rateLimitSync { + let timeSinceLastSync = nowInMilliseconds - lastUpdateInMilliseconds + let timeBetweenSessionSync = PlayerProgress.TIME_BETWEEN_SESSION_SYNC_IN_SECONDS * 1000 + safeToSync = timeSinceLastSync > timeBetweenSessionSync + if !safeToSync { + return // This only exits the update block + } + } + + session.serverUpdatedAt = nowInMilliseconds + } + session = session.freeze() + + guard safeToSync else { return } + NSLog("Sending sessionId(\(session.id)) to server with currentTime(\(session.currentTime))") var success = false if session.isLocal { @@ -68,16 +151,20 @@ class PlayerProgress { success = await ApiClient.reportPlaybackProgress(report: playbackReport, sessionId: session.id) } + // Remove old sessions after they synced with the server if success && !session.isActiveSession { - NSLog("Deleting sessionId(\(session.id)) as is no longer active") - session.thaw()?.delete() + if let session = session.thaw() { + try session.delete() + } } } - private static func updateLocalSessionFromServerMediaProgress() async { + private func updateLocalSessionFromServerMediaProgress() async throws { NSLog("updateLocalSessionFromServerMediaProgress: Checking if local media progress was updated on server") - guard let session = try! await Realm().objects(PlaybackSession.self).last(where: { $0.isActiveSession == true })?.freeze() else { + guard let session = try await Realm().objects(PlaybackSession.self).last(where: { + $0.isActiveSession == true && $0.serverConnectionConfigId == Store.serverConfig?.id + })?.freeze() else { NSLog("updateLocalSessionFromServerMediaProgress: Failed to get session") return } @@ -105,7 +192,7 @@ class PlayerProgress { if serverIsNewerThanLocal && currentTimeIsDifferent { NSLog("updateLocalSessionFromServerMediaProgress: Server has newer time than local serverLastUpdate=\(serverLastUpdate) localLastUpdate=\(localLastUpdate)") guard let session = session.thaw() else { return } - session.update { + try session.update { session.currentTime = serverCurrentTime session.updatedAt = serverLastUpdate } diff --git a/ios/App/Shared/util/ApiClient.swift b/ios/App/Shared/util/ApiClient.swift index 2d5258b0..2c247e35 100644 --- a/ios/App/Shared/util/ApiClient.swift +++ b/ios/App/Shared/util/ApiClient.swift @@ -200,7 +200,12 @@ class ApiClient { if let updates = response.localProgressUpdates { for update in updates { - Database.shared.saveLocalMediaProgress(update) + do { + try update.save() + } catch { + debugPrint("Failed to update local media progress") + debugPrint(error) + } } } diff --git a/ios/App/Shared/util/DaoExtensions.swift b/ios/App/Shared/util/DaoExtensions.swift index f1d61c3a..67c7923a 100644 --- a/ios/App/Shared/util/DaoExtensions.swift +++ b/ios/App/Shared/util/DaoExtensions.swift @@ -9,15 +9,15 @@ import Foundation import RealmSwift extension Object { - func save() { - let realm = try! Realm() - try! realm.write { + func save() throws { + let realm = try Realm() + try realm.write { realm.add(self, update: .modified) } } - func update(handler: () -> Void) { - try! self.realm?.write { + func update(handler: () -> Void) throws { + try self.realm?.write { handler() } } @@ -33,12 +33,12 @@ extension EmbeddedObject { } protocol Deletable { - func delete() + func delete() throws } extension Deletable where Self: Object { - func delete() { - try! self.realm?.write { + func delete() throws { + try self.realm?.write { self.realm?.delete(self) } } diff --git a/ios/App/Shared/util/Database.swift b/ios/App/Shared/util/Database.swift index 396682ae..8fd116b8 100644 --- a/ios/App/Shared/util/Database.swift +++ b/ios/App/Shared/util/Database.swift @@ -20,29 +20,43 @@ class Database { let realm = try! Realm() let existing: ServerConnectionConfig? = realm.object(ofType: ServerConnectionConfig.self, forPrimaryKey: config.id) - if config.index == 0 { - let lastConfig: ServerConnectionConfig? = realm.objects(ServerConnectionConfig.self).last - - if lastConfig != nil { - config.index = lastConfig!.index + 1 - } else { - config.index = 1 - } - } - - do { - try realm.write { - if existing != nil { - realm.delete(existing!) + if let existing = existing { + do { + try existing.update { + existing.name = config.name + existing.address = config.address + existing.userId = config.userId + existing.username = config.username + existing.token = config.token } - realm.add(config) + } catch { + NSLog("failed to update server config") + debugPrint(error) } - } catch(let exception) { - NSLog("failed to save server config") - debugPrint(exception) + + setLastActiveConfigIndex(index: existing.index) + } else { + if config.index == 0 { + let lastConfig: ServerConnectionConfig? = realm.objects(ServerConnectionConfig.self).last + + if lastConfig != nil { + config.index = lastConfig!.index + 1 + } else { + config.index = 1 + } + } + + do { + try realm.write { + realm.add(config) + } + } catch(let exception) { + NSLog("failed to save server config") + debugPrint(exception) + } + + setLastActiveConfigIndex(index: config.index) } - - setLastActiveConfigIndex(index: config.index) } public func deleteServerConnectionConfig(id: String) { @@ -112,48 +126,83 @@ class Database { } public func getLocalLibraryItems(mediaType: MediaType? = nil) -> [LocalLibraryItem] { - let realm = try! Realm() - return Array(realm.objects(LocalLibraryItem.self)) + do { + let realm = try Realm() + return Array(realm.objects(LocalLibraryItem.self)) + } catch { + debugPrint(error) + return [] + } } public func getLocalLibraryItem(byServerLibraryItemId: String) -> LocalLibraryItem? { - let realm = try! Realm() - return realm.objects(LocalLibraryItem.self).first(where: { $0.libraryItemId == byServerLibraryItemId }) + do { + let realm = try Realm() + return realm.objects(LocalLibraryItem.self).first(where: { $0.libraryItemId == byServerLibraryItemId }) + } catch { + debugPrint(error) + return nil + } } public func getLocalLibraryItem(localLibraryItemId: String) -> LocalLibraryItem? { - let realm = try! Realm() - return realm.object(ofType: LocalLibraryItem.self, forPrimaryKey: localLibraryItemId) + do { + let realm = try Realm() + return realm.object(ofType: LocalLibraryItem.self, forPrimaryKey: localLibraryItemId) + } catch { + debugPrint(error) + return nil + } } - public func saveLocalLibraryItem(localLibraryItem: LocalLibraryItem) { - let realm = try! Realm() - try! realm.write { realm.add(localLibraryItem, update: .modified) } + public func saveLocalLibraryItem(localLibraryItem: LocalLibraryItem) throws { + let realm = try Realm() + try realm.write { realm.add(localLibraryItem, update: .modified) } } public func getLocalFile(localFileId: String) -> LocalFile? { - let realm = try! Realm() - return realm.object(ofType: LocalFile.self, forPrimaryKey: localFileId) + do { + let realm = try Realm() + return realm.object(ofType: LocalFile.self, forPrimaryKey: localFileId) + } catch { + debugPrint(error) + return nil + } } public func getDownloadItem(downloadItemId: String) -> DownloadItem? { - let realm = try! Realm() - return realm.object(ofType: DownloadItem.self, forPrimaryKey: downloadItemId) + do { + let realm = try Realm() + return realm.object(ofType: DownloadItem.self, forPrimaryKey: downloadItemId) + } catch { + debugPrint(error) + return nil + } } public func getDownloadItem(libraryItemId: String) -> DownloadItem? { - let realm = try! Realm() - return realm.objects(DownloadItem.self).filter("libraryItemId == %@", libraryItemId).first + do { + let realm = try Realm() + return realm.objects(DownloadItem.self).filter("libraryItemId == %@", libraryItemId).first + } catch { + debugPrint(error) + return nil + } } public func getDownloadItem(downloadItemPartId: String) -> DownloadItem? { - let realm = try! Realm() - return realm.objects(DownloadItem.self).filter("SUBQUERY(downloadItemParts, $part, $part.id == %@) .@count > 0", downloadItemPartId).first + do { + let realm = try Realm() + return realm.objects(DownloadItem.self).filter("SUBQUERY(downloadItemParts, $part, $part.id == %@) .@count > 0", downloadItemPartId).first + } catch { + debugPrint(error) + return nil + } } - public func saveDownloadItem(_ downloadItem: DownloadItem) { - let realm = try! Realm() - return try! realm.write { realm.add(downloadItem, update: .modified) } + public func saveDownloadItem(_ downloadItem: DownloadItem) throws { + let realm = try Realm() + return try realm.write { realm.add(downloadItem, update: .modified) } } public func getDeviceSettings() -> DeviceSettings { @@ -162,31 +211,41 @@ class Database { } public func getAllLocalMediaProgress() -> [LocalMediaProgress] { - let realm = try! Realm() - return Array(realm.objects(LocalMediaProgress.self)) - } - - public func saveLocalMediaProgress(_ mediaProgress: LocalMediaProgress) { - let realm = try! Realm() - try! realm.write { realm.add(mediaProgress, update: .modified) } + do { + let realm = try Realm() + return Array(realm.objects(LocalMediaProgress.self)) + } catch { + debugPrint(error) + return [] + } } // For books this will just be the localLibraryItemId for podcast episodes this will be "{localLibraryItemId}-{episodeId}" public func getLocalMediaProgress(localMediaProgressId: String) -> LocalMediaProgress? { - let realm = try! Realm() - return realm.object(ofType: LocalMediaProgress.self, forPrimaryKey: localMediaProgressId) + do { + let realm = try Realm() + return realm.object(ofType: LocalMediaProgress.self, forPrimaryKey: localMediaProgressId) + } catch { + debugPrint(error) + return nil + } } - public func removeLocalMediaProgress(localMediaProgressId: String) { - let realm = try! Realm() - try! realm.write { + public func removeLocalMediaProgress(localMediaProgressId: String) throws { + let realm = try Realm() + try realm.write { let progress = realm.object(ofType: LocalMediaProgress.self, forPrimaryKey: localMediaProgressId) realm.delete(progress!) } } public func getPlaybackSession(id: String) -> PlaybackSession? { - let realm = try! Realm() - return realm.object(ofType: PlaybackSession.self, forPrimaryKey: id) + do { + let realm = try Realm() + return realm.object(ofType: PlaybackSession.self, forPrimaryKey: id) + } catch { + debugPrint(error) + return nil + } } } diff --git a/ios/App/Shared/util/NowPlayingInfo.swift b/ios/App/Shared/util/NowPlayingInfo.swift index 01e6d386..387c8911 100644 --- a/ios/App/Shared/util/NowPlayingInfo.swift +++ b/ios/App/Shared/util/NowPlayingInfo.swift @@ -59,12 +59,17 @@ class NowPlayingInfo { } } public func update(duration: Double, currentTime: Double, rate: Float) { - nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration - nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime - nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = rate - nowPlayingInfo[MPNowPlayingInfoPropertyDefaultPlaybackRate] = 1.0 - - MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo + // Update on the main to prevent access collisions + DispatchQueue.main.async { [weak self] in + if let self = self { + self.nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration + self.nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime + self.nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = rate + self.nowPlayingInfo[MPNowPlayingInfoPropertyDefaultPlaybackRate] = 1.0 + + MPNowPlayingInfoCenter.default().nowPlayingInfo = self.nowPlayingInfo + } + } } public func reset() { diff --git a/ios/App/Shared/util/Store.swift b/ios/App/Shared/util/Store.swift index 56fe7ad1..6073f2d7 100644 --- a/ios/App/Shared/util/Store.swift +++ b/ios/App/Shared/util/Store.swift @@ -9,10 +9,17 @@ import Foundation import RealmSwift class Store { - private static var _serverConfig: ServerConnectionConfig? public static var serverConfig: ServerConnectionConfig? { get { - return _serverConfig + do { + // Fetch each time, as holding onto a live or frozen realm object is bad + let index = Database.shared.getLastActiveConfigIndex() + let realm = try Realm() + return realm.objects(ServerConnectionConfig.self).first(where: { $0.index == index }) + } catch { + debugPrint(error) + return nil + } } set(updated) { if updated != nil { @@ -20,9 +27,6 @@ class Store { } else { Database.shared.setLastActiveConfigIndexToNil() } - - // Make safe for accessing on all threads - _serverConfig = updated?.freeze() } } }