Merge branch 'ios-downloads' into ios-downloads-realm-native

This commit is contained in:
ronaldheft 2022-08-14 17:48:31 -04:00
commit 934a07a5ad
20 changed files with 850 additions and 208 deletions

View file

@ -26,7 +26,7 @@
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
<div v-if="!isIos && userCanDownload">
<div v-if="userCanDownload">
<span v-if="isLocal" class="material-icons-outlined px-2 text-success text-lg">audio_file</span>
<span v-else-if="!localEpisode" class="material-icons mx-1 mt-2" :class="downloadItem ? 'animate-bounce text-warning text-opacity-75 text-xl' : 'text-gray-300 text-xl'" @click="downloadClick">{{ downloadItem ? 'downloading' : 'download' }}</span>
<span v-else class="material-icons px-2 text-success text-xl">download_done</span>
@ -143,7 +143,12 @@ export default {
},
downloadClick() {
if (this.downloadItem) return
this.download()
if (this.isIos) {
// no local folders on iOS
this.startDownload()
} else {
this.download()
}
},
async download(selectedLocalFolder = null) {
var localFolder = selectedLocalFolder
@ -183,7 +188,14 @@ export default {
}
},
async startDownload(localFolder) {
var downloadRes = await AbsDownloader.downloadLibraryItem({ libraryItemId: this.libraryItemId, localFolderId: localFolder.id, episodeId: this.episode.id })
var payload = {
libraryItemId: this.libraryItemId,
episodeId: this.episode.id
}
if (localFolder) {
this.localFolderId = localFolder.id
}
var downloadRes = await AbsDownloader.downloadLibraryItem(payload)
if (downloadRes && downloadRes.error) {
var errorMsg = downloadRes.error || 'Unknown error'
console.error('Download error', errorMsg)

View file

@ -34,18 +34,22 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
func applicationDidEnterBackground(_ application: UIApplication) {
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
NSLog("Audiobookself is now in the background")
}
func applicationWillEnterForeground(_ application: UIApplication) {
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
NSLog("Audiobookself is now in the foreground")
}
func applicationDidBecomeActive(_ application: UIApplication) {
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
NSLog("Audiobookself is now active")
}
func applicationWillTerminate(_ application: UIApplication) {
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
NSLog("Audiobookself is terminating")
}
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {

View file

@ -21,6 +21,7 @@ public class AbsAudioPlayer: CAPPlugin {
NotificationCenter.default.addObserver(self, selector: #selector(sendSleepTimerSet), name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(sendSleepTimerEnded), name: NSNotification.Name(PlayerEvents.sleepEnded.rawValue), object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(onPlaybackFailed), name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(onLocalMediaProgressUpdate), name: NSNotification.Name(PlayerEvents.localProgress.rawValue), object: nil)
self.bridge?.webView?.allowsBackForwardNavigationGestures = true;
@ -44,12 +45,20 @@ public class AbsAudioPlayer: CAPPlugin {
let isLocalItem = libraryItemId?.starts(with: "local_") ?? false
if (isLocalItem) {
let item = Database.shared.getLocalLibraryItem(localLibraryItem: libraryItemId!)
// TODO: Logic required for podcasts here
let playbackSession = item?.getPlaybackSession(episode: nil)
let item = Database.shared.getLocalLibraryItem(localLibraryItemId: libraryItemId!)
let episode = item?.getPodcastEpisode(episodeId: episodeId)
let playbackSession = item?.getPlaybackSession(episode: episode)
sendPrepareMetadataEvent(itemId: libraryItemId!, playWhenReady: playWhenReady)
do {
self.sendPlaybackSession(session: try playbackSession.asDictionary())
call.resolve(try playbackSession.asDictionary())
} catch(let exception) {
NSLog("failed to convert session to json")
debugPrint(exception)
call.resolve([:])
}
PlayerHandler.startPlayback(session: playbackSession!, playWhenReady: playWhenReady, playbackRate: playbackRate)
self.sendMetadata()
call.resolve()
} else { // Playing from the server
sendPrepareMetadataEvent(itemId: libraryItemId!, playWhenReady: playWhenReady)
ApiClient.startPlaybackSession(libraryItemId: libraryItemId!, episodeId: episodeId, forceTranscode: false) { session in
@ -62,12 +71,12 @@ public class AbsAudioPlayer: CAPPlugin {
call.resolve([:])
}
PlayerHandler.startPlayback(session: session, playWhenReady: playWhenReady, playbackRate: playbackRate)
self.sendMetadata()
}
}
}
@objc func closePlayback(_ call: CAPPluginCall) {
NSLog("Close playback")
@ -171,12 +180,21 @@ public class AbsAudioPlayer: CAPPlugin {
"value": PlayerHandler.getCurrentTime()
])
}
@objc func sendSleepTimerSet() {
self.notifyListeners("onSleepTimerSet", data: [
"value": PlayerHandler.remainingSleepTime
])
}
@objc func onLocalMediaProgressUpdate() {
guard let localMediaProgressId = PlayerHandler.getPlaybackSession()?.localMediaProgressId else { return }
guard let localMediaProgress = Database.shared.getLocalMediaProgress(localMediaProgressId: localMediaProgressId) else { return }
guard let progressUpdate = try? localMediaProgress.asDictionary() else { return }
NSLog("Sending local progress back to the UI")
self.notifyListeners("onLocalMediaProgressUpdate", data: progressUpdate)
}
@objc func onPlaybackFailed() {
if (PlayerHandler.getPlayMethod() == PlayMethod.directplay.rawValue) {
let playbackSession = PlayerHandler.getPlaybackSession()
@ -211,6 +229,7 @@ public class AbsAudioPlayer: CAPPlugin {
"playWhenReady": playWhenReady,
])
}
@objc func sendPlaybackSession(session: [String: Any]) {
self.notifyListeners("onPlaybackSession", data: session)
}

View file

@ -20,6 +20,9 @@ CAP_PLUGIN(AbsDatabase, "AbsDatabase",
CAP_PLUGIN_METHOD(getLocalLibraryItemByLId, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(getLocalLibraryItemsInFolder, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(getAllLocalMediaProgress, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(removeLocalMediaProgress, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(syncLocalMediaProgressWithServer, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(syncServerMediaProgressWithLocalMediaProgress, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(updateLocalMediaProgressFinished, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(updateDeviceSettings, CAPPluginReturnPromise);
)

View file

@ -8,6 +8,7 @@
import Foundation
import Capacitor
import RealmSwift
import SwiftUI
extension String {
@ -88,7 +89,7 @@ public class AbsDatabase: CAPPlugin {
@objc func getLocalLibraryItem(_ call: CAPPluginCall) {
do {
let item = Database.shared.getLocalLibraryItem(localLibraryItem: call.getString("id") ?? "")
let item = Database.shared.getLocalLibraryItem(localLibraryItemId: call.getString("id") ?? "")
switch item {
case .some(let foundItem):
call.resolve(try foundItem.asDictionary())
@ -104,7 +105,7 @@ public class AbsDatabase: CAPPlugin {
@objc func getLocalLibraryItemByLId(_ call: CAPPluginCall) {
do {
let item = Database.shared.getLocalLibraryItemByLLId(libraryItem: call.getString("libraryItemId") ?? "")
let item = Database.shared.getLocalLibraryItem(byServerLibraryItemId: call.getString("libraryItemId") ?? "")
switch item {
case .some(let foundItem):
call.resolve(try foundItem.asDictionary())
@ -121,9 +122,107 @@ public class AbsDatabase: CAPPlugin {
@objc func getLocalLibraryItemsInFolder(_ call: CAPPluginCall) {
call.resolve([ "value": [] ])
}
@objc func getAllLocalMediaProgress(_ call: CAPPluginCall) {
call.resolve([ "value": [] ])
do {
call.resolve([ "value": try Database.shared.getAllLocalMediaProgress().asDictionaryArray() ])
} catch {
NSLog("Error while loading local media progress")
debugPrint(error)
call.resolve(["value": []])
}
}
@objc func removeLocalMediaProgress(_ call: CAPPluginCall) {
let localMediaProgressId = call.getString("localMediaProgressId")
guard let localMediaProgressId = localMediaProgressId else {
call.reject("localMediaProgressId not specificed")
return
}
Database.shared.removeLocalMediaProgress(localMediaProgressId: localMediaProgressId)
call.resolve()
}
@objc func syncLocalMediaProgressWithServer(_ call: CAPPluginCall) {
guard Store.serverConfig != nil else {
call.reject("syncLocalMediaProgressWithServer not connected to server")
return
}
ApiClient.syncMediaProgress { results in
do {
call.resolve(try results.asDictionary())
} catch {
call.reject("Failed to report synced media progress")
}
}
}
@objc func syncServerMediaProgressWithLocalMediaProgress(_ call: CAPPluginCall) {
let serverMediaProgress = call.getJson("mediaProgress", type: MediaProgress.self)
let localLibraryItemId = call.getString("localLibraryItemId")
let localEpisodeId = call.getString("localEpisodeId")
let localMediaProgressId = call.getString("localMediaProgressId")
do {
guard let serverMediaProgress = serverMediaProgress else {
return call.reject("serverMediaProgress not specified")
}
guard localLibraryItemId != nil || localMediaProgressId != nil else {
return call.reject("localLibraryItemId or localMediaProgressId must be specified")
}
let localMediaProgress = LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localMediaProgressId, localLibraryItemId: localLibraryItemId, localEpisodeId: localEpisodeId)
guard var 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)
call.resolve(try localMediaProgress.asDictionary())
} catch {
call.reject("Failed to sync media progress")
debugPrint(error)
}
}
@objc func updateLocalMediaProgressFinished(_ call: CAPPluginCall) {
let localLibraryItemId = call.getString("localLibraryItemId")
let localEpisodeId = call.getString("localEpisodeId")
let localMediaProgressId = call.getString("localMediaProgressId")
let isFinished = call.getBool("isFinished", false)
NSLog("updateLocalMediaProgressFinished \(localMediaProgressId ?? "Unknown") | Is Finished: \(isFinished)")
let localMediaProgress = LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localMediaProgressId, localLibraryItemId: localLibraryItemId, localEpisodeId: localEpisodeId)
guard var 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) {
call.resolve(response)
}
} else {
call.resolve(response)
}
}
@objc func updateDeviceSettings(_ call: CAPPluginCall) {
let disableAutoRewind = call.getBool("disableAutoRewind") ?? false
let enableAltView = call.getBool("enableAltView") ?? false

View file

@ -12,7 +12,7 @@ import RealmSwift
@objc(AbsDownloader)
public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
static let downloadsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
static private let downloadsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
typealias DownloadProgressHandler = (_ downloadItem: DownloadItem, _ downloadItemPart: inout DownloadItemPart) throws -> Void
@ -23,11 +23,11 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
}()
private let progressStatusQueue = DispatchQueue(label: "progress-status-queue", attributes: .concurrent)
private var downloadItemProgress = [String: DownloadItem]()
private var isMonitoringProgress = false
private var monitoringProgressTimer: Timer?
public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
handleDownloadTaskUpdate(downloadTask: downloadTask) { downloadItem, downloadItemPart in
downloadItemPart.progress = 1
downloadItemPart.progress = 100
downloadItemPart.completed = true
do {
@ -103,47 +103,49 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
// We want to handle updating the UI in the background and throttled so we don't overload the UI with progress updates
private func notifyDownloadProgress() {
if !self.isMonitoringProgress {
self.isMonitoringProgress = true
DispatchQueue.global(qos: .userInteractive).async {
NSLog("Starting monitoring download progress...")
// Fetch active downloads in a thread-safe way
func fetchActiveDownloads() -> [String: DownloadItem]? {
self.progressStatusQueue.sync { self.downloadItemProgress }
}
// Remove a completed download item in a thread-safe way
func handleDoneDownloadItem(_ item: DownloadItem) {
self.progressStatusQueue.async(flags: .barrier) {
self.downloadItemProgress.removeValue(forKey: item.id!)
if self.monitoringProgressTimer == nil {
NSLog("Already monitoring progress, no need to start timer again")
} else {
DispatchQueue.runOnMainQueue {
self.monitoringProgressTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true, block: { t in
NSLog("Starting monitoring download progress...")
// Fetch active downloads in a thread-safe way
func fetchActiveDownloads() -> [String: DownloadItem]? {
self.progressStatusQueue.sync {
let activeDownloads = self.downloadItemProgress
if activeDownloads.isEmpty {
NSLog("Finishing monitoring download progress...")
t.invalidate()
}
return activeDownloads
}
}
Database.shared.removeDownloadItem(item)
self.handleDownloadTaskCompleteFromDownloadItem(item)
}
// While there are active download items, emit status updates
while !(fetchActiveDownloads()?.isEmpty ?? false) {
// Remove a completed download item in a thread-safe way
func handleDoneDownloadItem(_ item: DownloadItem) {
self.progressStatusQueue.async(flags: .barrier) {
self.downloadItemProgress.removeValue(forKey: item.id!)
}
Database.shared.removeDownloadItem(item)
self.handleDownloadTaskCompleteFromDownloadItem(item)
}
// Emit status for active downloads
if let activeDownloads = fetchActiveDownloads() {
for item in activeDownloads.values {
try? self.notifyListeners("onItemDownloadUpdate", data: item.asDictionary())
if item.isDoneDownloading() { handleDoneDownloadItem(item) }
}
}
// Wait 200ms before reporting status again
Thread.sleep(forTimeInterval: TimeInterval(0.2))
}
NSLog("Finished monitoring download progress...")
self.isMonitoringProgress = false
})
}
}
}
private func handleDownloadTaskCompleteFromDownloadItem(_ downloadItem: DownloadItem) {
var statusNotification = [String: Any]()
statusNotification["libraryItemId"] = downloadItem.libraryItemId
statusNotification["libraryItemId"] = downloadItem.id
if ( downloadItem.didDownloadSuccessfully() ) {
ApiClient.getLibraryItemWithProgress(libraryItemId: downloadItem.libraryItemId!, episodeId: downloadItem.episodeId) { libraryItem in
@ -155,19 +157,22 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
let files = downloadItem.downloadItemParts.enumerated().compactMap { _, part -> LocalFile? in
if part.filename == "cover.jpg" {
coverFile = part.destinationUri
return nil
} else {
return LocalFile(libraryItem.id, part.filename!, part.mimeType()!, part.destinationUri!, fileSize: Int(part.destinationURL!.fileSize))
}
return LocalFile(libraryItem.id, part.filename!, part.mimeType()!, part.destinationUri!, fileSize: Int(part.destinationURL!.fileSize))
}
var localLibraryItem = Database.shared.getLocalLibraryItem(byServerLibraryItemId: libraryItem.id)
if (localLibraryItem != nil && localLibraryItem!.isPodcast) {
try! localLibraryItem?.addFiles(files, item: libraryItem)
} else {
localLibraryItem = LocalLibraryItem(libraryItem, localUrl: localDirectory, server: Store.serverConfig!, files: files, coverPath: coverFile)
}
let localLibraryItem = LocalLibraryItem(libraryItem, localUrl: localDirectory, server: Store.serverConfig!, files: files, coverPath: coverFile)
Database.shared.saveLocalLibraryItem(localLibraryItem: localLibraryItem)
Database.shared.saveLocalLibraryItem(localLibraryItem: localLibraryItem!)
statusNotification["localLibraryItem"] = try? localLibraryItem.asDictionary()
if let progress = libraryItem.userMediaProgress {
// TODO: Handle podcast
let localMediaProgress = LocalMediaProgress(localLibraryItem: localLibraryItem, episode: nil, progress: progress)
let episode = downloadItem.media?.episodes?.first(where: { $0.id == downloadItem.episodeId })
let localMediaProgress = LocalMediaProgress(localLibraryItem: localLibraryItem!, episode: episode, progress: progress)
Database.shared.saveLocalMediaProgress(localMediaProgress)
statusNotification["localMediaProgress"] = try? localMediaProgress.asDictionary()
}
@ -237,7 +242,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
// Queue up everything for downloading
let downloadItem = DownloadItem(libraryItem: item, episodeId: episodeId, server: Store.serverConfig!)
for (i, track) in tracks.enumerated() {
downloadItem.downloadItemParts.append(try startLibraryItemTrackDownload(item: item, position: i, track: track))
downloadItem.downloadItemParts.append(try startLibraryItemTrackDownload(item: item, position: i, track: track, episode: episode))
}
// Also download the cover
@ -256,7 +261,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
}
}
private func startLibraryItemTrackDownload(item: LibraryItem, position: Int, track: AudioTrack) throws -> DownloadItemPart {
private func startLibraryItemTrackDownload(item: LibraryItem, position: Int, track: AudioTrack, episode: PodcastEpisode?) throws -> DownloadItemPart {
NSLog("TRACK \(track.contentUrl!)")
// If we don't name metadata, then we can't proceed
@ -269,7 +274,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
let localUrl = "\(itemDirectory)/\(filename)"
let task = session.downloadTask(with: serverUrl)
let downloadItemPart = DownloadItemPart(filename: filename, destination: localUrl, itemTitle: track.title ?? "Unknown", serverPath: Store.serverConfig!.address, audioTrack: track, episode: nil)
var downloadItemPart = DownloadItemPart(filename: filename, destination: localUrl, itemTitle: track.title ?? "Unknown", serverPath: Store.serverConfig!.address, audioTrack: track, episode: episode)
// Store the id on the task so the download item can be pulled from the database later
task.taskDescription = downloadItemPart.id
@ -305,16 +310,34 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
let itemDirectory = item.id
NSLog("ITEM DIR \(itemDirectory)")
do {
try FileManager.default.createDirectory(at: AbsDownloader.downloadsDirectory.appendingPathComponent(itemDirectory), withIntermediateDirectories: true)
} catch {
NSLog("Failed to CREATE LI DIRECTORY \(error)")
guard AbsDownloader.itemDownloadFolder(path: itemDirectory) != nil else {
NSLog("Failed to CREATE LI DIRECTORY \(itemDirectory)")
throw LibraryItemDownloadError.failedDirectory
}
return itemDirectory
}
static func itemDownloadFolder(path: String) -> URL? {
do {
var itemFolder = AbsDownloader.downloadsDirectory.appendingPathComponent(path)
if !FileManager.default.fileExists(atPath: itemFolder.path) {
try FileManager.default.createDirectory(at: itemFolder, withIntermediateDirectories: true)
}
// Make sure we don't backup download files to iCloud
var resourceValues = URLResourceValues()
resourceValues.isExcludedFromBackup = true
try itemFolder.setResourceValues(resourceValues)
return itemFolder
} catch {
NSLog("Failed to CREATE LI DIRECTORY \(error)")
return nil
}
}
}
enum LibraryItemDownloadError: String, Error {
@ -322,6 +345,7 @@ enum LibraryItemDownloadError: String, Error {
case noMetadata = "No metadata for track, unable to download"
case libraryItemNotPodcast = "Library item is not a podcast but episode was requested"
case podcastEpisodeNotFound = "Invalid podcast episode not found"
case podcastOnlySupported = "Only podcasts are supported for this function"
case unknownMediaType = "Unknown media type"
case failedDirectory = "Failed to create directory"
case failedDownload = "Failed to download item"

View file

@ -13,79 +13,102 @@ public class AbsFileSystem: CAPPlugin {
@objc func selectFolder(_ call: CAPPluginCall) {
let mediaType = call.getString("mediaType")
// TODO: Implement
NSLog("Select Folder for media type \(mediaType ?? "UNSET")")
call.resolve()
call.unavailable("Not available on iOS")
}
@objc func checkFolderPermission(_ call: CAPPluginCall) {
let folderUrl = call.getString("folderUrl")
// TODO: Is this even necessary on iOS?
NSLog("checkFolderPermission for folder \(folderUrl ?? "UNSET")")
call.resolve([
"value": true
])
call.unavailable("Not available on iOS")
}
@objc func scanFolder(_ call: CAPPluginCall) {
let folderId = call.getString("folderId")
let forceAudioProbe = call.getBool("forceAudioProbe", false)
// TODO: Implement
NSLog("scanFolder \(folderId ?? "UNSET") | Force Probe = \(forceAudioProbe)")
call.resolve()
call.unavailable("Not available on iOS")
}
@objc func removeFolder(_ call: CAPPluginCall) {
let folderId = call.getString("folderId")
// TODO: Implement
NSLog("removeFolder \(folderId ?? "UNSET")")
call.resolve()
call.unavailable("Not available on iOS")
}
@objc func removeLocalLibraryItem(_ call: CAPPluginCall) {
let localLibraryItemId = call.getString("localLibraryItemId")
// TODO: Implement
NSLog("removeLocalLibraryItem \(localLibraryItemId ?? "UNSET")")
call.resolve()
call.unavailable("Not available on iOS")
}
@objc func scanLocalLibraryItem(_ call: CAPPluginCall) {
let localLibraryItemId = call.getString("localLibraryItemId")
let forceAudioProbe = call.getBool("forceAudioProbe", false)
// TODO: Implement
NSLog("scanLocalLibraryItem \(localLibraryItemId ?? "UNSET") | Force Probe = \(forceAudioProbe)")
call.resolve()
call.unavailable("Not available on iOS")
}
@objc func deleteItem(_ call: CAPPluginCall) {
let localLibraryItemId = call.getString("localLibraryItemId")
let localLibraryItemId = call.getString("id")
let contentUrl = call.getString("contentUrl")
// TODO: Implement
NSLog("deleteItem \(localLibraryItemId ?? "UNSET") url \(contentUrl ?? "UNSET")")
call.resolve()
var success = false
do {
if let localLibraryItemId = localLibraryItemId, let item = Database.shared.getLocalLibraryItem(localLibraryItemId: localLibraryItemId) {
try FileManager.default.removeItem(at: item.contentDirectory!)
Database.shared.removeLocalLibraryItem(localLibraryItemId: localLibraryItemId)
success = true
}
} catch {
NSLog("Failed to delete \(error)")
success = false
}
call.resolve(["success": success])
}
@objc func deleteTrackFromItem(_ call: CAPPluginCall) {
let localLibraryItemId = call.getString("localLibraryItemId")
let localLibraryItemId = call.getString("id")
let trackLocalFileId = call.getString("trackLocalFileId")
let contentUrl = call.getString("contentUrl")
// TODO: Implement
NSLog("deleteTrackFromItem \(localLibraryItemId ?? "UNSET") track file \(trackLocalFileId ?? "UNSET") url \(contentUrl ?? "UNSET")")
NSLog("deleteTrackFromItem \(localLibraryItemId ?? "UNSET") track file \(trackLocalFileId ?? "UNSET")")
call.resolve()
var success = false
do {
if let localLibraryItemId = localLibraryItemId, let trackLocalFileId = trackLocalFileId, var item = Database.shared.getLocalLibraryItem(localLibraryItemId: localLibraryItemId) {
if let fileIndex = item.localFiles.firstIndex(where: { $0.id == trackLocalFileId }) {
try FileManager.default.removeItem(at: item.localFiles[fileIndex].contentPath)
item.localFiles.remove(at: fileIndex)
if item.isPodcast, var media = item.media {
media.episodes = media.episodes?.filter { $0.audioTrack?.localFileId != trackLocalFileId }
item.media = media
}
Database.shared.saveLocalLibraryItem(localLibraryItem: item)
call.resolve(try item.asDictionary())
success = true
}
}
} catch {
NSLog("Failed to delete \(error)")
success = false
}
if !success {
call.resolve(["success": success])
}
}
}

View file

@ -264,7 +264,7 @@ class PodcastEpisode: Object, Codable {
@Persisted var audioTrack: AudioTrack?
@Persisted var duration: Double = 0
@Persisted var size: Int = 0
// var serverEpisodeId: String?
var serverEpisodeId: String { self.id }
private enum CodingKeys : String, CodingKey {
case id,
@ -277,14 +277,44 @@ class PodcastEpisode: Object, Codable {
audioFile,
audioTrack,
duration,
size
size,
serverEpisodeId
}
// TODO: Encoding
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decode(String.self, forKey: .id)
index = try? values.decode(Int.self, forKey: .index)
episode = try? values.decode(String.self, forKey: .episode)
episodeType = try? values.decode(String.self, forKey: .episodeType)
title = try values.decode(String.self, forKey: .title)
subtitle = try? values.decode(String.self, forKey: .subtitle)
desc = try? values.decode(String.self, forKey: .desc)
audioFile = try? values.decode(AudioFile.self, forKey: .audioFile)
audioTrack = try? values.decode(AudioTrack.self, forKey: .audioTrack)
duration = try? values.decode(Double.self, forKey: .duration)
size = try? values.decode(Int.self, forKey: .size)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(index, forKey: .index)
try container.encode(episode, forKey: .episode)
try container.encode(episodeType, forKey: .episodeType)
try container.encode(title, forKey: .title)
try container.encode(subtitle, forKey: .subtitle)
try container.encode(desc, forKey: .desc)
try container.encode(audioFile, forKey: .audioFile)
try container.encode(audioTrack, forKey: .audioTrack)
try container.encode(duration, forKey: .duration)
try container.encode(size, forKey: .size)
try container.encode(serverEpisodeId, forKey: .serverEpisodeId)
}
}
class AudioFile: Object, Codable {
@Persisted var index: Int = 0
@Persisted var index: Int?
@Persisted var ino: String = ""
@Persisted var metadata: FileMetadata?
@ -299,7 +329,7 @@ class AudioFile: Object, Codable {
required init(from decoder: Decoder) throws {
super.init()
let values = try decoder.container(keyedBy: CodingKeys.self)
index = try values.decode(Int.self, forKey: .index)
index = try? values.decode(Int.self, forKey: .index)
ino = try values.decode(String.self, forKey: .ino)
metadata = try? values.decode(FileMetadata.self, forKey: .metadata)
}
@ -339,6 +369,9 @@ class Author: Object, Codable {
try container.encode(name, forKey: .name)
try container.encode(coverPath, forKey: .coverPath)
}
override init() {
super.init()
}
class Chapter: Object, Codable {
@ -381,8 +414,7 @@ class AudioTrack: Object, Codable {
@Persisted var contentUrl: String?
@Persisted var mimeType: String = ""
@Persisted var metadata: FileMetadata?
// var isLocal: Bool
// var localFileId: String?
var localFileId: String?
// var audioProbeResult: AudioProbeResult? Needed for local playback
@Persisted var serverIndex: Int?
@ -418,6 +450,20 @@ class AudioTrack: Object, Codable {
try container.encode(metadata, forKey: .metadata)
try container.encode(serverIndex, forKey: .serverIndex)
}
mutating func setLocalInfo(filenameIdMap: [String: String], serverIndex: Int) -> Bool {
if let localFileId = filenameIdMap[self.metadata?.filename ?? ""] {
self.localFileId = localFileId
self.serverIndex = serverIndex
return true
}
return false
}
func getLocalFile() -> LocalFile? {
guard let localFileId = self.localFileId else { return nil }
return Database.shared.getLocalFile(localFileId: localFileId)
}
}
class FileMetadata: Object, Codable {
@ -562,18 +608,17 @@ class MediaProgress: Object, Codable {
}
required init(from decoder: Decoder) throws {
super.init()
let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decode(String.self, forKey: .id)
libraryItemId = try values.decode(String.self, forKey: .libraryItemId)
episodeId = try? values.decode(String.self, forKey: .episodeId)
duration = try values.decode(Double.self, forKey: .duration)
progress = try values.decode(Double.self, forKey: .progress)
currentTime = try values.decode(Double.self, forKey: .currentTime)
duration = try values.doubleOrStringDecoder(key: .duration)
progress = try values.doubleOrStringDecoder(key: .progress)
currentTime = try values.doubleOrStringDecoder(key: .currentTime)
isFinished = try values.decode(Bool.self, forKey: .isFinished)
lastUpdate = try values.decode(Int.self, forKey: .lastUpdate)
startedAt = try values.decode(Int.self, forKey: .startedAt)
finishedAt = try? values.decode(Int.self, forKey: .finishedAt)
lastUpdate = try values.intOrStringDecoder(key: .lastUpdate)
startedAt = try values.intOrStringDecoder(key: .startedAt)
finishedAt = try? values.intOrStringDecoder(key: .finishedAt)
}
func encode(to encoder: Encoder) throws {

View file

@ -73,6 +73,35 @@ class DownloadItemPart: Object, Codable {
@Persisted var uri: String?
@Persisted var destinationUri: String?
@Persisted var progress: Double = 0
var task: URLSessionDownloadTask?
struct DownloadItemPart: Realmable, Codable {
var id: String = UUID().uuidString
var filename: String?
var itemTitle: String?
var serverPath: String?
var audioTrack: AudioTrack?
var episode: PodcastEpisode?
var completed: Bool = false
var moved: Bool = false
var failed: Bool = false
var uri: String?
var downloadURL: URL? {
if let uri = self.uri {
return URL(string: uri)
} else {
return nil
}
}
var destinationUri: String?
var destinationURL: URL? {
if let destinationUri = self.destinationUri {
return AbsDownloader.itemDownloadFolder(path: destinationUri)!
} else {
return nil
}
}
var progress: Double = 0
var task: URLSessionDownloadTask!
private enum CodingKeys : String, CodingKey {
@ -105,3 +134,36 @@ class DownloadItemPart: Object, Codable {
try container.encode(progress, forKey: .progress)
}
}
extension DownloadItemPart {
init(filename: String, destination: String, itemTitle: String, serverPath: String, audioTrack: AudioTrack?, episode: PodcastEpisode?) {
self.filename = filename
self.itemTitle = itemTitle
self.serverPath = serverPath
self.audioTrack = audioTrack
self.episode = episode
let config = Store.serverConfig!
var downloadUrl = ""
if (serverPath.hasSuffix("/cover")) {
downloadUrl += "\(config.address)\(serverPath)?token=\(config.token)"
downloadUrl += "&format=jpeg" // For cover images force to jpeg
} else {
downloadUrl = destination
}
self.uri = downloadUrl
self.destinationUri = destination
}
func mimeType() -> String? {
if let track = audioTrack {
return track.mimeType
} else if let podcastTrack = episode?.audioTrack {
return podcastTrack.mimeType
} else if serverPath?.hasSuffix("/cover") ?? false {
return "image/jpg"
} else {
return nil
}
}
}

View file

@ -22,6 +22,33 @@ class LocalLibraryItem: Object, Codable {
@Persisted var serverAddress: String?
@Persisted var serverUserId: String?
@Persisted(indexed: true) var libraryItemId: String?
var contentUrl: String? {
if let path = _contentUrl {
return AbsDownloader.itemDownloadFolder(path: path)!.absoluteString
} else {
return nil
}
}
var contentDirectory: URL? {
if let path = _contentUrl {
return AbsDownloader.itemDownloadFolder(path: path)
} else {
return nil
}
}
var coverContentUrl: String? {
if let path = self._coverContentUrl {
return AbsDownloader.itemDownloadFolder(path: path)!.absoluteString
} else {
return nil
}
}
var isBook: Bool { self.mediaType == "book" }
var isPodcast: Bool { self.mediaType == "podcast" }
private enum CodingKeys : String, CodingKey {
case id, basePath, contentUrl, isInvalid, mediaType, media, localFiles, coverContentUrl, isLocal, serverConnectionConfigId, serverAddress, serverUserId, libraryItemId
@ -37,14 +64,12 @@ class LocalLibraryItem: Object, Codable {
let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decode(String.self, forKey: .id)
basePath = try values.decode(String.self, forKey: .basePath)
contentUrl = try values.decode(String.self, forKey: .contentUrl)
isInvalid = try values.decode(Bool.self, forKey: .isInvalid)
mediaType = try values.decode(String.self, forKey: .mediaType)
media = try? values.decode(MediaType.self, forKey: .media)
if let files = try? values.decode([LocalFile].self, forKey: .localFiles) {
localFiles.append(objectsIn: files)
}
_coverContentUrl = try values.decode(String.self, forKey: .coverContentUrl)
isLocal = try values.decode(Bool.self, forKey: .isLocal)
serverConnectionConfigId = try? values.decode(String.self, forKey: .serverConnectionConfigId)
serverAddress = try? values.decode(String.self, forKey: .serverAddress)
@ -111,9 +136,13 @@ class LocalFile: Object, Codable {
@Persisted var contentUrl: String = ""
@Persisted var mimeType: String?
@Persisted var size: Int = 0
var contentUrl: String { AbsDownloader.itemDownloadFolder(path: _contentUrl)!.absoluteString }
var contentPath: URL { AbsDownloader.itemDownloadFolder(path: _contentUrl)! }
var basePath: String? { self.filename }
private enum CodingKeys : String, CodingKey {
case id, filename, contentUrl, absolutePath, mimeType, size
case id, filename, contentUrl, mimeType, size, basePath
}
override init() {
@ -124,7 +153,6 @@ class LocalFile: Object, Codable {
let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decode(String.self, forKey: .id)
filename = try? values.decode(String.self, forKey: .filename)
contentUrl = try values.decode(String.self, forKey: .contentUrl)
mimeType = try? values.decode(String.self, forKey: .mimeType)
size = try values.decode(Int.self, forKey: .size)
}
@ -134,9 +162,9 @@ class LocalFile: Object, Codable {
try container.encode(id, forKey: .id)
try container.encode(filename, forKey: .filename)
try container.encode(contentUrl, forKey: .contentUrl)
try container.encode(absolutePath, forKey: .absolutePath)
try container.encode(mimeType, forKey: .mimeType)
try container.encode(size, forKey: .size)
try container.encode(basePath, forKey: .basePath)
}
}
@ -157,6 +185,8 @@ class LocalMediaProgress: Object, Codable {
@Persisted var serverUserId: String?
@Persisted(indexed: true) var libraryItemId: String?
@Persisted(indexed: true) var episodeId: String?
var progressPercent: Int { Int(self.progress * 100) }
private enum CodingKeys : String, CodingKey {
case id, localLibraryItemId, localEpisodeId, duration, progress, currentTime, isFinished, lastUpdate, startedAt, finishedAt, serverConnectionConfigId, serverAddress, serverUserId, libraryItemId, episodeId

View file

@ -13,39 +13,44 @@ extension LocalLibraryItem {
self.contentUrl = localUrl
self.mediaType = item.mediaType
self.media = item.media
self.localFiles.append(objectsIn: files)
self.coverContentUrl = coverPath
self._coverContentUrl = coverPath
self.libraryItemId = item.id
self.serverConnectionConfigId = server.id
self.serverAddress = server.address
self.serverUserId = server.userId
// Link the audio tracks and files
linkLocalFiles(files, fromMedia: item.media)
}
var contentUrl: String? {
set(url) {
_contentUrl = url
}
get {
if let path = _contentUrl {
return AbsDownloader.downloadsDirectory.appendingPathComponent(path).absoluteString
} else {
return nil
}
}
mutating func addFiles(_ files: [LocalFile], item: LibraryItem) throws {
guard self.isPodcast else { throw LibraryItemDownloadError.podcastOnlySupported }
self.localFiles.append(contentsOf: files.filter({ $0.isAudioFile() }))
linkLocalFiles(self.localFiles, fromMedia: item.media)
}
var coverContentUrl: String? {
set(url) {
_coverContentUrl = url
}
get {
if let path = self._coverContentUrl {
return AbsDownloader.downloadsDirectory.appendingPathComponent(path).absoluteString
} else {
return nil
mutating private func linkLocalFiles(_ files: [LocalFile], fromMedia: MediaType) {
var fromMedia = fromMedia
let fileMap = files.map { ($0.filename ?? "", $0.id) }
let fileIdByFilename = Dictionary(fileMap, uniquingKeysWith: { (_, last) in last })
if ( self.isBook ) {
if let tracks = fromMedia.tracks {
for i in tracks.indices {
_ = fromMedia.tracks?[i].setLocalInfo(filenameIdMap: fileIdByFilename, serverIndex: i)
}
}
} else if ( self.isPodcast ) {
if let episodes = fromMedia.episodes {
fromMedia.episodes = episodes.compactMap { episode in
// Filter out episodes not downloaded
var episode = episode
let episodeIsDownloaded = episode.audioTrack?.setLocalInfo(filenameIdMap: fileIdByFilename, serverIndex: 0) ?? false
return episodeIsDownloaded ? episode : nil
}
}
}
self.media = fromMedia
}
func getDuration() -> Double {
@ -54,7 +59,13 @@ extension LocalLibraryItem {
return total
}
func getPlaybackSession(episode: LocalPodcastEpisode?) -> PlaybackSession {
func getPodcastEpisode(episodeId: String?) -> PodcastEpisode? {
guard self.isPodcast else { return nil }
guard let episodes = self.media?.episodes else { return nil }
return episodes.first(where: { $0.id == episodeId })
}
func getPlaybackSession(episode: PodcastEpisode?) -> PlaybackSession {
let localEpisodeId = episode?.id
let sessionId = "play_local_\(UUID().uuidString)"
@ -62,13 +73,13 @@ extension LocalLibraryItem {
let mediaProgressId = (localEpisodeId != nil) ? "\(self.id)-\(localEpisodeId!)" : self.id
let mediaProgress = Database.shared.getLocalMediaProgress(localMediaProgressId: mediaProgressId)
// TODO: Clean up add mediaType methods for displayTitle and displayAuthor
let mediaMetadata = self.media?.metadata
let audioTracks = self.media?.tracks
let authorName = mediaMetadata?.authorName
let chapters = self.media?.chapters
var audioTracks = self.media?.tracks
let authorName = mediaMetadata?.authorDisplayName
if let episode = episode {
// TODO: Implement podcast
if let episode = episode, let track = episode.audioTrack {
audioTracks = [track]
}
let dateNow = Date().timeIntervalSince1970
@ -78,18 +89,19 @@ extension LocalLibraryItem {
libraryItemId: self.libraryItemId,
episodeId: episode?.serverEpisodeId,
mediaType: self.mediaType,
chapters: [],
chapters: chapters ?? [],
displayTitle: mediaMetadata?.title,
displayAuthor: authorName,
coverPath: nil,
coverPath: self.coverContentUrl,
duration: self.getDuration(),
playMethod: 3,
playMethod: PlayMethod.local.rawValue,
startedAt: dateNow,
updatedAt: 0,
timeListening: 0.0,
audioTracks: [],
audioTracks: audioTracks ?? [],
currentTime: mediaProgress?.currentTime ?? 0.0,
libraryItem: nil,
localLibraryItem: self,
serverConnectionConfigId: self.serverConnectionConfigId,
serverAddress: self.serverAddress
)
@ -103,7 +115,7 @@ extension LocalFile {
self.id = "\(libraryItemId)_\(filename.toBase64())"
self.filename = filename
self.mimeType = mimeType
self.contentUrl = localUrl
self._contentUrl = localUrl
self.size = fileSize
}
@ -123,27 +135,84 @@ extension LocalFile {
}
extension LocalMediaProgress {
convenience init(localLibraryItem: LocalLibraryItem, episode: LocalPodcastEpisode?, progress: MediaProgress) {
convenience init(localLibraryItem: LocalLibraryItem, episode: PodcastEpisode?) {
self.init()
self.id = localLibraryItem.id
self.localLibraryItemId = localLibraryItem.id
self.libraryItemId = localLibraryItem.libraryItemId
if let episode = episode {
self.id += "-\(episode.id)"
self.episodeId = episode.id
}
self.serverAddress = localLibraryItem.serverAddress
self.serverUserId = localLibraryItem.serverUserId
self.serverConnectionConfigId = localLibraryItem.serverConnectionConfigId
self.duration = progress.duration
self.currentTime = progress.currentTime
self.duration = localLibraryItem.getDuration()
self.progress = 0.0
self.currentTime = 0.0
self.isFinished = false
self.lastUpdate = Int(Date().timeIntervalSince1970)
self.startedAt = 0
self.finishedAt = nil
if let episode = episode {
self.id += "-\(episode.id)"
self.episodeId = episode.id
self.duration = episode.duration ?? 0.0
}
}
init(localLibraryItem: LocalLibraryItem, episode: PodcastEpisode?, progress: MediaProgress) {
self.init(localLibraryItem: localLibraryItem, episode: episode)
self.duration = progress.duration
self.progress = progress.progress
self.currentTime = progress.currentTime
self.isFinished = progress.isFinished
self.lastUpdate = progress.lastUpdate
self.startedAt = progress.startedAt
self.finishedAt = progress.finishedAt
}
mutating func updateIsFinished(_ finished: Bool) {
if self.isFinished != finished {
self.progress = finished ? 1.0 : 0.0
}
if self.startedAt == 0 && finished {
self.startedAt = Int(Date().timeIntervalSince1970)
}
self.isFinished = finished
self.lastUpdate = Int(Date().timeIntervalSince1970)
self.finishedAt = finished ? lastUpdate : nil
}
mutating func updateFromPlaybackSession(_ playbackSession: PlaybackSession) {
self.currentTime = playbackSession.currentTime
self.progress = playbackSession.progress
self.lastUpdate = Int(Date().timeIntervalSince1970)
self.isFinished = playbackSession.progress >= 100.0
self.finishedAt = self.isFinished ? self.lastUpdate : nil
}
mutating func updateFromServerMediaProgress(_ serverMediaProgress: MediaProgress) {
self.isFinished = serverMediaProgress.isFinished
self.progress = serverMediaProgress.progress
self.currentTime = serverMediaProgress.currentTime
self.duration = serverMediaProgress.duration
self.lastUpdate = serverMediaProgress.lastUpdate
self.finishedAt = serverMediaProgress.finishedAt
self.startedAt = serverMediaProgress.startedAt
}
static func fetchOrCreateLocalMediaProgress(localMediaProgressId: String?, localLibraryItemId: String?, localEpisodeId: String?) -> LocalMediaProgress? {
if let localMediaProgressId = localMediaProgressId {
return Database.shared.getLocalMediaProgress(localMediaProgressId: localMediaProgressId)
} else 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
}
}
}

View file

@ -7,7 +7,7 @@
import Foundation
struct PlaybackSession: Decodable, Encodable {
struct PlaybackSession: Codable {
var id: String
var userId: String?
var libraryItemId: String?
@ -26,7 +26,25 @@ struct PlaybackSession: Decodable, Encodable {
var audioTracks: [AudioTrack]
var currentTime: Double
var libraryItem: LibraryItem?
//var localLibraryItem: LocalLibraryItem?
var localLibraryItem: LocalLibraryItem?
var serverConnectionConfigId: String?
var serverAddress: String?
var isLocal: Bool { self.localLibraryItem != nil }
var localMediaProgressId: String {
if let episodeId = episodeId {
return "\(localLibraryItem!.id)-\(episodeId)"
} else {
return localLibraryItem!.id
}
}
var totalDuration: Double {
var total = 0.0
self.audioTracks.forEach { total += $0.duration }
return total
}
var progress: Double { self.currentTime / self.totalDuration }
}

View file

@ -267,6 +267,7 @@ class AudioPlayer: NSObject {
let startOffset = audioTrack.startOffset ?? 0.0
return startOffset + currentTrackTime
}
public func getPlayMethod() -> Int {
return self.playbackSession.playMethod
}
@ -286,6 +287,17 @@ class AudioPlayer: NSObject {
let urlstr = "\(Store.serverConfig!.address)/s/item/\(itemId)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)"
let url = URL(string: urlstr)!
return AVURLAsset(url: url)
} else if (playbackSession.playMethod == PlayMethod.local.rawValue) {
guard let localFile = track.getLocalFile() else {
// Worst case we can stream the file
NSLog("Unable to play local file. Resulting to streaming \(track.localFileId ?? "Unknown")")
let filename = track.metadata?.filename ?? ""
let filenameEncoded = filename.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed)
let urlstr = "\(Store.serverConfig!.address)/s/item/\(itemId)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)"
let url = URL(string: urlstr)!
return AVURLAsset(url: url)
}
return AVURLAsset(url: localFile.contentPath)
} else { // HLS Transcode
let headers: [String: String] = [
"Authorization": "Bearer \(Store.serverConfig!.token)"

View file

@ -11,7 +11,7 @@ class PlayerHandler {
private static var player: AudioPlayer?
private static var session: PlaybackSession?
private static var timer: Timer?
private static var lastSyncTime:Double = 0.0
private static var lastSyncTime: Double = 0.0
private static var _remainingSleepTime: Int? = nil
public static var remainingSleepTime: Int? {
@ -52,6 +52,22 @@ class PlayerHandler {
}
}
public static func startTickTimer() {
DispatchQueue.runOnMainQueue {
NSLog("Starting the tick timer")
timer?.invalidate()
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
self.tick()
}
}
}
public static func stopTickTimer() {
NSLog("Stopping the tick timer")
timer?.invalidate()
timer = nil
}
public static func startPlayback(session: PlaybackSession, playWhenReady: Bool, playbackRate: Float) {
if player != nil {
player?.destroy()
@ -63,31 +79,34 @@ class PlayerHandler {
self.session = session
player = AudioPlayer(playbackSession: session, playWhenReady: playWhenReady, playbackRate: playbackRate)
DispatchQueue.runOnMainQueue {
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
self.tick()
}
}
startTickTimer()
}
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
timer?.invalidate()
timer = nil
NowPlayingInfo.shared.reset()
}
public static func getCurrentTime() -> Double? {
self.player?.getCurrentTime()
}
public static func setPlaybackSpeed(speed: Float) {
self.player?.setPlaybackRate(speed)
}
public static func getPlayMethod() -> Int? {
self.player?.getPlayMethod()
}
public static func getPlaybackSession() -> PlaybackSession? {
self.player?.getPlaybackSession()
}
@ -100,6 +119,7 @@ class PlayerHandler {
let destinationTime = player.getCurrentTime() + amount
player.seek(destinationTime, from: "handler")
}
public static func seekBackward(amount: Double) {
guard let player = player else {
return
@ -108,9 +128,11 @@ class PlayerHandler {
let destinationTime = player.getCurrentTime() - amount
player.seek(destinationTime, from: "handler")
}
public static func seek(amount: Double) {
player?.seek(amount, from: "handler")
}
public static func getMetdata() -> [String: Any] {
DispatchQueue.main.async {
syncProgress()
@ -140,15 +162,15 @@ class PlayerHandler {
remainingSleepTime! -= 1
}
}
public static func syncProgress() {
if session == nil { return }
guard let player = player else { return }
// Prevent a sync at the current time
let playerCurrentTime = player.getCurrentTime()
if (lastSyncReport != nil && lastSyncReport?.currentTime == playerCurrentTime) {
// No need to syncProgress
return
}
let hasSyncAtCurrentTime = lastSyncReport?.currentTime.isEqual(to: playerCurrentTime) ?? false
if hasSyncAtCurrentTime { return }
// Prevent multiple sync requests
let timeSinceLastSync = Date().timeIntervalSince1970 - lastSyncTime
@ -157,16 +179,51 @@ class PlayerHandler {
return
}
lastSyncTime = Date().timeIntervalSince1970 // seconds
// 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
let report = PlaybackReport(currentTime: playerCurrentTime, duration: player.getDuration(), timeListened: listeningTimePassedSinceLastSync)
session!.currentTime = playerCurrentTime
listeningTimePassedSinceLastSync = 0
lastSyncReport = report
// TODO: check if online
NSLog("sending playback report")
ApiClient.reportPlaybackProgress(report: report, sessionId: session!.id)
let sessionIsLocal = session!.isLocal
if !sessionIsLocal {
if Connectivity.isConnectedToInternet {
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)")
}
}
}
}
}
private static func syncLocalProgress() -> LocalMediaProgress? {
guard let session = session else { return nil }
let localMediaProgress = LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: session.localMediaProgressId, localLibraryItemId: session.localLibraryItem?.id, localEpisodeId: session.episodeId)
guard var localMediaProgress = localMediaProgress else {
// Local media progress should have been created
// If we're here, it means a library id is invalid
return nil
}
localMediaProgress.updateFromPlaybackSession(session)
Database.shared.saveLocalMediaProgress(localMediaProgress)
NSLog("Local progress saved to the database")
// Send the local progress back to front-end
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.localProgress.rawValue), object: nil)
return localMediaProgress
}
}

View file

@ -37,7 +37,29 @@ class ApiClient {
}
}
}
public static func postResource(endpoint: String, parameters: [String: String], callback: ((_ success: Bool) -> Void)?) {
public static func postResource<T: Encodable, U: Decodable>(endpoint: String, parameters: T, decodable: U.Type = U.self, callback: ((_ param: U) -> Void)?) {
if (Store.serverConfig == nil) {
NSLog("Server config not set")
return
}
let headers: HTTPHeaders = [
"Authorization": "Bearer \(Store.serverConfig!.token)"
]
AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, headers: headers).responseDecodable(of: decodable) { response in
switch response.result {
case .success(let obj):
callback?(obj)
case .failure(let error):
NSLog("api request to \(endpoint) failed")
print(error)
}
}
}
public static func postResource<T:Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) {
if (Store.serverConfig == nil) {
NSLog("Server config not set")
callback?(false)
@ -50,7 +72,7 @@ class ApiClient {
AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, headers: headers).response { response in
switch response.result {
case .success(let _):
case .success(_):
callback?(true)
case .failure(let error):
NSLog("api request to \(endpoint) failed")
@ -60,6 +82,30 @@ class ApiClient {
}
}
}
public static func patchResource<T: Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) {
if (Store.serverConfig == nil) {
NSLog("Server config not set")
callback?(false)
return
}
let headers: HTTPHeaders = [
"Authorization": "Bearer \(Store.serverConfig!.token)"
]
AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .patch, parameters: parameters, encoder: JSONParameterEncoder.default, headers: headers).response { response in
switch response.result {
case .success(_):
callback?(true)
case .failure(let error):
NSLog("api request to \(endpoint) failed")
print(error)
callback?(false)
}
}
}
public static func getResource<T: Decodable>(endpoint: String, decodable: T.Type = T.self, callback: ((_ param: T?) -> Void)?) {
if (Store.serverConfig == nil) {
NSLog("Server config not set")
@ -96,7 +142,7 @@ class ApiClient {
}
}
ApiClient.postResource(endpoint: endpoint, parameters: [
let parameters: [String: Any] = [
"forceDirectPlay": !forceTranscode ? "1" : "",
"forceTranscode": forceTranscode ? "1" : "",
"mediaPlayer": "AVPlayer",
@ -105,7 +151,8 @@ class ApiClient {
"model": modelCode,
"clientVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
]
], decodable: PlaybackSession.self) { obj in
]
ApiClient.postResource(endpoint: endpoint, parameters: parameters, decodable: PlaybackSession.self) { obj in
var session = obj
session.serverConnectionConfigId = Store.serverConfig!.id
@ -119,6 +166,44 @@ class ApiClient {
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) {
postResource(endpoint: "api/session/local", parameters: localMediaProgress, callback: callback)
}
public static func syncMediaProgress(callback: @escaping (_ results: LocalMediaProgressSyncResultsPayload) -> Void) {
let localMediaProgressList = Database.shared.getAllLocalMediaProgress().filter {
$0.serverConnectionConfigId == Store.serverConfig?.id
}
if ( !localMediaProgressList.isEmpty ) {
let payload = LocalMediaProgressSyncPayload(localMediaProgress: localMediaProgressList)
NSLog("Sending sync local progress request with \(localMediaProgressList.count) progress items")
postResource(endpoint: "api/me/sync-local-progress", parameters: payload, decodable: MediaProgressSyncResponsePayload.self) { response in
let resultsPayload = LocalMediaProgressSyncResultsPayload(numLocalMediaProgressForServer: localMediaProgressList.count, numServerProgressUpdates: response.numServerProgressUpdates, numLocalProgressUpdates: response.localProgressUpdates?.count)
NSLog("Media Progress Sync | \(String(describing: try? resultsPayload.asDictionary()))")
if let updates = response.localProgressUpdates {
for update in updates {
Database.shared.saveLocalMediaProgress(update)
}
}
callback(resultsPayload)
}
} else {
NSLog("No local media progress to sync")
callback(LocalMediaProgressSyncResultsPayload(numLocalMediaProgressForServer: 0, numServerProgressUpdates: 0, numLocalProgressUpdates: 0))
}
}
public static func updateMediaProgress<T:Encodable>(libraryItemId: String, episodeId: String?, payload: T, callback: @escaping () -> Void) {
NSLog("updateMediaProgress \(libraryItemId) \(episodeId ?? "NIL") \(payload)")
let endpoint = episodeId?.isEmpty ?? true ? "api/me/progress/\(libraryItemId)" : "api/me/progress/\(libraryItemId)/\(episodeId ?? "")"
patchResource(endpoint: endpoint, parameters: payload) { success in
callback()
}
}
public static func getLibraryItemWithProgress(libraryItemId:String, episodeId:String?, callback: @escaping (_ param: LibraryItem?) -> Void) {
var endpoint = "api/items/\(libraryItemId)?expanded=1&include=progress"
if episodeId != nil {
@ -130,3 +215,35 @@ class ApiClient {
}
}
}
struct LocalMediaProgressSyncPayload: Codable {
var localMediaProgress: [LocalMediaProgress]
}
struct MediaProgressSyncResponsePayload: Decodable {
var numServerProgressUpdates: Int?
var localProgressUpdates: [LocalMediaProgress]?
private enum CodingKeys : String, CodingKey {
case numServerProgressUpdates, localProgressUpdates
}
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
numServerProgressUpdates = try? values.intOrStringDecoder(key: .numServerProgressUpdates)
localProgressUpdates = try? values.decode([LocalMediaProgress].self, forKey: .localProgressUpdates)
}
}
struct LocalMediaProgressSyncResultsPayload: Codable {
var numLocalMediaProgressForServer: Int?
var numServerProgressUpdates: Int?
var numLocalProgressUpdates: Int?
}
struct Connectivity {
static private let sharedInstance = NetworkReachabilityManager()!
static var isConnectedToInternet:Bool {
return self.sharedInstance.isReachable
}
}

View file

@ -116,14 +116,14 @@ class Database {
return Array(realm.objects(LocalLibraryItem.self))
}
public func getLocalLibraryItemByLLId(libraryItem: String) -> LocalLibraryItem? {
public func getLocalLibraryItem(byServerLibraryItemId: String) -> LocalLibraryItem? {
let realm = try! Realm()
return realm.objects(LocalLibraryItem.self).first(where: { $0.libraryItemId == libraryItem })
return realm.objects(LocalLibraryItem.self).first(where: { $0.libraryItemId == byServerLibraryItemId })
}
public func getLocalLibraryItem(localLibraryItem: String) -> LocalLibraryItem? {
public func getLocalLibraryItem(localLibraryItemId: String) -> LocalLibraryItem? {
let realm = try! Realm()
return realm.object(ofType: LocalLibraryItem.self, forPrimaryKey: localLibraryItem)
return realm.object(ofType: LocalLibraryItem.self, forPrimaryKey: localLibraryItemId)
}
public func saveLocalLibraryItem(localLibraryItem: LocalLibraryItem) {
@ -131,6 +131,19 @@ class Database {
try! realm.write { realm.add(localLibraryItem, update: .modified) }
}
public func removeLocalLibraryItem(localLibraryItemId: String) {
let realm = try! Realm()
try! realm.write {
let item = getLocalLibraryItem(localLibraryItemId: localLibraryItemId)
realm.delete(item!)
}
}
public func getLocalFile(localFileId: String) -> LocalFile? {
let realm = try! Realm()
return realm.object(ofType: LocalFile.self, forPrimaryKey: localFileId)
}
public func getDownloadItem(downloadItemId: String) -> DownloadItem? {
let realm = try! Realm()
return realm.object(ofType: DownloadItem.self, forPrimaryKey: downloadItemId)
@ -166,17 +179,14 @@ class Database {
return realm.objects(DeviceSettings.self).first ?? getDefaultDeviceSettings()
}
public func removeLocalLibraryItem(localLibraryItemId: String) {
public func getAllLocalMediaProgress() -> [LocalMediaProgress] {
let realm = try! Realm()
try! realm.write {
let item = getLocalLibraryItemByLLId(libraryItem: localLibraryItemId)
realm.delete(item!)
}
return Array(realm.objects(LocalMediaProgress.self))
}
public func saveLocalMediaProgress(_ mediaProgress: LocalMediaProgress) {
let realm = try! Realm()
try! realm.write { realm.add(mediaProgress) }
try! realm.write { realm.add(mediaProgress, update: .modified) }
}
// For books this will just be the localLibraryItemId for podcast episodes this will be "{localLibraryItemId}-{episodeId}"

View file

@ -6,8 +6,9 @@
//
import Foundation
import SwiftUI
import RealmSwift
import Capacitor
import CoreMedia
extension String: Error {}
@ -31,6 +32,34 @@ extension Collection where Iterator.Element: Encodable {
}
}
extension KeyedDecodingContainer {
func doubleOrStringDecoder(key: KeyedDecodingContainer<K>.Key) throws -> Double {
do {
return try decode(Double.self, forKey: key)
} catch {
let stringValue = try decode(String.self, forKey: key)
return Double(stringValue) ?? 0.0
}
}
func intOrStringDecoder(key: KeyedDecodingContainer<K>.Key) throws -> Int {
do {
return try decode(Int.self, forKey: key)
} catch {
let stringValue = try decode(String.self, forKey: key)
return Int(stringValue) ?? 0
}
}
}
extension CAPPluginCall {
func getJson<T: Decodable>(_ key: String, type: T.Type) -> T? {
guard let value = getObject(key) else { return nil }
guard let json = try? JSONSerialization.data(withJSONObject: value) else { return nil }
return try? JSONDecoder().decode(type, from: json)
}
}
extension DispatchQueue {
static func runOnMainQueue(callback: @escaping (() -> Void)) {
if Thread.isMainThread {

View file

@ -15,6 +15,10 @@ struct NowPlayingMetadata {
var title: String
var author: String?
var series: String?
var coverUrl: URL? {
guard let url = URL(string: "\(Store.serverConfig!.address)/api/items/\(itemId)/cover?token=\(Store.serverConfig!.token)") else { return nil }
return url
}
}
class NowPlayingInfo {
@ -30,18 +34,27 @@ class NowPlayingInfo {
public func setSessionMetadata(metadata: NowPlayingMetadata) {
setMetadata(artwork: nil, metadata: metadata)
guard let url = URL(string: "\(Store.serverConfig!.address)/api/items/\(metadata.itemId)/cover?token=\(Store.serverConfig!.token)") else {
return
}
ApiClient.getData(from: url) { [self] image in
guard let downloadedImage = image else {
return
let isLocalItem = metadata.itemId.starts(with: "local_")
if isLocalItem {
guard let artworkUrl = metadata.artworkUrl else { return }
let coverImage = UIImage(contentsOfFile: artworkUrl)
guard let coverImage = coverImage else { return }
let artwork = MPMediaItemArtwork(boundsSize: coverImage.size) { _ -> UIImage in
return coverImage
}
let artwork = MPMediaItemArtwork.init(boundsSize: downloadedImage.size, requestHandler: { _ -> UIImage in
return downloadedImage
})
self.setMetadata(artwork: artwork, metadata: metadata)
} else {
guard let url = metadata.coverUrl else { return }
ApiClient.getData(from: url) { [self] image in
guard let downloadedImage = image else {
return
}
let artwork = MPMediaItemArtwork.init(boundsSize: downloadedImage.size, requestHandler: { _ -> UIImage in
return downloadedImage
})
self.setMetadata(artwork: artwork, metadata: metadata)
}
}
}
public func update(duration: Double, currentTime: Double, rate: Float) {
@ -52,6 +65,7 @@ class NowPlayingInfo {
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}
public func reset() {
nowPlayingInfo = [:]
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
@ -76,6 +90,7 @@ class NowPlayingInfo {
nowPlayingInfo[MPMediaItemPropertyArtist] = metadata!.author ?? "unknown"
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = metadata!.series
}
private func shouldFetchCover(id: String) -> Bool {
nowPlayingInfo[MPNowPlayingInfoPropertyExternalContentIdentifier] as? String != id || nowPlayingInfo[MPMediaItemPropertyArtwork] == nil
}

View file

@ -13,4 +13,5 @@ enum PlayerEvents: String {
case sleepSet = "com.audiobookshelf.app.player.sleep.set"
case sleepEnded = "com.audiobookshelf.app.player.sleep.ended"
case failed = "com.audiobookshelf.app.player.failed"
case localProgress = "com.audiobookshelf.app.player.localProgress"
}

View file

@ -11,7 +11,7 @@
<span class="material-icons" @click="showItemDialog">more_vert</span>
</div>
<p class="px-2 text-sm mb-0.5 text-white text-opacity-75">Folder: {{ folderName }}</p>
<p v-if="!isIos" class="px-2 text-sm mb-0.5 text-white text-opacity-75">Folder: {{ folderName }}</p>
<p class="px-2 mb-4 text-xs text-gray-400">{{ libraryItemId ? 'Linked to item on server ' + liServerAddress : 'Not linked to server item' }}</p>
@ -22,11 +22,11 @@
<div v-if="!isPodcast" class="w-full">
<p class="text-base mb-2">Audio Tracks ({{ audioTracks.length }})</p>
<draggable v-model="audioTracksCopy" v-bind="dragOptions" handle=".drag-handle" draggable=".item" tag="div" @start="drag = true" @end="drag = false" @update="draggableUpdate">
<draggable v-model="audioTracksCopy" v-bind="dragOptions" handle=".drag-handle" draggable=".item" tag="div" @start="drag = true" @end="drag = false" @update="draggableUpdate" :disabled="isIos">
<transition-group type="transition" :name="!drag ? 'dragtrack' : null">
<template v-for="track in audioTracksCopy">
<div :key="track.localFileId" class="flex items-center my-1 item">
<div class="w-8 h-12 flex items-center justify-center" style="min-width: 32px">
<div v-if="!isIos" class="w-8 h-12 flex items-center justify-center" style="min-width: 32px">
<span class="material-icons drag-handle text-lg text-white text-opacity-50 hover:text-opacity-100">menu</span>
</div>
<div class="w-8 h-12 flex items-center justify-center" style="min-width: 32px">
@ -39,7 +39,7 @@
<p class="text-xs">{{ track.mimeType }}</p>
<p class="text-sm">{{ $elapsedPretty(track.duration) }}</p>
</div>
<div class="w-12 h-12 flex items-center justify-center" style="min-width: 48px">
<div v-if="!isIos" class="w-12 h-12 flex items-center justify-center" style="min-width: 48px">
<span class="material-icons" @click="showTrackDialog(track)">more_vert</span>
</div>
</div>
@ -138,6 +138,9 @@ export default {
}
},
computed: {
isIos() {
return this.$platform === 'ios'
},
basePath() {
return this.localLibraryItem ? this.localLibraryItem.basePath : null
},
@ -194,24 +197,14 @@ export default {
}
]
} else {
return [
{
text: 'Scan',
value: 'scan'
},
{
text: 'Force Re-Scan',
value: 'rescan'
},
{
text: 'Remove',
value: 'remove'
},
{
text: 'Remove & Delete Files',
value: 'delete'
}
]
var options = []
if ( !this.isIos ) {
options.push({ text: 'Scan', value: 'scan'})
options.push({ text: 'Force Re-Scan', value: 'rescan'})
options.push({ text: 'Remove', value: 'remove'})
}
options.push({ text: 'Remove & Delete Files', value: 'delete'})
return options
}
}
},
@ -329,13 +322,13 @@ export default {
async deleteItem() {
const { value } = await Dialog.confirm({
title: 'Confirm',
message: `Warning! This will delete the folder "${this.basePath}" and all contents. Are you sure?`
message: `Warning! This will delete "${this.media.metadata.title}" and all associated local files. Are you sure?`
})
if (value) {
var res = await AbsFileSystem.deleteItem(this.localLibraryItem)
if (res && res.success) {
this.$toast.success('Deleted Successfully')
this.$router.replace(`/localMedia/folders/${this.folderId}`)
this.$router.replace(this.isIos ? '/bookshelf' : `/localMedia/folders/${this.folderId}`)
} else this.$toast.error('Failed to delete')
}
},