mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2025-08-20 01:28:57 +02:00
Sync offline playback progress
This commit is contained in:
parent
7c5ee940d3
commit
6aa0f2253b
4 changed files with 73 additions and 26 deletions
|
@ -11,7 +11,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
// Override point for customization after application launch.
|
// Override point for customization after application launch.
|
||||||
|
|
||||||
let configuration = Realm.Configuration(
|
let configuration = Realm.Configuration(
|
||||||
schemaVersion: 2,
|
schemaVersion: 3,
|
||||||
migrationBlock: { migration, oldSchemaVersion in
|
migrationBlock: { migration, oldSchemaVersion in
|
||||||
if (oldSchemaVersion < 1) {
|
if (oldSchemaVersion < 1) {
|
||||||
NSLog("Realm schema version was \(oldSchemaVersion)")
|
NSLog("Realm schema version was \(oldSchemaVersion)")
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import RealmSwift
|
import RealmSwift
|
||||||
|
|
||||||
class PlaybackSession: Object, Codable {
|
class PlaybackSession: Object, Codable, Deletable {
|
||||||
@Persisted(primaryKey: true) var id: String = ""
|
@Persisted(primaryKey: true) var id: String = ""
|
||||||
var userId: String?
|
var userId: String?
|
||||||
@Persisted var libraryItemId: String?
|
@Persisted var libraryItemId: String?
|
||||||
|
@ -20,7 +20,7 @@ class PlaybackSession: Object, Codable {
|
||||||
@Persisted var displayAuthor: String?
|
@Persisted var displayAuthor: String?
|
||||||
@Persisted var coverPath: String?
|
@Persisted var coverPath: String?
|
||||||
@Persisted var duration: Double = 0
|
@Persisted var duration: Double = 0
|
||||||
@Persisted var playMethod: Int = 1
|
@Persisted var playMethod: Int = PlayMethod.directplay.rawValue
|
||||||
@Persisted var startedAt: Double?
|
@Persisted var startedAt: Double?
|
||||||
@Persisted var updatedAt: Double?
|
@Persisted var updatedAt: Double?
|
||||||
@Persisted var timeListening: Double = 0
|
@Persisted var timeListening: Double = 0
|
||||||
|
@ -30,8 +30,24 @@ class PlaybackSession: Object, Codable {
|
||||||
@Persisted var localLibraryItem: LocalLibraryItem?
|
@Persisted var localLibraryItem: LocalLibraryItem?
|
||||||
@Persisted var serverConnectionConfigId: String?
|
@Persisted var serverConnectionConfigId: String?
|
||||||
@Persisted var serverAddress: String?
|
@Persisted var serverAddress: String?
|
||||||
|
@Persisted var isActiveSession = true
|
||||||
|
|
||||||
var isLocal: Bool { self.localLibraryItem != nil }
|
var isLocal: Bool { self.localLibraryItem != nil }
|
||||||
|
var mediaPlayer: String { "AVPlayer" }
|
||||||
|
var deviceInfo: [String: String?]? {
|
||||||
|
var systemInfo = utsname()
|
||||||
|
uname(&systemInfo)
|
||||||
|
let modelCode = withUnsafePointer(to: &systemInfo.machine) {
|
||||||
|
$0.withMemoryRebound(to: CChar.self, capacity: 1) {
|
||||||
|
ptr in String.init(validatingUTF8: ptr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
"manufacturer": "Apple",
|
||||||
|
"model": modelCode,
|
||||||
|
"clientVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
var localMediaProgressId: String? {
|
var localMediaProgressId: String? {
|
||||||
if let localLibraryItem = localLibraryItem, let episodeId = episodeId {
|
if let localLibraryItem = localLibraryItem, let episodeId = episodeId {
|
||||||
|
@ -72,10 +88,11 @@ class PlaybackSession: Object, Codable {
|
||||||
self.localLibraryItem = localLibraryItem
|
self.localLibraryItem = localLibraryItem
|
||||||
self.serverConnectionConfigId = serverConnectionConfigId
|
self.serverConnectionConfigId = serverConnectionConfigId
|
||||||
self.serverAddress = serverAddress
|
self.serverAddress = serverAddress
|
||||||
|
self.isActiveSession = true
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum CodingKeys : String, CodingKey {
|
private enum CodingKeys : String, CodingKey {
|
||||||
case id, userId, libraryItemId, episodeId, mediaType, chapters, displayTitle, displayAuthor, coverPath, duration, playMethod, startedAt, updatedAt, timeListening, audioTracks, currentTime, libraryItem, localLibraryItem, serverConnectionConfigId, serverAddress, isLocal, localMediaProgressId
|
case id, userId, libraryItemId, episodeId, mediaType, chapters, displayTitle, displayAuthor, coverPath, duration, playMethod, mediaPlayer, deviceInfo, startedAt, updatedAt, timeListening, audioTracks, currentTime, libraryItem, localLibraryItem, serverConnectionConfigId, serverAddress, isLocal, localMediaProgressId
|
||||||
}
|
}
|
||||||
|
|
||||||
override init() {}
|
override init() {}
|
||||||
|
@ -107,6 +124,7 @@ class PlaybackSession: Object, Codable {
|
||||||
localLibraryItem = try values.decodeIfPresent(LocalLibraryItem.self, forKey: .localLibraryItem)
|
localLibraryItem = try values.decodeIfPresent(LocalLibraryItem.self, forKey: .localLibraryItem)
|
||||||
serverConnectionConfigId = try values.decodeIfPresent(String.self, forKey: .serverConnectionConfigId)
|
serverConnectionConfigId = try values.decodeIfPresent(String.self, forKey: .serverConnectionConfigId)
|
||||||
serverAddress = try values.decodeIfPresent(String.self, forKey: .serverAddress)
|
serverAddress = try values.decodeIfPresent(String.self, forKey: .serverAddress)
|
||||||
|
isActiveSession = true
|
||||||
}
|
}
|
||||||
|
|
||||||
func encode(to encoder: Encoder) throws {
|
func encode(to encoder: Encoder) throws {
|
||||||
|
@ -122,6 +140,8 @@ class PlaybackSession: Object, Codable {
|
||||||
try container.encode(coverPath, forKey: .coverPath)
|
try container.encode(coverPath, forKey: .coverPath)
|
||||||
try container.encode(duration, forKey: .duration)
|
try container.encode(duration, forKey: .duration)
|
||||||
try container.encode(playMethod, forKey: .playMethod)
|
try container.encode(playMethod, forKey: .playMethod)
|
||||||
|
try container.encode(mediaPlayer, forKey: .mediaPlayer)
|
||||||
|
try container.encode(deviceInfo, forKey: .deviceInfo)
|
||||||
try container.encode(startedAt, forKey: .startedAt)
|
try container.encode(startedAt, forKey: .startedAt)
|
||||||
try container.encode(updatedAt, forKey: .updatedAt)
|
try container.encode(updatedAt, forKey: .updatedAt)
|
||||||
try container.encode(timeListening, forKey: .timeListening)
|
try container.encode(timeListening, forKey: .timeListening)
|
||||||
|
|
|
@ -34,7 +34,6 @@ class PlayerHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private static var listeningTimePassedSinceLastSync: Double = 0.0
|
private static var listeningTimePassedSinceLastSync: Double = 0.0
|
||||||
private static var lastSyncReport: PlaybackReport?
|
|
||||||
|
|
||||||
public static var paused: Bool {
|
public static var paused: Bool {
|
||||||
get {
|
get {
|
||||||
|
@ -69,15 +68,35 @@ class PlayerHandler {
|
||||||
timer = nil
|
timer = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func cleanupOldSessions(currentSessionId: String?) {
|
||||||
|
if let currentSessionId = currentSessionId {
|
||||||
|
let realm = try! Realm()
|
||||||
|
let oldSessions = realm.objects(PlaybackSession.self)
|
||||||
|
.where({ $0.isActiveSession == true && $0.id != currentSessionId })
|
||||||
|
try! realm.write {
|
||||||
|
for s in oldSessions {
|
||||||
|
s.isActiveSession = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static func startPlayback(sessionId: String, playWhenReady: Bool, playbackRate: Float) {
|
public static func startPlayback(sessionId: String, playWhenReady: Bool, playbackRate: Float) {
|
||||||
guard let session = Database.shared.getPlaybackSession(id: sessionId) else { return }
|
guard let session = Database.shared.getPlaybackSession(id: sessionId) else { return }
|
||||||
|
|
||||||
|
// Clean up the existing player
|
||||||
if player != nil {
|
if player != nil {
|
||||||
player?.destroy()
|
player?.destroy()
|
||||||
player = nil
|
player = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cleanup old sessions
|
||||||
|
cleanupOldSessions(currentSessionId: sessionId)
|
||||||
|
|
||||||
|
// 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))
|
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)
|
player = AudioPlayer(sessionId: sessionId, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
||||||
|
|
||||||
startTickTimer()
|
startTickTimer()
|
||||||
|
@ -93,6 +112,8 @@ class PlayerHandler {
|
||||||
player?.destroy()
|
player?.destroy()
|
||||||
player = nil
|
player = nil
|
||||||
|
|
||||||
|
cleanupOldSessions(currentSessionId: nil)
|
||||||
|
|
||||||
NowPlayingInfo.shared.reset()
|
NowPlayingInfo.shared.reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -180,10 +201,8 @@ class PlayerHandler {
|
||||||
guard let player = player else { return }
|
guard let player = player else { return }
|
||||||
guard let session = getPlaybackSession() else { return }
|
guard let session = getPlaybackSession() else { return }
|
||||||
|
|
||||||
// Prevent a sync at the current time
|
// Get current time
|
||||||
let playerCurrentTime = player.getCurrentTime()
|
let playerCurrentTime = player.getCurrentTime()
|
||||||
let hasSyncAtCurrentTime = lastSyncReport?.currentTime.isEqual(to: playerCurrentTime) ?? false
|
|
||||||
if hasSyncAtCurrentTime { return }
|
|
||||||
|
|
||||||
// Prevent multiple sync requests
|
// Prevent multiple sync requests
|
||||||
let timeSinceLastSync = Date().timeIntervalSince1970 - lastSyncTime
|
let timeSinceLastSync = Date().timeIntervalSince1970 - lastSyncTime
|
||||||
|
@ -196,29 +215,18 @@ class PlayerHandler {
|
||||||
guard !playerCurrentTime.isNaN else { return }
|
guard !playerCurrentTime.isNaN else { return }
|
||||||
|
|
||||||
lastSyncTime = Date().timeIntervalSince1970 // seconds
|
lastSyncTime = Date().timeIntervalSince1970 // seconds
|
||||||
let report = PlaybackReport(currentTime: playerCurrentTime, duration: player.getDuration(), timeListened: listeningTimePassedSinceLastSync)
|
|
||||||
|
|
||||||
session.update {
|
session.update {
|
||||||
session.currentTime = playerCurrentTime
|
session.currentTime = playerCurrentTime
|
||||||
|
session.timeListening += listeningTimePassedSinceLastSync
|
||||||
}
|
}
|
||||||
listeningTimePassedSinceLastSync = 0
|
listeningTimePassedSinceLastSync = 0
|
||||||
lastSyncReport = report
|
|
||||||
|
|
||||||
let sessionIsLocal = session.isLocal
|
// Persist items in the database and sync to the server
|
||||||
if !sessionIsLocal {
|
if session.isLocal {
|
||||||
if Connectivity.isConnectedToInternet {
|
_ = syncLocalProgress()
|
||||||
NSLog("sending playback report")
|
|
||||||
ApiClient.reportPlaybackProgress(report: report, sessionId: session.id)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if let localMediaProgress = syncLocalProgress() {
|
|
||||||
if Connectivity.isConnectedToInternet {
|
|
||||||
ApiClient.reportLocalMediaProgress(localMediaProgress) { success in
|
|
||||||
NSLog("Synced local media progress: \(success)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
syncPlaybackSessionsToServer()
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func syncLocalProgress() -> LocalMediaProgress? {
|
private static func syncLocalProgress() -> LocalMediaProgress? {
|
||||||
|
@ -241,4 +249,23 @@ class PlayerHandler {
|
||||||
|
|
||||||
return localMediaProgress
|
return localMediaProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func syncPlaybackSessionsToServer() {
|
||||||
|
guard Connectivity.isConnectedToInternet else { return }
|
||||||
|
DispatchQueue.global(qos: .utility).async {
|
||||||
|
let realm = try! Realm()
|
||||||
|
for session in realm.objects(PlaybackSession.self) {
|
||||||
|
NSLog("Sending sessionId(\(session.id)) to server")
|
||||||
|
let sessionRef = ThreadSafeReference(to: session)
|
||||||
|
ApiClient.reportLocalPlaybackProgress(session.freeze()) { success in
|
||||||
|
// Remove old sessions after they synced with the server
|
||||||
|
let session = try! Realm().resolve(sessionRef)
|
||||||
|
if success && !(session?.isActiveSession ?? false) {
|
||||||
|
NSLog("Deleting sessionId(\(session!.id)) as is no longer active")
|
||||||
|
session?.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -166,8 +166,8 @@ class ApiClient {
|
||||||
try? postResource(endpoint: "api/session/\(sessionId)/sync", parameters: report.asDictionary().mapValues({ value in "\(value)" }), callback: nil)
|
try? postResource(endpoint: "api/session/\(sessionId)/sync", parameters: report.asDictionary().mapValues({ value in "\(value)" }), callback: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func reportLocalMediaProgress(_ localMediaProgress: LocalMediaProgress, callback: @escaping (_ success: Bool) -> Void) {
|
public static func reportLocalPlaybackProgress(_ session: PlaybackSession, callback: @escaping (_ success: Bool) -> Void) {
|
||||||
let progress = localMediaProgress.freeze()
|
let progress = session.freeze()
|
||||||
postResource(endpoint: "api/session/local", parameters: progress, callback: callback)
|
postResource(endpoint: "api/session/local", parameters: progress, callback: callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue