Merge pull request #330 from ronaldheft/bug-ios-background-currenttime

Improve iOS Player Sync Logic
This commit is contained in:
advplyr
2022-08-27 14:22:56 -05:00
committed by GitHub
18 changed files with 700 additions and 372 deletions
+220 -18
View File
@@ -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 {
+90 -155
View File
@@ -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() }
}
}
+109 -22
View File
@@ -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
}