diff --git a/Server.js b/Server.js deleted file mode 100644 index 14f6ad5e..00000000 --- a/Server.js +++ /dev/null @@ -1,304 +0,0 @@ -import { io } from 'socket.io-client' -import { Storage } from '@capacitor/storage' -import axios from 'axios' -import EventEmitter from 'events' - -class Server extends EventEmitter { - constructor(store) { - super() - - this.store = store - - this.url = null - this.socket = null - - this.user = null - this.connected = false - this.initialized = false - - this.stream = null - - this.isConnectingSocket = false - } - - get token() { - return this.user ? this.user.token : null - } - - getAxiosConfig() { - return { headers: { Authorization: `Bearer ${this.token}` } } - } - - getServerUrl(url) { - if (!url) return null - try { - var urlObject = new URL(url) - return `${urlObject.protocol}//${urlObject.hostname}:${urlObject.port}` - } catch (error) { - console.error('Invalid URL', error) - return null - } - } - - setUser(user) { - this.user = user - this.store.commit('user/setUser', user) - if (user) { - // this.store.commit('user/setSettings', user.settings) - Storage.set({ key: 'token', value: user.token }) - } else { - Storage.remove({ key: 'token' }) - } - } - - setServerUrl(url) { - this.url = url - this.store.commit('setServerUrl', url) - - if (url) { - Storage.set({ key: 'serverUrl', value: url }) - } else { - Storage.remove({ key: 'serverUrl' }) - } - } - - async connect(url, token) { - if (this.connected) { - console.warn('[SOCKET] Connection already established for ' + this.url) - return { success: true } - } - if (!url) { - console.error('Invalid url to connect') - return { - error: 'Invalid URL' - } - } - - var serverUrl = this.getServerUrl(url) - var res = await this.ping(serverUrl) - - if (!res || !res.success) { - return { - error: res ? res.error : 'Unknown Error' - } - } - var authRes = await this.authorize(serverUrl, token) - if (!authRes || authRes.error) { - return { - error: authRes ? authRes.error : 'Authorization Error' - } - } - - this.setServerUrl(serverUrl) - - this.setUser(authRes.user) - this.connectSocket() - - return { success: true } - } - - async check(url) { - var serverUrl = this.getServerUrl(url) - if (!serverUrl) { - return { - error: 'Invalid server url' - } - } - var res = await this.ping(serverUrl) - if (!res || res.error) { - return { - error: res ? res.error : 'Ping Failed' - } - } - return { - success: true, - serverUrl - } - } - - async login(url, username, password) { - var serverUrl = this.getServerUrl(url) - var authUrl = serverUrl + '/login' - return axios.post(authUrl, { username, password }).then((res) => { - if (!res.data || !res.data.user) { - console.error(res.data.error) - return { - error: res.data.error || 'Unknown Error' - } - } - - this.setServerUrl(serverUrl) - this.setUser(res.data.user) - this.connectSocket() - return { - user: res.data.user - } - }).catch(error => { - console.error('[Server] Server auth failed', error) - var errorMsg = null - if (error.response) { - errorMsg = error.response.data || 'Unknown Error' - } else if (error.request) { - errorMsg = 'Server did not respond' - } else { - errorMsg = 'Failed to send request' - } - return { - error: errorMsg - } - }) - } - - logout() { - this.setUser(null) - this.stream = null - if (this.socket) { - this.socket.disconnect() - } - this.emit('logout') - } - - authorize(serverUrl, token) { - var authUrl = serverUrl + '/api/authorize' - return axios.post(authUrl, null, { headers: { Authorization: `Bearer ${token}` } }).then((res) => { - return res.data - }).catch(error => { - console.error('[Server] Server auth failed', error) - var errorMsg = null - if (error.response) { - errorMsg = error.response.data || 'Unknown Error' - } else if (error.request) { - errorMsg = 'Server did not respond' - } else { - errorMsg = 'Failed to send request' - } - return { - error: errorMsg - } - }) - } - - ping(url) { - var pingUrl = url + '/ping' - console.log('[Server] Check server', pingUrl) - return axios.get(pingUrl, { timeout: 1000 }).then((res) => { - return res.data - }).catch(error => { - console.error('Server check failed', error) - var errorMsg = null - if (error.response) { - errorMsg = error.response.data || 'Unknown Error' - } else if (error.request) { - errorMsg = 'Server did not respond' - } else { - errorMsg = 'Failed to send request' - } - return { - success: false, - error: errorMsg - } - }) - } - - connectSocket() { - if (this.socket && !this.connected) { - this.socket.connect() - console.log('[SOCKET] Submitting connect') - return - } - if (this.connected || this.socket) { - if (this.socket) console.error('[SOCKET] Socket already established', this.url) - else console.error('[SOCKET] Already connected to socket', this.url) - return - } - - console.log('[SOCKET] Connect Socket', this.url) - - const socketOptions = { - transports: ['websocket'], - upgrade: false, - // reconnectionAttempts: 3 - } - this.socket = io(this.url, socketOptions) - this.socket.on('connect', () => { - console.log('[SOCKET] Socket Connected ' + this.socket.id) - - // Authenticate socket with token - this.socket.emit('auth', this.token) - this.connected = true - this.emit('connected', true) - this.store.commit('setSocketConnected', true) - }) - this.socket.on('disconnect', (reason) => { - console.log('[SOCKET] Socket Disconnected: ' + reason) - this.connected = false - this.emit('connected', false) - this.emit('initialized', false) - this.initialized = false - this.store.commit('setSocketConnected', false) - - // this.socket.removeAllListeners() - // if (this.socket.io && this.socket.io.removeAllListeners) { - // console.log(`[SOCKET] Removing ALL IO listeners`) - // this.socket.io.removeAllListeners() - // } - }) - this.socket.on('init', (data) => { - console.log('[SOCKET] Initial socket data received', data) - if (data.stream) { - this.stream = data.stream - this.store.commit('setStreamAudiobook', data.stream.audiobook) - this.emit('initialStream', data.stream) - } - if (data.serverSettings) { - this.store.commit('setServerSettings', data.serverSettings) - } - this.initialized = true - this.emit('initialized', true) - }) - - this.socket.on('user_updated', (user) => { - if (this.user && user.id === this.user.id) { - this.setUser(user) - } - }) - - this.socket.on('current_user_audiobook_update', (payload) => { - this.emit('currentUserAudiobookUpdate', payload) - }) - - this.socket.on('show_error_toast', (payload) => { - this.emit('show_error_toast', payload) - }) - this.socket.on('show_success_toast', (payload) => { - this.emit('show_success_toast', payload) - }) - - this.socket.onAny((evt, args) => { - console.log(`[SOCKET] ${this.socket.id}: ${evt} ${JSON.stringify(args)}`) - }) - - this.socket.on('connect_error', (err) => { - console.error('[SOCKET] connection failed', err) - this.emit('socketConnectionFailed', err) - }) - - this.socket.io.on("reconnect_attempt", (attempt) => { - console.log(`[SOCKET] Reconnect Attempt ${this.socket.id}: ${attempt}`) - }) - - this.socket.io.on("reconnect_error", (err) => { - console.log(`[SOCKET] Reconnect Error ${this.socket.id}: ${err}`) - }) - - this.socket.io.on("reconnect_failed", () => { - console.log(`[SOCKET] Reconnect Failed ${this.socket.id}`) - }) - - this.socket.io.on("reconnect", () => { - console.log(`[SOCKET] Reconnect Success ${this.socket.id}`) - }) - } -} - -export default Server \ No newline at end of file diff --git a/android/app/build.gradle b/android/app/build.gradle index c4fd591e..121c2674 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -85,6 +85,12 @@ dependencies { // OK HTTP implementation 'com.squareup.okhttp3:okhttp:4.9.2' + + // Jackson for JSON + implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.12.1' + + // FFMPEG-Kit + implementation 'com.arthenica:ffmpeg-kit-full:4.5.1' } apply from: 'capacitor.build.gradle' diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index 4ad9dba0..64af165f 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -9,14 +9,13 @@ android { apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" dependencies { - implementation project(':capacitor-community-sqlite') implementation project(':capacitor-app') implementation project(':capacitor-dialog') + implementation project(':capacitor-haptics') implementation project(':capacitor-network') implementation project(':capacitor-status-bar') implementation project(':capacitor-storage') implementation project(':robingenz-capacitor-app-update') - implementation project(':capacitor-data-storage-sqlite') } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1e6ea18f..f3adddf9 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -7,7 +7,8 @@ - + + + android:name=".player.PlayerNotificationService"> diff --git a/android/app/src/main/assets/capacitor.plugins.json b/android/app/src/main/assets/capacitor.plugins.json index 1c40357f..32b34bfb 100644 --- a/android/app/src/main/assets/capacitor.plugins.json +++ b/android/app/src/main/assets/capacitor.plugins.json @@ -1,8 +1,4 @@ [ - { - "pkg": "@capacitor-community/sqlite", - "classpath": "com.getcapacitor.community.database.sqlite.CapacitorSQLitePlugin" - }, { "pkg": "@capacitor/app", "classpath": "com.capacitorjs.plugins.app.AppPlugin" @@ -11,6 +7,10 @@ "pkg": "@capacitor/dialog", "classpath": "com.capacitorjs.plugins.dialog.DialogPlugin" }, + { + "pkg": "@capacitor/haptics", + "classpath": "com.capacitorjs.plugins.haptics.HapticsPlugin" + }, { "pkg": "@capacitor/network", "classpath": "com.capacitorjs.plugins.network.NetworkPlugin" @@ -26,9 +26,5 @@ { "pkg": "@robingenz/capacitor-app-update", "classpath": "dev.robingenz.capacitor.appupdate.AppUpdatePlugin" - }, - { - "pkg": "capacitor-data-storage-sqlite", - "classpath": "com.jeep.plugin.capacitor.capacitordatastoragesqlite.CapacitorDataStorageSqlitePlugin" } ] diff --git a/android/app/src/main/java/com/audiobookshelf/app/AudioDownloader.kt b/android/app/src/main/java/com/audiobookshelf/app/AudioDownloader.kt deleted file mode 100644 index 83107ae0..00000000 --- a/android/app/src/main/java/com/audiobookshelf/app/AudioDownloader.kt +++ /dev/null @@ -1,354 +0,0 @@ -package com.audiobookshelf.app - -import android.app.DownloadManager -import android.content.Context -import android.net.Uri -import android.os.Build -import android.os.Environment -import android.util.Log -import androidx.documentfile.provider.DocumentFile -import com.anggrayudi.storage.callback.FileCallback -import com.anggrayudi.storage.file.* -import com.anggrayudi.storage.media.FileDescription -import com.getcapacitor.JSObject -import com.getcapacitor.Plugin -import com.getcapacitor.PluginCall -import com.getcapacitor.PluginMethod -import com.getcapacitor.annotation.CapacitorPlugin -import java.io.File - - -@CapacitorPlugin(name = "AudioDownloader") -class AudioDownloader : Plugin() { - private val tag = "AudioDownloader" - - lateinit var mainActivity:MainActivity - lateinit var downloadManager:DownloadManager - -// data class AudiobookItem(val uri: Uri, val name: String, val size: Long, val coverUrl: String) { -// fun toJSObject() : JSObject { -// var obj = JSObject() -// obj.put("uri", this.uri) -// obj.put("name", this.name) -// obj.put("size", this.size) -// obj.put("coverUrl", this.coverUrl) -// return obj -// } -// } - - override fun load() { - mainActivity = (activity as MainActivity) - downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager - - var recieverEvent: (evt: String, id: Long) -> Unit = { evt: String, id: Long -> - if (evt == "complete") { - } - if (evt == "clicked") { - Log.d(tag, "Clicked $id back in the audiodownloader") - } - } - mainActivity.registerBroadcastReceiver(recieverEvent) - - Log.d(tag, "Build SDK ${Build.VERSION.SDK_INT}") - } - - -// @PluginMethod -// fun load(call: PluginCall) { -// var audiobookUrls = call.data.getJSONArray("audiobookUrls") -// var len = audiobookUrls?.length() -// if (len == null) { -// len = 0 -// } -// Log.d(tag, "CALLED LOAD $len") -// var audiobookItems:MutableList = mutableListOf() -// -// (0 until len).forEach { -// var jsobj = audiobookUrls.get(it) as JSONObject -// var audiobookUrl = jsobj.get("contentUrl").toString() -// var coverUrl = jsobj.get("coverUrl").toString() -// var storageId = "" -// if(jsobj.has("storageId")) jsobj.get("storageId").toString() -// -// var basePath = "" -// if(jsobj.has("basePath")) jsobj.get("basePath").toString() -// -// var coverBasePath = "" -// if(jsobj.has("coverBasePath")) jsobj.get("coverBasePath").toString() -// -// Log.d(tag, "LOOKUP $storageId $basePath $audiobookUrl") -// -// var audiobookFile: DocumentFile? = null -// var coverFile: DocumentFile? = null -// -// // Android 9 OR Below use storage id and base path -// if (Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.P) { -// audiobookFile = DocumentFileCompat.fromSimplePath(context, storageId, basePath) -// if (coverUrl != null && coverUrl != "") { -// coverFile = DocumentFileCompat.fromSimplePath(context, storageId, coverBasePath) -// } -// } else { -// // Android 10 and up manually deleting will still load the file causing crash -// var exists = checkUriExists(Uri.parse(audiobookUrl)) -// if (exists) { -// Log.d(tag, "Audiobook exists") -// audiobookFile = DocumentFileCompat.fromUri(context, Uri.parse(audiobookUrl)) -// } else { -// Log.e(tag, "Audiobook does not exist") -// } -// -// var coverExists = checkUriExists(Uri.parse(coverUrl)) -// if (coverExists) { -// Log.d(tag, "Cover Exists") -// coverFile = DocumentFileCompat.fromUri(context, Uri.parse(coverUrl)) -// } else if (coverUrl != null && coverUrl != "") { -// Log.e(tag, "Cover does not exist") -// } -// } -// -// if (audiobookFile == null) { -// Log.e(tag, "Audiobook was not found $audiobookUrl") -// } else { -// Log.d(tag, "Audiobook File Found StorageId:${audiobookFile.getStorageId(context)} | AbsolutePath:${audiobookFile.getAbsolutePath(context)} | BasePath:${audiobookFile.getBasePath(context)}") -// -// var _name = audiobookFile.name -// if (_name == null) _name = "" -// -// var size = audiobookFile.length() -// -// if (audiobookFile.uri.toString() !== audiobookUrl) { -// Log.d(tag, "Audiobook URI ${audiobookFile.uri} is different from $audiobookUrl => using the latter") -// } -// -// // Use existing URI's - bug happening where new uri is different from initial -// var abItem = AudiobookItem(Uri.parse(audiobookUrl), _name, size, coverUrl) -// -// Log.d(tag, "Setting AB ITEM ${abItem.name} | ${abItem.size} | ${abItem.uri} | ${abItem.coverUrl}") -// -// audiobookItems.add(abItem) -// } -// } -// -// Log.d(tag, "Load Finished ${audiobookItems.size} found") -// -// var audiobookObjs:List = audiobookItems.map{ it.toJSObject() } -// var mediaItemNoticePayload = JSObject() -// mediaItemNoticePayload.put("items", audiobookObjs) -// notifyListeners("onMediaLoaded", mediaItemNoticePayload) -// } - - @PluginMethod - fun download(call: PluginCall) { - var audiobookId = call.data.getString("audiobookId", "audiobook").toString() - var url = call.data.getString("downloadUrl", "unknown").toString() - var coverDownloadUrl = call.data.getString("coverDownloadUrl", "").toString() - var title = call.data.getString("title", "Audiobook").toString() - var filename = call.data.getString("filename", "audiobook.mp3").toString() - var coverFilename = call.data.getString("coverFilename", "cover.png").toString() - var downloadFolderUrl = call.data.getString("downloadFolderUrl", "").toString() - var folder = DocumentFileCompat.fromUri(context, Uri.parse(downloadFolderUrl))!! - Log.d(tag, "Called download: $url | Folder: ${folder.name} | $downloadFolderUrl") - - var dlfilename = audiobookId + "." + File(filename).extension - var coverdlfilename = audiobookId + "." + File(coverFilename).extension - Log.d(tag, "DL Filename $dlfilename | Cover DL Filename $coverdlfilename") - - var canWriteToFolder = folder.canWrite() - if (!canWriteToFolder) { - Log.e(tag, "Error Cannot Write to Folder ${folder.baseName}") - val ret = JSObject() - ret.put("error", "Cannot write to ${folder.baseName}") - call.resolve(ret) - return - } - - var dlRequest = DownloadManager.Request(Uri.parse(url)) - dlRequest.setTitle("Ab: $title") - dlRequest.setDescription("Downloading to ${folder.name}") - dlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) - dlRequest.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, dlfilename) - - var audiobookDownloadId = downloadManager.enqueue(dlRequest) - var coverDownloadId:Long? = null - - if (coverDownloadUrl != "") { - var coverDlRequest = DownloadManager.Request(Uri.parse(coverDownloadUrl)) - coverDlRequest.setTitle("Cover: $title") - coverDlRequest.setDescription("Downloading to ${folder.name}") - coverDlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_ONLY_COMPLETION) - coverDlRequest.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, coverdlfilename) - coverDownloadId = downloadManager.enqueue(coverDlRequest) - } - - var progressReceiver : (id:Long, prog: Long) -> Unit = { id:Long, prog: Long -> - if (id == audiobookDownloadId) { - var jsobj = JSObject() - jsobj.put("audiobookId", audiobookId) - jsobj.put("progress", prog) - notifyListeners("onDownloadProgress", jsobj) - } - } - - var coverDocFile:DocumentFile? = null - - var doneReceiver : (id:Long, success: Boolean) -> Unit = { id:Long, success: Boolean -> - Log.d(tag, "RECEIVER DONE $id, SUCCES? $success") - var docfile:DocumentFile? = null - - // Download was complete, now find downloaded file - if (id == coverDownloadId) { - docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, coverdlfilename) - Log.d(tag, "Move Cover File ${docfile?.name}") - - // For unknown reason, Android 10 test was using the title set in "setTitle" for the dl manager as the filename - // check if this was the case - if (docfile?.name == null) { - docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, "Cover: $title") - Log.d(tag, "Cover File name attempt 2 ${docfile?.name}") - } - } else if (id == audiobookDownloadId) { - docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, dlfilename) - Log.d(tag, "Move Audiobook File ${docfile?.name}") - - if (docfile?.name == null) { - docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, "Ab: $title") - Log.d(tag, "File name attempt 2 ${docfile?.name}") - } - } - - // Callback for moving the downloaded file - var callback = object : FileCallback() { - override fun onPrepare() { - Log.d(tag, "PREPARING MOVE FILE") - } - override fun onFailed(errorCode:ErrorCode) { - Log.e(tag, "FAILED MOVE FILE $errorCode") - - docfile?.delete() - coverDocFile?.delete() - - if (id == audiobookDownloadId) { - var jsobj = JSObject() - jsobj.put("audiobookId", audiobookId) - jsobj.put("error", "Move failed") - notifyListeners("onDownloadFailed", jsobj) - } - } - override fun onCompleted(result:Any) { - var resultDocFile = result as DocumentFile - var simplePath = resultDocFile.getSimplePath(context) - var storageId = resultDocFile.getStorageId(context) - var size = resultDocFile.length() - Log.d(tag, "Finished Moving File, NAME: ${resultDocFile.name} | URI:${resultDocFile.uri} | AbsolutePath:${resultDocFile.getAbsolutePath(context)} | $storageId | SimplePath: $simplePath") - - var abFolder = folder.findFolder(title) - var jsobj = JSObject() - jsobj.put("audiobookId", audiobookId) - jsobj.put("downloadId", id) - jsobj.put("storageId", storageId) - jsobj.put("storageType", resultDocFile.getStorageType(context)) - jsobj.put("folderUrl", abFolder?.uri) - jsobj.put("folderName", abFolder?.name) - jsobj.put("downloadFolderUrl", downloadFolderUrl) - jsobj.put("contentUrl", resultDocFile.uri) - jsobj.put("basePath", resultDocFile.getBasePath(context)) - jsobj.put("filename", filename) - jsobj.put("simplePath", simplePath) - jsobj.put("size", size) - - if (resultDocFile.name == filename) { - Log.d(tag, "Audiobook Finishing Moving") - } else if (resultDocFile.name == coverFilename) { - coverDocFile = docfile - Log.d(tag, "Audiobook Cover Finished Moving") - jsobj.put("isCover", true) - } - notifyListeners("onDownloadComplete", jsobj) - } - } - - // After file is downloaded, move the files into an audiobook directory inside the user selected folder - if (id == coverDownloadId) { - docfile?.moveFileTo(context, folder, FileDescription(coverFilename, title, MimeType.IMAGE), callback) - } else if (id == audiobookDownloadId) { - docfile?.moveFileTo(context, folder, FileDescription(filename, title, MimeType.AUDIO), callback) - } - } - - var progressUpdater = DownloadProgressUpdater(downloadManager, audiobookDownloadId, progressReceiver, doneReceiver) - progressUpdater.run() - if (coverDownloadId != null) { - var coverProgressUpdater = DownloadProgressUpdater(downloadManager, coverDownloadId, progressReceiver, doneReceiver) - coverProgressUpdater.run() - } - - val ret = JSObject() - ret.put("audiobookDownloadId", audiobookDownloadId) - ret.put("coverDownloadId", coverDownloadId) - call.resolve(ret) - } - - internal class DownloadProgressUpdater(private val manager: DownloadManager, private val downloadId: Long, private var receiver: (Long, Long) -> Unit, private var doneReceiver: (Long, Boolean) -> Unit) : Thread() { - private val query: DownloadManager.Query = DownloadManager.Query() - private var totalBytes: Int = 0 - private var TAG = "DownloadProgressUpdater" - - init { - query.setFilterById(this.downloadId) - } - - override fun run() { - Log.d(TAG, "RUN FOR ID $downloadId") - var keepRunning = true - var increment = 0 - while (keepRunning) { - Thread.sleep(500) - increment++ - - if (increment % 4 == 0) { - Log.d(TAG, "Loop $increment : $downloadId") - } - - manager.query(query).use { - if (it.moveToFirst()) { - //get total bytes of the file - if (totalBytes <= 0) { - totalBytes = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)) - if (totalBytes <= 0) { - Log.e(TAG, "Download Is 0 Bytes $downloadId") - doneReceiver(downloadId, false) - keepRunning = false - this.interrupt() - return - } - } - - val downloadStatus = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_STATUS)) - val bytesDownloadedSoFar = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)) - - if (increment % 4 == 0) { - Log.d(TAG, "BYTES $increment : $downloadId : $bytesDownloadedSoFar : TOTAL: $totalBytes") - } - - if (downloadStatus == DownloadManager.STATUS_SUCCESSFUL || downloadStatus == DownloadManager.STATUS_FAILED) { - if (downloadStatus == DownloadManager.STATUS_SUCCESSFUL) { - doneReceiver(downloadId, true) - } else { - doneReceiver(downloadId, false) - } - keepRunning = false - this.interrupt() - } else { - //update progress - val percentProgress = ((bytesDownloadedSoFar * 100L) / totalBytes) - receiver(downloadId, percentProgress) - } - } else { - Log.e(TAG, "NOT FOUND IN QUERY") - keepRunning = false - } - } - } - } - } -} diff --git a/android/app/src/main/java/com/audiobookshelf/app/Audiobook.kt b/android/app/src/main/java/com/audiobookshelf/app/Audiobook.kt deleted file mode 100644 index 2ba2c0c9..00000000 --- a/android/app/src/main/java/com/audiobookshelf/app/Audiobook.kt +++ /dev/null @@ -1,102 +0,0 @@ -package com.audiobookshelf.app - -import android.net.Uri -import android.os.Bundle -import android.support.v4.media.MediaBrowserCompat -import android.support.v4.media.MediaDescriptionCompat -import android.support.v4.media.MediaMetadataCompat -import com.getcapacitor.JSObject - -class Audiobook { - var id:String - var ino:String - var libraryId:String - var folderId:String - var book:Book - var duration:Float - var size:Long - var numTracks:Int - var isMissing:Boolean - var isInvalid:Boolean - var path:String - - var isDownloaded:Boolean = false - var downloadFolderUrl:String = "" - var folderUrl:String = "" - var contentUrl:String = "" - var filename:String = "" - var localCoverUrl:String = "" - var localCover:String = "" - - var serverUrl:String = "" - var token:String = "" - - constructor(jsobj: JSObject, serverUrl:String, token:String) { - this.serverUrl = serverUrl - this.token = token - - id = jsobj.getString("id", "").toString() - ino = jsobj.getString("ino", "").toString() - libraryId = jsobj.getString("libraryId", "").toString() - folderId = jsobj.getString("folderId", "").toString() - - var bookJsObj = jsobj.getJSObject("book") - book = bookJsObj?.let { Book(it) }!! - - duration = jsobj.getDouble("duration").toFloat() - size = jsobj.getLong("size") - numTracks = jsobj.getInteger("numTracks")!! - isMissing = jsobj.getBoolean("isMissing") - isInvalid = jsobj.getBoolean("isInvalid") - path = jsobj.getString("path", "").toString() - - isDownloaded = jsobj.getBoolean("isDownloaded") - if (isDownloaded) { - downloadFolderUrl = jsobj.getString("downloadFolderUrl", "").toString() - folderUrl = jsobj.getString("folderUrl", "").toString() - contentUrl = jsobj.getString("contentUrl", "").toString() - filename = jsobj.getString("filename", "").toString() - localCover = jsobj.getString("localCover", "").toString() - localCoverUrl = jsobj.getString("localCoverUrl", "").toString() - } - } - - fun getCover():Uri { - if (isDownloaded) { -// return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon) - return Uri.parse(localCoverUrl) - } - if (book.cover == "" || serverUrl == "" || token == "") return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon) - return Uri.parse("$serverUrl${book.cover}?token=$token&ts=${book.lastUpdate}") - } - - fun getDurationLong():Long { - return duration.toLong() * 1000L - } - - fun toMediaMetadata():MediaMetadataCompat { - return MediaMetadataCompat.Builder().apply { - putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id) - putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, book.title) - putString(MediaMetadataCompat.METADATA_KEY_TITLE, book.title) - putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, book.authorFL) - putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, getCover().toString()) - putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getCover().toString()) - putString(MediaMetadataCompat.METADATA_KEY_ART_URI, getCover().toString()) - putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, book.authorFL) - -// val extras = Bundle() -// if (isDownloaded) { -// extras.putLong( -// MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS, -// MediaDescriptionCompat.STATUS_DOWNLOADED) -// } -// extras.putInt( -// MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS, -// MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED) - -// putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, RESOURCE_ROOT_URI + -// context.resources.getResourceEntryName(R.drawable.notification_bg_low_normal)) - }.build() - } -} diff --git a/android/app/src/main/java/com/audiobookshelf/app/AudiobookManager.kt b/android/app/src/main/java/com/audiobookshelf/app/AudiobookManager.kt deleted file mode 100644 index 421dbd72..00000000 --- a/android/app/src/main/java/com/audiobookshelf/app/AudiobookManager.kt +++ /dev/null @@ -1,390 +0,0 @@ -package com.audiobookshelf.app - -import android.app.Activity -import android.content.Context -import android.content.SharedPreferences -import android.os.Handler -import android.os.Looper -import android.support.v4.media.MediaMetadataCompat - -import android.util.Log -import com.getcapacitor.JSArray -import com.getcapacitor.JSObject -import com.jeep.plugin.capacitor.capacitordatastoragesqlite.CapacitorDataStorageSqlite -import okhttp3.* -import org.json.JSONArray -import java.io.IOException - -class AudiobookManager { - var tag = "AudiobookManager" - - interface OnStreamData { - fun onStreamReady(asd:AudiobookStreamData) - } - - var hasLoaded = false - var isLoading = false - var ctx: Context - var serverUrl = "" - var token = "" - private var client:OkHttpClient - - var localMediaManager:LocalMediaManager - - var audiobooks:MutableList = mutableListOf() - var audiobooksInProgress:MutableList = mutableListOf() - - var storageSharedPreferences: SharedPreferences? = null - - constructor(_ctx:Context, _client:OkHttpClient) { - ctx = _ctx - client = _client - - localMediaManager = LocalMediaManager(ctx) - } - - fun init() { - storageSharedPreferences = ctx.getSharedPreferences("CapacitorStorage", Activity.MODE_PRIVATE) - serverUrl = storageSharedPreferences?.getString("serverUrl", "").toString() - Log.d(tag, "SHARED PREF SERVERURL $serverUrl") - token = storageSharedPreferences?.getString("token", "").toString() - Log.d(tag, "SHARED PREF TOKEN $token") - } - - fun getPlaybackRate() : Float { - if (storageSharedPreferences != null) { - var userSettings = storageSharedPreferences?.getString("userSettings", "").toString() - if (userSettings != "") { - var json = JSObject(userSettings) - var playbackRate = json.getString("playbackRate", "1") - if (playbackRate != null) { - return playbackRate.toFloat() - } - } - } - return 1f - } - - fun loadCategories(cb: (() -> Unit)) { - Log.d(tag, "LOAD Categories $serverUrl | $token") - var url = "$serverUrl/api/libraries/main/categories" - val request = Request.Builder() - .url(url).addHeader("Authorization", "Bearer $token") - .build() - - client.newCall(request).enqueue(object : Callback { - override fun onFailure(call: Call, e: IOException) { - Log.d(tag, "FAILURE TO CONNECT") - e.printStackTrace() - cb() - } - - override fun onResponse(call: Call, response: Response) { - response.use { - if (!response.isSuccessful) throw IOException("Unexpected code $response") - - var bodyString = response.body!!.string() - var results = JSONArray(bodyString) -// var results = resJson.getJSONArray("results") - - var totalShelves = results.length() - 1 - Log.d(tag, "Got categories $totalShelves") - for (i in 0..totalShelves) { - var shelfobj = results.get(i) - var jsobj = JSObject(shelfobj.toString()) - var shelfId = jsobj.getString("id", "") - Log.d(tag, "Category shelf id $shelfId") - if (shelfId == "continue-reading") { - var entities = jsobj.getJSONArray("entities") - var totalEntities = entities.length() - 1 - Log.d(tag, "Shelf total entities $totalEntities") - for (y in 0..totalEntities) { - var abobj = entities.get(y) - Log.d(tag, "Shelf category ab id $y = ${abobj.toString()}") - var abjsobj = JSObject(abobj.toString()) - abjsobj.put("isDownloaded", false) - var audiobook = Audiobook(abjsobj, serverUrl, token) - if (audiobook.isMissing || audiobook.isInvalid || audiobook.numTracks <= 0) { - Log.d(tag, "Not an audiobook or invalid/missing") - } else { - var audiobookExists = audiobooksInProgress.find { it.id == audiobook.id } - if (audiobookExists == null) { - audiobooksInProgress.add(audiobook) - } - } - } - } - } - Log.d(tag, "${audiobooksInProgress.size} Audiobooks In Progress Loaded") - cb() - } - } - }) - } - - fun loadAudiobooks(cb: (() -> Unit)) { - Log.d(tag, "Load Audiobooks: $serverUrl | $token") - if (serverUrl == "" || token == "") { - Log.d(tag, "Load Audiobooks: No Server or Token set") - cb() - return - } else if (!serverUrl.startsWith("http")) { - Log.e(tag, "Load Audiobooks: Invalid server url $serverUrl") - cb() - return - } - - // First load currently reading - loadCategories() { - // Then load all - var url = "$serverUrl/api/libraries/main/books/all?sort=book.title" - val request = Request.Builder() - .url(url).addHeader("Authorization", "Bearer $token") - .build() - - client.newCall(request).enqueue(object : Callback { - override fun onFailure(call: Call, e: IOException) { - Log.d(tag, "Load Audiobooks: FAILURE TO CONNECT") - e.printStackTrace() - cb() - } - - override fun onResponse(call: Call, response: Response) { - response.use { - if (!response.isSuccessful) throw IOException("Unexpected code $response") - - var bodyString = response.body!!.string() - var resJson = JSObject(bodyString) - var results = resJson.getJSONArray("results") - - var totalBooks = results.length() - 1 - for (i in 0..totalBooks) { - var abobj = results.get(i) - var jsobj = JSObject(abobj.toString()) - - jsobj.put("isDownloaded", false) - var audiobook = Audiobook(jsobj, serverUrl, token) - - if (audiobook.isMissing || audiobook.isInvalid) { - Log.d(tag, "Audiobook ${audiobook.book.title} is missing or invalid") - } else if (audiobook.numTracks <= 0) { - Log.d(tag, "Audiobook ${audiobook.book.title} has audio tracks") - } else { - var audiobookExists = audiobooks.find { it.id == audiobook.id } - if (audiobookExists == null) { - audiobooks.add(audiobook) - } else { - Log.d(tag, "Audiobook already there from downloaded") - } - } - } - Log.d(tag, "${audiobooks.size} Audiobooks Loaded") - cb() - } - } - }) - } - } - - fun load() { - isLoading = true - hasLoaded = true - - localMediaManager.loadLocalAudio() - - // Load downloads from sql db - var db = CapacitorDataStorageSqlite(ctx) - db.openStore("storage", "downloads", false, "no-encryption", 1) - var keyvalues = db.keysvalues() - keyvalues.forEach { - Log.d(tag, "keyvalue ${it.getString("key")} | ${it.getString("value")}") - - var dlobj = JSObject(it.getString("value")) - if (dlobj.has("audiobook")) { - var abobj = dlobj.getJSObject("audiobook")!! - abobj.put("isDownloaded", true) - abobj.put("contentUrl", dlobj.getString("contentUrl", "").toString()) - abobj.put("filename", dlobj.getString("filename", "").toString()) - abobj.put("folderUrl", dlobj.getString("folderUrl", "").toString()) - abobj.put("downloadFolderUrl", dlobj.getString("downloadFolderUrl", "").toString()) - abobj.put("localCoverUrl", dlobj.getString("coverUrl", "").toString()) - abobj.put("localCover", dlobj.getString("cover", "").toString()) - - var audiobook = Audiobook(abobj, serverUrl, token) - audiobooks.add(audiobook) - } - } - } - - fun openStream(audiobook:Audiobook, streamListener:OnStreamData) { - var url = "$serverUrl/api/books/${audiobook.id}/stream" - val request = Request.Builder() - .url(url).addHeader("Authorization", "Bearer $token") - .build() - - client.newCall(request).enqueue(object : Callback { - override fun onFailure(call: Call, e: IOException) { - e.printStackTrace() - } - - override fun onResponse(call: Call, response: Response) { - response.use { - if (!response.isSuccessful) throw IOException("Unexpected code $response") - - var playbackRate = getPlaybackRate() - - var bodyString = response.body!!.string() - var stream = JSObject(bodyString) - var streamId = stream.getString("streamId", "").toString() - var startTime = stream.getDouble("startTime") - var streamUrl = stream.getString("streamUrl", "").toString() - - var startTimeLong = (startTime * 1000).toLong() - - var abStreamDataObj = JSObject() - abStreamDataObj.put("id", streamId) - abStreamDataObj.put("audiobookId", audiobook.id) - abStreamDataObj.put("playlistUrl", "$serverUrl$streamUrl") - abStreamDataObj.put("title", audiobook.book.title) - abStreamDataObj.put("author", audiobook.book.authorFL) - abStreamDataObj.put("token", token) - abStreamDataObj.put("cover", audiobook.getCover()) - abStreamDataObj.put("duration", audiobook.getDurationLong()) - abStreamDataObj.put("startTime", startTimeLong) - abStreamDataObj.put("playbackSpeed", playbackRate) - abStreamDataObj.put("playWhenReady", true) - abStreamDataObj.put("isLocal", false) - - var audiobookStreamData = AudiobookStreamData(abStreamDataObj) - - Handler(Looper.getMainLooper()).post() { - Log.d(tag, "Stream Ready on Main Looper") - streamListener.onStreamReady(audiobookStreamData) - } - - Log.d(tag, "Init Player Stream") - } - } - }) - } - - fun initDownloadPlay(audiobook:Audiobook):AudiobookStreamData { - var playbackRate = getPlaybackRate() - - var abStreamDataObj = JSObject() - abStreamDataObj.put("id", "download") - abStreamDataObj.put("audiobookId", audiobook.id) - abStreamDataObj.put("contentUrl", audiobook.contentUrl) - abStreamDataObj.put("title", audiobook.book.title) - abStreamDataObj.put("author", audiobook.book.authorFL) - abStreamDataObj.put("token", null) - abStreamDataObj.put("cover", audiobook.getCover()) - abStreamDataObj.put("duration", audiobook.getDurationLong()) - abStreamDataObj.put("startTime", 0) - abStreamDataObj.put("playbackSpeed", playbackRate) - abStreamDataObj.put("playWhenReady", true) - abStreamDataObj.put("isLocal", true) - - var audiobookStreamData = AudiobookStreamData(abStreamDataObj) - return audiobookStreamData - } - - fun initLocalPlay(local: LocalMediaManager.LocalAudio):AudiobookStreamData { - var abStreamDataObj = JSObject() - abStreamDataObj.put("id", "local") - abStreamDataObj.put("audiobookId", local.id) - abStreamDataObj.put("contentUrl", local.uri.toString()) - abStreamDataObj.put("title", local.name) - abStreamDataObj.put("author", "") - abStreamDataObj.put("token", null) - abStreamDataObj.put("cover", local.coverUri) - abStreamDataObj.put("duration", local.duration) - abStreamDataObj.put("startTime", 0) - abStreamDataObj.put("playbackSpeed", 1) - abStreamDataObj.put("playWhenReady", true) - abStreamDataObj.put("isLocal", true) - - var audiobookStreamData = AudiobookStreamData(abStreamDataObj) - return audiobookStreamData - } - - private fun levenshtein(lhs : CharSequence, rhs : CharSequence) : Int { - val lhsLength = lhs.length + 1 - val rhsLength = rhs.length + 1 - - var cost = Array(lhsLength) { it } - var newCost = Array(lhsLength) { 0 } - - for (i in 1..rhsLength-1) { - newCost[0] = i - - for (j in 1..lhsLength-1) { - val match = if(lhs[j - 1] == rhs[i - 1]) 0 else 1 - - val costReplace = cost[j - 1] + match - val costInsert = cost[j] + 1 - val costDelete = newCost[j - 1] + 1 - - newCost[j] = Math.min(Math.min(costInsert, costDelete), costReplace) - } - - val swap = cost - cost = newCost - newCost = swap - } - - return cost[lhsLength - 1] - } - - fun searchForAudiobook(query:String):Audiobook? { - var closestDistance = 99 - var closestMatch:Audiobook? = null - audiobooks.forEach { - var dist = levenshtein(it.book.title, query) - Log.d(tag, "LEVENSHTEIN $dist") - if (dist < closestDistance) { - closestDistance = dist - closestMatch = it - } - } - if (closestMatch != null) { - Log.d(tag, "Closest Search is ${closestMatch?.book?.title} with distance $closestDistance") - if (closestDistance < 2) { - return closestMatch - } - return null - } - return null - } - - fun getFirstAudiobook():Audiobook? { - if (audiobooks.isEmpty()) return null - return audiobooks[0] - } - - fun getFirstLocal(): LocalMediaManager.LocalAudio? { - if (localMediaManager.localAudioFiles.isEmpty()) return null - return localMediaManager.localAudioFiles[0] - } - - // Used for media browser loadChildren, fallback to using the samples if no audiobooks are there - fun getAudiobooksMediaMetadata() : List { - var mediaMetadata:MutableList = mutableListOf() - if (audiobooks.isEmpty()) { - localMediaManager.localAudioFiles.forEach { mediaMetadata.add(it.toMediaMetadata()) } - } else { - audiobooks.forEach { mediaMetadata.add(it.toMediaMetadata()) } - } - return mediaMetadata - } - // Used for media browser loadChildren, fallback to using the samples if no audiobooks are there - fun getDownloadedAudiobooksMediaMetadata() : List { - var mediaMetadata:MutableList = mutableListOf() - if (audiobooks.isEmpty()) { - localMediaManager.localAudioFiles.forEach { mediaMetadata.add(it.toMediaMetadata()) } - } else { - audiobooks.forEach { if (it.isDownloaded) { mediaMetadata.add(it.toMediaMetadata()) } } - } - return mediaMetadata - } -} diff --git a/android/app/src/main/java/com/audiobookshelf/app/AudiobookProgressSyncer.kt b/android/app/src/main/java/com/audiobookshelf/app/AudiobookProgressSyncer.kt deleted file mode 100644 index ff6b19d1..00000000 --- a/android/app/src/main/java/com/audiobookshelf/app/AudiobookProgressSyncer.kt +++ /dev/null @@ -1,182 +0,0 @@ -package com.audiobookshelf.app - -import android.os.Handler -import android.os.Looper -import android.util.Log -import com.getcapacitor.JSObject -import okhttp3.* -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.RequestBody.Companion.toRequestBody -import java.io.IOException -import java.util.* -import kotlin.concurrent.schedule - -/* - * Normal progress sync is handled in webview, but when using android auto webview may not be open. - * If webview is not open sync progress every 5s. Webview can be closed at any time so interval is always set. - */ -class AudiobookProgressSyncer constructor(playerNotificationService:PlayerNotificationService, client: OkHttpClient) { - private val tag = "AudiobookProgressSync" - private val playerNotificationService:PlayerNotificationService = playerNotificationService - private val client:OkHttpClient = client - - private var listeningTimerTask: TimerTask? = null - var listeningTimerRunning:Boolean = false - - private var webviewOpenOnStart:Boolean = false - private var webviewClosedMidSession:Boolean = false - private var listeningBookTitle:String? = "" - private var listeningBookIsLocal:Boolean = false - private var listeningBookId:String? = "" - private var listeningStreamId:String? = "" - - private var lastPlaybackTime:Long = 0 - private var lastUpdateTime:Long = 0 - - fun start() { - if (listeningTimerRunning) { - Log.d(tag, "start: Timer already running for $listeningBookTitle") - if (playerNotificationService.getCurrentBookTitle() != listeningBookTitle) { - Log.d(tag, "start: Changed audiobook stream - resetting timer") - listeningTimerTask?.cancel() - } - } - listeningTimerRunning = true - - webviewOpenOnStart = playerNotificationService.getIsWebviewOpen() - listeningBookTitle = playerNotificationService.getCurrentBookTitle() - listeningBookIsLocal = playerNotificationService.getCurrentBookIsLocal() - listeningBookId = playerNotificationService.getCurrentBookId() - listeningStreamId = playerNotificationService.getCurrentStreamId() - - lastPlaybackTime = playerNotificationService.getCurrentTime() - lastUpdateTime = System.currentTimeMillis() / 1000L - - listeningTimerTask = Timer("ListeningTimer", false).schedule(0L, 5000L) { - Handler(Looper.getMainLooper()).post() { - // Webview was closed while android auto is open - switch to native sync - var isWebviewOpen = playerNotificationService.getIsWebviewOpen() - if (!isWebviewOpen && webviewOpenOnStart) { - Log.d(tag, "Listening Timer: webview closed Switching to native sync tracking") - webviewOpenOnStart = false - webviewClosedMidSession = true - lastUpdateTime = System.currentTimeMillis() / 1000L - } else if (isWebviewOpen && webviewClosedMidSession) { - Log.d(tag, "Listening Timer: webview re-opened Switching back to webview sync tracking") - webviewClosedMidSession = false - webviewOpenOnStart = true - lastUpdateTime = System.currentTimeMillis() / 1000L - } - if (!webviewOpenOnStart && playerNotificationService.currentPlayer.isPlaying) { - sync() - } - } - } - } - - fun stop() { - if (!listeningTimerRunning) return - Log.d(tag, "stop: Stopping listening for $listeningBookTitle") - - if (!webviewOpenOnStart) { - sync() - } - reset() - } - - fun reset() { - listeningTimerTask?.cancel() - listeningTimerTask = null - listeningTimerRunning = false - listeningBookTitle = "" - listeningBookId = "" - listeningBookIsLocal = false - listeningStreamId = "" - } - - fun sync() { - var currTime = System.currentTimeMillis() / 1000L - var elapsed = currTime - lastUpdateTime - lastUpdateTime = currTime - - if (!listeningBookIsLocal) { - Log.d(tag, "ListeningTimer: Sending sync data to server: elapsed $elapsed | $listeningStreamId | $listeningBookId") - - // Send sync data only for streaming books - var syncData: JSObject = JSObject() - syncData.put("timeListened", elapsed) - syncData.put("currentTime", playerNotificationService.getCurrentTime() / 1000) - syncData.put("streamId", listeningStreamId) - syncData.put("audiobookId", listeningBookId) - sendStreamSyncData(syncData) { - Log.d(tag, "Stream sync done") - } - } else if (listeningStreamId == "download") { - // TODO: Save downloaded audiobook progress & send to server if connected - Log.d(tag, "ListeningTimer: Is listening download") - - // Send sync data only for local books - var syncData: JSObject = JSObject() - var duration = playerNotificationService.getAudiobookDuration() / 1000 - var currentTime = playerNotificationService.getCurrentTime() / 1000 - syncData.put("totalDuration", duration) - syncData.put("currentTime", currentTime) - syncData.put("progress", if (duration > 0) (currentTime / duration) else 0) - syncData.put("isRead", false) - syncData.put("lastUpdate", System.currentTimeMillis()) - syncData.put("audiobookId", listeningBookId) - sendLocalSyncData(syncData) { - Log.d(tag, "Local sync done") - } - } - } - - fun sendLocalSyncData(payload:JSObject, cb: (() -> Unit)) { - var serverUrl = playerNotificationService.getServerUrl() - var token = playerNotificationService.getUserToken() - - if (serverUrl == "" || token == "") { - return - } - - Log.d(tag, "Sync Local $serverUrl | $token") - var url = "$serverUrl/api/syncLocal" - sendServerRequest(url, token, payload, cb) - } - - fun sendStreamSyncData(payload:JSObject, cb: (() -> Unit)) { - var serverUrl = playerNotificationService.getServerUrl() - var token = playerNotificationService.getUserToken() - - if (serverUrl == "" || token == "") { - return - } - - Log.d(tag, "Sync Stream $serverUrl | $token") - var url = "$serverUrl/api/syncStream" - sendServerRequest(url, token, payload, cb) - } - - fun sendServerRequest(url:String, token:String, payload:JSObject, cb: () -> Unit) { - val mediaType = "application/json; charset=utf-8".toMediaType() - val requestBody = payload.toString().toRequestBody(mediaType) - val request = Request.Builder().post(requestBody) - .url(url).addHeader("Authorization", "Bearer $token") - .build() - - client.newCall(request).enqueue(object : Callback { - override fun onFailure(call: Call, e: IOException) { - Log.d(tag, "FAILURE TO CONNECT") - e.printStackTrace() - cb() - } - - override fun onResponse(call: Call, response: Response) { - response.use { - if (!response.isSuccessful) throw IOException("Unexpected code $response") - cb() - } - } - }) - } -} diff --git a/android/app/src/main/java/com/audiobookshelf/app/AudiobookStreamData.kt b/android/app/src/main/java/com/audiobookshelf/app/AudiobookStreamData.kt deleted file mode 100644 index 1e2977e7..00000000 --- a/android/app/src/main/java/com/audiobookshelf/app/AudiobookStreamData.kt +++ /dev/null @@ -1,176 +0,0 @@ -package com.audiobookshelf.app - -import android.net.Uri -import android.support.v4.media.MediaMetadataCompat -import android.util.Log -import com.getcapacitor.JSObject -import com.google.android.exoplayer2.MediaItem -import com.google.android.exoplayer2.MediaMetadata -import com.google.android.exoplayer2.util.MimeTypes -import java.lang.Exception - -class AudiobookStreamData { - var id:String = "unset" - var audiobookId:String = "" - var token:String = "" - var playlistUrl:String = "" - var title:String = "No Title" - var author:String = "Unknown" - var series:String = "" - var cover:String = "" - var playWhenReady:Boolean = false - var startTime:Long = 0 - var playbackSpeed:Float = 1f - var duration:Long = 0 - var tracks:MutableList = mutableListOf() - - var isLocal:Boolean = false - var contentUrl:String = "" - - var hasPlayerLoaded:Boolean = false - - var playlistUri:Uri = Uri.EMPTY - var coverUri:Uri = Uri.EMPTY - var contentUri:Uri = Uri.EMPTY // For Local only - - constructor(jsondata:JSObject) { - id = jsondata.getString("id", "unset").toString() - audiobookId = jsondata.getString("audiobookId", "").toString() - title = jsondata.getString("title", "No Title").toString() - token = jsondata.getString("token", "").toString() - author = jsondata.getString("author", "Unknown").toString() - series = jsondata.getString("series", "").toString() - cover = jsondata.getString("cover", "").toString() - playlistUrl = jsondata.getString("playlistUrl", "").toString() - playWhenReady = jsondata.getBoolean("playWhenReady", false) == true - - if (jsondata.has("startTime")) { - startTime = jsondata.getString("startTime", "0")!!.toLong() - } - - if (jsondata.has("duration")) { - duration = jsondata.getString("duration", "0")!!.toLong() - } - - if (jsondata.has("playbackSpeed")) { - playbackSpeed = jsondata.getDouble("playbackSpeed")!!.toFloat() - } - - - // Local data - isLocal = jsondata.getBoolean("isLocal", false) == true - contentUrl = jsondata.getString("contentUrl", "").toString() - - if (playlistUrl != "") { - playlistUri = Uri.parse(playlistUrl) - } - if (cover != "" && cover != null) { - coverUri = Uri.parse(cover) - } else { - coverUri = Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon) - cover = coverUri.toString() - } - - if (contentUrl != "") { - contentUri = Uri.parse(contentUrl) - } - - // Tracks for cast - try { - var tracksTest = jsondata.getJSONArray("tracks") - Log.d("AudiobookStreamData", "Load tracks from json array ${tracksTest.length()}") - for (i in 0 until tracksTest.length()) { - var track = tracksTest.get(i) - Log.d("AudiobookStreamData", "Extracting track $track") - tracks.add(track as String) - } - } catch(e:Exception) { - Log.d("AudiobookStreamData", "No tracks found $e") - } - } - - fun clearCover() { - coverUri = Uri.EMPTY - cover = "" - } - - fun getMediaMetadataCompat():MediaMetadataCompat { - var metadataBuilder = MediaMetadataCompat.Builder() - .putString(MediaMetadataCompat.METADATA_KEY_TITLE, title) - .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title) - .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, author) - .putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, author) - .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, author) - .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, series) - .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id) - -// if (cover != "") { -// metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, cover) -// metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, cover) -// } - return metadataBuilder.build() - } - - fun getMediaMetadata():MediaMetadata { - var metadataBuilder = MediaMetadata.Builder() - .setTitle(title) - .setDisplayTitle(title) - .setArtist(author) - .setAlbumArtist(author) - .setSubtitle(author) - -// if (coverUri != Uri.EMPTY) { -// metadataBuilder.setArtworkUri(coverUri) -// } - if (playlistUri != Uri.EMPTY) { - metadataBuilder.setMediaUri(playlistUri) - } - if (contentUri != Uri.EMPTY) { - metadataBuilder.setMediaUri(contentUri) - } - return metadataBuilder.build() - } - - fun getMimeType():String { - return if (isLocal) { - MimeTypes.BASE_TYPE_AUDIO - } else { - MimeTypes.APPLICATION_M3U8 - } - } - - fun getMediaUri():Uri { - return if (isLocal) { - contentUri - } else { - Uri.parse("$playlistUrl?token=$token") - } - } - - fun getCastQueue():ArrayList { - var mediaQueue: java.util.ArrayList = java.util.ArrayList() - - for (i in 0 until tracks.size) { - var track = tracks[i] - var metadataBuilder = MediaMetadata.Builder() - .setTitle(title) - .setDisplayTitle(title) - .setArtist(author) - .setAlbumArtist(author) - .setSubtitle(author) - .setTrackNumber(i + 1) - - if (coverUri != Uri.EMPTY) { - metadataBuilder.setArtworkUri(coverUri) - } - - var mimeType = MimeTypes.BASE_TYPE_AUDIO - - var mediaMetadata = metadataBuilder.build() - var mediaItem = MediaItem.Builder().setUri(Uri.parse(track)).setMediaMetadata(mediaMetadata).setMimeType(mimeType).build() - mediaQueue.add(mediaItem) - } - - return mediaQueue - } -} diff --git a/android/app/src/main/java/com/audiobookshelf/app/Book.kt b/android/app/src/main/java/com/audiobookshelf/app/Book.kt deleted file mode 100644 index 2ff4e714..00000000 --- a/android/app/src/main/java/com/audiobookshelf/app/Book.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.audiobookshelf.app - -import com.getcapacitor.JSObject - -class Book { - var title:String - var subtitle:String - var author:String - var authorFL:String - var narrator:String - var series:String - var volumeNumber:String - var publisher:String - var description:String - var publishYear:String - var language:String - var cover:String - var coverFullPath:String - var genres:String - var lastUpdate:Long - - constructor(jsobj: JSObject) { - title = jsobj.getString("title", "").toString() - subtitle = jsobj.getString("subtitle", "").toString() - author = jsobj.getString("author", "").toString() - authorFL = jsobj.getString("authorFL", "").toString() - narrator = jsobj.getString("narrator", "").toString() - series = jsobj.getString("series", "").toString() - volumeNumber = jsobj.getString("volumeNumber", "").toString() - publisher = jsobj.getString("publisher", "").toString() - description = jsobj.getString("description", "").toString() - publishYear = jsobj.getString("publishYear", "").toString() - language = jsobj.getString("language", "").toString() - cover = jsobj.getString("cover", "").toString() - coverFullPath = jsobj.getString("coverFullPath", "").toString() - genres = jsobj.getString("genres", "").toString() - lastUpdate = jsobj.getLong("lastUpdate") - } -} diff --git a/android/app/src/main/java/com/audiobookshelf/app/CastOptionsProvider.kt b/android/app/src/main/java/com/audiobookshelf/app/CastOptionsProvider.kt index 27b34caf..b48d17de 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/CastOptionsProvider.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/CastOptionsProvider.kt @@ -11,6 +11,7 @@ import com.google.android.gms.cast.framework.media.CastMediaOptions class CastOptionsProvider : OptionsProvider { override fun getCastOptions(context: Context): CastOptions { Log.d("CastOptionsProvider", "getCastOptions") + var appId = "FD1F76C5" return CastOptions.Builder() .setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID).setCastMediaOptions( CastMediaOptions.Builder() diff --git a/android/app/src/main/java/com/audiobookshelf/app/LocalMediaManager.kt b/android/app/src/main/java/com/audiobookshelf/app/LocalMediaManager.kt deleted file mode 100644 index 5d234125..00000000 --- a/android/app/src/main/java/com/audiobookshelf/app/LocalMediaManager.kt +++ /dev/null @@ -1,120 +0,0 @@ -package com.audiobookshelf.app - -import android.Manifest -import android.content.ContentResolver -import android.content.ContentUris -import android.content.Context -import android.content.pm.PackageManager -import android.content.res.AssetFileDescriptor -import android.database.Cursor -import android.media.MediaPlayer -import android.net.Uri -import android.os.Build -import android.provider.MediaStore -import android.support.v4.media.MediaMetadataCompat -import android.util.Log -import androidx.annotation.AnyRes -import androidx.core.content.ContextCompat -import androidx.core.net.toFile -import androidx.core.net.toUri -import com.bumptech.glide.Glide -import java.io.File -import java.io.IOException - - -class LocalMediaManager { - private var ctx: Context - val tag = "LocalAudioManager" - - constructor(ctx: Context) { - this.ctx = ctx - } - - data class LocalAudio(val uri: Uri, - val id: String, - val name: String, - val duration: Int, - val size: Int, - val coverUri: Uri? - ) { - - fun toMediaMetadata(): MediaMetadataCompat { - return MediaMetadataCompat.Builder().apply { - putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id) - putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, name) - putString(MediaMetadataCompat.METADATA_KEY_TITLE, name) - - if (coverUri != null) { - putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString()) - } - }.build() - } - } - val localAudioFiles = mutableListOf() - - /** - * get uri to drawable or any other resource type if u wish - * @param context - context - * @param drawableId - drawable res id - * @return - uri - */ - fun getUriToDrawable(context: Context, - @AnyRes drawableId: Int): Uri { - return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE - + "://" + context.resources.getResourcePackageName(drawableId) - + '/' + context.resources.getResourceTypeName(drawableId) - + '/' + context.resources.getResourceEntryName(drawableId)) - } - - fun loadLocalAudio() { - localAudioFiles.clear() - - localAudioFiles += LocalAudio(Uri.parse("asset:///public/samples/Anthem/AnthemSample.m4b"), "anthem_sample", "Anthem", 60000, 10000, getUriToDrawable(ctx, R.drawable.exo_icon_localaudio)) - localAudioFiles += LocalAudio(Uri.parse("asset:///public/samples/Legend of Sleepy Hollow/LegendOfSleepyHollowSample.m4b"), "sleepy_hollow", "Legend of Sleepy Hollow", 60000, 10000, getUriToDrawable(ctx, R.drawable.exo_icon_localaudio)) - - // TODO: No longer reading in local audio files - just use samples -// if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { -// Log.e(tag, "Permission not granted to read from external storage") -// return -// } -// -// val collection = -// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { -// MediaStore.Audio.Media.getContentUri( -// MediaStore.VOLUME_EXTERNAL -// ) -// } else { -// MediaStore.Audio.Media.EXTERNAL_CONTENT_URI -// } -// -// val proj = arrayOf(MediaStore.Audio.Media._ID, MediaStore.Audio.Media.DISPLAY_NAME, MediaStore.Audio.Media.DURATION, MediaStore.Audio.Media.SIZE) -// val audioCursor: Cursor? = ctx.contentResolver.query(collection, proj, null, null, null) -// -// audioCursor?.use { cursor -> -// // Cache column indices. -// val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID) -// val nameColumn = -// cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME) -// val durationColumn = -// cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION) -// val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.SIZE) -// -// while (cursor.moveToNext()) { -// // Get values of columns for a given video. -// val id = cursor.getLong(idColumn) -// val name = cursor.getString(nameColumn) -// val duration = cursor.getInt(durationColumn) -// val size = cursor.getInt(sizeColumn) -// -// val contentUri: Uri = ContentUris.withAppendedId( -// MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, -// id -// ) -// Log.d(tag, "Found local audio file $name") -// localAudioFiles += LocalAudio(contentUri, id.toString(), name, duration, size, null) -// } -// } -// -// Log.d(tag, "${localAudioFiles.size} Local Audio Files found") - } -} diff --git a/android/app/src/main/java/com/audiobookshelf/app/MainActivity.kt b/android/app/src/main/java/com/audiobookshelf/app/MainActivity.kt index 95dd3868..654f77f1 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/MainActivity.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/MainActivity.kt @@ -1,16 +1,22 @@ package com.audiobookshelf.app +import android.Manifest import android.app.DownloadManager import android.content.* +import android.content.pm.PackageManager import android.os.* import android.util.Log +import androidx.core.app.ActivityCompat import com.anggrayudi.storage.SimpleStorage import com.anggrayudi.storage.SimpleStorageHelper -import com.audiobookshelf.app.data.DbManager +import com.audiobookshelf.app.data.AbsDatabase +import com.audiobookshelf.app.player.PlayerNotificationService +import com.audiobookshelf.app.plugins.AbsDownloader +import com.audiobookshelf.app.plugins.AbsAudioPlayer +import com.audiobookshelf.app.plugins.AbsFileSystem import com.getcapacitor.BridgeActivity import io.paperdb.Paper - class MainActivity : BridgeActivity() { private val tag = "MainActivity" @@ -24,6 +30,11 @@ class MainActivity : BridgeActivity() { val storageHelper = SimpleStorageHelper(this) val storage = SimpleStorage(this) + val REQUEST_PERMISSIONS = 1 + var PERMISSIONS_ALL = arrayOf( + Manifest.permission.READ_EXTERNAL_STORAGE + ) + val broadcastReceiver = object: BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { when (intent?.action) { @@ -43,10 +54,21 @@ class MainActivity : BridgeActivity() { super.onCreate(savedInstanceState) Log.d(tag, "onCreate") - registerPlugin(MyNativeAudio::class.java) - registerPlugin(AudioDownloader::class.java) - registerPlugin(StorageManager::class.java) - registerPlugin(DbManager::class.java) + +// var ss = SimpleStorage(this) +// ss.requestFullStorageAccess() + + var permission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) + if (permission != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(this, + PERMISSIONS_ALL, + REQUEST_PERMISSIONS) + } + + registerPlugin(AbsAudioPlayer::class.java) + registerPlugin(AbsDownloader::class.java) + registerPlugin(AbsFileSystem::class.java) + registerPlugin(AbsDatabase::class.java) var filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE).apply { addAction(DownloadManager.ACTION_NOTIFICATION_CLICKED) @@ -63,6 +85,7 @@ class MainActivity : BridgeActivity() { override fun onPostCreate(savedInstanceState: Bundle?) { super.onPostCreate(savedInstanceState) + Log.d(tag, "onPostCreate MainActivity") mConnection = object : ServiceConnection { override fun onServiceDisconnected(name: ComponentName) { @@ -73,20 +96,21 @@ class MainActivity : BridgeActivity() { override fun onServiceConnected(name: ComponentName, service: IBinder) { Log.d(tag, "Service Connected $name") - mBounded = true val mLocalBinder = service as PlayerNotificationService.LocalBinder foregroundService = mLocalBinder.getService() - // Let MyNativeAudio know foreground service is ready and setup event listener + // Let NativeAudio know foreground service is ready and setup event listener if (pluginCallback != null) { pluginCallback() } } } - val startIntent = Intent(this, PlayerNotificationService::class.java) - bindService(startIntent, mConnection as ServiceConnection, Context.BIND_AUTO_CREATE); + Intent(this, PlayerNotificationService::class.java).also { intent -> + Log.d(tag, "Binding PlayerNotificationService") + bindService(intent, mConnection, Context.BIND_AUTO_CREATE) + } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/PlayerNotificationService.kt b/android/app/src/main/java/com/audiobookshelf/app/PlayerNotificationService.kt deleted file mode 100644 index 4b83dc56..00000000 --- a/android/app/src/main/java/com/audiobookshelf/app/PlayerNotificationService.kt +++ /dev/null @@ -1,1052 +0,0 @@ -package com.audiobookshelf.app - -import android.annotation.SuppressLint -import android.app.* -import android.content.Context -import android.content.Intent -import android.graphics.Color -import android.hardware.Sensor -import android.hardware.SensorManager -import android.net.Uri -import android.os.* -import android.support.v4.media.MediaBrowserCompat -import android.support.v4.media.MediaDescriptionCompat -import android.support.v4.media.MediaMetadataCompat -import android.support.v4.media.session.MediaControllerCompat -import android.support.v4.media.session.MediaSessionCompat -import android.support.v4.media.session.PlaybackStateCompat -import android.util.Log -import android.view.KeyEvent -import androidx.annotation.RequiresApi -import androidx.core.app.NotificationCompat -import androidx.documentfile.provider.DocumentFile -import androidx.media.MediaBrowserServiceCompat -import androidx.media.utils.MediaConstants -import com.anggrayudi.storage.file.isExternalStorageDocument -import com.audiobookshelf.app.data.DbManager -import com.getcapacitor.Bridge -import com.getcapacitor.JSObject -import com.google.android.exoplayer2.* -import com.google.android.exoplayer2.audio.AudioAttributes -import com.google.android.exoplayer2.ext.cast.CastPlayer -import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector -import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator -import com.google.android.exoplayer2.source.MediaSource -import com.google.android.exoplayer2.source.ProgressiveMediaSource -import com.google.android.exoplayer2.source.hls.HlsMediaSource -import com.google.android.exoplayer2.ui.PlayerNotificationManager -import com.google.android.exoplayer2.upstream.* -import com.google.android.gms.cast.* -import com.google.android.gms.cast.framework.* -import kotlinx.coroutines.* -import okhttp3.OkHttpClient -import org.json.JSONObject -import java.util.* -import kotlin.concurrent.schedule - -const val SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m - -class PlayerNotificationService : MediaBrowserServiceCompat() { - - companion object { - var isStarted = false - } - - interface MyCustomObjectListener { - fun onPlayingUpdate(isPlaying: Boolean) - fun onMetadata(metadata: JSObject) - fun onPrepare(audiobookId: String, playWhenReady: Boolean) - fun onSleepTimerEnded(currentPosition: Long) - fun onSleepTimerSet(sleepTimeRemaining: Int) - } - - private val tag = "PlayerService" - private val binder = LocalBinder() - - var listener:MyCustomObjectListener? = null - - private lateinit var ctx:Context - private lateinit var mediaSessionConnector: MediaSessionConnector - private lateinit var playerNotificationManager: PlayerNotificationManager - private lateinit var mediaSession: MediaSessionCompat - private lateinit var transportControls:MediaControllerCompat.TransportControls - private lateinit var audiobookManager:AudiobookManager - - lateinit var mPlayer: SimpleExoPlayer - lateinit var currentPlayer:Player - var castPlayer:CastPlayer? = null - - lateinit var sleepTimerManager:SleepTimerManager - lateinit var castManager:CastManager - lateinit var audiobookProgressSyncer:AudiobookProgressSyncer - - private var notificationId = 10; - private var channelId = "audiobookshelf_channel" - private var channelName = "Audiobookshelf Channel" - - private var currentAudiobookStreamData:AudiobookStreamData? = null - - private var mediaButtonClickCount: Int = 0 - var mediaButtonClickTimeout: Long = 1000 //ms - var seekAmount: Long = 20000 //ms - - private var lastPauseTime: Long = 0 //ms - private var onSeekBack: Boolean = false - - var isAndroidAuto = false - var webviewBridge:Bridge? = null - - // The following are used for the shake detection - private var isShakeSensorRegistered:Boolean = false - private var mSensorManager: SensorManager? = null - private var mAccelerometer: Sensor? = null - private var mShakeDetector: ShakeDetector? = null - private var shakeSensorUnregisterTask:TimerTask? = null - - fun setCustomObjectListener(mylistener: MyCustomObjectListener) { - listener = mylistener - } - fun setBridge(bridge: Bridge) { - webviewBridge = bridge - } - fun getIsWebviewOpen():Boolean { - return webviewBridge?.app?.isActive == true - } - - /* - Service related stuff - */ - override fun onBind(intent: Intent): IBinder? { - Log.d(tag, "onBind") - - // Android Auto Media Browser Service - if (SERVICE_INTERFACE == intent.action) { - Log.d(tag, "Is Media Browser Service") - return super.onBind(intent); - } - return binder - } - - inner class LocalBinder : Binder() { - // Return this instance of LocalService so clients can call public methods - fun getService(): PlayerNotificationService = this@PlayerNotificationService - } - - fun stopService(context: Context) { - val stopIntent = Intent(context, PlayerNotificationService::class.java) - context.stopService(stopIntent) - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - Log.d(tag, "onStartCommand $startId") - isStarted = true - - return START_STICKY - } - - override fun onStart(intent: Intent?, startId: Int) { - Log.d(tag, "onStart $startId") - - } - - @RequiresApi(Build.VERSION_CODES.O) - private fun createNotificationChannel(channelId: String, channelName: String): String { - val chan = NotificationChannel(channelId, - channelName, NotificationManager.IMPORTANCE_LOW) - chan.lightColor = Color.DKGRAY - chan.lockscreenVisibility = Notification.VISIBILITY_PUBLIC - val service = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - service.createNotificationChannel(chan) - return channelId - } - - private fun playLocal(local: LocalMediaManager.LocalAudio, playWhenReady: Boolean) { - var asd = audiobookManager.initLocalPlay(local) - asd.playWhenReady = playWhenReady - initPlayer(asd) - } - - private fun playFirstLocal(playWhenReady: Boolean) { - var localAudio = audiobookManager.getFirstLocal() - if (localAudio != null) { - playLocal(localAudio, playWhenReady) - } - } - - private fun playAudiobookFromMediaBrowser(audiobook: Audiobook, playWhenReady: Boolean) { - if (!audiobook.isDownloaded) { - var streamListener = object : AudiobookManager.OnStreamData { - override fun onStreamReady(asd: AudiobookStreamData) { - Log.d(tag, "Stream Ready ${asd.playlistUrl}") - asd.playWhenReady = playWhenReady - initPlayer(asd) - } - } - audiobookManager.openStream(audiobook, streamListener) - } else { - var asd = audiobookManager.initDownloadPlay(audiobook) - asd.playWhenReady = playWhenReady - initPlayer(asd) - } - } - - private fun playFirstAudiobook(playWhenReady: Boolean) { - var firstAudiobook = audiobookManager.getFirstAudiobook() - if (firstAudiobook != null) { - playAudiobookFromMediaBrowser(firstAudiobook, playWhenReady) - } else { - playFirstLocal(playWhenReady) - } - } - - private fun openFromMediaId(mediaId: String, playWhenReady: Boolean) { - var audiobook = audiobookManager.audiobooks.find { it.id == mediaId } - if (audiobook == null) { - var localAudio = audiobookManager.localMediaManager.localAudioFiles.find { it.id == mediaId } - if (localAudio != null) { - playLocal(localAudio, playWhenReady) - return - } - - Log.e(tag, "Audiobook NOT FOUND") - return - } - - playAudiobookFromMediaBrowser(audiobook, playWhenReady) - } - - private fun openFromSearch(query: String?, playWhenReady: Boolean) { - if (query?.isNullOrEmpty() == true) { - Log.d(tag, "Empty search query play first audiobook") - playFirstAudiobook(playWhenReady) - return - } - - var audiobook = audiobookManager.searchForAudiobook(query) - if (audiobook == null) { - Log.e(tag, "No Audiobook found for search $query") - pause() - return - } - - playAudiobookFromMediaBrowser(audiobook, playWhenReady) - } - - // detach player - override fun onDestroy() { - playerNotificationManager.setPlayer(null) - mPlayer.release() - mediaSession.release() - audiobookProgressSyncer.reset() - Log.d(tag, "onDestroy") - isStarted = false - - super.onDestroy() - } - - //removing service when user swipe out our app - override fun onTaskRemoved(rootIntent: Intent?) { - super.onTaskRemoved(rootIntent) - Log.d(tag, "onTaskRemoved") - stopSelf() - } - - - override fun onCreate() { - super.onCreate() - ctx = this - - // Initialize player - var customLoadControl:LoadControl = DefaultLoadControl.Builder().setBufferDurationsMs( - 1000 * 20, // 20s min buffer - 1000 * 45, // 45s max buffer - 1000 * 5, // 5s playback start - 1000 * 20 // 20s playback rebuffer - ).build() - - var simpleExoPlayerBuilder = SimpleExoPlayer.Builder(this) - simpleExoPlayerBuilder.setLoadControl(customLoadControl) - simpleExoPlayerBuilder.setSeekBackIncrementMs(10000) - simpleExoPlayerBuilder.setSeekForwardIncrementMs(10000) - mPlayer = simpleExoPlayerBuilder.build() - mPlayer.setHandleAudioBecomingNoisy(true) - mPlayer.addListener(getPlayerListener()) - var audioAttributes:AudioAttributes = AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.CONTENT_TYPE_SPEECH).build() - mPlayer.setAudioAttributes(audioAttributes, true) - - currentPlayer = mPlayer - - var client: OkHttpClient = OkHttpClient() - - // Initialize sleep timer - sleepTimerManager = SleepTimerManager(this) - - // Initialize Cast Manager - castManager = CastManager(this) - - // Initialize Audiobook Progress Syncer (Only used for android auto when webview is not open) - audiobookProgressSyncer = AudiobookProgressSyncer(this, client) - - // Initialize shake sensor - Log.d(tag, "onCreate Register sensor listener ${mAccelerometer?.isWakeUpSensor}") - initSensor() - - // Initialize audiobook manager - audiobookManager = AudiobookManager(ctx, client) - audiobookManager.init() - - channelId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - createNotificationChannel(channelId, channelName) - } else "" - - val sessionActivityPendingIntent = - packageManager?.getLaunchIntentForPackage(packageName)?.let { sessionIntent -> - PendingIntent.getActivity(this, 0, sessionIntent, 0) - } - - mediaSession = MediaSessionCompat(this, tag) - .apply { - setSessionActivity(sessionActivityPendingIntent) - isActive = true - } - - - Log.d(tag, "Media Session Set") - - val mediaController = MediaControllerCompat(ctx, mediaSession.sessionToken) - - // This is for Media Browser - sessionToken = mediaSession.sessionToken - - val builder = PlayerNotificationManager.Builder( - ctx, - notificationId, - channelId) - - builder.setMediaDescriptionAdapter(AbMediaDescriptionAdapter(mediaController, this)) - - builder.setNotificationListener(object : PlayerNotificationManager.NotificationListener { - override fun onNotificationPosted( - notificationId: Int, - notification: Notification, - onGoing: Boolean) { - - // Start foreground service - Log.d(tag, "Notification Posted $notificationId - Start Foreground | $notification") - startForeground(notificationId, notification) - } - - override fun onNotificationCancelled( - notificationId: Int, - dismissedByUser: Boolean - ) { - if (dismissedByUser) { - Log.d(tag, "onNotificationCancelled dismissed by user") - stopSelf() - } else { - Log.d(tag, "onNotificationCancelled not dismissed by user") - } - } - }) - - playerNotificationManager = builder.build() - playerNotificationManager.setMediaSessionToken(mediaSession.sessionToken) - playerNotificationManager.setUsePlayPauseActions(true) - playerNotificationManager.setUseNextAction(false) - playerNotificationManager.setUsePreviousAction(false) - playerNotificationManager.setUseChronometer(false) - playerNotificationManager.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX) - playerNotificationManager.setUseFastForwardActionInCompactView(true) - playerNotificationManager.setUseRewindActionInCompactView(true) - - // Unknown action - playerNotificationManager.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE) - - transportControls = mediaController.transportControls - - // Color is set based on the art - cannot override -// playerNotificationManager.setColor(Color.RED) -// playerNotificationManager.setColorized(true) - - // Icon needs to be black and white -// playerNotificationManager.setSmallIcon(R.drawable.icon_32) - - mediaSessionConnector = MediaSessionConnector(mediaSession) - val queueNavigator: TimelineQueueNavigator = object : TimelineQueueNavigator(mediaSession) { - override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat { - var builder = MediaDescriptionCompat.Builder() - .setMediaId(currentAudiobookStreamData!!.id) - .setTitle(currentAudiobookStreamData!!.title) - .setSubtitle(currentAudiobookStreamData!!.author) - .setMediaUri(currentAudiobookStreamData!!.playlistUri) - .setIconUri(currentAudiobookStreamData!!.coverUri) - return builder.build() - } - } - - val myPlaybackPreparer:MediaSessionConnector.PlaybackPreparer = object :MediaSessionConnector.PlaybackPreparer { - override fun onCommand(player: Player, controlDispatcher: ControlDispatcher, command: String, extras: Bundle?, cb: ResultReceiver?): Boolean { - Log.d(tag, "ON COMMAND $command") - return false - } - - override fun getSupportedPrepareActions(): Long { - return PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID or - PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or - PlaybackStateCompat.ACTION_PREPARE_FROM_SEARCH or - PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH - } - - override fun onPrepare(playWhenReady: Boolean) { - Log.d(tag, "ON PREPARE $playWhenReady") - playFirstAudiobook(playWhenReady) - } - - override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) { - Log.d(tag, "ON PREPARE FROM MEDIA ID $mediaId $playWhenReady") - openFromMediaId(mediaId, playWhenReady) - } - - override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) { - Log.d(tag, "ON PREPARE FROM SEARCH $query") - openFromSearch(query, playWhenReady) - } - - override fun onPrepareFromUri(uri: Uri, playWhenReady: Boolean, extras: Bundle?) { - Log.d(tag, "ON PREPARE FROM URI $uri") - } - } - - mediaSessionConnector.setEnabledPlaybackActions( - PlaybackStateCompat.ACTION_PLAY_PAUSE - or PlaybackStateCompat.ACTION_PLAY - or PlaybackStateCompat.ACTION_PAUSE - or PlaybackStateCompat.ACTION_SEEK_TO - or PlaybackStateCompat.ACTION_FAST_FORWARD - or PlaybackStateCompat.ACTION_REWIND - or PlaybackStateCompat.ACTION_STOP - ) - mediaSessionConnector.setQueueNavigator(queueNavigator) - mediaSessionConnector.setPlaybackPreparer(myPlaybackPreparer) - mediaSessionConnector.setPlayer(mPlayer) - - //attach player to playerNotificationManager - playerNotificationManager.setPlayer(mPlayer) - - mediaSession.setCallback(object : MediaSessionCompat.Callback() { - override fun onPrepare() { - Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT") - playFirstAudiobook(true) - } - - override fun onPlay() { - Log.d(tag, "ON PLAY MEDIA SESSION COMPAT") - play() - } - - override fun onPrepareFromSearch(query: String?, extras: Bundle?) { - Log.d(tag, "ON PREPARE FROM SEARCH $query") - super.onPrepareFromSearch(query, extras) - } - - override fun onPlayFromSearch(query: String?, extras: Bundle?) { - Log.d(tag, "ON PLAY FROM SEARCH $query") - openFromSearch(query, true) - } - - override fun onPause() { - Log.d(tag, "ON PAUSE MEDIA SESSION COMPAT") - pause() - } - - override fun onStop() { - pause() - } - - override fun onSkipToPrevious() { - seekBackward(seekAmount) - } - - override fun onSkipToNext() { - seekForward(seekAmount) - } - - override fun onFastForward() { - seekForward(seekAmount) - } - - override fun onRewind() { - seekForward(seekAmount) - } - - override fun onSeekTo(pos: Long) { - seekPlayer(pos) - } - - override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { - Log.d(tag, "ON PLAY FROM MEDIA ID $mediaId") - if (mediaId.isNullOrEmpty()) { - playFirstAudiobook(true) - return - } - openFromMediaId(mediaId, true) - } - - override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean { - return handleCallMediaButton(mediaButtonEvent) - } - }) - } - - fun handleCallMediaButton(intent: Intent): Boolean { - if(Intent.ACTION_MEDIA_BUTTON == intent.getAction()) { - var keyEvent = intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT) - if (keyEvent?.getAction() == KeyEvent.ACTION_UP) { - when (keyEvent?.getKeyCode()) { - KeyEvent.KEYCODE_HEADSETHOOK -> { - if (0 == mediaButtonClickCount) { - if (mPlayer.isPlaying) - pause() - else - play() - } - handleMediaButtonClickCount() - } - KeyEvent.KEYCODE_MEDIA_PLAY -> { - if (0 == mediaButtonClickCount) { - play() - sleepTimerManager.checkShouldExtendSleepTimer() - } - handleMediaButtonClickCount() - } - KeyEvent.KEYCODE_MEDIA_PAUSE -> { - if (0 == mediaButtonClickCount) pause() - handleMediaButtonClickCount() - } - KeyEvent.KEYCODE_MEDIA_NEXT -> { - seekForward(seekAmount) - } - KeyEvent.KEYCODE_MEDIA_PREVIOUS -> { - seekBackward(seekAmount) - } - KeyEvent.KEYCODE_MEDIA_STOP -> { - terminateStream() - } - KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> { - if (mPlayer.isPlaying) { - if (0 == mediaButtonClickCount) pause() - handleMediaButtonClickCount() - } else { - if (0 == mediaButtonClickCount) { - play() - sleepTimerManager.checkShouldExtendSleepTimer() - } - handleMediaButtonClickCount() - } - } - else -> { - Log.d(tag, "KeyCode:${keyEvent?.getKeyCode()}") - return false - } - } - } - } - return true - } - - fun handleMediaButtonClickCount() { - mediaButtonClickCount++ - if (1 == mediaButtonClickCount) { - Timer().schedule(mediaButtonClickTimeout) { - mediaBtnHandler.sendEmptyMessage(mediaButtonClickCount) - mediaButtonClickCount = 0 - } - } - } - - private val mediaBtnHandler : Handler = @SuppressLint("HandlerLeak") - object : Handler(){ - override fun handleMessage(msg: Message) { - super.handleMessage(msg) - if (2 == msg.what) { - seekBackward(seekAmount) - play() - } - else if (msg.what >= 3) { - seekForward(seekAmount) - play() - } - } - } - - fun getPlayerListener(): Player.Listener { - return object : Player.Listener { - override fun onPlayerError(error: PlaybackException) { - error.message?.let { Log.e(tag, it) } - error.localizedMessage?.let { Log.e(tag, it) } - } - - override fun onEvents(player: Player, events: Player.Events) { - if (events.contains(Player.EVENT_POSITION_DISCONTINUITY)) { - Log.d(tag, "EVENT_POSITION_DISCONTINUITY") - } - - if (events.contains(Player.EVENT_IS_LOADING_CHANGED)) { - Log.d(tag, "EVENT_IS_LOADING_CHANGED : " + mPlayer.isLoading.toString()) - } - - if (events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED)) { - if (currentPlayer.playbackState == Player.STATE_READY) { - Log.d(tag, "STATE_READY : " + mPlayer.duration.toString()) - - currentAudiobookStreamData!!.hasPlayerLoaded = true - if (lastPauseTime == 0L) { - sendClientMetadata("ready_no_sync") - lastPauseTime = -1; - } else sendClientMetadata("ready") - } - if (currentPlayer.playbackState == Player.STATE_BUFFERING) { - Log.d(tag, "STATE_BUFFERING : " + mPlayer.currentPosition.toString()) - if (lastPauseTime == 0L) sendClientMetadata("buffering_no_sync") - else sendClientMetadata("buffering") - } - if (currentPlayer.playbackState == Player.STATE_ENDED) { - Log.d(tag, "STATE_ENDED") - sendClientMetadata("ended") - } - if (currentPlayer.playbackState == Player.STATE_IDLE) { - Log.d(tag, "STATE_IDLE") - sendClientMetadata("idle") - } - } - - if (events.contains(Player.EVENT_MEDIA_METADATA_CHANGED)) { - Log.d(tag, "EVENT_MEDIA_METADATA_CHANGED") - } - if (events.contains(Player.EVENT_PLAYLIST_METADATA_CHANGED)) { - Log.d(tag, "EVENT_PLAYLIST_METADATA_CHANGED") - } - if (events.contains(Player.EVENT_IS_PLAYING_CHANGED)) { - Log.d(tag, "EVENT IS PLAYING CHANGED") - - if (player.isPlaying) { - if (lastPauseTime > 0) { - if (onSeekBack) onSeekBack = false - else { - var backTime = calcPauseSeekBackTime() - if (backTime > 0) { - if (backTime >= mPlayer.currentPosition) backTime = mPlayer.currentPosition - 500 - Log.d(tag, "SeekBackTime $backTime") - onSeekBack = true - seekBackward(backTime) - } - } - } - } else lastPauseTime = System.currentTimeMillis() - - // If app is only running in android auto then webview will not be open - // so progress needs to be synced natively - Log.d(tag, "Playing ${getCurrentBookTitle()} | ${currentPlayer.mediaMetadata.title} | ${currentPlayer.mediaMetadata.displayTitle}") - if (player.isPlaying) { - audiobookProgressSyncer.start() - } else { - audiobookProgressSyncer.stop() - } - - listener?.onPlayingUpdate(player.isPlaying) - } - } - } - } - - - /* - User callable methods - */ - fun initPlayer(audiobookStreamData: AudiobookStreamData) { - currentAudiobookStreamData = audiobookStreamData - - Log.d(tag, "Init Player Audiobook ${currentAudiobookStreamData!!.playlistUrl} | ${currentAudiobookStreamData!!.title} | ${currentAudiobookStreamData!!.author}") - - if (mPlayer.isPlaying) { - Log.d(tag, "Init Player audiobook already playing") - } - - // Issue with onenote plus crashing when using local cover art. https://github.com/advplyr/audiobookshelf-app/issues/35 - // Same issue with sony xperia https://github.com/advplyr/audiobookshelf-app/issues/94 - if (currentAudiobookStreamData?.coverUri != null && currentAudiobookStreamData?.isLocal == true) { - var deviceName = Build.DEVICE - var deviceMan = Build.MANUFACTURER - var deviceModel = Build.MODEL - Log.d(tag, "Checking device $deviceName | Model $deviceModel | Manufacturer $deviceMan") - if (deviceMan.toLowerCase().contains("oneplus") || deviceName.toLowerCase().contains("oneplus")) { - Log.d(tag, "Detected OnePlus device - removing local cover") - currentAudiobookStreamData?.clearCover() - } else if (deviceName.toLowerCase().contains("xperia") || deviceModel.toLowerCase().contains("xperia")) { - Log.d(tag, "Detected Sony Xperia device - removing local cover") - currentAudiobookStreamData?.clearCover() - } - } - - var metadata = currentAudiobookStreamData!!.getMediaMetadataCompat() - mediaSession.setMetadata(metadata) - - var mediaUri:Uri = currentAudiobookStreamData!!.getMediaUri() - var mimeType:String = currentAudiobookStreamData!!.getMimeType() - - var mediaMetadata = currentAudiobookStreamData!!.getMediaMetadata() - var mediaItem = MediaItem.Builder().setUri(mediaUri).setMediaMetadata(mediaMetadata).setMimeType(mimeType).build() - - if (mPlayer == currentPlayer) { - var mediaSource:MediaSource - - if (currentAudiobookStreamData!!.isLocal) { - Log.d(tag, "Playing Local File") - var dataSourceFactory = DefaultDataSourceFactory(ctx, channelId) - mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem) - } else { - Log.d(tag, "Playing HLS File") - var dataSourceFactory = DefaultHttpDataSource.Factory() - dataSourceFactory.setUserAgent(channelId) - dataSourceFactory.setDefaultRequestProperties(hashMapOf("Authorization" to "Bearer ${currentAudiobookStreamData!!.token}")) - mediaSource = HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem) - } - mPlayer.setMediaSource(mediaSource, currentAudiobookStreamData!!.startTime) - } else if (castPlayer != null) { - var mediaQueue = currentAudiobookStreamData!!.getCastQueue() - // TODO: Start position will need to be adjusted if using multi-track queue - castPlayer?.setMediaItems(mediaQueue, 0, 0) - } - - currentPlayer.prepare() - currentPlayer.playWhenReady = currentAudiobookStreamData!!.playWhenReady - currentPlayer.setPlaybackSpeed(audiobookStreamData.playbackSpeed) - - lastPauseTime = 0 - } - - fun switchToPlayer(useCastPlayer: Boolean) { - currentPlayer = if (useCastPlayer) { - Log.d(tag, "switchToPlayer: Using Cast Player " + castPlayer?.deviceInfo) - mediaSessionConnector.setPlayer(castPlayer) - castPlayer as CastPlayer - } else { - Log.d(tag, "switchToPlayer: Using ExoPlayer") - mediaSessionConnector.setPlayer(mPlayer) - mPlayer - } - if (currentAudiobookStreamData != null) { - Log.d(tag, "switchToPlayer: Initing current ab stream data") - initPlayer(currentAudiobookStreamData!!) - } - } - - fun getCurrentTime() : Long { - return currentPlayer.currentPosition - } - - fun getBufferedTime() : Long { - return currentPlayer.bufferedPosition - } - - fun getTheLastPauseTime() : Long { - return lastPauseTime - } - - fun getDuration() : Long { - return currentPlayer.duration - } - - fun getCurrentBookTitle() : String? { - return currentAudiobookStreamData?.title - } - - fun getCurrentBookIsLocal() : Boolean { - return currentAudiobookStreamData?.isLocal == true - } - - fun getCurrentBookId() : String? { - return currentAudiobookStreamData?.audiobookId - } - - fun getCurrentStreamId() : String? { - return currentAudiobookStreamData?.id - } - - // The duration stored on the audiobook - fun getAudiobookDuration() : Long { - if (currentAudiobookStreamData == null) return 0L - return currentAudiobookStreamData!!.duration - } - - fun getServerUrl(): String { - return audiobookManager.serverUrl - } - - fun getUserToken() : String { - return audiobookManager.token - } - - fun calcPauseSeekBackTime() : Long { - if (lastPauseTime <= 0) return 0 - var time: Long = System.currentTimeMillis() - lastPauseTime - var seekback: Long = 0 - if (time < 60000) seekback = 0 - else if (time < 120000) seekback = 10000 - else if (time < 300000) seekback = 15000 - else if (time < 1800000) seekback = 20000 - else if (time < 3600000) seekback = 25000 - else seekback = 29500 - return seekback - } - - fun getPlayStatus() : Boolean { - return mPlayer.isPlaying - } - - fun getCurrentAudiobookId() : String { - return currentAudiobookStreamData?.id.toString() - } - - fun play() { - if (currentPlayer.isPlaying) { - Log.d(tag, "Already playing") - return - } - currentPlayer.volume = 1F - if (currentPlayer == castPlayer) { - Log.d(tag, "CAST Player set on play ${currentPlayer.isLoading} || ${currentPlayer.duration} | ${currentPlayer.currentPosition}") - } - currentPlayer.play() - } - - fun pause() { - currentPlayer.pause() - } - - fun seekPlayer(time: Long) { - currentPlayer.seekTo(time) - } - - fun seekForward(amount: Long) { - currentPlayer.seekTo(mPlayer.currentPosition + amount) - } - - fun seekBackward(amount: Long) { - currentPlayer.seekTo(mPlayer.currentPosition - amount) - } - - fun setPlaybackSpeed(speed: Float) { - currentPlayer.setPlaybackSpeed(speed) - } - - fun terminateStream() { - if (currentPlayer.playbackState == Player.STATE_READY) { - currentPlayer.clearMediaItems() - } - currentAudiobookStreamData?.id = "" - lastPauseTime = 0 - } - - fun sendClientMetadata(stateName: String) { - var metadata = JSObject() - var duration = mPlayer.duration - if (duration < 0) duration = 0 - metadata.put("duration", duration) - metadata.put("currentTime", mPlayer.currentPosition) - metadata.put("stateName", stateName) - listener?.onMetadata(metadata) - } - - - // - // MEDIA BROWSER STUFF (ANDROID AUTO) - // - private val ANDROID_AUTO_PKG_NAME = "com.google.android.projection.gearhead" - private val ANDROID_AUTO_SIMULATOR_PKG_NAME = "com.google.android.autosimulator" - private val ANDROID_WEARABLE_PKG_NAME = "com.google.android.wearable.app" - private val ANDROID_GSEARCH_PKG_NAME = "com.google.android.googlequicksearchbox" - private val ANDROID_AUTOMOTIVE_PKG_NAME = "com.google.android.carassistant" - private val VALID_MEDIA_BROWSERS = mutableListOf(ANDROID_AUTO_PKG_NAME, ANDROID_AUTO_SIMULATOR_PKG_NAME, ANDROID_WEARABLE_PKG_NAME, ANDROID_GSEARCH_PKG_NAME, ANDROID_AUTOMOTIVE_PKG_NAME) - - private val AUTO_MEDIA_ROOT = "/" - private val ALL_ROOT = "__ALL__" - private lateinit var browseTree:BrowseTree - - - // Only allowing android auto or similar to access media browser service - // normal loading of audiobooks is handled in webview (not natively) - private fun isValid(packageName: String, uid: Int) : Boolean { - Log.d(tag, "onGetRoot: Checking package $packageName with uid $uid") - if (!VALID_MEDIA_BROWSERS.contains(packageName)) { - Log.d(tag, "onGetRoot: package $packageName not valid for the media browser service") - return false - } - return true - } - - override fun onGetRoot(clientPackageName: String, clientUid: Int, rootHints: Bundle?): BrowserRoot? { - // Verify that the specified package is allowed to access your content - return if (!isValid(clientPackageName, clientUid)) { - // No further calls will be made to other media browsing methods. - null - } else { - // Flag is used to enable syncing progress natively (normally syncing is handled in webview) - isAndroidAuto = true - - val extras = Bundle() - extras.putBoolean( - MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true) - extras.putInt( - MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE, - MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM) - extras.putInt( - MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE, - MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM) - - BrowserRoot(AUTO_MEDIA_ROOT, extras) - } - } - - override fun onLoadChildren(parentMediaId: String, result: Result>) { - Log.d(tag, "ON LOAD CHILDREN $parentMediaId") - - var flag = if (parentMediaId == AUTO_MEDIA_ROOT) MediaBrowserCompat.MediaItem.FLAG_BROWSABLE else MediaBrowserCompat.MediaItem.FLAG_PLAYABLE - - if (!audiobookManager.hasLoaded) { - result.detach() - audiobookManager.load() - audiobookManager.loadAudiobooks() { - audiobookManager.isLoading = false - - Log.d(tag, "LOADED AUDIOBOOKS") - - var audiobooks:List = audiobookManager.getAudiobooksMediaMetadata() - var downloadedBooks:List = audiobookManager.getDownloadedAudiobooksMediaMetadata() - - browseTree = BrowseTree(this, audiobookManager.audiobooksInProgress, audiobooks, downloadedBooks) - val children = browseTree[parentMediaId]?.map { item -> - MediaBrowserCompat.MediaItem(item.description, flag) - } - result.sendResult(children as MutableList?) - } - return - } else if (audiobookManager.isLoading) { - Log.d(tag, "AUDIOBOOKS LOADING") - result.detach() - return - } - - val children = browseTree[parentMediaId]?.map { item -> - MediaBrowserCompat.MediaItem(item.description, flag) - } - if (children != null) { - Log.d(tag, "BROWSE TREE $parentMediaId CHILDREN ${children.size}") - } - result.sendResult(children as MutableList?) - - // TODO: For using sub menus. Check if this is the root menu: - if (AUTO_MEDIA_ROOT == parentMediaId) { - // build the MediaItem objects for the top level, - // and put them in the mediaItems list - } else { - // examine the passed parentMediaId to see which submenu we're at, - // and put the children of that menu in the mediaItems list - } - } - - override fun onSearch(query: String, extras: Bundle?, result: Result>) { - val mediaItems: MutableList = mutableListOf() - - if (!audiobookManager.hasLoaded) { - result.detach() - audiobookManager.load() - audiobookManager.loadAudiobooks() { - audiobookManager.isLoading = false - - Log.d(tag, "LOADED AUDIOBOOKS") - var audiobooks:List = audiobookManager.getAudiobooksMediaMetadata() - var downloadedBooks:List = audiobookManager.getDownloadedAudiobooksMediaMetadata() - - browseTree = BrowseTree(this, audiobookManager.audiobooksInProgress, audiobooks, downloadedBooks) - val children = browseTree[ALL_ROOT]?.map { item -> - MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) - } - if (children != null) { - Log.d(tag, "BROWSE TREE CHILDREN ${children.size}") - } - result.sendResult(children as MutableList?) - } - return - } else if (audiobookManager.isLoading) { - Log.d(tag, "AUDIOBOOKS LOADING") - result.detach() - return - } - - if (audiobookManager.audiobooks.size == 0) { - Log.d(tag, "AudiobookManager: Sending no items") - result.sendResult(mediaItems) - return - } - - val children = browseTree[ALL_ROOT]?.map { item -> - MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) - } - if (children != null) { - Log.d(tag, "NO CHILDREN ON SEARCH ${children.size}") - } - result.sendResult(children as MutableList?) - } - - - // - // SHAKE SENSOR - // - private fun initSensor() { - // ShakeDetector initialization - mSensorManager = getSystemService(SENSOR_SERVICE) as SensorManager - mAccelerometer = mSensorManager!!.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) - - mShakeDetector = ShakeDetector() - mShakeDetector!!.setOnShakeListener(object : ShakeDetector.OnShakeListener { - override fun onShake(count: Int) { - Log.d(tag, "PHONE SHAKE! $count") - sleepTimerManager.handleShake() - } - }) - } - - // Shake sensor used for sleep timer - fun registerSensor() { - if (isShakeSensorRegistered) { - Log.w(tag, "Shake sensor already registered") - return - } - shakeSensorUnregisterTask?.cancel() - - Log.d(tag, "Registering shake SENSOR ${mAccelerometer?.isWakeUpSensor}") - var success = mSensorManager!!.registerListener( - mShakeDetector, - mAccelerometer, - SensorManager.SENSOR_DELAY_UI - ) - if (success) isShakeSensorRegistered = true - } - - fun unregisterSensor() { - if (!isShakeSensorRegistered) return - - // Unregister shake sensor after wake up expiration - shakeSensorUnregisterTask?.cancel() - shakeSensorUnregisterTask = Timer("ShakeUnregisterTimer", false).schedule(SLEEP_TIMER_WAKE_UP_EXPIRATION) { - Handler(Looper.getMainLooper()).post() { - Log.d(tag, "wake time expired: Unregistering shake sensor") - mSensorManager!!.unregisterListener(mShakeDetector) - isShakeSensorRegistered = false - } - } - } -} - diff --git a/android/app/src/main/java/com/audiobookshelf/app/StorageManager.kt b/android/app/src/main/java/com/audiobookshelf/app/StorageManager.kt deleted file mode 100644 index 312065e3..00000000 --- a/android/app/src/main/java/com/audiobookshelf/app/StorageManager.kt +++ /dev/null @@ -1,264 +0,0 @@ -package com.audiobookshelf.app - -import android.database.Cursor -import android.net.Uri -import android.os.Build -import android.util.Log -import androidx.annotation.RequiresApi -import androidx.documentfile.provider.DocumentFile - -import com.anggrayudi.storage.SimpleStorage -import com.anggrayudi.storage.callback.FolderPickerCallback -import com.anggrayudi.storage.callback.StorageAccessCallback -import com.anggrayudi.storage.file.* -import com.getcapacitor.* -import com.getcapacitor.annotation.CapacitorPlugin - -@CapacitorPlugin(name = "StorageManager") -class StorageManager : Plugin() { - private val TAG = "StorageManager" - - lateinit var mainActivity:MainActivity - - data class MediaFile(val uri: Uri, val name: String, val simplePath: String, val size: Long, val type: String, val isAudio: Boolean) { - fun toJSObject() : JSObject { - var obj = JSObject() - obj.put("uri", this.uri) - obj.put("name", this.name) - obj.put("simplePath", this.simplePath) - obj.put("size", this.size) - obj.put("type", this.type) - obj.put("isAudio", this.isAudio) - return obj - } - } - - data class MediaFolder(val uri: Uri, val name: String, val simplePath: String, val mediaFiles:List) { - fun toJSObject() : JSObject { - var obj = JSObject() - obj.put("uri", this.uri) - obj.put("name", this.name) - obj.put("simplePath", this.simplePath) - obj.put("files", this.mediaFiles.map { it.toJSObject() }) - return obj - } - } - - override fun load() { - mainActivity = (activity as MainActivity) - - mainActivity.storage.storageAccessCallback = object : StorageAccessCallback { - override fun onRootPathNotSelected( - requestCode: Int, - rootPath: String, - uri: Uri, - selectedStorageType: StorageType, - expectedStorageType: StorageType - ) { - Log.d(TAG, "STORAGE ACCESS CALLBACK") - } - - override fun onCanceledByUser(requestCode: Int) { - Log.d(TAG, "STORAGE ACCESS CALLBACK") - } - - override fun onExpectedStorageNotSelected(requestCode: Int, selectedFolder: DocumentFile, selectedStorageType: StorageType, expectedBasePath: String, expectedStorageType: StorageType) { - Log.d(TAG, "STORAGE ACCESS CALLBACK") - } - - override fun onStoragePermissionDenied(requestCode: Int) { - Log.d(TAG, "STORAGE ACCESS CALLBACK") - } - - override fun onRootPathPermissionGranted(requestCode: Int, root: DocumentFile) { - Log.d(TAG, "STORAGE ACCESS CALLBACK") - } - } - } - - @PluginMethod - fun selectFolder(call: PluginCall) { - mainActivity.storage.folderPickerCallback = object : FolderPickerCallback { - override fun onFolderSelected(requestCode: Int, folder: DocumentFile) { - Log.d(TAG, "ON FOLDER SELECTED ${folder.uri} ${folder.name}") - - var absolutePath = folder.getAbsolutePath(activity) - var storageId = folder.getStorageId(activity) - var storageType = folder.getStorageType(activity) - var simplePath = folder.getSimplePath(activity) - var basePath = folder.getBasePath(activity) - - var jsobj = JSObject() - jsobj.put("uri", folder.uri) - jsobj.put("absolutePath", absolutePath) - jsobj.put("storageId", storageId) - jsobj.put("storageType", storageType) - jsobj.put("simplePath", simplePath) - jsobj.put("basePath", basePath) - call.resolve(jsobj) - } - - override fun onStorageAccessDenied(requestCode: Int, folder: DocumentFile?, storageType: StorageType) { - Log.e(TAG, "STORAGE ACCESS DENIED") - var jsobj = JSObject() - jsobj.put("error", "Access Denied") - call.resolve(jsobj) - } - - override fun onStoragePermissionDenied(requestCode: Int) { - Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode") - var jsobj = JSObject() - jsobj.put("error", "Permission Denied") - call.resolve(jsobj) - } - } - mainActivity.storage.openFolderPicker(6) - } - - @RequiresApi(Build.VERSION_CODES.R) - @PluginMethod - fun requestStoragePermission(call: PluginCall) { - Log.d(TAG, "Request Storage Permissions") - mainActivity.storageHelper.requestStorageAccess() - call.resolve() - } - - @PluginMethod - fun checkStoragePermission(call: PluginCall) { - var res = false - - if (Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.P) { - res = SimpleStorage.hasStoragePermission(context) - Log.d(TAG, "Check Storage Access $res") - } else { - Log.d(TAG, "Has permission on Android 10 or up") - res = true - } - - var jsobj = JSObject() - jsobj.put("value", res) - call.resolve(jsobj) - } - - @PluginMethod - fun checkFolderPermissions(call: PluginCall) { - var folderUrl = call.data.getString("folderUrl", "").toString() - Log.d(TAG, "Check Folder Permissions for $folderUrl") - - var hasAccess = SimpleStorage.hasStorageAccess(context,folderUrl,true) - - var jsobj = JSObject() - jsobj.put("value", hasAccess) - call.resolve(jsobj) - } - - @PluginMethod - fun searchFolder(call: PluginCall) { - var folderUrl = call.data.getString("folderUrl", "").toString() - Log.d(TAG, "Searching folder $folderUrl") - - var df: DocumentFile? = DocumentFileCompat.fromUri(context, Uri.parse(folderUrl)) - - if (df == null) { - Log.e(TAG, "Folder Doc File Invalid $folderUrl") - var jsobj = JSObject() - jsobj.put("folders", JSArray()) - jsobj.put("files", JSArray()) - call.resolve(jsobj) - return - } - - Log.d(TAG, "Folder as DF ${df.isDirectory} | ${df.getSimplePath(context)} | ${df.getBasePath(context)} | ${df.name}") - - var mediaFolders = mutableListOf() - var foldersFound = df.search(false, DocumentFileType.FOLDER) - - foldersFound.forEach { - Log.d(TAG, "Iterating over Folder Found ${it.name} | ${it.getSimplePath(context)} | URI: ${it.uri}") - var folderName = it.name ?: "" - var mediaFiles = mutableListOf() - - var filesInFolder = it.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*")) - filesInFolder.forEach { it2 -> - var mimeType = it2?.mimeType ?: "" - var filename = it2?.name ?: "" - var isAudio = mimeType.startsWith("audio") - Log.d(TAG, "Found $mimeType file $filename in folder $folderName") - var imageFile = MediaFile(it2.uri, filename, it2.getSimplePath(context), it2.length(), mimeType, isAudio) - mediaFiles.add(imageFile) - } - if (mediaFiles.size > 0) { - mediaFolders.add(MediaFolder(it.uri, folderName, it.getSimplePath(context), mediaFiles)) - } - } - - // Files in root dir - var rootMediaFiles = mutableListOf() - var mediaFilesFound:List = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*")) - mediaFilesFound.forEach { - Log.d(TAG, "Folder Root File Found ${it.name} | ${it.getSimplePath(context)} | URI: ${it.uri} | ${it.mimeType}") - var mimeType = it?.mimeType ?: "" - var filename = it?.name ?: "" - var isAudio = mimeType.startsWith("audio") - Log.d(TAG, "Found $mimeType file $filename in root folder") - var imageFile = MediaFile(it.uri, filename, it.getSimplePath(context), it.length(), mimeType, isAudio) - rootMediaFiles.add(imageFile) - } - - var jsobj = JSObject() - jsobj.put("folders", mediaFolders.map{ it.toJSObject() }) - jsobj.put("files", rootMediaFiles.map{ it.toJSObject() }) - call.resolve(jsobj) - } - - - @PluginMethod - fun delete(call: PluginCall) { - var url = call.data.getString("url", "").toString() - var coverUrl = call.data.getString("coverUrl", "").toString() - var folderUrl = call.data.getString("folderUrl", "").toString() - - if (folderUrl != "") { - Log.d(TAG, "CALLED DELETE FOLDER: $folderUrl") - var folder = DocumentFileCompat.fromUri(context, Uri.parse(folderUrl)) - var success = folder?.deleteRecursively(context) - var jsobj = JSObject() - jsobj.put("success", success) - call.resolve() - } else { - // Older audiobooks did not store a folder url, use cover and audiobook url - var abExists = checkUriExists(Uri.parse(url)) - if (abExists) { - var abfile = DocumentFileCompat.fromUri(context, Uri.parse(url)) - abfile?.delete() - } - - var coverExists = checkUriExists(Uri.parse(coverUrl)) - if (coverExists) { - var coverfile = DocumentFileCompat.fromUri(context, Uri.parse(coverUrl)) - coverfile?.delete() - } - } - } - - - fun checkUriExists(uri: Uri?): Boolean { - if (uri == null) return false - val resolver = context.contentResolver - var cursor: Cursor? = null - return try { - cursor = resolver.query(uri, null, null, null, null) - //cursor null: content Uri was invalid or some other error occurred - //cursor.moveToFirst() false: Uri was ok but no entry found. - (cursor != null && cursor.moveToFirst()) - } catch (t: Throwable) { - false - } finally { - try { - cursor?.close() - } catch (t: Throwable) { - } - false - } - } -} diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/AudioProbeResult.kt b/android/app/src/main/java/com/audiobookshelf/app/data/AudioProbeResult.kt new file mode 100644 index 00000000..5187a349 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/AudioProbeResult.kt @@ -0,0 +1,74 @@ +package com.audiobookshelf.app.data + +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +@JsonIgnoreProperties(ignoreUnknown = true) +data class AudioProbeStream( + val index:Int, + val codec_name:String, + val codec_long_name:String, + val channels:Int, + val channel_layout:String, + val duration:Double, + val bit_rate:Double +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class AudioProbeChapterTags( + val title:String +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class AudioProbeChapter( + val id:Int, + val start:Int, + val end:Int, + val tags:AudioProbeChapterTags? +) { + @JsonIgnore + fun getBookChapter():BookChapter { + var startS = start / 1000.0 + var endS = end / 1000.0 + var title = tags?.title ?: "Chapter $id" + return BookChapter(id, startS, endS, title) + } +} + +@JsonIgnoreProperties(ignoreUnknown = true) +data class AudioProbeFormatTags( + val artist:String?, + val album:String?, + val comment:String?, + val date:String?, + val genre:String?, + val title:String? +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class AudioProbeFormat( + val filename:String, + val format_name:String, + val duration:Double, + val size:Long, + val bit_rate:Double, + val tags:AudioProbeFormatTags +) + +@JsonIgnoreProperties(ignoreUnknown = true) +class AudioProbeResult ( + val streams:MutableList, + val chapters:MutableList, + val format:AudioProbeFormat) { + + val duration get() = format.duration + val size get() = format.size + val title get() = format.tags.title ?: format.filename.split("/").last() + val artist get() = format.tags.artist ?: "" + + @JsonIgnore + fun getBookChapters(): List { + if (chapters.isEmpty()) return mutableListOf() + return chapters.map { it.getBookChapter() } + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt b/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt new file mode 100644 index 00000000..f45bcf53 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt @@ -0,0 +1,339 @@ +package com.audiobookshelf.app.data + +import android.net.Uri +import android.support.v4.media.MediaMetadataCompat +import com.audiobookshelf.app.R +import com.audiobookshelf.app.device.DeviceManager +import com.fasterxml.jackson.annotation.* + +@JsonIgnoreProperties(ignoreUnknown = true) +data class LibraryItem( + var id:String, + var ino:String, + var libraryId:String, + var folderId:String, + var path:String, + var relPath:String, + var mtimeMs:Long, + var ctimeMs:Long, + var birthtimeMs:Long, + var addedAt:Long, + var updatedAt:Long, + var lastScan:Long?, + var scanVersion:String?, + var isMissing:Boolean, + var isInvalid:Boolean, + var mediaType:String, + var media:MediaType, + var libraryFiles:MutableList? +) { + @get:JsonIgnore + val title get() = media.metadata.title + @get:JsonIgnore + val authorName get() = media.metadata.getAuthorDisplayName() + + @JsonIgnore + fun getCoverUri():Uri { + if (media.coverPath == null) { + return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon) + } + + return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}") + } + + @JsonIgnore + fun getMediaMetadata(): MediaMetadataCompat { + return MediaMetadataCompat.Builder().apply { + putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id) + putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title) + putString(MediaMetadataCompat.METADATA_KEY_TITLE, title) + putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, authorName) + putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, getCoverUri().toString()) + putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getCoverUri().toString()) + putString(MediaMetadataCompat.METADATA_KEY_ART_URI, getCoverUri().toString()) + putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, authorName) + }.build() + } +} + +// This auto-detects whether it is a Book or Podcast +@JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION) +@JsonSubTypes( + JsonSubTypes.Type(Book::class), + JsonSubTypes.Type(Podcast::class) +) +open class MediaType(var metadata:MediaTypeMetadata, var coverPath:String?) { + @JsonIgnore + open fun getAudioTracks():List { return mutableListOf() } + @JsonIgnore + open fun setAudioTracks(audioTracks:MutableList) { } + @JsonIgnore + open fun addAudioTrack(audioTrack:AudioTrack) { } + @JsonIgnore + open fun removeAudioTrack(localFileId:String) { } +} + +@JsonIgnoreProperties(ignoreUnknown = true) +class Podcast( + metadata:PodcastMetadata, + coverPath:String?, + var tags:MutableList, + var episodes:MutableList?, + var autoDownloadEpisodes:Boolean +) : MediaType(metadata, coverPath) { + @JsonIgnore + override fun getAudioTracks():List { + var tracks = episodes?.map { it.audioTrack } + return tracks?.filterNotNull() ?: mutableListOf() + } + @JsonIgnore + override fun setAudioTracks(audioTracks:MutableList) { + // Remove episodes no longer there in tracks + episodes = episodes?.filter { ep -> + audioTracks.find { it.localFileId == ep.audioTrack?.localFileId } != null + } as MutableList + // Add new episodes + audioTracks.forEach { at -> + if (episodes?.find{ it.audioTrack?.localFileId == at.localFileId } == null) { + var newEpisode = PodcastEpisode("local_" + at.localFileId,episodes?.size ?: 0 + 1,null,null,at.title,null,null,null,at) + episodes?.add(newEpisode) + } + } + + var index = 1 + episodes?.forEach { + it.index = index + index++ + } + } + @JsonIgnore + override fun addAudioTrack(audioTrack:AudioTrack) { + var newEpisode = PodcastEpisode("local_" + audioTrack.localFileId,episodes?.size ?: 0 + 1,null,null,audioTrack.title,null,null,null,audioTrack) + episodes?.add(newEpisode) + + var index = 1 + episodes?.forEach { + it.index = index + index++ + } + } + @JsonIgnore + override fun removeAudioTrack(localFileId:String) { + episodes?.removeIf { it.audioTrack?.localFileId == localFileId } + + var index = 1 + episodes?.forEach { + it.index = index + index++ + } + } + @JsonIgnore + fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode) { + var newEpisode = PodcastEpisode("local_" + episode.id,episodes?.size ?: 0 + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,audioTrack) + episodes?.add(newEpisode) + + var index = 1 + episodes?.forEach { + it.index = index + index++ + } + } +} + +@JsonIgnoreProperties(ignoreUnknown = true) +class Book( + metadata:BookMetadata, + coverPath:String?, + var tags:List, + var audioFiles:List?, + var chapters:List?, + var tracks:MutableList?, + var size:Long?, + var duration:Double? +) : MediaType(metadata, coverPath) { + @JsonIgnore + override fun getAudioTracks():List { + return tracks ?: mutableListOf() + } + @JsonIgnore + override fun setAudioTracks(audioTracks:MutableList) { + tracks = audioTracks + + // TODO: Is it necessary to calculate this each time? check if can remove safely + var totalDuration = 0.0 + tracks?.forEach { + totalDuration += it.duration + } + duration = totalDuration + } + @JsonIgnore + override fun addAudioTrack(audioTrack:AudioTrack) { + tracks?.add(audioTrack) + + var totalDuration = 0.0 + tracks?.forEach { + totalDuration += it.duration + } + duration = totalDuration + } + @JsonIgnore + override fun removeAudioTrack(localFileId:String) { + tracks?.removeIf { it.localFileId == localFileId } + + tracks?.sortBy { it.index } + + var index = 1 + var startOffset = 0.0 + var totalDuration = 0.0 + tracks?.forEach { + it.index = index + it.startOffset = startOffset + totalDuration += it.duration + + index++ + startOffset += it.duration + } + duration = totalDuration + } +} + +// This auto-detects whether it is a BookMetadata or PodcastMetadata +@JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION) +@JsonSubTypes( + JsonSubTypes.Type(BookMetadata::class), + JsonSubTypes.Type(PodcastMetadata::class) +) +open class MediaTypeMetadata(var title:String) { + @JsonIgnore + open fun getAuthorDisplayName():String { return "Unknown" } +} + +@JsonIgnoreProperties(ignoreUnknown = true) +class BookMetadata( + title:String, + var subtitle:String?, + var authors:MutableList?, + var narrators:MutableList?, + var genres:MutableList, + var publishedYear:String?, + var publishedDate:String?, + var publisher:String?, + var description:String?, + var isbn:String?, + var asin:String?, + var language:String?, + var explicit:Boolean, + // In toJSONExpanded + var authorName:String?, + var authorNameLF:String?, + var narratorName:String?, + var seriesName:String? +) : MediaTypeMetadata(title) { + @JsonIgnore + override fun getAuthorDisplayName():String { return authorName ?: "Unknown" } +} + +@JsonIgnoreProperties(ignoreUnknown = true) +class PodcastMetadata( + title:String, + var author:String?, + var feedUrl:String?, + var genres:MutableList +) : MediaTypeMetadata(title) { + @JsonIgnore + override fun getAuthorDisplayName():String { return author ?: "Unknown" } +} + +@JsonIgnoreProperties(ignoreUnknown = true) +data class Author( + var id:String, + var name:String, + var coverPath:String? +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class PodcastEpisode( + var id:String, + var index:Int, + var episode:String?, + var episodeType:String?, + var title:String?, + var subtitle:String?, + var description:String?, + var audioFile:AudioFile?, + var audioTrack:AudioTrack? +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class LibraryFile( + var ino:String, + var metadata:FileMetadata +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class FileMetadata( + var filename:String, + var ext:String, + var path:String, + var relPath:String +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class AudioFile( + var index:Int, + var ino:String, + var metadata:FileMetadata +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class Library( + var id:String, + var name:String, + var folders:MutableList, + var icon:String, + var mediaType:String +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class Folder( + var id:String, + var fullPath:String +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class AudioTrack( + var index:Int, + var startOffset:Double, + var duration:Double, + var title:String, + var contentUrl:String, + var mimeType:String, + var metadata:FileMetadata?, + var isLocal:Boolean, + var localFileId:String?, + var audioProbeResult:AudioProbeResult?, + var serverIndex:Int? // Need to know if server track index is different +) { + + @get:JsonIgnore + val startOffsetMs get() = (startOffset * 1000L).toLong() + @get:JsonIgnore + val durationMs get() = (duration * 1000L).toLong() + @get:JsonIgnore + val endOffsetMs get() = startOffsetMs + durationMs + @get:JsonIgnore + val relPath get() = metadata?.relPath ?: "" + + @JsonIgnore + fun getBookChapter():BookChapter { + return BookChapter(index + 1,startOffset, startOffset + duration, title) + } +} + +@JsonIgnoreProperties(ignoreUnknown = true) +data class BookChapter( + var id:Int, + var start:Double, + var end:Double, + var title:String? +) diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/DbManager.kt b/android/app/src/main/java/com/audiobookshelf/app/data/DbManager.kt index 71a87c5a..cc23d8e3 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/DbManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/DbManager.kt @@ -1,18 +1,142 @@ package com.audiobookshelf.app.data import android.util.Log -import com.getcapacitor.JSObject -import com.getcapacitor.Plugin -import com.getcapacitor.PluginCall -import com.getcapacitor.PluginMethod -import com.getcapacitor.annotation.CapacitorPlugin +import com.audiobookshelf.app.plugins.AbsDownloader import io.paperdb.Paper import org.json.JSONObject -@CapacitorPlugin(name = "DbManager") -class DbManager : Plugin() { +class DbManager { val tag = "DbManager" + fun getDeviceData(): DeviceData { + return Paper.book("device").read("data") ?: DeviceData(mutableListOf(), null, null) + } + fun saveDeviceData(deviceData:DeviceData) { + Paper.book("device").write("data", deviceData) + } + + fun getLocalLibraryItems(mediaType:String? = null):MutableList { + var localLibraryItems:MutableList = mutableListOf() + Paper.book("localLibraryItems").allKeys.forEach { + var localLibraryItem:LocalLibraryItem? = Paper.book("localLibraryItems").read(it) + if (localLibraryItem != null && (mediaType.isNullOrEmpty() || mediaType == localLibraryItem?.mediaType)) { + // TODO: Check to make sure all file paths exist +// if (localMediaItem.coverContentUrl != null) { +// var file = DocumentFile.fromSingleUri(ctx) +// if (!file.exists()) { +// Log.e(tag, "Local media item cover url does not exist ${localMediaItem.coverContentUrl}") +// removeLocalMediaItem(localMediaItem.id) +// } else { +// localMediaItems.add(localMediaItem) +// } +// } else { + localLibraryItems.add(localLibraryItem) +// } + } + } + return localLibraryItems + } + + fun getLocalLibraryItemsInFolder(folderId:String):List { + var localLibraryItems = getLocalLibraryItems() + return localLibraryItems.filter { + it.folderId == folderId + } + } + + fun getLocalLibraryItemByLLId(libraryItemId:String):LocalLibraryItem? { + return getLocalLibraryItems().find { it.libraryItemId == libraryItemId } + } + + fun getLocalLibraryItem(localLibraryItemId:String):LocalLibraryItem? { + return Paper.book("localLibraryItems").read(localLibraryItemId) + } + + fun removeLocalLibraryItem(localLibraryItemId:String) { + Paper.book("localLibraryItems").delete(localLibraryItemId) + } + + fun saveLocalLibraryItems(localLibraryItems:List) { + localLibraryItems.map { + Paper.book("localLibraryItems").write(it.id, it) + } + } + + fun saveLocalLibraryItem(localLibraryItem:LocalLibraryItem) { + Paper.book("localLibraryItems").write(localLibraryItem.id, localLibraryItem) + } + + fun saveLocalFolder(localFolder:LocalFolder) { + Paper.book("localFolders").write(localFolder.id,localFolder) + } + + fun getLocalFolder(folderId:String):LocalFolder? { + return Paper.book("localFolders").read(folderId) + } + + fun getAllLocalFolders():List { + var localFolders:MutableList = mutableListOf() + Paper.book("localFolders").allKeys.forEach { localFolderId -> + Paper.book("localFolders").read(localFolderId)?.let { + localFolders.add(it) + } + } + return localFolders + } + + fun removeLocalFolder(folderId:String) { + var localLibraryItems = getLocalLibraryItemsInFolder(folderId) + localLibraryItems.forEach { + Paper.book("localLibraryItems").delete(it.id) + } + Paper.book("localFolders").delete(folderId) + } + + fun saveDownloadItem(downloadItem: AbsDownloader.DownloadItem) { + Paper.book("downloadItems").write(downloadItem.id, downloadItem) + } + + fun removeDownloadItem(downloadItemId:String) { + Paper.book("downloadItems").delete(downloadItemId) + } + + fun getDownloadItems():List { + var downloadItems:MutableList = mutableListOf() + Paper.book("downloadItems").allKeys.forEach { downloadItemId -> + Paper.book("downloadItems").read(downloadItemId)?.let { + downloadItems.add(it) + } + } + return downloadItems + } + + fun saveLocalMediaProgress(mediaProgress:LocalMediaProgress) { + Paper.book("localMediaProgress").write(mediaProgress.id,mediaProgress) + } + // For books this will just be the localLibraryItemId for podcast episodes this will be "{localLibraryItemId}-{episodeId}" + fun getLocalMediaProgress(localMediaProgressId:String):LocalMediaProgress? { + return Paper.book("localMediaProgress").read(localMediaProgressId) + } + fun getAllLocalMediaProgress():List { + var mediaProgress:MutableList = mutableListOf() + Paper.book("localMediaProgress").allKeys.forEach { localMediaProgressId -> + Paper.book("localMediaProgress").read(localMediaProgressId)?.let { + mediaProgress.add(it) + } + } + return mediaProgress + } + fun removeLocalMediaProgress(localMediaProgressId:String) { + Paper.book("localMediaProgress").delete(localMediaProgressId) + } + + fun saveLocalPlaybackSession(playbackSession:PlaybackSession) { + Paper.book("localPlaybackSession").write(playbackSession.id,playbackSession) + } + fun getLocalPlaybackSession(playbackSessionId:String):PlaybackSession? { + return Paper.book("localPlaybackSession").read(playbackSessionId) + } + fun saveObject(db:String, key:String, value:JSONObject) { Log.d(tag, "Saving Object $key ${value.toString()}") Paper.book(db).write(key, value) @@ -23,32 +147,4 @@ class DbManager : Plugin() { Log.d(tag, "Loaded Object $key $json") return json } - - @PluginMethod - fun saveFromWebview(call: PluginCall) { - var db = call.getString("db", "").toString() - var key = call.getString("key", "").toString() - var value = call.getObject("value") - if (db == "" || key == "" || value == null) { - Log.d(tag, "saveFromWebview Invalid key/value") - } else { - var json = value as JSONObject - saveObject(db, key, json) - } - call.resolve() - } - - @PluginMethod - fun loadFromWebview(call:PluginCall) { - var db = call.getString("db", "").toString() - var key = call.getString("key", "").toString() - if (db == "" || key == "") { - Log.d(tag, "loadFromWebview Invalid Key") - call.resolve() - return - } - var json = loadObject(db, key) - var jsobj = JSObject.fromJSONObject(json) - call.resolve(jsobj) - } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt b/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt new file mode 100644 index 00000000..6de16cd1 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt @@ -0,0 +1,50 @@ +package com.audiobookshelf.app.data + +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import java.util.* + +data class ServerConnectionConfig( + var id:String, + var index:Int, + var name:String, + var address:String, + var userId:String, + var username:String, + var token:String +) + +data class DeviceData( + var serverConnectionConfigs:MutableList, + var lastServerConnectionConfigId:String?, + var currentLocalPlaybackSession:PlaybackSession? // Stored to open up where left off for local media +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class LocalFile( + var id:String, + var filename:String?, + var contentUrl:String, + var basePath:String, + var absolutePath:String, + var simplePath:String, + var mimeType:String?, + var size:Long +) { + @JsonIgnore + fun isAudioFile():Boolean { + return mimeType?.startsWith("audio") == true + } +} + +@JsonIgnoreProperties(ignoreUnknown = true) +data class LocalFolder( + var id:String, + var name:String, + var contentUrl:String, + var basePath:String, + var absolutePath:String, + var simplePath:String, + var storageType:String, + var mediaType:String +) diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/FolderScanResult.kt b/android/app/src/main/java/com/audiobookshelf/app/data/FolderScanResult.kt new file mode 100644 index 00000000..f4f706bf --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/FolderScanResult.kt @@ -0,0 +1,15 @@ +package com.audiobookshelf.app.data + +data class FolderScanResult( + var itemsAdded:Int, + var itemsUpdated:Int, + var itemsRemoved:Int, + var itemsUpToDate:Int, + val localFolder:LocalFolder, + val localLibraryItems:List, +) + +data class LocalLibraryItemScanResult( + val updated:Boolean, + val localLibraryItem:LocalLibraryItem, +) diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt new file mode 100644 index 00000000..01b8bcc7 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt @@ -0,0 +1,78 @@ +package com.audiobookshelf.app.data + +import com.audiobookshelf.app.device.DeviceManager +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import java.util.* + +@JsonIgnoreProperties(ignoreUnknown = true) +data class LocalLibraryItem( + var id:String, + var folderId:String, + var basePath:String, + var absolutePath:String, + var contentUrl:String, + var isInvalid:Boolean, + var mediaType:String, + var media:MediaType, + var localFiles:MutableList, + var coverContentUrl:String?, + var coverAbsolutePath:String?, + var isLocal:Boolean, + // If local library item is linked to a server item + var serverConnectionConfigId:String?, + var serverAddress:String?, + var serverUserId:String?, + var libraryItemId:String? + ) { + + @JsonIgnore + fun getDuration():Double { + var total = 0.0 + var audioTracks = media.getAudioTracks() + audioTracks.forEach{ total += it.duration } + return total + } + + @JsonIgnore + fun updateFromScan(audioTracks:MutableList, _localFiles:MutableList) { + media.setAudioTracks(audioTracks) + localFiles = _localFiles + + if (coverContentUrl != null) { + if (localFiles.find { it.contentUrl == coverContentUrl } == null) { + // Cover was removed + coverContentUrl = null + coverAbsolutePath = null + media.coverPath = null + } + } + } + + @JsonIgnore + fun getPlaybackSession(episodeId:String):PlaybackSession { + var sessionId = "play-${UUID.randomUUID()}" + + val mediaProgressId = if (episodeId.isNullOrEmpty()) id else "$id-$episodeId" + var mediaProgress = DeviceManager.dbManager.getLocalMediaProgress(mediaProgressId) + var currentTime = mediaProgress?.currentTime ?: 0.0 + + // TODO: Clean up add mediaType methods for displayTitle and displayAuthor + var mediaMetadata = media.metadata + var chapters = if (mediaType == "book") (media as Book).chapters else mutableListOf() + var authorName = "Unknown" + if (mediaType == "book") { + var bookMetadata = mediaMetadata as BookMetadata + authorName = bookMetadata?.authorName ?: "Unknown" + } + + var episodeIdNullable = if (episodeId.isNullOrEmpty()) null else episodeId + var dateNow = System.currentTimeMillis() + return PlaybackSession(sessionId,serverUserId,libraryItemId,episodeIdNullable, mediaType, mediaMetadata, chapters ?: mutableListOf(), mediaMetadata.title, authorName,null,getDuration(),PLAYMETHOD_LOCAL,dateNow,0L,0L, media.getAudioTracks() as MutableList,currentTime,null,this,serverConnectionConfigId, serverAddress) + } + + @JsonIgnore + fun removeLocalFile(localFileId:String) { + localFiles.removeIf { it.id == localFileId } + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaItem.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaItem.kt new file mode 100644 index 00000000..2a14c847 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaItem.kt @@ -0,0 +1,71 @@ +package com.audiobookshelf.app.data + +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +/* + Used as a helper class to generate LocalLibraryItem from scan results + */ + +@JsonIgnoreProperties(ignoreUnknown = true) +data class LocalMediaItem( + var id:String, + var name: String, + var mediaType:String, + var folderId:String, + var contentUrl:String, + var simplePath: String, + var basePath:String, + var absolutePath:String, + var audioTracks:MutableList, + var localFiles:MutableList, + var coverContentUrl:String?, + var coverAbsolutePath:String? +) { + + @JsonIgnore + fun getDuration():Double { + var total = 0.0 + audioTracks.forEach{ total += it.duration } + return total + } + + @JsonIgnore + fun getTotalSize():Long { + var total = 0L + localFiles.forEach { total += it.size } + return total + } + + @JsonIgnore + fun getMediaMetadata():MediaTypeMetadata { + return if (mediaType == "book") { + BookMetadata(name,null, mutableListOf(), mutableListOf(), mutableListOf(),null,null,null,null,null,null,null,false,null,null,null,null) + } else { + PodcastMetadata(name,null,null, mutableListOf()) + } + } + + @JsonIgnore + fun getAudiobookChapters():List { + if (mediaType != "book" || audioTracks.isEmpty()) return mutableListOf() + if (audioTracks.size == 1) { // Single track audiobook look for chapters from ffprobe + return audioTracks[0].audioProbeResult?.getBookChapters() ?: mutableListOf() + } + // Multi-track make chapters from tracks + return audioTracks.map { it.getBookChapter() } + } + + @JsonIgnore + fun getLocalLibraryItem():LocalLibraryItem { + var mediaMetadata = getMediaMetadata() + if (mediaType == "book") { + var chapters = getAudiobookChapters() + var book = Book(mediaMetadata as BookMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), chapters,audioTracks,getTotalSize(),getDuration()) + return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false,mediaType, book, localFiles, coverContentUrl, coverAbsolutePath,true,null,null,null,null) + } else { + var podcast = Podcast(mediaMetadata as PodcastMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), false) + return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false, mediaType, podcast,localFiles,coverContentUrl, coverAbsolutePath, true, null,null,null,null) + } + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaProgress.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaProgress.kt new file mode 100644 index 00000000..8640ba7f --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaProgress.kt @@ -0,0 +1,22 @@ +package com.audiobookshelf.app.data + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +@JsonIgnoreProperties(ignoreUnknown = true) +data class LocalMediaProgress( + var id:String, + var localLibraryItemId:String, + var episodeId:String?, + var duration:Double, + var progress:Double, // 0 to 1 + var currentTime:Double, + var isFinished:Boolean, + var lastUpdate:Long, + var startedAt:Long, + var finishedAt:Long?, + // For local lib items from server to support server sync + var serverConnectionConfigId:String?, + var serverAddress:String?, + var serverUserId:String?, + var libraryItemId:String? +) diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt b/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt new file mode 100644 index 00000000..7a04d72c --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt @@ -0,0 +1,192 @@ +package com.audiobookshelf.app.data + +import android.net.Uri +import android.support.v4.media.MediaMetadataCompat +import com.audiobookshelf.app.R +import com.audiobookshelf.app.device.DeviceManager +import com.audiobookshelf.app.player.MediaProgressSyncData +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.MediaMetadata +import com.google.android.gms.cast.MediaInfo +import com.google.android.gms.cast.MediaQueueItem +import com.google.android.gms.common.images.WebImage + +// TODO: enum or something in kotlin? +val PLAYMETHOD_DIRECTPLAY = 0 +val PLAYMETHOD_DIRECTSTREAM = 1 +val PLAYMETHOD_TRANSCODE = 2 +val PLAYMETHOD_LOCAL = 3 + +@JsonIgnoreProperties(ignoreUnknown = true) +class PlaybackSession( + var id:String, + var userId:String?, + var libraryItemId:String?, + var episodeId:String?, + var mediaType:String, + var mediaMetadata:MediaTypeMetadata, + var chapters:List, + var displayTitle: String?, + var displayAuthor: String?, + var coverPath:String?, + var duration:Double, + var playMethod:Int, + var startedAt:Long, + var updatedAt:Long, + var timeListening:Long, + var audioTracks:MutableList, + var currentTime:Double, + var libraryItem:LibraryItem?, + var localLibraryItem:LocalLibraryItem?, + var serverConnectionConfigId:String?, + var serverAddress:String? +) { + + @get:JsonIgnore + val isHLS get() = playMethod == PLAYMETHOD_TRANSCODE + @get:JsonIgnore + val isLocal get() = playMethod == PLAYMETHOD_LOCAL + @get:JsonIgnore + val currentTimeMs get() = (currentTime * 1000L).toLong() + @get:JsonIgnore + val localLibraryItemId get() = localLibraryItem?.id ?: "" + @get:JsonIgnore + val localMediaProgressId get() = if (episodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$episodeId" + @get:JsonIgnore + val progress get() = currentTime / getTotalDuration() + + @JsonIgnore + fun getCurrentTrackIndex():Int { + for (i in 0..(audioTracks.size - 1)) { + var track = audioTracks[i] + if (currentTimeMs >= track.startOffsetMs && (track.endOffsetMs) > currentTimeMs) { + return i + } + } + return audioTracks.size - 1 + } + + @JsonIgnore + fun getCurrentTrackTimeMs():Long { + var currentTrack = audioTracks[this.getCurrentTrackIndex()] + var time = currentTime - currentTrack.startOffset + return (time * 1000L).toLong() + } + + @JsonIgnore + fun getTrackStartOffsetMs(index:Int):Long { + var currentTrack = audioTracks[index] + return (currentTrack.startOffset * 1000L).toLong() + } + + @JsonIgnore + fun getTotalDuration():Double { + var total = 0.0 + audioTracks.forEach { total += it.duration } + return total + } + + @JsonIgnore + fun getCoverUri(): Uri { + if (localLibraryItem?.coverContentUrl != null) return Uri.parse(localLibraryItem?.coverContentUrl) ?: Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon) + + if (coverPath == null) return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon) + return Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}") + } + + @JsonIgnore + fun getContentUri(audioTrack:AudioTrack): Uri { + if (isLocal) return Uri.parse(audioTrack.contentUrl) // Local content url + return Uri.parse("$serverAddress${audioTrack.contentUrl}?token=${DeviceManager.token}") + } + + @JsonIgnore + fun getMediaMetadataCompat(): MediaMetadataCompat { + var metadataBuilder = MediaMetadataCompat.Builder() + .putString(MediaMetadataCompat.METADATA_KEY_TITLE, displayTitle) + .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle) + .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, displayAuthor) + .putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, displayAuthor) + .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, displayAuthor) + .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, "series") + .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id) + return metadataBuilder.build() + } + + @JsonIgnore + fun getExoMediaMetadata(audioTrack:AudioTrack): MediaMetadata { + var metadataBuilder = MediaMetadata.Builder() + .setTitle(displayTitle) + .setDisplayTitle(displayTitle) + .setArtist(displayAuthor) + .setAlbumArtist(displayAuthor) + .setSubtitle(displayAuthor) + + var contentUri = this.getContentUri(audioTrack) + metadataBuilder.setMediaUri(contentUri) + + return metadataBuilder.build() + } + + @JsonIgnore + fun getMediaItems():List { + var mediaItems:MutableList = mutableListOf() + + for (audioTrack in audioTracks) { + var mediaMetadata = this.getExoMediaMetadata(audioTrack) + var mediaUri = this.getContentUri(audioTrack) + var mimeType = audioTrack.mimeType + + var queueItem = getQueueItem(audioTrack) // Queue item used in exo player CastManager + var mediaItem = MediaItem.Builder().setUri(mediaUri).setTag(queueItem).setMediaMetadata(mediaMetadata).setMimeType(mimeType).build() + mediaItems.add(mediaItem) + } + return mediaItems + } + + @JsonIgnore + fun getCastMediaMetadata(audioTrack:AudioTrack):com.google.android.gms.cast.MediaMetadata { + var castMetadata = com.google.android.gms.cast.MediaMetadata(com.google.android.gms.cast.MediaMetadata.MEDIA_TYPE_AUDIOBOOK_CHAPTER) + castMetadata.addImage(WebImage(getCoverUri())) + castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_TITLE, displayTitle) + castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_ARTIST, displayAuthor) + castMetadata.putInt(com.google.android.gms.cast.MediaMetadata.KEY_TRACK_NUMBER, audioTrack.index) + return castMetadata + } + + @JsonIgnore + fun getQueueItem(audioTrack:AudioTrack):MediaQueueItem { + var castMetadata = getCastMediaMetadata(audioTrack) + + var mediaUri = getContentUri(audioTrack) + var mediaInfoBuilder = MediaInfo.Builder(mediaUri.toString()) + mediaInfoBuilder.setContentUrl(mediaUri.toString()) + mediaInfoBuilder.setMetadata(castMetadata) + mediaInfoBuilder.setContentType(audioTrack.mimeType) + var mediaInfo = mediaInfoBuilder.build() + + var queueItem = MediaQueueItem.Builder(mediaInfo) + queueItem.setItemId(audioTrack.index) + queueItem.setPlaybackDuration(audioTrack.duration) + return queueItem.build() + } + + @JsonIgnore + fun clone():PlaybackSession { + return PlaybackSession(id,userId,libraryItemId,episodeId,mediaType,mediaMetadata,chapters,displayTitle,displayAuthor,coverPath,duration,playMethod,startedAt,updatedAt,timeListening,audioTracks,currentTime,libraryItem,localLibraryItem,serverConnectionConfigId,serverAddress) + } + + @JsonIgnore + fun syncData(syncData:MediaProgressSyncData) { + timeListening += syncData.timeListened + updatedAt = System.currentTimeMillis() + currentTime = syncData.currentTime + } + + @JsonIgnore + fun getNewLocalMediaProgress():LocalMediaProgress { + return LocalMediaProgress(localMediaProgressId,localLibraryItemId,episodeId,getTotalDuration(),progress,currentTime,false,updatedAt,startedAt,null,serverConnectionConfigId,serverAddress,userId,libraryItemId) + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt b/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt new file mode 100644 index 00000000..6e67e3e9 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt @@ -0,0 +1,25 @@ +package com.audiobookshelf.app.device + +import android.util.Log +import com.audiobookshelf.app.data.DbManager +import com.audiobookshelf.app.data.DeviceData +import com.audiobookshelf.app.data.ServerConnectionConfig + +object DeviceManager { + val tag = "DeviceManager" + val dbManager:DbManager = DbManager() + var deviceData:DeviceData = dbManager.getDeviceData() + var serverConnectionConfig: ServerConnectionConfig? = null + + val serverAddress get() = serverConnectionConfig?.address ?: "" + val serverUserId get() = serverConnectionConfig?.userId ?: "" + val token get() = serverConnectionConfig?.token ?: "" + + init { + Log.d(tag, "Device Manager Singleton invoked") + } + + fun getBase64Id(id:String):String { + return android.util.Base64.encodeToString(id.toByteArray(), android.util.Base64.DEFAULT) + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/device/FolderScanner.kt b/android/app/src/main/java/com/audiobookshelf/app/device/FolderScanner.kt new file mode 100644 index 00000000..1ce1e100 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/device/FolderScanner.kt @@ -0,0 +1,424 @@ +package com.audiobookshelf.app.device + +import android.content.Context +import android.net.Uri +import android.util.Log +import androidx.documentfile.provider.DocumentFile +import com.anggrayudi.storage.file.* +import com.arthenica.ffmpegkit.FFmpegKitConfig +import com.arthenica.ffmpegkit.FFprobeKit +import com.arthenica.ffmpegkit.Level +import com.audiobookshelf.app.data.* +import com.audiobookshelf.app.plugins.AbsDownloader +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue + +class FolderScanner(var ctx: Context) { + private val tag = "FolderScanner" + + private fun getLocalLibraryItemId(mediaItemId:String):String { + return "local_" + DeviceManager.getBase64Id(mediaItemId) + } + + enum class ItemScanResult { + ADDED, REMOVED, UPDATED, UPTODATE + } + + // TODO: CLEAN this monster! Divide into bite-size methods + fun scanForMediaItems(localFolder:LocalFolder, forceAudioProbe:Boolean):FolderScanResult? { + FFmpegKitConfig.enableLogCallback { log -> + if (log.level != Level.AV_LOG_STDERR) { // STDERR is filled with junk + Log.d(tag, "FFmpeg-Kit Log: (${log.level}) ${log.message}") + } + } + + var df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(localFolder.contentUrl)) + + if (df == null) { + Log.e(tag, "Folder Doc File Invalid $localFolder.contentUrl") + return null + } + + var mediaItemsUpdated = 0 + var mediaItemsAdded = 0 + var mediaItemsRemoved = 0 + var mediaItemsUpToDate = 0 + + // Search for files in media item folder + var foldersFound = df.search(false, DocumentFileType.FOLDER) + + // Match folders found with local library items already saved in db + var existingLocalLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(localFolder.id) + + // Remove existing items no longer there + existingLocalLibraryItems = existingLocalLibraryItems.filter { lli -> + var fileFound = foldersFound.find { f -> lli.id == getLocalLibraryItemId(f.id) } + if (fileFound == null) { + Log.d(tag, "Existing local library item is no longer in file system ${lli.media.metadata.title}") + DeviceManager.dbManager.removeLocalLibraryItem(lli.id) + mediaItemsRemoved++ + } + fileFound != null + } + + foldersFound.forEach { itemFolder -> + Log.d(tag, "Iterating over Folder Found ${itemFolder.name} | ${itemFolder.getSimplePath(ctx)} | URI: ${itemFolder.uri}") + var existingItem = existingLocalLibraryItems.find { emi -> emi.id == getLocalLibraryItemId(itemFolder.id) } + + var result = scanLibraryItemFolder(itemFolder, localFolder, existingItem, forceAudioProbe) + + if (result == ItemScanResult.REMOVED) mediaItemsRemoved++ + else if (result == ItemScanResult.UPDATED) mediaItemsUpdated++ + else if (result == ItemScanResult.ADDED) mediaItemsAdded++ + else mediaItemsUpToDate++ + } + + Log.d(tag, "Folder $${localFolder.name} scan Results: $mediaItemsAdded Added | $mediaItemsUpdated Updated | $mediaItemsRemoved Removed | $mediaItemsUpToDate Up-to-date") + + return if (mediaItemsAdded > 0 || mediaItemsUpdated > 0 || mediaItemsRemoved > 0) { + var folderLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(localFolder.id) // Get all local media items + FolderScanResult(mediaItemsAdded, mediaItemsUpdated, mediaItemsRemoved, mediaItemsUpToDate, localFolder, folderLibraryItems) + } else { + Log.d(tag, "No Media Items to save") + FolderScanResult(mediaItemsAdded, mediaItemsUpdated, mediaItemsRemoved, mediaItemsUpToDate, localFolder, mutableListOf()) + } + } + + fun scanLibraryItemFolder(itemFolder:DocumentFile, localFolder:LocalFolder, existingItem:LocalLibraryItem?, forceAudioProbe:Boolean):ItemScanResult { + var itemFolderName = itemFolder.name ?: "" + var itemId = getLocalLibraryItemId(itemFolder.id) + + var existingLocalFiles = existingItem?.localFiles ?: mutableListOf() + var existingAudioTracks = existingItem?.media?.getAudioTracks() ?: mutableListOf() + var isNewOrUpdated = existingItem == null + + var audioTracks = mutableListOf() + var localFiles = mutableListOf() + var index = 1 + var startOffset = 0.0 + var coverContentUrl:String? = null + var coverAbsolutePath:String? = null + + var filesInFolder = itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*")) + var isPodcast = localFolder.mediaType == "podcast" + + var existingLocalFilesRemoved = existingLocalFiles.filter { elf -> + filesInFolder.find { fif -> DeviceManager.getBase64Id(fif.id) == elf.id } == null // File was not found in media item folder + } + if (existingLocalFilesRemoved.isNotEmpty()) { + Log.d(tag, "${existingLocalFilesRemoved.size} Local files were removed from local media item ${existingItem?.media?.metadata?.title}") + isNewOrUpdated = true + } + + filesInFolder.forEach { file -> + var mimeType = file?.mimeType ?: "" + var filename = file?.name ?: "" + var isAudio = mimeType.startsWith("audio") + Log.d(tag, "Found $mimeType file $filename in folder $itemFolderName") + + var localFileId = DeviceManager.getBase64Id(file.id) + + var localFile = LocalFile(localFileId,filename,file.uri.toString(),file.getBasePath(ctx), file.getAbsolutePath(ctx),file.getSimplePath(ctx),mimeType,file.length()) + localFiles.add(localFile) + + Log.d(tag, "File attributes Id:${localFileId}|ContentUrl:${localFile.contentUrl}|isDownloadsDocument:${file.isDownloadsDocument}") + + if (isAudio) { + var audioTrackToAdd:AudioTrack? = null + + var existingAudioTrack = existingAudioTracks.find { eat -> eat.localFileId == localFileId } + if (existingAudioTrack != null) { // Update existing audio track + if (existingAudioTrack.index != index) { + Log.d(tag, "Updating Audio track index from ${existingAudioTrack.index} to $index") + existingAudioTrack.index = index + isNewOrUpdated = true + } + if (existingAudioTrack.startOffset != startOffset) { + Log.d(tag, "Updating Audio track startOffset ${existingAudioTrack.startOffset} to $startOffset") + existingAudioTrack.startOffset = startOffset + isNewOrUpdated = true + } + } + + if (existingAudioTrack == null || forceAudioProbe) { + Log.d(tag, "Scanning Audio File Path ${localFile.absolutePath}") + + // TODO: Make asynchronous + var session = FFprobeKit.execute("-i \"${localFile.absolutePath}\" -print_format json -show_format -show_streams -select_streams a -show_chapters -loglevel quiet") + + val audioProbeResult = jacksonObjectMapper().readValue(session.output) + Log.d(tag, "Probe Result DATA ${audioProbeResult.duration} | ${audioProbeResult.size} | ${audioProbeResult.title} | ${audioProbeResult.artist}") + + if (existingAudioTrack != null) { + // Update audio probe data on existing audio track + existingAudioTrack.audioProbeResult = audioProbeResult + audioTrackToAdd = existingAudioTrack + } else { + // Create new audio track + var track = AudioTrack(index, startOffset, audioProbeResult.duration, filename, localFile.contentUrl, mimeType, null, true, localFileId, audioProbeResult, null) + audioTrackToAdd = track + } + + startOffset += audioProbeResult.duration + isNewOrUpdated = true + } else { + audioTrackToAdd = existingAudioTrack + } + + startOffset += audioTrackToAdd.duration + index++ + audioTracks.add(audioTrackToAdd) + } else { + var existingLocalFile = existingLocalFiles.find { elf -> elf.id == localFileId } + + if (existingLocalFile == null) { + isNewOrUpdated = true + } + if (existingItem != null && existingItem.coverContentUrl == null) { + // Existing media item did not have a cover - cover found on scan + isNewOrUpdated = true + existingItem.coverAbsolutePath = localFile.absolutePath + existingItem.coverContentUrl = localFile.contentUrl + existingItem.media.coverPath = localFile.absolutePath + } + + // First image file use as cover path + if (coverContentUrl == null) { + coverContentUrl = localFile.contentUrl + coverAbsolutePath = localFile.absolutePath + } + } + } + + if (existingItem != null && audioTracks.isEmpty()) { + Log.d(tag, "Local library item ${existingItem.media.metadata.title} no longer has audio tracks - removing item") + DeviceManager.dbManager.removeLocalLibraryItem(existingItem.id) + return ItemScanResult.REMOVED + } else if (existingItem != null && !isNewOrUpdated) { + Log.d(tag, "Local library item ${existingItem.media.metadata.title} has no updates") + return ItemScanResult.UPTODATE + } else if (existingItem != null) { + Log.d(tag, "Updating local library item ${existingItem.media.metadata.title}") + existingItem.updateFromScan(audioTracks,localFiles) + DeviceManager.dbManager.saveLocalLibraryItem(existingItem) + return ItemScanResult.UPDATED + } else if (audioTracks.isNotEmpty()) { + Log.d(tag, "Found local media item named $itemFolderName with ${audioTracks.size} tracks and ${localFiles.size} local files") + var localMediaItem = LocalMediaItem(itemId, itemFolderName, localFolder.mediaType, localFolder.id, itemFolder.uri.toString(), itemFolder.getSimplePath(ctx), itemFolder.getBasePath(ctx), itemFolder.getAbsolutePath(ctx),audioTracks,localFiles,coverContentUrl,coverAbsolutePath) + var localLibraryItem = localMediaItem.getLocalLibraryItem() + DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem) + return ItemScanResult.ADDED + } else { + return ItemScanResult.UPTODATE + } + } + + // Scan item after download and create local library item + fun scanDownloadItem(downloadItem: AbsDownloader.DownloadItem):LocalLibraryItem? { + var folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl)) + var foldersFound = folderDf?.search(false, DocumentFileType.FOLDER) ?: mutableListOf() + var itemFolderUrl = "" + var itemFolderBasePath = "" + var itemFolderAbsolutePath = "" + foldersFound.forEach { + if (it.name == downloadItem.itemTitle) { + itemFolderUrl = it.uri.toString() + itemFolderBasePath = it.getBasePath(ctx) + itemFolderAbsolutePath = it.getAbsolutePath(ctx) + } + } + + if (itemFolderUrl == "") { + Log.d(tag, "scanDownloadItem failed to find media folder") + return null + } + var df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(itemFolderUrl)) + + if (df == null) { + Log.e(tag, "Folder Doc File Invalid ${downloadItem.itemFolderPath}") + return null + } + Log.d(tag, "scanDownloadItem starting for ${downloadItem.itemFolderPath} | ${df.uri}") + + // Search for files in media item folder + var filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*")) + Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}") + + var localLibraryItem:LocalLibraryItem? = null + if (downloadItem.mediaType == "book") { + localLibraryItem = LocalLibraryItem("local_${downloadItem.libraryItemId}", downloadItem.localFolder.id, itemFolderBasePath, itemFolderAbsolutePath, itemFolderUrl, false, downloadItem.mediaType, downloadItem.media, mutableListOf(), null, null, true, downloadItem.serverConnectionConfigId, downloadItem.serverAddress, downloadItem.serverUserId, downloadItem.libraryItemId) + } else { + // Lookup or create podcast local library item + localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem("local_${downloadItem.libraryItemId}") + if (localLibraryItem == null) { + Log.d(tag, "Podcast local library item not created yet for ${downloadItem.media.metadata.title}") + localLibraryItem = LocalLibraryItem("local_${downloadItem.libraryItemId}", downloadItem.localFolder.id, itemFolderBasePath, itemFolderAbsolutePath, itemFolderUrl, false, downloadItem.mediaType, downloadItem.media, mutableListOf(), null, null, true,downloadItem.serverConnectionConfigId,downloadItem.serverAddress,downloadItem.serverUserId,downloadItem.libraryItemId) + } + } + + var audioTracks:MutableList = mutableListOf() + + filesFound.forEach { docFile -> + var itemPart = downloadItem.downloadItemParts.find { itemPart -> + itemPart.filename == docFile.name + } + if (itemPart == null) { + if (downloadItem.mediaType == "book") { // for books every download item should be a file found + Log.e(tag, "scanDownloadItem: Item part not found for doc file ${docFile.name} | ${docFile.getAbsolutePath(ctx)} | ${docFile.uri}") + } + } else if (itemPart.audioTrack != null) { // Is audio track + var audioTrackFromServer = itemPart.audioTrack + + var localFileId = DeviceManager.getBase64Id(docFile.id) + var localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length()) + localLibraryItem.localFiles.add(localFile) + + // TODO: Make asynchronous + var session = FFprobeKit.execute("-i \"${localFile.absolutePath}\" -print_format json -show_format -show_streams -select_streams a -show_chapters -loglevel quiet") + + val audioProbeResult = jacksonObjectMapper().readValue(session.output) + Log.d(tag, "Probe Result DATA ${audioProbeResult.duration} | ${audioProbeResult.size} | ${audioProbeResult.title} | ${audioProbeResult.artist}") + + // Create new audio track + var track = AudioTrack(audioTrackFromServer?.index ?: -1, audioTrackFromServer?.startOffset ?: 0.0, audioProbeResult.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, audioTrackFromServer?.index ?: -1) + audioTracks.add(track) + + // Add podcast episodes to library + itemPart.episode?.let { podcastEpisode -> + var podcast = localLibraryItem.media as Podcast + podcast.addEpisode(track, podcastEpisode) + } + } else { // Cover image + var localFileId = DeviceManager.getBase64Id(docFile.id) + var localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length()) + + localLibraryItem.coverAbsolutePath = localFile.absolutePath + localLibraryItem.coverContentUrl = localFile.contentUrl + localLibraryItem.localFiles.add(localFile) + } + } + + if (audioTracks.isEmpty()) { + Log.d(tag, "scanDownloadItem did not find any audio tracks in folder for ${downloadItem.itemFolderPath}") + return null + } + + // For books sort audio tracks then set + if (downloadItem.mediaType == "book") { + audioTracks.sortBy { it.index } + + var indexCheck = 1 + var startOffset = 0.0 + audioTracks.forEach { audioTrack -> + if (audioTrack.index != indexCheck || audioTrack.startOffset != startOffset) { + audioTrack.index = indexCheck + audioTrack.startOffset = startOffset + } + indexCheck++ + startOffset += audioTrack.duration + } + + localLibraryItem.media.setAudioTracks(audioTracks) + } + + DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem) + + return localLibraryItem + } + + fun scanLocalLibraryItem(localLibraryItem:LocalLibraryItem, forceAudioProbe:Boolean):LocalLibraryItemScanResult? { + var df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(localLibraryItem.contentUrl)) + + if (df == null) { + Log.e(tag, "Item Folder Doc File Invalid ${localLibraryItem.absolutePath}") + return null + } + Log.d(tag, "scanLocalLibraryItem starting for ${localLibraryItem.absolutePath} | ${df.uri}") + + var wasUpdated = false + + // Search for files in media item folder + var filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*")) + Log.d(tag, "scanLocalLibraryItem ${filesFound.size} files found in ${localLibraryItem.absolutePath}") + + filesFound.forEach { + try { + Log.d(tag, "Checking file found ${it.name} | ${it.id}") + }catch(e:Exception) { + Log.d(tag, "Check file found exception", e) + } + } + + var existingAudioTracks = localLibraryItem.media.getAudioTracks() + + // Remove any files no longer found in library item folder + var existingLocalFileIds = localLibraryItem.localFiles.map { it.id } + existingLocalFileIds.forEach { localFileId -> + Log.d(tag, "Checking local file id is there $localFileId") + if (filesFound.find { DeviceManager.getBase64Id(it.id) == localFileId } == null) { + Log.d(tag, "scanLocalLibraryItem file $localFileId was removed from ${localLibraryItem.absolutePath}") + localLibraryItem.localFiles.removeIf { it.id == localFileId } + + if (existingAudioTracks.find { it.localFileId == localFileId } != null) { + Log.d(tag, "scanLocalLibraryItem audio track file ${localFileId} was removed from ${localLibraryItem.absolutePath}") + localLibraryItem.media.removeAudioTrack(localFileId) + } + wasUpdated = true + } + } + + filesFound.forEach { docFile -> + var localFileId = DeviceManager.getBase64Id(docFile.id) + var existingLocalFile = localLibraryItem.localFiles.find { it.id == localFileId } + + if (existingLocalFile == null || (existingLocalFile.isAudioFile() && forceAudioProbe)) { + + var localFile = existingLocalFile ?: LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx), docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length()) + if (existingLocalFile == null) { + localLibraryItem.localFiles.add(localFile) + Log.d(tag, "scanLocalLibraryItem new file found ${localFile.filename}") + } + + if (localFile.isAudioFile()) { + // TODO: Make asynchronous + var session = FFprobeKit.execute("-i \"${localFile.absolutePath}\" -print_format json -show_format -show_streams -select_streams a -show_chapters -loglevel quiet") + + val audioProbeResult = jacksonObjectMapper().readValue(session.output) + Log.d(tag, "Probe Result DATA ${audioProbeResult.duration} | ${audioProbeResult.size} | ${audioProbeResult.title} | ${audioProbeResult.artist}") + + var existingTrack = existingAudioTracks.find { audioTrack -> + audioTrack.localFileId == localFile.id + } + + if (existingTrack == null) { + // Create new audio track + var lastTrack = existingAudioTracks.lastOrNull() + var startOffset = (lastTrack?.startOffset ?: 0.0) + (lastTrack?.duration ?: 0.0) + var track = AudioTrack(existingAudioTracks.size, startOffset, audioProbeResult.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, null) + localLibraryItem.media.addAudioTrack(track) + wasUpdated = true + } else { + existingTrack.audioProbeResult = audioProbeResult + // TODO: Update data found from probe + wasUpdated = true + } + } else { // Check if cover is empty + if (localLibraryItem.coverContentUrl == null) { + Log.d(tag, "scanLocalLibraryItem setting cover for ${localLibraryItem.media.metadata.title}") + localLibraryItem.coverContentUrl = localFile.contentUrl + localLibraryItem.coverAbsolutePath = localFile.absolutePath + wasUpdated = true + } + } + } + } + + if (wasUpdated) { + Log.d(tag, "Local library item was updated - saving it") + DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem) + } else { + Log.d(tag, "Local library item was up-to-date") + } + return LocalLibraryItemScanResult(wasUpdated, localLibraryItem) + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt b/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt new file mode 100644 index 00000000..94366594 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt @@ -0,0 +1,70 @@ +package com.audiobookshelf.app.media + +import com.audiobookshelf.app.data.LibraryItem +import com.audiobookshelf.app.data.PlaybackSession +import com.audiobookshelf.app.server.ApiHandler +import java.util.* + +class MediaManager(var apiHandler: ApiHandler) { + var serverLibraryItems = listOf() + + fun loadLibraryItems(cb: (List) -> Unit) { + if (serverLibraryItems.isNotEmpty()) { + cb(serverLibraryItems) + } else { + apiHandler.getLibraryItems("main") { libraryItems -> + serverLibraryItems = libraryItems + cb(libraryItems) + } + } + } + + fun getFirstItem() : LibraryItem? { + return if (serverLibraryItems.isNotEmpty()) serverLibraryItems[0] else null + } + + fun getById(id:String) : LibraryItem? { + return serverLibraryItems.find { it.id == id } + } + + fun getFromSearch(query:String?) : LibraryItem? { + if (query.isNullOrEmpty()) return getFirstItem() + return serverLibraryItems.find { + it.title.lowercase(Locale.getDefault()).contains(query.lowercase(Locale.getDefault())) + } + } + + fun play(libraryItem:LibraryItem, cb: (PlaybackSession) -> Unit) { + apiHandler.playLibraryItem(libraryItem.id,"",false) { + cb(it) + } + } + + private fun levenshtein(lhs : CharSequence, rhs : CharSequence) : Int { + val lhsLength = lhs.length + 1 + val rhsLength = rhs.length + 1 + + var cost = Array(lhsLength) { it } + var newCost = Array(lhsLength) { 0 } + + for (i in 1..rhsLength-1) { + newCost[0] = i + + for (j in 1..lhsLength-1) { + val match = if(lhs[j - 1] == rhs[i - 1]) 0 else 1 + + val costReplace = cost[j - 1] + match + val costInsert = cost[j] + 1 + val costDelete = newCost[j - 1] + 1 + + newCost[j] = Math.min(Math.min(costInsert, costDelete), costReplace) + } + + val swap = cost + cost = newCost + newCost = swap + } + + return cost[lhsLength - 1] + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/AbMediaDescriptionAdapter.kt b/android/app/src/main/java/com/audiobookshelf/app/player/AbMediaDescriptionAdapter.kt similarity index 97% rename from android/app/src/main/java/com/audiobookshelf/app/AbMediaDescriptionAdapter.kt rename to android/app/src/main/java/com/audiobookshelf/app/player/AbMediaDescriptionAdapter.kt index 69b5f19c..2fa70e92 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/AbMediaDescriptionAdapter.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/AbMediaDescriptionAdapter.kt @@ -1,10 +1,11 @@ -package com.audiobookshelf.app +package com.audiobookshelf.app.player import android.app.PendingIntent import android.graphics.Bitmap import android.net.Uri import android.support.v4.media.session.MediaControllerCompat import android.util.Log +import com.audiobookshelf.app.R import com.bumptech.glide.Glide import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.request.RequestOptions diff --git a/android/app/src/main/java/com/audiobookshelf/app/BrowseTree.kt b/android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt similarity index 74% rename from android/app/src/main/java/com/audiobookshelf/app/BrowseTree.kt rename to android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt index 1bcece9c..68569ed7 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/BrowseTree.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt @@ -1,4 +1,4 @@ -package com.audiobookshelf.app +package com.audiobookshelf.app.player import android.content.ContentResolver import android.content.Context @@ -6,12 +6,14 @@ import android.net.Uri import android.support.v4.media.MediaMetadataCompat import android.util.Log import androidx.annotation.AnyRes +import com.audiobookshelf.app.R +import com.audiobookshelf.app.data.LibraryItem class BrowseTree( val context: Context, - audiobooksInProgress: List, - audiobookMetadata: List, + itemsInProgress: List, + itemsMetadata: List, downloadedMetadata: List ) { private val mediaIdToChildren = mutableMapOf>() @@ -33,7 +35,6 @@ class BrowseTree( init { val rootList = mediaIdToChildren[AUTO_BROWSE_ROOT] ?: mutableListOf() - val continueReadingMetadata = MediaMetadataCompat.Builder().apply { putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, CONTINUE_ROOT) putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Reading") @@ -42,27 +43,20 @@ class BrowseTree( val allMetadata = MediaMetadataCompat.Builder().apply { putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, ALL_ROOT) - putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Audiobooks") + putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Library Items") var resource = getUriToDrawable(context, R.drawable.exo_icon_books).toString() Log.d("BrowseTree", "RESOURCE $resource") putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, resource) }.build() - val downloadsMetadata = MediaMetadataCompat.Builder().apply { putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, DOWNLOADS_ROOT) putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Downloads") putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_downloaddone).toString()) }.build() -// val localsMetadata = MediaMetadataCompat.Builder().apply { -// putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, LOCAL_ROOT) -// putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Samples") -// putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_localaudio).toString()) -// }.build() - - if (audiobooksInProgress.isNotEmpty()) { + if (itemsInProgress.isNotEmpty()) { rootList += continueReadingMetadata } rootList += allMetadata @@ -70,13 +64,13 @@ class BrowseTree( // rootList += localsMetadata mediaIdToChildren[AUTO_BROWSE_ROOT] = rootList - audiobooksInProgress.forEach { audiobook -> + itemsInProgress.forEach { libraryItem -> val children = mediaIdToChildren[CONTINUE_ROOT] ?: mutableListOf() - children += audiobook.toMediaMetadata() + children += libraryItem.getMediaMetadata() mediaIdToChildren[CONTINUE_ROOT] = children } - audiobookMetadata.forEach { + itemsMetadata.forEach { val allChildren = mediaIdToChildren[ALL_ROOT] ?: mutableListOf() allChildren += it mediaIdToChildren[ALL_ROOT] = allChildren @@ -87,13 +81,6 @@ class BrowseTree( allChildren += it mediaIdToChildren[DOWNLOADS_ROOT] = allChildren } - -// localAudio.forEach { local -> -// val localChildren = mediaIdToChildren[LOCAL_ROOT] ?: mutableListOf() -// localChildren += local.toMediaMetadata() -// mediaIdToChildren[LOCAL_ROOT] = localChildren -// } -// Log.d("BrowseTree", "Set LOCAL AUDIO ${localAudio.size}") } operator fun get(mediaId: String) = mediaIdToChildren[mediaId] diff --git a/android/app/src/main/java/com/audiobookshelf/app/CastManager.kt b/android/app/src/main/java/com/audiobookshelf/app/player/CastManager.kt similarity index 92% rename from android/app/src/main/java/com/audiobookshelf/app/CastManager.kt rename to android/app/src/main/java/com/audiobookshelf/app/player/CastManager.kt index 854eeaf6..30b349a0 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/CastManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/CastManager.kt @@ -1,4 +1,4 @@ -package com.audiobookshelf.app +package com.audiobookshelf.app.player import android.app.Activity import android.app.AlertDialog @@ -9,20 +9,22 @@ import androidx.mediarouter.app.MediaRouteChooserDialog import androidx.mediarouter.media.MediaRouteSelector import androidx.mediarouter.media.MediaRouter import com.getcapacitor.PluginCall +import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.ext.cast.CastPlayer +import com.google.android.exoplayer2.ext.cast.MediaItemConverter import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener import com.google.android.gms.cast.Cast import com.google.android.gms.cast.CastDevice import com.google.android.gms.cast.CastMediaControlIntent +import com.google.android.gms.cast.MediaQueueItem import com.google.android.gms.cast.framework.* import org.json.JSONObject -import java.util.ArrayList class CastManager constructor(playerNotificationService:PlayerNotificationService) { - private val tag = "SleepTimerManager" + private val tag = "CastManager" private val playerNotificationService:PlayerNotificationService = playerNotificationService - private var newConnectionListener:SessionListener? = null + private var newConnectionListener: SessionListener? = null private var mainActivity:Activity? = null private fun switchToPlayer(useCastPlayer:Boolean) { @@ -291,6 +293,22 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic } } + inner class CustomConverter : MediaItemConverter { + override fun toMediaQueueItem(mediaItem: MediaItem): MediaQueueItem { + // The MediaQueueItem you build is expected to be in the tag. + var queueItem = (mediaItem.playbackProperties!!.tag as MediaQueueItem?)!! + Log.d(tag, "Test toMediaQueueItem ${queueItem.media!!.contentUrl} | ${queueItem.playbackDuration} | ${queueItem.itemId}") + return queueItem + } + + override fun toMediaItem(mediaQueueItem: MediaQueueItem): MediaItem { + return MediaItem.Builder() + .setUri(mediaQueueItem.media!!.contentUrl) + .setTag(mediaQueueItem) + .build() + } + } + private fun listenForConnection(callback: ConnectionCallback) { // We should only ever have one of these listeners active at a time, so remove previous getSessionManager()?.removeSessionManagerListener(newConnectionListener, CastSession::class.java) @@ -302,9 +320,10 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic try { val castContext = CastContext.getSharedInstance(mainActivity) - playerNotificationService.castPlayer = CastPlayer(castContext).apply { + + playerNotificationService.castPlayer = CastPlayer(castContext, CustomConverter()).apply { setSessionAvailabilityListener(CastSessionAvailabilityListener()) - addListener(playerNotificationService.getPlayerListener()) + addListener(PlayerListener(playerNotificationService)) } Log.d(tag, "CAST Cast Player Applied") switchToPlayer(true) @@ -313,8 +332,6 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic "Exception thrown when attempting to obtain CastContext. " + e.message) return } - - // media.setSession(castSession) // callback.onJoin(ChromecastUtilities.createSessionObject(castSession)) } diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/MediaProgressSyncer.kt b/android/app/src/main/java/com/audiobookshelf/app/player/MediaProgressSyncer.kt new file mode 100644 index 00000000..60cfe8e1 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/MediaProgressSyncer.kt @@ -0,0 +1,132 @@ +package com.audiobookshelf.app.player + +import android.os.Handler +import android.os.Looper +import android.util.Log +import com.audiobookshelf.app.data.LocalMediaProgress +import com.audiobookshelf.app.data.PlaybackSession +import com.audiobookshelf.app.device.DeviceManager +import com.audiobookshelf.app.server.ApiHandler +import java.util.* +import kotlin.concurrent.schedule +import kotlin.math.roundToInt + +data class MediaProgressSyncData( + var timeListened:Long, // seconds + var duration:Double, // seconds + var currentTime:Double // seconds +) + +class MediaProgressSyncer(playerNotificationService:PlayerNotificationService, apiHandler: ApiHandler) { + private val tag = "MediaProgressSync" + private val playerNotificationService:PlayerNotificationService = playerNotificationService + private val apiHandler = apiHandler + + private var listeningTimerTask: TimerTask? = null + var listeningTimerRunning:Boolean = false + + private var lastSyncTime:Long = 0 + + var currentPlaybackSession: PlaybackSession? = null // copy of pb session currently syncing + var currentLocalMediaProgress: LocalMediaProgress? = null + + val currentDisplayTitle get() = currentPlaybackSession?.displayTitle ?: "Unset" + val currentIsLocal get() = currentPlaybackSession?.isLocal == true + val currentSessionId get() = currentPlaybackSession?.id ?: "" + val currentPlaybackDuration get() = currentPlaybackSession?.duration ?: 0.0 + + fun start() { + if (listeningTimerRunning) { + Log.d(tag, "start: Timer already running for $currentDisplayTitle") + if (playerNotificationService.getCurrentPlaybackSessionId() != currentSessionId) { + Log.d(tag, "Playback session changed, reset timer") + currentLocalMediaProgress = null + listeningTimerTask?.cancel() + lastSyncTime = 0L + } else { + return + } + } + listeningTimerRunning = true + lastSyncTime = System.currentTimeMillis() + currentPlaybackSession = playerNotificationService.getCurrentPlaybackSessionCopy() + + listeningTimerTask = Timer("ListeningTimer", false).schedule(0L, 5000L) { + Handler(Looper.getMainLooper()).post() { + if (playerNotificationService.currentPlayer.isPlaying) { + var currentTime = playerNotificationService.getCurrentTimeSeconds() + sync(currentTime) + } + } + } + } + + fun stop() { + if (!listeningTimerRunning) return + Log.d(tag, "stop: Stopping listening for $currentDisplayTitle") + + var currentTime = playerNotificationService.getCurrentTimeSeconds() + sync(currentTime) + reset() + } + + fun sync(currentTime:Double) { + var diffSinceLastSync = System.currentTimeMillis() - lastSyncTime + if (diffSinceLastSync < 1000L) { + return + } + var listeningTimeToAdd = diffSinceLastSync / 1000L + lastSyncTime = System.currentTimeMillis() + + var syncData = MediaProgressSyncData(listeningTimeToAdd,currentPlaybackDuration,currentTime) + + currentPlaybackSession?.syncData(syncData) + if (currentIsLocal) { + // Save local progress sync + currentPlaybackSession?.let { + DeviceManager.dbManager.saveLocalPlaybackSession(it) + saveLocalProgress(it) + + // Send sync to server also if connected to this server and local item belongs to this server + if (it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) { + apiHandler.sendLocalProgressSync(it) { + Log.d(tag, "Local progress sync data sent to server $currentDisplayTitle for time $currentTime") + } + } + } + } else { + apiHandler.sendProgressSync(currentSessionId, syncData) { + Log.d(tag, "Progress sync data sent to server $currentDisplayTitle for time $currentTime") + } + } + } + + private fun saveLocalProgress(playbackSession:PlaybackSession) { + if (currentLocalMediaProgress == null) { + var mediaProgress = DeviceManager.dbManager.getLocalMediaProgress(playbackSession.localMediaProgressId) + if (mediaProgress == null) { + currentLocalMediaProgress = playbackSession.getNewLocalMediaProgress() + } else { + currentLocalMediaProgress = mediaProgress + } + } else { + currentLocalMediaProgress?.currentTime = playbackSession.currentTime + currentLocalMediaProgress?.lastUpdate = playbackSession.updatedAt + currentLocalMediaProgress?.progress = playbackSession.progress + } + currentLocalMediaProgress?.let { + DeviceManager.dbManager.saveLocalMediaProgress(it) + playerNotificationService.clientEventEmitter?.onLocalMediaProgressUpdate(it) + Log.d(tag, "Saved Local Progress Current Time: ${it.currentTime} | Duration ${it.duration} | Progress ${(it.progress * 100).roundToInt()}%") + } + } + + fun reset() { + listeningTimerTask?.cancel() + listeningTimerTask = null + listeningTimerRunning = false + currentPlaybackSession = null + currentLocalMediaProgress = null + lastSyncTime = 0L + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionCallback.kt b/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionCallback.kt new file mode 100644 index 00000000..b30c5007 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionCallback.kt @@ -0,0 +1,194 @@ +package com.audiobookshelf.app.player + +import android.annotation.SuppressLint +import android.content.Intent +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.Message +import android.support.v4.media.session.MediaSessionCompat +import android.util.Log +import android.view.KeyEvent +import com.audiobookshelf.app.data.LibraryItem +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import java.util.* +import kotlin.concurrent.schedule + +class MediaSessionCallback(var playerNotificationService:PlayerNotificationService) : MediaSessionCompat.Callback() { + var tag = "MediaSessionCallback" + + private var mediaButtonClickCount: Int = 0 + var mediaButtonClickTimeout: Long = 1000 //ms + var seekAmount: Long = 20000 //ms + + override fun onPrepare() { + Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT") + playerNotificationService.mediaManager.getFirstItem()?.let { li -> + playerNotificationService.mediaManager.play(li) { + Log.d(tag, "About to prepare player with li ${li.title}") + Handler(Looper.getMainLooper()).post() { + playerNotificationService.preparePlayer(it,true) + } + } + } + } + + override fun onPlay() { + Log.d(tag, "ON PLAY MEDIA SESSION COMPAT") + playerNotificationService.play() + } + + override fun onPrepareFromSearch(query: String?, extras: Bundle?) { + Log.d(tag, "ON PREPARE FROM SEARCH $query") + super.onPrepareFromSearch(query, extras) + } + + override fun onPlayFromSearch(query: String?, extras: Bundle?) { + Log.d(tag, "ON PLAY FROM SEARCH $query") + playerNotificationService.mediaManager.getFromSearch(query)?.let { li -> + playerNotificationService.mediaManager.play(li) { + Log.d(tag, "About to prepare player with li ${li.title}") + Handler(Looper.getMainLooper()).post() { + playerNotificationService.preparePlayer(it,true) + } + } + } + } + + override fun onPause() { + Log.d(tag, "ON PAUSE MEDIA SESSION COMPAT") + playerNotificationService.pause() + } + + override fun onStop() { + playerNotificationService.pause() + } + + override fun onSkipToPrevious() { + playerNotificationService.seekBackward(seekAmount) + } + + override fun onSkipToNext() { + playerNotificationService.seekForward(seekAmount) + } + + override fun onFastForward() { + playerNotificationService.seekForward(seekAmount) + } + + override fun onRewind() { + playerNotificationService.seekForward(seekAmount) + } + + override fun onSeekTo(pos: Long) { + playerNotificationService.seekPlayer(pos) + } + + override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { + Log.d(tag, "ON PLAY FROM MEDIA ID $mediaId") + var libraryItem: LibraryItem? = null + if (mediaId.isNullOrEmpty()) { + libraryItem = playerNotificationService.mediaManager.getFirstItem() + } else { + libraryItem = playerNotificationService.mediaManager.getById(mediaId) + } + + libraryItem?.let { li -> + playerNotificationService.mediaManager.play(li) { + Log.d(tag, "About to prepare player with li ${li.title}") + Handler(Looper.getMainLooper()).post() { + playerNotificationService.preparePlayer(it,true) + } + } + } + } + + override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean { + return handleCallMediaButton(mediaButtonEvent) + } + + fun handleCallMediaButton(intent: Intent): Boolean { + if(Intent.ACTION_MEDIA_BUTTON == intent.getAction()) { + var keyEvent = intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT) + if (keyEvent?.getAction() == KeyEvent.ACTION_UP) { + when (keyEvent?.getKeyCode()) { + KeyEvent.KEYCODE_HEADSETHOOK -> { + if (0 == mediaButtonClickCount) { + if (playerNotificationService.mPlayer.isPlaying) + playerNotificationService.pause() + else + playerNotificationService.play() + } + handleMediaButtonClickCount() + } + KeyEvent.KEYCODE_MEDIA_PLAY -> { + if (0 == mediaButtonClickCount) { + playerNotificationService.play() + playerNotificationService.sleepTimerManager.checkShouldExtendSleepTimer() + } + handleMediaButtonClickCount() + } + KeyEvent.KEYCODE_MEDIA_PAUSE -> { + if (0 == mediaButtonClickCount) playerNotificationService.pause() + handleMediaButtonClickCount() + } + KeyEvent.KEYCODE_MEDIA_NEXT -> { + playerNotificationService.seekForward(seekAmount) + } + KeyEvent.KEYCODE_MEDIA_PREVIOUS -> { + playerNotificationService.seekBackward(seekAmount) + } + KeyEvent.KEYCODE_MEDIA_STOP -> { + playerNotificationService.terminateStream() + } + KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> { + if (playerNotificationService.mPlayer.isPlaying) { + if (0 == mediaButtonClickCount) playerNotificationService.pause() + handleMediaButtonClickCount() + } else { + if (0 == mediaButtonClickCount) { + playerNotificationService.play() + playerNotificationService.sleepTimerManager.checkShouldExtendSleepTimer() + } + handleMediaButtonClickCount() + } + } + else -> { + Log.d(tag, "KeyCode:${keyEvent?.getKeyCode()}") + return false + } + } + } + } + return true + } + + fun handleMediaButtonClickCount() { + mediaButtonClickCount++ + if (1 == mediaButtonClickCount) { + Timer().schedule(mediaButtonClickTimeout) { + mediaBtnHandler.sendEmptyMessage(mediaButtonClickCount) + mediaButtonClickCount = 0 + } + } + } + + + private val mediaBtnHandler : Handler = @SuppressLint("HandlerLeak") + object : Handler(){ + override fun handleMessage(msg: Message) { + super.handleMessage(msg) + if (2 == msg.what) { + playerNotificationService.seekBackward(seekAmount) + playerNotificationService.play() + } + else if (msg.what >= 3) { + playerNotificationService.seekForward(seekAmount) + playerNotificationService.play() + } + } + } + +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionPlaybackPreparer.kt b/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionPlaybackPreparer.kt new file mode 100644 index 00000000..e04ec0b9 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionPlaybackPreparer.kt @@ -0,0 +1,70 @@ +package com.audiobookshelf.app.player + +import android.net.Uri +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.ResultReceiver +import android.support.v4.media.session.PlaybackStateCompat +import android.util.Log +import com.audiobookshelf.app.data.LibraryItem +import com.google.android.exoplayer2.ControlDispatcher +import com.google.android.exoplayer2.Player +import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector + +class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificationService) : MediaSessionConnector.PlaybackPreparer { + var tag = "MediaSessionPlaybackPreparer" + + override fun onCommand(player: Player, controlDispatcher: ControlDispatcher, command: String, extras: Bundle?, cb: ResultReceiver?): Boolean { + Log.d(tag, "ON COMMAND $command") + return false + } + + override fun getSupportedPrepareActions(): Long { + return PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID or + PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or + PlaybackStateCompat.ACTION_PREPARE_FROM_SEARCH or + PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH + } + + override fun onPrepare(playWhenReady: Boolean) { + Log.d(tag, "ON PREPARE $playWhenReady") + playerNotificationService.mediaManager.getFirstItem()?.let { li -> + playerNotificationService.mediaManager.play(li) { + Handler(Looper.getMainLooper()).post() { + playerNotificationService.preparePlayer(it,playWhenReady) + } + } + } + } + + override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) { + Log.d(tag, "ON PREPARE FROM MEDIA ID $mediaId $playWhenReady") + + var libraryItem: LibraryItem? = playerNotificationService.mediaManager.getById(mediaId) + libraryItem?.let { li -> + playerNotificationService.mediaManager.play(li) { + Log.d(tag, "About to prepare player with li ${li.title}") + Handler(Looper.getMainLooper()).post() { + playerNotificationService.preparePlayer(it,playWhenReady) + } + } + } + } + + override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) { + Log.d(tag, "ON PREPARE FROM SEARCH $query") + playerNotificationService.mediaManager.getFromSearch(query)?.let { li -> + playerNotificationService.mediaManager.play(li) { + Log.d(tag, "About to prepare player with li ${li.title}") + Handler(Looper.getMainLooper()).post() { + playerNotificationService.preparePlayer(it,playWhenReady) + } + } + } + } + + override fun onPrepareFromUri(uri: Uri, playWhenReady: Boolean, extras: Bundle?) { + Log.d(tag, "ON PREPARE FROM URI $uri") + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerListener.kt b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerListener.kt new file mode 100644 index 00000000..2c5400ad --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerListener.kt @@ -0,0 +1,102 @@ +package com.audiobookshelf.app.player + +import android.util.Log +import com.google.android.exoplayer2.PlaybackException +import com.google.android.exoplayer2.Player + +class PlayerListener(var playerNotificationService:PlayerNotificationService) : Player.Listener { + var tag = "PlayerListener" + + companion object { + var lastPauseTime: Long = 0 //ms + } + + private var onSeekBack: Boolean = false + + override fun onPlayerError(error: PlaybackException) { + error.message?.let { Log.e(tag, it) } + error.localizedMessage?.let { Log.e(tag, it) } + } + + override fun onEvents(player: Player, events: Player.Events) { + if (events.contains(Player.EVENT_POSITION_DISCONTINUITY)) { + Log.d(tag, "EVENT_POSITION_DISCONTINUITY") + } + + if (events.contains(Player.EVENT_IS_LOADING_CHANGED)) { + Log.d(tag, "EVENT_IS_LOADING_CHANGED : " + playerNotificationService.mPlayer.isLoading.toString()) + } + + if (events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED)) { + if (playerNotificationService.currentPlayer.playbackState == Player.STATE_READY) { + Log.d(tag, "STATE_READY : " + playerNotificationService.mPlayer.duration.toString()) + + if (lastPauseTime == 0L) { + playerNotificationService.sendClientMetadata("ready_no_sync") + lastPauseTime = -1; + } else playerNotificationService.sendClientMetadata("ready") + } + if (playerNotificationService.currentPlayer.playbackState == Player.STATE_BUFFERING) { + Log.d(tag, "STATE_BUFFERING : " + playerNotificationService.mPlayer.currentPosition.toString()) + if (lastPauseTime == 0L) playerNotificationService.sendClientMetadata("buffering_no_sync") + else playerNotificationService.sendClientMetadata("buffering") + } + if (playerNotificationService.currentPlayer.playbackState == Player.STATE_ENDED) { + Log.d(tag, "STATE_ENDED") + playerNotificationService.sendClientMetadata("ended") + } + if (playerNotificationService.currentPlayer.playbackState == Player.STATE_IDLE) { + Log.d(tag, "STATE_IDLE") + playerNotificationService.sendClientMetadata("idle") + } + } + + if (events.contains(Player.EVENT_MEDIA_METADATA_CHANGED)) { + Log.d(tag, "EVENT_MEDIA_METADATA_CHANGED") + } + if (events.contains(Player.EVENT_PLAYLIST_METADATA_CHANGED)) { + Log.d(tag, "EVENT_PLAYLIST_METADATA_CHANGED") + } + if (events.contains(Player.EVENT_IS_PLAYING_CHANGED)) { + Log.d(tag, "EVENT IS PLAYING CHANGED") + + if (player.isPlaying) { + if (lastPauseTime > 0) { + if (onSeekBack) onSeekBack = false + else { + var backTime = calcPauseSeekBackTime() + if (backTime > 0) { + if (backTime >= playerNotificationService.mPlayer.currentPosition) backTime = playerNotificationService.mPlayer.currentPosition - 500 + Log.d(tag, "SeekBackTime $backTime") + onSeekBack = true + playerNotificationService.seekBackward(backTime) + } + } + } + } else lastPauseTime = System.currentTimeMillis() + + // Start/stop progress sync interval + Log.d(tag, "Playing ${playerNotificationService.getCurrentBookTitle()}") + if (player.isPlaying) { + playerNotificationService.mediaProgressSyncer.start() + } else { + playerNotificationService.mediaProgressSyncer.stop() + } + + playerNotificationService.clientEventEmitter?.onPlayingUpdate(player.isPlaying) + } + } + + fun calcPauseSeekBackTime() : Long { + if (lastPauseTime <= 0) return 0 + var time: Long = System.currentTimeMillis() - lastPauseTime + var seekback: Long = 0 + if (time < 60000) seekback = 0 + else if (time < 120000) seekback = 10000 + else if (time < 300000) seekback = 15000 + else if (time < 1800000) seekback = 20000 + else if (time < 3600000) seekback = 25000 + else seekback = 29500 + return seekback + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationListener.kt b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationListener.kt new file mode 100644 index 00000000..d1db2bdc --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationListener.kt @@ -0,0 +1,31 @@ +package com.audiobookshelf.app.player + +import android.app.Notification +import android.util.Log +import com.google.android.exoplayer2.ui.PlayerNotificationManager + +class PlayerNotificationListener(var playerNotificationService:PlayerNotificationService) : PlayerNotificationManager.NotificationListener { + var tag = "PlayerNotificationListener" + + override fun onNotificationPosted( + notificationId: Int, + notification: Notification, + onGoing: Boolean) { + + // Start foreground service + Log.d(tag, "Notification Posted $notificationId - Start Foreground | $notification") + playerNotificationService.startForeground(notificationId, notification) + } + + override fun onNotificationCancelled( + notificationId: Int, + dismissedByUser: Boolean + ) { + if (dismissedByUser) { + Log.d(tag, "onNotificationCancelled dismissed by user") + playerNotificationService.stopSelf() + } else { + Log.d(tag, "onNotificationCancelled not dismissed by user") + } + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt new file mode 100644 index 00000000..25ef78ca --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt @@ -0,0 +1,594 @@ +package com.audiobookshelf.app.player + +import android.app.* +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.hardware.Sensor +import android.hardware.SensorManager +import android.os.* +import android.support.v4.media.MediaBrowserCompat +import android.support.v4.media.MediaDescriptionCompat +import android.support.v4.media.MediaMetadataCompat +import android.support.v4.media.session.MediaControllerCompat +import android.support.v4.media.session.MediaSessionCompat +import android.support.v4.media.session.PlaybackStateCompat +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationCompat +import androidx.media.MediaBrowserServiceCompat +import androidx.media.utils.MediaConstants +import com.audiobookshelf.app.data.LibraryItem +import com.audiobookshelf.app.data.LocalMediaProgress +import com.audiobookshelf.app.data.PlaybackSession +import com.audiobookshelf.app.device.DeviceManager +import com.audiobookshelf.app.media.MediaManager +import com.audiobookshelf.app.server.ApiHandler +import com.getcapacitor.JSObject +import com.google.android.exoplayer2.* +import com.google.android.exoplayer2.audio.AudioAttributes +import com.google.android.exoplayer2.ext.cast.CastPlayer +import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector +import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator +import com.google.android.exoplayer2.source.MediaSource +import com.google.android.exoplayer2.source.ProgressiveMediaSource +import com.google.android.exoplayer2.source.hls.HlsMediaSource +import com.google.android.exoplayer2.ui.PlayerControlView +import com.google.android.exoplayer2.ui.PlayerNotificationManager +import com.google.android.exoplayer2.upstream.* +import okhttp3.OkHttpClient +import java.util.* +import kotlin.concurrent.schedule + +const val SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m + +class PlayerNotificationService : MediaBrowserServiceCompat() { + + companion object { + var isStarted = false + } + + interface ClientEventEmitter { + fun onPlaybackSession(playbackSession:PlaybackSession) + fun onPlaybackClosed() + fun onPlayingUpdate(isPlaying: Boolean) + fun onMetadata(metadata: JSObject) + fun onPrepare(audiobookId: String, playWhenReady: Boolean) + fun onSleepTimerEnded(currentPosition: Long) + fun onSleepTimerSet(sleepTimeRemaining: Int) + fun onLocalMediaProgressUpdate(localMediaProgress: LocalMediaProgress) + } + + private val tag = "PlayerService" + private val binder = LocalBinder() + + var clientEventEmitter:ClientEventEmitter? = null + + private lateinit var ctx:Context + private lateinit var mediaSessionConnector: MediaSessionConnector + private lateinit var playerNotificationManager: PlayerNotificationManager + private lateinit var mediaSession: MediaSessionCompat + private lateinit var transportControls:MediaControllerCompat.TransportControls + lateinit var mediaManager: MediaManager + lateinit var apiHandler: ApiHandler + + lateinit var mPlayer: SimpleExoPlayer + lateinit var currentPlayer:Player + var castPlayer:CastPlayer? = null + + lateinit var sleepTimerManager:SleepTimerManager + lateinit var castManager:CastManager + lateinit var mediaProgressSyncer:MediaProgressSyncer + + private var notificationId = 10; + private var channelId = "audiobookshelf_channel" + private var channelName = "Audiobookshelf Channel" + + private var currentPlaybackSession:PlaybackSession? = null + + var isAndroidAuto = false + + // The following are used for the shake detection + private var isShakeSensorRegistered:Boolean = false + private var mSensorManager: SensorManager? = null + private var mAccelerometer: Sensor? = null + private var mShakeDetector: ShakeDetector? = null + private var shakeSensorUnregisterTask:TimerTask? = null + + /* + Service related stuff + */ + override fun onBind(intent: Intent): IBinder? { + Log.d(tag, "onBind") + + // Android Auto Media Browser Service + if (SERVICE_INTERFACE == intent.action) { + Log.d(tag, "Is Media Browser Service") + return super.onBind(intent); + } + return binder + } + + inner class LocalBinder : Binder() { + // Return this instance of LocalService so clients can call public methods + fun getService(): PlayerNotificationService = this@PlayerNotificationService + } + + fun stopService(context: Context) { + val stopIntent = Intent(context, PlayerNotificationService::class.java) + context.stopService(stopIntent) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + isStarted = true + Log.d(tag, "onStartCommand $startId") + + return START_STICKY + } + + override fun onStart(intent: Intent?, startId: Int) { + Log.d(tag, "onStart $startId") + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun createNotificationChannel(channelId: String, channelName: String): String { + val chan = NotificationChannel(channelId, + channelName, NotificationManager.IMPORTANCE_LOW) + chan.lightColor = Color.DKGRAY + chan.lockscreenVisibility = Notification.VISIBILITY_PUBLIC + val service = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + service.createNotificationChannel(chan) + return channelId + } + + // detach player + override fun onDestroy() { + playerNotificationManager.setPlayer(null) + mPlayer.release() + mediaSession.release() + mediaProgressSyncer.reset() + Log.d(tag, "onDestroy") + isStarted = false + + super.onDestroy() + } + + //removing service when user swipe out our app + override fun onTaskRemoved(rootIntent: Intent?) { + super.onTaskRemoved(rootIntent) + Log.d(tag, "onTaskRemoved") + stopSelf() + } + + + override fun onCreate() { + super.onCreate() + ctx = this + + // Initialize player + var customLoadControl:LoadControl = DefaultLoadControl.Builder().setBufferDurationsMs( + 1000 * 20, // 20s min buffer + 1000 * 45, // 45s max buffer + 1000 * 5, // 5s playback start + 1000 * 20 // 20s playback rebuffer + ).build() + + var simpleExoPlayerBuilder = SimpleExoPlayer.Builder(this) + simpleExoPlayerBuilder.setLoadControl(customLoadControl) + simpleExoPlayerBuilder.setSeekBackIncrementMs(10000) + simpleExoPlayerBuilder.setSeekForwardIncrementMs(10000) + mPlayer = simpleExoPlayerBuilder.build() + mPlayer.setHandleAudioBecomingNoisy(true) + mPlayer.addListener(PlayerListener(this)) + var audioAttributes:AudioAttributes = AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.CONTENT_TYPE_SPEECH).build() + mPlayer.setAudioAttributes(audioAttributes, true) + + currentPlayer = mPlayer + + // Initialize API + apiHandler = ApiHandler(ctx) + + // Initialize sleep timer + sleepTimerManager = SleepTimerManager(this) + + // Initialize Cast Manager + castManager = CastManager(this) + + // Initialize Media Progress Syncer + mediaProgressSyncer = MediaProgressSyncer(this, apiHandler) + + // Initialize shake sensor + Log.d(tag, "onCreate Register sensor listener ${mAccelerometer?.isWakeUpSensor}") + initSensor() + + // Initialize media manager + mediaManager = MediaManager(apiHandler) + + channelId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + createNotificationChannel(channelId, channelName) + } else "" + + val sessionActivityPendingIntent = + packageManager?.getLaunchIntentForPackage(packageName)?.let { sessionIntent -> + PendingIntent.getActivity(this, 0, sessionIntent, 0) + } + + mediaSession = MediaSessionCompat(this, tag) + .apply { + setSessionActivity(sessionActivityPendingIntent) + isActive = true + } + + val mediaController = MediaControllerCompat(ctx, mediaSession.sessionToken) + + // This is for Media Browser + sessionToken = mediaSession.sessionToken + + val builder = PlayerNotificationManager.Builder( + ctx, + notificationId, + channelId) + + builder.setMediaDescriptionAdapter(AbMediaDescriptionAdapter(mediaController, this)) + builder.setNotificationListener(PlayerNotificationListener(this)) + + playerNotificationManager = builder.build() + playerNotificationManager.setMediaSessionToken(mediaSession.sessionToken) + playerNotificationManager.setUsePlayPauseActions(true) + playerNotificationManager.setUseNextAction(false) + playerNotificationManager.setUsePreviousAction(false) + playerNotificationManager.setUseChronometer(false) + playerNotificationManager.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX) + playerNotificationManager.setUseFastForwardActionInCompactView(true) + playerNotificationManager.setUseRewindActionInCompactView(true) + + // Unknown action + playerNotificationManager.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE) + + transportControls = mediaController.transportControls + + mediaSessionConnector = MediaSessionConnector(mediaSession) + val queueNavigator: TimelineQueueNavigator = object : TimelineQueueNavigator(mediaSession) { + override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat { + var builder = MediaDescriptionCompat.Builder() + .setMediaId(currentPlaybackSession!!.id) + .setTitle(currentPlaybackSession!!.displayTitle) + .setSubtitle(currentPlaybackSession!!.displayAuthor) + .setIconUri(currentPlaybackSession!!.getCoverUri()) + return builder.build() + } + // .setMediaUri(currentPlaybackSession!!.getContentUri()) + } + + mediaSessionConnector.setEnabledPlaybackActions( + PlaybackStateCompat.ACTION_PLAY_PAUSE + or PlaybackStateCompat.ACTION_PLAY + or PlaybackStateCompat.ACTION_PAUSE + or PlaybackStateCompat.ACTION_SEEK_TO + or PlaybackStateCompat.ACTION_FAST_FORWARD + or PlaybackStateCompat.ACTION_REWIND + or PlaybackStateCompat.ACTION_STOP + ) + mediaSessionConnector.setQueueNavigator(queueNavigator) + mediaSessionConnector.setPlaybackPreparer(MediaSessionPlaybackPreparer(this)) + mediaSessionConnector.setPlayer(mPlayer) + + //attach player to playerNotificationManager + playerNotificationManager.setPlayer(mPlayer) + mediaSession.setCallback(MediaSessionCallback(this)) + } + + /* + User callable methods + */ + fun preparePlayer(playbackSession: PlaybackSession, playWhenReady:Boolean) { + currentPlaybackSession = playbackSession + + clientEventEmitter?.onPlaybackSession(playbackSession) + + var metadata = playbackSession.getMediaMetadataCompat() + mediaSession.setMetadata(metadata) + var mediaItems = playbackSession.getMediaItems() + if (mPlayer == currentPlayer) { + + var mediaSource:MediaSource + + if (playbackSession.isLocal) { + Log.d(tag, "Playing Local Item") + var dataSourceFactory = DefaultDataSourceFactory(ctx, channelId) + mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0]) + } else if (!playbackSession.isHLS) { + Log.d(tag, "Direct Playing Item") + var dataSourceFactory = DefaultDataSourceFactory(ctx, channelId) + mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0]) + } else { + Log.d(tag, "Playing HLS Item") + var dataSourceFactory = DefaultHttpDataSource.Factory() + dataSourceFactory.setUserAgent(channelId) + dataSourceFactory.setDefaultRequestProperties(hashMapOf("Authorization" to "Bearer ${DeviceManager.token}")) + mediaSource = HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0]) + } + mPlayer.setMediaSource(mediaSource) + + } else if (castPlayer != null) { + castPlayer?.addMediaItem(mediaItems[0]) // TODO: Media items never actually get added, not sure what is going on.... + Log.d(tag, "Cast Player ADDED MEDIA ITEM ${castPlayer?.currentMediaItem} | ${castPlayer?.duration} | ${castPlayer?.mediaItemCount}") + } + + // Add remaining media items if multi-track + if (mediaItems.size > 1) { + currentPlayer.addMediaItems(mediaItems.subList(1, mediaItems.size)) + Log.d(tag, "currentPlayer total media items ${currentPlayer.mediaItemCount}") + + var currentTrackIndex = playbackSession.getCurrentTrackIndex() + var currentTrackTime = playbackSession.getCurrentTrackTimeMs() + Log.d(tag, "currentPlayer current track index $currentTrackIndex & current track time $currentTrackTime") + currentPlayer.seekTo(currentTrackIndex, currentTrackTime) + } else { + currentPlayer.seekTo(playbackSession.currentTimeMs) + } + + Log.d(tag, "Prepare complete for session ${currentPlaybackSession?.displayTitle} | ${currentPlayer.mediaItemCount}") + currentPlayer.playWhenReady = playWhenReady + currentPlayer.setPlaybackSpeed(1f) // TODO: Playback speed should come from settings + currentPlayer.prepare() + } + + fun switchToPlayer(useCastPlayer: Boolean) { + currentPlayer = if (useCastPlayer) { + Log.d(tag, "switchToPlayer: Using Cast Player " + castPlayer?.deviceInfo) + mediaSessionConnector.setPlayer(castPlayer) + castPlayer as CastPlayer + } else { + Log.d(tag, "switchToPlayer: Using ExoPlayer") + mediaSessionConnector.setPlayer(mPlayer) + mPlayer + } + currentPlaybackSession?.let { + Log.d(tag, "switchToPlayer: Preparing current playback session ${it.displayTitle}") + preparePlayer(it, false) + } + } + + fun getCurrentTime() : Long { + if (currentPlayer.mediaItemCount > 1) { + var windowIndex = currentPlayer.currentWindowIndex + var currentTrackStartOffset = currentPlaybackSession?.getTrackStartOffsetMs(windowIndex) ?: 0L + return currentPlayer.currentPosition + currentTrackStartOffset + } else { + return currentPlayer.currentPosition + } + } + + fun getCurrentTimeSeconds() : Double { + return getCurrentTime() / 1000.0 + } + + fun getBufferedTime() : Long { + if (currentPlayer.mediaItemCount > 1) { + var windowIndex = currentPlayer.currentWindowIndex + var currentTrackStartOffset = currentPlaybackSession?.getTrackStartOffsetMs(windowIndex) ?: 0L + return currentPlayer.bufferedPosition + currentTrackStartOffset + } else { + return currentPlayer.bufferedPosition + } + } + + fun getDuration() : Long { + return currentPlayer.duration + } + + fun getCurrentBookTitle() : String? { + return currentPlaybackSession?.displayTitle + } + + fun getCurrentPlaybackSessionCopy() :PlaybackSession? { + return currentPlaybackSession?.clone() + } + + fun getCurrentPlaybackSessionId() :String? { + return currentPlaybackSession?.id + } + + fun play() { + if (currentPlayer.isPlaying) { + Log.d(tag, "Already playing") + return + } + currentPlayer.volume = 1F + if (currentPlayer == castPlayer) { + Log.d(tag, "CAST Player set on play ${currentPlayer.isLoading} || ${currentPlayer.duration} | ${currentPlayer.currentPosition}") + } + + currentPlayer.play() + } + + fun pause() { + currentPlayer.pause() + } + + fun playPause():Boolean { + return if (currentPlayer.isPlaying) { + pause() + false + } else { + play() + true + } + } + + fun seekPlayer(time: Long) { + if (currentPlayer.mediaItemCount > 1) { + currentPlaybackSession?.currentTime = time / 1000.0 + var newWindowIndex = currentPlaybackSession?.getCurrentTrackIndex() ?: 0 + var newTimeOffset = currentPlaybackSession?.getCurrentTrackTimeMs() ?: 0 + currentPlayer.seekTo(newWindowIndex, newTimeOffset) + } else { + currentPlayer.seekTo(time) + } + } + + fun seekForward(amount: Long) { + currentPlayer.seekTo(mPlayer.currentPosition + amount) + } + + fun seekBackward(amount: Long) { + currentPlayer.seekTo(mPlayer.currentPosition - amount) + } + + fun setPlaybackSpeed(speed: Float) { + currentPlayer.setPlaybackSpeed(speed) + } + + fun terminateStream() { + currentPlayer.clearMediaItems() + currentPlaybackSession = null + clientEventEmitter?.onPlaybackClosed() + PlayerListener.lastPauseTime = 0 + } + + fun sendClientMetadata(stateName: String) { + var metadata = JSObject() + var duration = currentPlaybackSession?.getTotalDuration() ?: 0 + metadata.put("duration", duration) + metadata.put("currentTime", getCurrentTime()) + metadata.put("stateName", stateName) + clientEventEmitter?.onMetadata(metadata) + } + + // + // MEDIA BROWSER STUFF (ANDROID AUTO) + // + private val ANDROID_AUTO_PKG_NAME = "com.google.android.projection.gearhead" + private val ANDROID_AUTO_SIMULATOR_PKG_NAME = "com.google.android.autosimulator" + private val ANDROID_WEARABLE_PKG_NAME = "com.google.android.wearable.app" + private val ANDROID_GSEARCH_PKG_NAME = "com.google.android.googlequicksearchbox" + private val ANDROID_AUTOMOTIVE_PKG_NAME = "com.google.android.carassistant" + private val VALID_MEDIA_BROWSERS = mutableListOf(ANDROID_AUTO_PKG_NAME, ANDROID_AUTO_SIMULATOR_PKG_NAME, ANDROID_WEARABLE_PKG_NAME, ANDROID_GSEARCH_PKG_NAME, ANDROID_AUTOMOTIVE_PKG_NAME) + + private val AUTO_MEDIA_ROOT = "/" + private val ALL_ROOT = "__ALL__" + private lateinit var browseTree:BrowseTree + + + // Only allowing android auto or similar to access media browser service + // normal loading of audiobooks is handled in webview (not natively) + private fun isValid(packageName: String, uid: Int) : Boolean { + Log.d(tag, "onGetRoot: Checking package $packageName with uid $uid") + if (!VALID_MEDIA_BROWSERS.contains(packageName)) { + Log.d(tag, "onGetRoot: package $packageName not valid for the media browser service") + return false + } + return true + } + + override fun onGetRoot(clientPackageName: String, clientUid: Int, rootHints: Bundle?): BrowserRoot? { + // Verify that the specified package is allowed to access your content + return if (!isValid(clientPackageName, clientUid)) { + // No further calls will be made to other media browsing methods. + null + } else { + // Flag is used to enable syncing progress natively (normally syncing is handled in webview) + isAndroidAuto = true + + val extras = Bundle() + extras.putBoolean( + MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true) + extras.putInt( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE, + MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM) + extras.putInt( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE, + MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM) + + BrowserRoot(AUTO_MEDIA_ROOT, extras) + } + } + + override fun onLoadChildren(parentMediaId: String, result: Result>) { + Log.d(tag, "ON LOAD CHILDREN $parentMediaId") + + var flag = if (parentMediaId == AUTO_MEDIA_ROOT) MediaBrowserCompat.MediaItem.FLAG_BROWSABLE else MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + + result.detach() + mediaManager.loadLibraryItems { libraryItems -> + var itemMediaMetadata:List = libraryItems.map { it.getMediaMetadata() } + browseTree = BrowseTree(this, mutableListOf(), itemMediaMetadata, mutableListOf()) + val children = browseTree[parentMediaId]?.map { item -> + MediaBrowserCompat.MediaItem(item.description, flag) + } + result.sendResult(children as MutableList?) + } + + // TODO: For using sub menus. Check if this is the root menu: +// if (AUTO_MEDIA_ROOT == parentMediaId) { + // build the MediaItem objects for the top level, + // and put them in the mediaItems list +// } else { + // examine the passed parentMediaId to see which submenu we're at, + // and put the children of that menu in the mediaItems list +// } + } + + override fun onSearch(query: String, extras: Bundle?, result: Result>) { + result.detach() + mediaManager.loadLibraryItems { libraryItems -> + var itemMediaMetadata:List = libraryItems.map { it.getMediaMetadata() } + browseTree = BrowseTree(this, mutableListOf(), itemMediaMetadata, mutableListOf()) + val children = browseTree[ALL_ROOT]?.map { item -> + MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) + } + result.sendResult(children as MutableList?) + } + } + + // + // SHAKE SENSOR + // + private fun initSensor() { + // ShakeDetector initialization + mSensorManager = getSystemService(SENSOR_SERVICE) as SensorManager + mAccelerometer = mSensorManager!!.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) + + mShakeDetector = ShakeDetector() + mShakeDetector!!.setOnShakeListener(object : ShakeDetector.OnShakeListener { + override fun onShake(count: Int) { + Log.d(tag, "PHONE SHAKE! $count") + sleepTimerManager.handleShake() + } + }) + } + + // Shake sensor used for sleep timer + fun registerSensor() { + if (isShakeSensorRegistered) { + Log.w(tag, "Shake sensor already registered") + return + } + shakeSensorUnregisterTask?.cancel() + + Log.d(tag, "Registering shake SENSOR ${mAccelerometer?.isWakeUpSensor}") + var success = mSensorManager!!.registerListener( + mShakeDetector, + mAccelerometer, + SensorManager.SENSOR_DELAY_UI + ) + if (success) isShakeSensorRegistered = true + } + + fun unregisterSensor() { + if (!isShakeSensorRegistered) return + + // Unregister shake sensor after wake up expiration + shakeSensorUnregisterTask?.cancel() + shakeSensorUnregisterTask = Timer("ShakeUnregisterTimer", false).schedule(SLEEP_TIMER_WAKE_UP_EXPIRATION) { + Handler(Looper.getMainLooper()).post() { + Log.d(tag, "wake time expired: Unregistering shake sensor") + mSensorManager!!.unregisterListener(mShakeDetector) + isShakeSensorRegistered = false + } + } + } +} + diff --git a/android/app/src/main/java/com/audiobookshelf/app/ShakeDetector.kt b/android/app/src/main/java/com/audiobookshelf/app/player/ShakeDetector.kt similarity index 97% rename from android/app/src/main/java/com/audiobookshelf/app/ShakeDetector.kt rename to android/app/src/main/java/com/audiobookshelf/app/player/ShakeDetector.kt index c61974a5..c6126e31 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/ShakeDetector.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/ShakeDetector.kt @@ -1,10 +1,9 @@ -package com.audiobookshelf.app +package com.audiobookshelf.app.player import android.hardware.Sensor import android.hardware.SensorEvent import android.hardware.SensorEventListener import android.hardware.SensorManager -import java.lang.Math.sqrt import kotlin.math.sqrt class ShakeDetector : SensorEventListener { diff --git a/android/app/src/main/java/com/audiobookshelf/app/SleepTimerManager.kt b/android/app/src/main/java/com/audiobookshelf/app/player/SleepTimerManager.kt similarity index 90% rename from android/app/src/main/java/com/audiobookshelf/app/SleepTimerManager.kt rename to android/app/src/main/java/com/audiobookshelf/app/player/SleepTimerManager.kt index 7d5e3dae..39d2a389 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/SleepTimerManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/SleepTimerManager.kt @@ -1,4 +1,4 @@ -package com.audiobookshelf.app +package com.audiobookshelf.app.player import android.os.Handler import android.os.Looper @@ -87,7 +87,7 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification } } - playerNotificationService.listener?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds()) + playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds()) sleepTimerRunning = true sleepTimerTask = Timer("SleepTimer", false).schedule(0L, 1000L) { @@ -99,14 +99,14 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification Log.d(tag, "Timer Elapsed $sleepTimerElapsed | Sleep TIMER time remaining $sleepTimeSecondsRemaining s") if (sleepTimeSecondsRemaining > 0) { - playerNotificationService.listener?.onSleepTimerSet(sleepTimeSecondsRemaining) + playerNotificationService.clientEventEmitter?.onSleepTimerSet(sleepTimeSecondsRemaining) } if (sleepTimeSecondsRemaining <= 0) { Log.d(tag, "Sleep Timer Pausing Player on Chapter") pause() - playerNotificationService.listener?.onSleepTimerEnded(getCurrentTime()) + playerNotificationService.clientEventEmitter?.onSleepTimerEnded(getCurrentTime()) clearSleepTimer() sleepTimerFinishedAt = System.currentTimeMillis() } else if (sleepTimeSecondsRemaining <= 30) { @@ -136,7 +136,7 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification fun cancelSleepTimer() { Log.d(tag, "Canceling Sleep Timer") clearSleepTimer() - playerNotificationService.listener?.onSleepTimerSet(0) + playerNotificationService.clientEventEmitter?.onSleepTimerSet(0) } private fun extendSleepTime() { @@ -150,7 +150,7 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification if (sleepTimerEndTime > getDuration()) sleepTimerEndTime = getDuration() } - playerNotificationService.listener?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds()) + playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds()) } fun checkShouldExtendSleepTimer() { @@ -197,7 +197,7 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification } setVolume(1F) - playerNotificationService.listener?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds()) + playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds()) } fun decreaseSleepTime(time: Long) { @@ -219,6 +219,6 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification } setVolume(1F) - playerNotificationService.listener?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds()) + playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds()) } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/MyNativeAudio.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt similarity index 60% rename from android/app/src/main/java/com/audiobookshelf/app/MyNativeAudio.kt rename to android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt index 2da64dbd..e27e3914 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/MyNativeAudio.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt @@ -1,31 +1,47 @@ -package com.audiobookshelf.app +package com.audiobookshelf.app.plugins import android.content.Intent import android.os.Handler import android.os.Looper import android.util.Log import androidx.core.content.ContextCompat -import com.capacitorjs.plugins.app.AppPlugin +import com.audiobookshelf.app.MainActivity +import com.audiobookshelf.app.data.LocalMediaProgress +import com.audiobookshelf.app.data.PlaybackSession +import com.audiobookshelf.app.device.DeviceManager +import com.audiobookshelf.app.player.CastManager +import com.audiobookshelf.app.player.PlayerNotificationService +import com.audiobookshelf.app.server.ApiHandler +import com.fasterxml.jackson.core.json.JsonReadFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.getcapacitor.* import com.getcapacitor.annotation.CapacitorPlugin import org.json.JSONObject -@CapacitorPlugin(name = "MyNativeAudio") -class MyNativeAudio : Plugin() { - private val tag = "MyNativeAudio" +@CapacitorPlugin(name = "AbsAudioPlayer") +class AbsAudioPlayer : Plugin() { + private val tag = "AbsAudioPlayer" - lateinit var mainActivity:MainActivity + lateinit var mainActivity: MainActivity + lateinit var apiHandler:ApiHandler lateinit var playerNotificationService: PlayerNotificationService override fun load() { mainActivity = (activity as MainActivity) + apiHandler = ApiHandler(mainActivity) var foregroundServiceReady : () -> Unit = { playerNotificationService = mainActivity.foregroundService - playerNotificationService.setBridge(bridge) + playerNotificationService.clientEventEmitter = (object : PlayerNotificationService.ClientEventEmitter { + override fun onPlaybackSession(playbackSession: PlaybackSession) { + notifyListeners("onPlaybackSession", JSObject(jacksonObjectMapper().writeValueAsString(playbackSession))) + } + + override fun onPlaybackClosed() { + emit("onPlaybackClosed", true) + } - playerNotificationService.setCustomObjectListener(object : PlayerNotificationService.MyCustomObjectListener { override fun onPlayingUpdate(isPlaying: Boolean) { emit("onPlayingUpdate", isPlaying) } @@ -48,6 +64,10 @@ class MyNativeAudio : Plugin() { override fun onSleepTimerSet(sleepTimeRemaining: Int) { emit("onSleepTimerSet", sleepTimeRemaining) } + + override fun onLocalMediaProgressUpdate(localMediaProgress: LocalMediaProgress) { + notifyListeners("onLocalMediaProgressUpdate", JSObject(jacksonObjectMapper().writeValueAsString(localMediaProgress))) + } }) } mainActivity.pluginCallback = foregroundServiceReady @@ -60,27 +80,43 @@ class MyNativeAudio : Plugin() { } @PluginMethod - fun initPlayer(call: PluginCall) { + fun prepareLibraryItem(call: PluginCall) { + // Need to make sure the player service has been started if (!PlayerNotificationService.isStarted) { - Log.w(tag, "Starting foreground service --") + Log.w(tag, "prepareLibraryItem: PlayerService not started - Starting foreground service --") Intent(mainActivity, PlayerNotificationService::class.java).also { intent -> ContextCompat.startForegroundService(mainActivity, intent) } } - var jsobj = JSObject() - var audiobookStreamData:AudiobookStreamData = AudiobookStreamData(call.data) - if (audiobookStreamData.playlistUrl == "" && audiobookStreamData.contentUrl == "") { - Log.e(tag, "Invalid URL for init audio player") + var libraryItemId = call.getString("libraryItemId", "").toString() + var episodeId = call.getString("episodeId", "").toString() + var playWhenReady = call.getBoolean("playWhenReady") == true - jsobj.put("success", false) - return call.resolve(jsobj) + if (libraryItemId.isEmpty()) { + Log.e(tag, "Invalid call to play library item no library item id") + return call.resolve() } - Handler(Looper.getMainLooper()).post() { - playerNotificationService.initPlayer(audiobookStreamData) - jsobj.put("success", true) - call.resolve(jsobj) + if (libraryItemId.startsWith("local")) { // Play local media item + DeviceManager.dbManager.getLocalLibraryItem(libraryItemId)?.let { + Handler(Looper.getMainLooper()).post() { + Log.d(tag, "Preparing Local Media item ${jacksonObjectMapper().writeValueAsString(it)}") + var playbackSession = it.getPlaybackSession(episodeId) + playerNotificationService.preparePlayer(playbackSession, playWhenReady) + } + return call.resolve(JSObject()) + } + } else { // Play library item from server + apiHandler.playLibraryItem(libraryItemId, episodeId, false) { + + Handler(Looper.getMainLooper()).post() { + Log.d(tag, "Preparing Player TEST ${jacksonObjectMapper().writeValueAsString(it)}") + playerNotificationService.preparePlayer(it, playWhenReady) + } + + call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(it))) + } } } @@ -96,28 +132,6 @@ class MyNativeAudio : Plugin() { } } - @PluginMethod - fun getStreamSyncData(call: PluginCall) { - Handler(Looper.getMainLooper()).post() { - var isPlaying = playerNotificationService.getPlayStatus() - var lastPauseTime = playerNotificationService.getTheLastPauseTime() - Log.d(tag, "Get Last Pause Time $lastPauseTime") - var currentTime = playerNotificationService.getCurrentTime() - //if (!isPlaying) currentTime -= playerNotificationService.calcPauseSeekBackTime() - var id = playerNotificationService.getCurrentAudiobookId() - Log.d(tag, "Get Current id $id") - var duration = playerNotificationService.getDuration() - Log.d(tag, "Get duration $duration") - val ret = JSObject() - ret.put("lastPauseTime", lastPauseTime) - ret.put("currentTime", currentTime) - ret.put("isPlaying", isPlaying) - ret.put("id", id) - ret.put("duration", duration) - call.resolve(ret) - } - } - @PluginMethod fun pausePlayer(call: PluginCall) { Handler(Looper.getMainLooper()).post() { @@ -134,6 +148,14 @@ class MyNativeAudio : Plugin() { } } + @PluginMethod + fun playPause(call: PluginCall) { + Handler(Looper.getMainLooper()).post() { + var playing = playerNotificationService.playPause() + call.resolve(JSObject("{\"playing\":$playing}")) + } + } + @PluginMethod fun seekPlayer(call: PluginCall) { var time:Long = call.getString("timeMs", "0")!!.toLong() @@ -233,19 +255,19 @@ class MyNativeAudio : Plugin() { @PluginMethod fun requestSession(call: PluginCall) { Log.d(tag, "CAST REQUEST SESSION PLUGIN") + call.resolve() + playerNotificationService.castManager.requestSession(mainActivity, object : CastManager.RequestSessionCallback() { + override fun onError(errorCode: Int) { + Log.e(tag, "CAST REQUEST SESSION CALLBACK ERROR $errorCode") + } - playerNotificationService.castManager.requestSession(mainActivity, object : CastManager.RequestSessionCallback() { - override fun onError(errorCode: Int) { - Log.e(tag, "CAST REQUEST SESSION CALLBACK ERROR $errorCode") - } + override fun onCancel() { + Log.d(tag, "CAST REQUEST SESSION ON CANCEL") + } - override fun onCancel() { - Log.d(tag, "CAST REQUEST SESSION ON CANCEL") - } - - override fun onJoin(jsonSession: JSONObject?) { - Log.d(tag, "CAST REQUEST SESSION ON JOIN") - } - }) + override fun onJoin(jsonSession: JSONObject?) { + Log.d(tag, "CAST REQUEST SESSION ON JOIN") + } + }) } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt new file mode 100644 index 00000000..cf1d8222 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt @@ -0,0 +1,238 @@ +package com.audiobookshelf.app.data + +import android.util.Log +import com.audiobookshelf.app.MainActivity +import com.audiobookshelf.app.device.DeviceManager +import com.audiobookshelf.app.server.ApiHandler +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.getcapacitor.JSObject +import com.getcapacitor.Plugin +import com.getcapacitor.PluginCall +import com.getcapacitor.PluginMethod +import com.getcapacitor.annotation.CapacitorPlugin +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import org.json.JSONObject + +@CapacitorPlugin(name = "AbsDatabase") +class AbsDatabase : Plugin() { + val tag = "AbsDatabase" + + lateinit var mainActivity: MainActivity + lateinit var apiHandler: ApiHandler + + data class LocalMediaProgressPayload(val value:List) + data class LocalLibraryItemsPayload(val value:List) + data class LocalFoldersPayload(val value:List) + + override fun load() { + mainActivity = (activity as MainActivity) + apiHandler = ApiHandler(mainActivity) + } + + @PluginMethod + fun getDeviceData(call:PluginCall) { + GlobalScope.launch(Dispatchers.IO) { + var deviceData = DeviceManager.dbManager.getDeviceData() + call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(deviceData))) + } + } + + @PluginMethod + fun getLocalFolders(call:PluginCall) { + GlobalScope.launch(Dispatchers.IO) { + var folders = DeviceManager.dbManager.getAllLocalFolders() + call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(LocalFoldersPayload(folders)))) + } + } + + @PluginMethod + fun getLocalFolder(call:PluginCall) { + var folderId = call.getString("folderId", "").toString() + GlobalScope.launch(Dispatchers.IO) { + DeviceManager.dbManager.getLocalFolder(folderId)?.let { + var folderObj = jacksonObjectMapper().writeValueAsString(it) + call.resolve(JSObject(folderObj)) + } ?: call.resolve() + } + } + + @PluginMethod + fun getLocalLibraryItem(call:PluginCall) { + var id = call.getString("id", "").toString() + + GlobalScope.launch(Dispatchers.IO) { + var localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(id) + if (localLibraryItem == null) { + call.resolve() + } else { + call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(localLibraryItem))) + } + } + } + + @PluginMethod + fun getLocalLibraryItemByLLId(call:PluginCall) { + var libraryItemId = call.getString("libraryItemId", "").toString() + GlobalScope.launch(Dispatchers.IO) { + var localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLLId(libraryItemId) + if (localLibraryItem == null) { + call.resolve() + } else { + call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(localLibraryItem))) + } + } + } + + @PluginMethod + fun getLocalLibraryItems(call:PluginCall) { + var mediaType = call.getString("mediaType", "").toString() + + GlobalScope.launch(Dispatchers.IO) { + var localLibraryItems = DeviceManager.dbManager.getLocalLibraryItems(mediaType) + call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(LocalLibraryItemsPayload(localLibraryItems)))) + } + } + + @PluginMethod + fun getLocalLibraryItemsInFolder(call:PluginCall) { + var folderId = call.getString("folderId", "").toString() + GlobalScope.launch(Dispatchers.IO) { + var localLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(folderId) + call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(LocalLibraryItemsPayload(localLibraryItems)))) + } + } + + @PluginMethod + fun setCurrentServerConnectionConfig(call:PluginCall) { + var serverConnectionConfigId = call.getString("id", "").toString() + var serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConnectionConfigId } + + var userId = call.getString("userId", "").toString() + var username = call.getString("username", "").toString() + var token = call.getString("token", "").toString() + + GlobalScope.launch(Dispatchers.IO) { + if (serverConnectionConfig == null) { // New Server Connection + var serverAddress = call.getString("address", "").toString() + + // Create new server connection config + var sscId = DeviceManager.getBase64Id("$serverAddress@$username") + var sscIndex = DeviceManager.deviceData.serverConnectionConfigs.size + serverConnectionConfig = ServerConnectionConfig(sscId, sscIndex, "$serverAddress ($username)", serverAddress, userId, username, token) + + // Add and save + DeviceManager.deviceData.serverConnectionConfigs.add(serverConnectionConfig!!) + DeviceManager.deviceData.lastServerConnectionConfigId = serverConnectionConfig?.id + DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData) + } else { + var shouldSave = false + if (serverConnectionConfig?.username != username || serverConnectionConfig?.token != token) { + serverConnectionConfig?.userId = userId + serverConnectionConfig?.username = username + serverConnectionConfig?.name = "${serverConnectionConfig?.address} (${serverConnectionConfig?.username})" + serverConnectionConfig?.token = token + shouldSave = true + } + + // Set last connection config + if (DeviceManager.deviceData.lastServerConnectionConfigId != serverConnectionConfigId) { + DeviceManager.deviceData.lastServerConnectionConfigId = serverConnectionConfigId + shouldSave = true + } + + if (shouldSave) DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData) + } + + DeviceManager.serverConnectionConfig = serverConnectionConfig + call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(DeviceManager.serverConnectionConfig))) + } + } + + @PluginMethod + fun removeServerConnectionConfig(call:PluginCall) { + GlobalScope.launch(Dispatchers.IO) { + var serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString() + DeviceManager.deviceData.serverConnectionConfigs = DeviceManager.deviceData.serverConnectionConfigs.filter { it.id != serverConnectionConfigId } as MutableList + if (DeviceManager.deviceData.lastServerConnectionConfigId == serverConnectionConfigId) { + DeviceManager.deviceData.lastServerConnectionConfigId = null + } + DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData) + if (DeviceManager.serverConnectionConfig?.id == serverConnectionConfigId) { + DeviceManager.serverConnectionConfig = null + } + call.resolve() + } + } + + @PluginMethod + fun logout(call:PluginCall) { + GlobalScope.launch(Dispatchers.IO) { + DeviceManager.serverConnectionConfig = null + DeviceManager.deviceData.lastServerConnectionConfigId = null + DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData) + call.resolve() + } + } + + @PluginMethod + fun getAllLocalMediaProgress(call:PluginCall) { + GlobalScope.launch(Dispatchers.IO) { + var localMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress() + call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(LocalMediaProgressPayload(localMediaProgress)))) + } + } + + @PluginMethod + fun removeLocalMediaProgress(call:PluginCall) { + var localMediaProgressId = call.getString("localMediaProgressId", "").toString() + DeviceManager.dbManager.removeLocalMediaProgress(localMediaProgressId) + call.resolve() + } + + @PluginMethod + fun syncLocalMediaProgressWithServer(call:PluginCall) { + if (DeviceManager.serverConnectionConfig == null) { + Log.e(tag, "syncLocalMediaProgressWithServer not connected to server") + return call.resolve() + } + apiHandler.syncMediaProgress { + call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(it))) + } + } + + // + // Generic Webview calls to db + // + @PluginMethod + fun saveFromWebview(call: PluginCall) { + var db = call.getString("db", "").toString() + var key = call.getString("key", "").toString() + var value = call.getObject("value") + + GlobalScope.launch(Dispatchers.IO) { + if (db == "" || key == "" || value == null) { + Log.d(tag, "saveFromWebview Invalid key/value") + } else { + var json = value as JSONObject + DeviceManager.dbManager.saveObject(db, key, json) + } + call.resolve() + } + } + + @PluginMethod + fun loadFromWebview(call:PluginCall) { + var db = call.getString("db", "").toString() + var key = call.getString("key", "").toString() + if (db == "" || key == "") { + Log.d(tag, "loadFromWebview Invalid Key") + call.resolve() + return + } + var json = DeviceManager.dbManager.loadObject(db, key) + var jsobj = JSObject.fromJSONObject(json) + call.resolve(jsobj) + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt new file mode 100644 index 00000000..75c56558 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt @@ -0,0 +1,351 @@ +package com.audiobookshelf.app.plugins + +import android.app.DownloadManager +import android.content.Context +import android.net.Uri +import android.os.Build +import android.util.Log +import com.audiobookshelf.app.MainActivity +import com.audiobookshelf.app.data.* +import com.audiobookshelf.app.device.DeviceManager +import com.audiobookshelf.app.device.FolderScanner +import com.audiobookshelf.app.server.ApiHandler +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.getcapacitor.JSObject +import com.getcapacitor.Plugin +import com.getcapacitor.PluginCall +import com.getcapacitor.PluginMethod +import com.getcapacitor.annotation.CapacitorPlugin +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.io.File +import java.util.* + + +@CapacitorPlugin(name = "AbsDownloader") +class AbsDownloader : Plugin() { + private val tag = "AbsDownloader" + + lateinit var mainActivity: MainActivity + lateinit var downloadManager: DownloadManager + lateinit var apiHandler: ApiHandler + lateinit var folderScanner: FolderScanner + + data class DownloadItemPart( + val id: String, + val filename: String, + val destinationPath:String, + val itemTitle: String, + val serverPath: String, + val localFolderName: String, + val localFolderId: String, + val audioTrack: AudioTrack?, + val episode:PodcastEpisode?, + var completed:Boolean, + @JsonIgnore val uri: Uri, + @JsonIgnore val destinationUri: Uri, + var downloadId: Long?, + var progress: Long + ) { + @JsonIgnore + fun getDownloadRequest(): DownloadManager.Request { + var dlRequest = DownloadManager.Request(uri) + dlRequest.setTitle(filename) + dlRequest.setDescription("Downloading to $localFolderName for book $itemTitle") + dlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + dlRequest.setDestinationUri(destinationUri) + return dlRequest + } + } + + data class DownloadItem( + val id: String, + val libraryItemId:String, + val episodeId:String?, + val serverConnectionConfigId:String, + val serverAddress:String, + val serverUserId:String, + val mediaType: String, + val itemFolderPath:String, + val localFolder: LocalFolder, + val itemTitle: String, + val media:MediaType, + val downloadItemParts: MutableList + ) + + var downloadQueue: MutableList = mutableListOf() + + override fun load() { + mainActivity = (activity as MainActivity) + downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + folderScanner = FolderScanner(mainActivity) + apiHandler = ApiHandler(mainActivity) + + var recieverEvent: (evt: String, id: Long) -> Unit = { evt: String, id: Long -> + if (evt == "complete") { + } + if (evt == "clicked") { + Log.d(tag, "Clicked $id back in the downloader") + } + } + mainActivity.registerBroadcastReceiver(recieverEvent) + + Log.d(tag, "Build SDK ${Build.VERSION.SDK_INT}") + } + + @PluginMethod + fun downloadLibraryItem(call: PluginCall) { + var libraryItemId = call.data.getString("libraryItemId").toString() + var episodeId = call.data.getString("episodeId").toString() + var localFolderId = call.data.getString("localFolderId").toString() + Log.d(tag, "Download library item $libraryItemId to folder $localFolderId") + + var downloadId = if (episodeId.isNullOrEmpty()) libraryItemId else "$libraryItemId-$episodeId" + if (downloadQueue.find { it.id == downloadId } != null) { + Log.d(tag, "Download already started for this media entity $downloadId") + return call.resolve(JSObject("{\"error\":\"Download already started for this media entity\"}")) + } + + apiHandler.getLibraryItem(libraryItemId) { libraryItem -> + Log.d(tag, "Got library item from server ${libraryItem.id}") + + var localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId) + if (localFolder != null) { + + if (!episodeId.isNullOrEmpty() && libraryItem.mediaType != "podcast") { + Log.e(tag, "Library item is not a podcast but episode was requested") + call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}")) + } else if (!episodeId.isNullOrEmpty()) { + var podcast = libraryItem.media as Podcast + var episode = podcast.episodes?.find { podcastEpisode -> + podcastEpisode.id == episodeId + } + if (episode == null) { + call.resolve(JSObject("{\"error\":\"Invalid podcast episode not found\"}")) + } else { + startLibraryItemDownload(libraryItem, localFolder, episode) + call.resolve() + } + } else { + startLibraryItemDownload(libraryItem, localFolder, null) + call.resolve() + } + } else { + call.resolve(JSObject("{\"error\":\"Local Folder Not Found\"}")) + } + } + } + + // Clean folder path so it can be used in URL + fun cleanRelPath(relPath: String): String { + var cleanedRelPath = relPath.replace("\\", "/").replace("%", "%25").replace("#", "%23") + return if (cleanedRelPath.startsWith("/")) cleanedRelPath.substring(1) else cleanedRelPath + } + + // Item filenames could be the same if they are in subfolders, this will make them unique + fun getFilenameFromRelPath(relPath: String): String { + var cleanedRelPath = relPath.replace("\\", "_").replace("/", "_") + return if (cleanedRelPath.startsWith("_")) cleanedRelPath.substring(1) else cleanedRelPath + } + + fun getAbMetadataText(libraryItem:LibraryItem):String { + var bookMedia = libraryItem.media as com.audiobookshelf.app.data.Book + var fileString = ";ABMETADATA1\n" +// fileString += "#libraryItemId=${libraryItem.id}\n" +// fileString += "title=${bookMedia.metadata.title}\n" +// fileString += "author=${bookMedia.metadata.authorName}\n" +// fileString += "narrator=${bookMedia.metadata.narratorName}\n" +// fileString += "series=${bookMedia.metadata.seriesName}\n" + return fileString + } + + fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) { + if (libraryItem.mediaType == "book") { + var bookTitle = libraryItem.media.metadata.title + var tracks = libraryItem.media.getAudioTracks() + Log.d(tag, "Starting library item download with ${tracks.size} tracks") + var itemFolderPath = localFolder.absolutePath + "/" + bookTitle + var downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, libraryItem.media, mutableListOf()) + + // Create download item part for each audio track + tracks.forEach { audioTrack -> + var serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioTrack.relPath)}" + var destinationFilename = getFilenameFromRelPath(audioTrack.relPath) + Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName ${destinationFilename}") + var destinationFile = File("$itemFolderPath/$destinationFilename") + + if (destinationFile.exists()) { + Log.d(tag, "Audio file already exists, removing it from ${destinationFile.absolutePath}") + destinationFile.delete() + } + + var destinationUri = Uri.fromFile(destinationFile) + var downloadUri = Uri.parse("${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}") + Log.d(tag, "Audio File Destination Uri $destinationUri | Download URI $downloadUri") + var downloadItemPart = DownloadItemPart(DeviceManager.getBase64Id(destinationFile.absolutePath), destinationFilename, destinationFile.absolutePath, bookTitle, serverPath, localFolder.name, localFolder.id, audioTrack, null, false, downloadUri, destinationUri, null, 0) + + downloadItem.downloadItemParts.add(downloadItemPart) + + var dlRequest = downloadItemPart.getDownloadRequest() + var downloadId = downloadManager.enqueue(dlRequest) + downloadItemPart.downloadId = downloadId + } + + if (downloadItem.downloadItemParts.isNotEmpty()) { + // Add cover download item + if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) { + var serverPath = "/api/items/${libraryItem.id}/cover?format=jpeg" + var destinationFilename = "cover.jpg" + var destinationFile = File("$itemFolderPath/$destinationFilename") + + if (destinationFile.exists()) { + Log.d(tag, "Cover already exists, removing it from ${destinationFile.absolutePath}") + destinationFile.delete() + } + + var destinationUri = Uri.fromFile(destinationFile) + var downloadUri = Uri.parse("${DeviceManager.serverAddress}${serverPath}&token=${DeviceManager.token}") + var downloadItemPart = DownloadItemPart(DeviceManager.getBase64Id(destinationFile.absolutePath), destinationFilename, destinationFile.absolutePath, bookTitle, serverPath, localFolder.name, localFolder.id, null,null, false, downloadUri, destinationUri, null, 0) + + downloadItem.downloadItemParts.add(downloadItemPart) + + var dlRequest = downloadItemPart.getDownloadRequest() + var downloadId = downloadManager.enqueue(dlRequest) + downloadItemPart.downloadId = downloadId + } + + // TODO: Cannot create new text file here but can download here... ?? +// var abmetadataFile = File(itemFolderPath, "abmetadata.abs") +// abmetadataFile.createNewFileIfPossible() +// abmetadataFile.writeText(getAbMetadataText(libraryItem)) + + downloadQueue.add(downloadItem) + startWatchingDownloads(downloadItem) + DeviceManager.dbManager.saveDownloadItem(downloadItem) + } + } else { + // Podcast episode download + + var podcastTitle = libraryItem.media.metadata.title + var audioTrack = episode?.audioTrack + Log.d(tag, "Starting podcast episode download") + var itemFolderPath = localFolder.absolutePath + "/" + podcastTitle + var downloadItemId = "${libraryItem.id}-${episode?.id}" + var downloadItem = DownloadItem(downloadItemId, libraryItem.id, episode?.id, DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, podcastTitle, libraryItem.media, mutableListOf()) + + var serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioTrack?.relPath ?: "")}" + var destinationFilename = getFilenameFromRelPath(audioTrack?.relPath ?: "") + Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack?.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName ${destinationFilename}") + var destinationFile = File("$itemFolderPath/$destinationFilename") + if (destinationFile.exists()) { + Log.d(tag, "Audio file already exists, removing it from ${destinationFile.absolutePath}") + destinationFile.delete() + } + + var destinationUri = Uri.fromFile(destinationFile) + var downloadUri = Uri.parse("${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}") + Log.d(tag, "Audio File Destination Uri $destinationUri | Download URI $downloadUri") + var downloadItemPart = DownloadItemPart(DeviceManager.getBase64Id(destinationFile.absolutePath), destinationFilename, destinationFile.absolutePath, podcastTitle, serverPath, localFolder.name, localFolder.id, audioTrack, episode,false, downloadUri, destinationUri, null, 0) + downloadItem.downloadItemParts.add(downloadItemPart) + + var dlRequest = downloadItemPart.getDownloadRequest() + var downloadId = downloadManager.enqueue(dlRequest) + downloadItemPart.downloadId = downloadId + + if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) { + var serverPath = "/api/items/${libraryItem.id}/cover?format=jpeg" + var destinationFilename = "cover.jpg" + var destinationFile = File("$itemFolderPath/$destinationFilename") + + if (destinationFile.exists()) { + Log.d(tag, "Podcast cover already exists - not downloading cover again") + } else { + var destinationUri = Uri.fromFile(destinationFile) + var downloadUri = Uri.parse("${DeviceManager.serverAddress}${serverPath}&token=${DeviceManager.token}") + var downloadItemPart = DownloadItemPart(DeviceManager.getBase64Id(destinationFile.absolutePath), destinationFilename, destinationFile.absolutePath, podcastTitle, serverPath, localFolder.name, localFolder.id, null,null, false, downloadUri, destinationUri, null, 0) + + downloadItem.downloadItemParts.add(downloadItemPart) + + var dlRequest = downloadItemPart.getDownloadRequest() + var downloadId = downloadManager.enqueue(dlRequest) + downloadItemPart.downloadId = downloadId + } + } + + downloadQueue.add(downloadItem) + startWatchingDownloads(downloadItem) + DeviceManager.dbManager.saveDownloadItem(downloadItem) + } + } + + fun startWatchingDownloads(downloadItem: DownloadItem) { + GlobalScope.launch(Dispatchers.IO) { + while (downloadItem.downloadItemParts.find { !it.completed } != null) { // While some item is not completed + var numPartsBefore = downloadItem.downloadItemParts.size + checkDownloads(downloadItem) + + // Keep database updated as item parts finish downloading + if (downloadItem.downloadItemParts.size > 0 && downloadItem.downloadItemParts.size != numPartsBefore) { + Log.d(tag, "Save download item on num parts changed from $numPartsBefore to ${downloadItem.downloadItemParts.size}") + DeviceManager.dbManager.saveDownloadItem(downloadItem) + } + + notifyListeners("onItemDownloadUpdate", JSObject(jacksonObjectMapper().writeValueAsString(downloadItem))) + delay(500) + } + + var localLibraryItem = folderScanner.scanDownloadItem(downloadItem) + DeviceManager.dbManager.removeDownloadItem(downloadItem.id) + downloadQueue.remove(downloadItem) + + Log.d(tag, "Item download complete ${downloadItem.itemTitle} | local library item id: ${localLibraryItem?.id} | Items remaining in Queue ${downloadQueue.size}") + + var jsobj = JSObject() + jsobj.put("libraryItemId", downloadItem.id) + jsobj.put("localFolderId", downloadItem.localFolder.id) + if (localLibraryItem != null) { + jsobj.put("localLibraryItem", JSObject(jacksonObjectMapper().writeValueAsString(localLibraryItem))) + } + notifyListeners("onItemDownloadComplete", jsobj) + } + } + + fun checkDownloads(downloadItem: DownloadItem) { + var itemParts = downloadItem.downloadItemParts.map { it } + for (downloadItemPart in itemParts) { + if (downloadItemPart.downloadId != null) { + var dlid = downloadItemPart.downloadId!! + val query = DownloadManager.Query().setFilterById(dlid) + downloadManager.query(query).use { + if (it.moveToFirst()) { + val totalBytes = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)) + val downloadStatus = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_STATUS)) + val bytesDownloadedSoFar = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)) + Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} bytes $totalBytes | bytes dled $bytesDownloadedSoFar | downloadStatus $downloadStatus") + + if (downloadStatus == DownloadManager.STATUS_SUCCESSFUL) { + Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Done") +// downloadItem.downloadItemParts.remove(downloadItemPart) + downloadItemPart.completed = true + } else if (downloadStatus == DownloadManager.STATUS_FAILED) { + Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Failed") + downloadItem.downloadItemParts.remove(downloadItemPart) +// downloadItemPart.completed = true + } else { + //update progress + val percentProgress = if (totalBytes > 0) ((bytesDownloadedSoFar * 100L) / totalBytes) else 0 + Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Progress = $percentProgress%") + downloadItemPart.progress = percentProgress + } + } else { + Log.d(tag, "Download ${downloadItemPart.filename} not found in dlmanager") + downloadItem.downloadItemParts.remove(downloadItemPart) + } + } + } + } + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt new file mode 100644 index 00000000..556e481e --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt @@ -0,0 +1,251 @@ +package com.audiobookshelf.app.plugins + +import android.database.Cursor +import android.net.Uri +import android.os.Build +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.documentfile.provider.DocumentFile +import com.anggrayudi.storage.SimpleStorage +import com.anggrayudi.storage.callback.FolderPickerCallback +import com.anggrayudi.storage.callback.StorageAccessCallback +import com.anggrayudi.storage.file.* +import com.audiobookshelf.app.MainActivity +import com.audiobookshelf.app.data.LocalFolder +import com.audiobookshelf.app.data.LocalLibraryItem +import com.audiobookshelf.app.device.DeviceManager +import com.audiobookshelf.app.device.FolderScanner +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.getcapacitor.* +import com.getcapacitor.annotation.CapacitorPlugin +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch + +@CapacitorPlugin(name = "AbsFileSystem") +class AbsFileSystem : Plugin() { + private val TAG = "AbsFileSystem" + private val tag = "AbsFileSystem" + + lateinit var mainActivity: MainActivity + + override fun load() { + mainActivity = (activity as MainActivity) + + mainActivity.storage.storageAccessCallback = object : StorageAccessCallback { + override fun onRootPathNotSelected( + requestCode: Int, + rootPath: String, + uri: Uri, + selectedStorageType: StorageType, + expectedStorageType: StorageType + ) { + Log.d(TAG, "STORAGE ACCESS CALLBACK") + } + + override fun onCanceledByUser(requestCode: Int) { + Log.d(TAG, "STORAGE ACCESS CALLBACK") + } + + override fun onExpectedStorageNotSelected(requestCode: Int, selectedFolder: DocumentFile, selectedStorageType: StorageType, expectedBasePath: String, expectedStorageType: StorageType) { + Log.d(TAG, "STORAGE ACCESS CALLBACK") + } + + override fun onStoragePermissionDenied(requestCode: Int) { + Log.d(TAG, "STORAGE ACCESS CALLBACK") + } + + override fun onRootPathPermissionGranted(requestCode: Int, root: DocumentFile) { + Log.d(TAG, "STORAGE ACCESS CALLBACK") + } + } + } + + @PluginMethod + fun selectFolder(call: PluginCall) { + var mediaType = call.data.getString("mediaType", "book").toString() + + mainActivity.storage.folderPickerCallback = object : FolderPickerCallback { + override fun onFolderSelected(requestCode: Int, folder: DocumentFile) { + Log.d(TAG, "ON FOLDER SELECTED ${folder.uri} ${folder.name}") + var absolutePath = folder.getAbsolutePath(activity) + var storageType = folder.getStorageType(activity) + var simplePath = folder.getSimplePath(activity) + var basePath = folder.getBasePath(activity) + var folderId = android.util.Base64.encodeToString(folder.id.toByteArray(), android.util.Base64.DEFAULT) + + var localFolder = LocalFolder(folderId, folder.name ?: "", folder.uri.toString(),basePath,absolutePath, simplePath, storageType.toString(), mediaType) + + DeviceManager.dbManager.saveLocalFolder(localFolder) + call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(localFolder))) + } + + override fun onStorageAccessDenied(requestCode: Int, folder: DocumentFile?, storageType: StorageType) { + Log.e(TAG, "STORAGE ACCESS DENIED") + var jsobj = JSObject() + jsobj.put("error", "Access Denied") + call.resolve(jsobj) + } + + override fun onStoragePermissionDenied(requestCode: Int) { + Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode") + var jsobj = JSObject() + jsobj.put("error", "Permission Denied") + call.resolve(jsobj) + } + } + + mainActivity.storage.openFolderPicker(6) + } + + @RequiresApi(Build.VERSION_CODES.R) + @PluginMethod + fun requestStoragePermission(call: PluginCall) { + Log.d(TAG, "Request Storage Permissions") + mainActivity.storageHelper.requestStorageAccess() + call.resolve() + } + + @PluginMethod + fun checkStoragePermission(call: PluginCall) { + var res = false + if (Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.P) { + res = SimpleStorage.hasStoragePermission(context) + Log.d(TAG, "checkStoragePermission: Check Storage Access $res") + } else { + Log.d(TAG, "checkStoragePermission: Has permission on Android 10 or up") + res = true + } + + var jsobj = JSObject() + jsobj.put("value", res) + call.resolve(jsobj) + } + + @PluginMethod + fun checkFolderPermissions(call: PluginCall) { + var folderUrl = call.data.getString("folderUrl", "").toString() + Log.d(TAG, "Check Folder Permissions for $folderUrl") + + var hasAccess = SimpleStorage.hasStorageAccess(context,folderUrl,true) + + var jsobj = JSObject() + jsobj.put("value", hasAccess) + call.resolve(jsobj) + } + + @PluginMethod + fun scanFolder(call: PluginCall) { + var folderId = call.data.getString("folderId", "").toString() + var forceAudioProbe = call.data.getBoolean("forceAudioProbe") + Log.d(TAG, "Scan Folder $folderId | Force Audio Probe $forceAudioProbe") + + var folder: LocalFolder? = DeviceManager.dbManager.getLocalFolder(folderId) + folder?.let { + var folderScanner = FolderScanner(context) + var folderScanResult = folderScanner.scanForMediaItems(it, forceAudioProbe) + if (folderScanResult == null) { + Log.d(TAG, "NO Scan DATA") + return call.resolve(JSObject()) + } else { + Log.d(TAG, "Scan DATA ${jacksonObjectMapper().writeValueAsString(folderScanResult)}") + return call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(folderScanResult))) + } + } ?: call.resolve(JSObject()) + } + + @PluginMethod + fun removeFolder(call: PluginCall) { + var folderId = call.data.getString("folderId", "").toString() + DeviceManager.dbManager.removeLocalFolder(folderId) + call.resolve() + } + + @PluginMethod + fun removeLocalLibraryItem(call: PluginCall) { + var localLibraryItemId = call.data.getString("localLibraryItemId", "").toString() + DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId) + call.resolve() + } + + @PluginMethod + fun scanLocalLibraryItem(call: PluginCall) { + var localLibraryItemId = call.data.getString("localLibraryItemId", "").toString() + var forceAudioProbe = call.data.getBoolean("forceAudioProbe") + Log.d(TAG, "Scan Local library item $localLibraryItemId | Force Audio Probe $forceAudioProbe") + GlobalScope.launch(Dispatchers.IO) { + var localLibraryItem: LocalLibraryItem? = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId) + localLibraryItem?.let { + var folderScanner = FolderScanner(context) + var scanResult = folderScanner.scanLocalLibraryItem(it, forceAudioProbe) + if (scanResult == null) { + Log.d(TAG, "NO Scan DATA") + call.resolve(JSObject()) + } else { + Log.d(TAG, "Scan DATA ${jacksonObjectMapper().writeValueAsString(scanResult)}") + call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(scanResult))) + } + } ?: call.resolve(JSObject()) + } + } + + @PluginMethod + fun deleteItem(call: PluginCall) { + var localLibraryItemId = call.data.getString("id", "").toString() + var absolutePath = call.data.getString("absolutePath", "").toString() + var contentUrl = call.data.getString("contentUrl", "").toString() + Log.d(tag, "deleteItem $absolutePath | $contentUrl") + + var docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl)) + var success = docfile?.delete() == true + if (success) { + DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId) + } + call.resolve(JSObject("{\"success\":$success}")) + } + + @PluginMethod + fun deleteTrackFromItem(call: PluginCall) { + var localLibraryItemId = call.data.getString("id", "").toString() + var trackLocalFileId = call.data.getString("trackLocalFileId", "").toString() + var contentUrl = call.data.getString("trackContentUrl", "").toString() + Log.d(tag, "deleteTrackFromItem $contentUrl") + + var localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId) + if (localLibraryItem == null) { + Log.e(tag, "deleteTrackFromItem: LLI does not exist $localLibraryItemId") + return call.resolve(JSObject("{\"success\":false}")) + } + + var docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl)) + var success = docfile?.delete() == true + if (success) { + localLibraryItem?.media?.removeAudioTrack(trackLocalFileId) + localLibraryItem?.removeLocalFile(trackLocalFileId) + DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem) + call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(localLibraryItem))) + } else { + call.resolve(JSObject("{\"success\":false}")) + } + } + + fun checkUriExists(uri: Uri?): Boolean { + if (uri == null) return false + val resolver = context.contentResolver + var cursor: Cursor? = null + return try { + cursor = resolver.query(uri, null, null, null, null) + //cursor null: content Uri was invalid or some other error occurred + //cursor.moveToFirst() false: Uri was ok but no entry found. + (cursor != null && cursor.moveToFirst()) + } catch (t: Throwable) { + false + } finally { + try { + cursor?.close() + } catch (t: Throwable) { + } + false + } + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt new file mode 100644 index 00000000..d195e475 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt @@ -0,0 +1,220 @@ +package com.audiobookshelf.app.server + +import android.content.Context +import android.content.SharedPreferences +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.util.Log +import androidx.core.content.ContextCompat.getSystemService +import com.audiobookshelf.app.data.Library +import com.audiobookshelf.app.data.LibraryItem +import com.audiobookshelf.app.data.LocalMediaProgress +import com.audiobookshelf.app.data.PlaybackSession +import com.audiobookshelf.app.device.DeviceManager +import com.audiobookshelf.app.player.MediaProgressSyncData +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.getcapacitor.JSArray +import com.getcapacitor.JSObject +import okhttp3.* +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.IOException + + +class ApiHandler { + val tag = "ApiHandler" + private var client = OkHttpClient() + var ctx: Context + var storageSharedPreferences: SharedPreferences? = null + + data class LocalMediaProgressSyncPayload(val localMediaProgress:List) + @JsonIgnoreProperties(ignoreUnknown = true) + data class MediaProgressSyncResponsePayload(val numServerProgressUpdates:Int, val localProgressUpdates:List) + data class LocalMediaProgressSyncResultsPayload(var numLocalMediaProgressForServer:Int, var numServerProgressUpdates:Int, var numLocalProgressUpdates:Int) + + constructor(_ctx: Context) { + ctx = _ctx + } + + fun getRequest(endpoint:String, cb: (JSObject) -> Unit) { + val request = Request.Builder() + .url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}") + .build() + makeRequest(request, cb) + } + + fun postRequest(endpoint:String, payload: JSObject, cb: (JSObject) -> Unit) { + val mediaType = "application/json; charset=utf-8".toMediaType() + val requestBody = payload.toString().toRequestBody(mediaType) + val request = Request.Builder().post(requestBody) + .url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}") + .build() + makeRequest(request, cb) + } + + fun isOnline(): Boolean { + val connectivityManager = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + if (connectivityManager != null) { + val capabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork) + if (capabilities != null) { + if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) { + Log.i("Internet", "NetworkCapabilities.TRANSPORT_CELLULAR") + return true + } else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) { + Log.i("Internet", "NetworkCapabilities.TRANSPORT_WIFI") + return true + } else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) { + Log.i("Internet", "NetworkCapabilities.TRANSPORT_ETHERNET") + return true + } + } + } + return false + } + + fun makeRequest(request:Request, cb: (JSObject) -> Unit) { + client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + Log.d(tag, "FAILURE TO CONNECT") + e.printStackTrace() + cb(JSObject()) + } + + override fun onResponse(call: Call, response: Response) { + response.use { + if (!it.isSuccessful) throw IOException("Unexpected code $response") + + var bodyString = it.body!!.string() + if (bodyString == "OK") { + cb(JSObject()) + } else { + var jsonObj = JSObject() + if (bodyString.startsWith("[")) { + var array = JSArray(bodyString) + jsonObj.put("value", array) + } else { + jsonObj = JSObject(bodyString) + } + cb(jsonObj) + } + } + } + }) + } + + fun getLibraries(cb: (List) -> Unit) { + val mapper = jacksonObjectMapper() + getRequest("/api/libraries") { + val libraries = mutableListOf() + if (it.has("value")) { + var array = it.getJSONArray("value")!! + for (i in 0 until array.length()) { + val library = mapper.readValue(array.get(i).toString()) + libraries.add(library) + } + } + cb(libraries) + } + } + + fun getLibraryItem(libraryItemId:String, cb: (LibraryItem) -> Unit) { + getRequest("/api/items/$libraryItemId?expanded=1") { + val libraryItem = jacksonObjectMapper().readValue(it.toString()) + cb(libraryItem) + } + } + + fun getLibraryItems(libraryId:String, cb: (List) -> Unit) { + getRequest("/api/libraries/$libraryId/items?limit=100&minified=1") { + val items = mutableListOf() + if (it.has("results")) { + var array = it.getJSONArray("results") + for (i in 0 until array.length()) { + val item = jacksonObjectMapper().readValue(array.get(i).toString()) + items.add(item) + } + } + cb(items) + } + } + + fun playLibraryItem(libraryItemId:String, episodeId:String, forceTranscode:Boolean, cb: (PlaybackSession) -> Unit) { + var payload = JSObject() + payload.put("mediaPlayer", "exo-player") + + // Only if direct play fails do we force transcode + // TODO: Fallback to transcode + if (!forceTranscode) payload.put("forceDirectPlay", true) + else payload.put("forceTranscode", true) + + val endpoint = if (episodeId.isNullOrEmpty()) "/api/items/$libraryItemId/play" else "/api/items/$libraryItemId/play/$episodeId" + postRequest(endpoint, payload) { + it.put("serverConnectionConfigId", DeviceManager.serverConnectionConfig?.id) + it.put("serverAddress", DeviceManager.serverAddress) + val playbackSession = jacksonObjectMapper().readValue(it.toString()) + cb(playbackSession) + } + } + + fun sendProgressSync(sessionId:String, syncData: MediaProgressSyncData, cb: () -> Unit) { + var payload = JSObject(jacksonObjectMapper().writeValueAsString(syncData)) + + postRequest("/api/session/$sessionId/sync", payload) { + cb() + } + } + + fun sendLocalProgressSync(playbackSession:PlaybackSession, cb: () -> Unit) { + var payload = JSObject(jacksonObjectMapper().writeValueAsString(playbackSession)) + + postRequest("/api/session/local", payload) { + cb() + } + } + + fun syncMediaProgress(cb: (LocalMediaProgressSyncResultsPayload) -> Unit) { + if (!isOnline()) { + Log.d(tag, "Error not online") + cb(LocalMediaProgressSyncResultsPayload(0,0,0)) + return + } + + // Get all local media progress connected to items on the current connected server + var localMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress().filter { + it.serverConnectionConfigId == DeviceManager.serverConnectionConfig?.id + } + + var localSyncResultsPayload = LocalMediaProgressSyncResultsPayload(localMediaProgress.size,0, 0) + + if (localMediaProgress.isNotEmpty()) { + Log.d(tag, "Sending sync local progress request with ${localMediaProgress.size} progress items") + var payload = JSObject(jacksonObjectMapper().writeValueAsString(LocalMediaProgressSyncPayload(localMediaProgress))) + postRequest("/api/me/sync-local-progress", payload) { + Log.d(tag, "Media Progress Sync payload $payload - response ${it.toString()}") + + if (it.toString() == "{}") { + Log.e(tag, "Progress sync received empty object") + } else { + val progressSyncResponsePayload = jacksonObjectMapper().readValue(it.toString()) + + localSyncResultsPayload.numLocalProgressUpdates = progressSyncResponsePayload.localProgressUpdates.size + localSyncResultsPayload.numServerProgressUpdates = progressSyncResponsePayload.numServerProgressUpdates + Log.d(tag, "Media Progress Sync | Local Updates: $localSyncResultsPayload") + if (progressSyncResponsePayload.localProgressUpdates.isNotEmpty()) { + // Update all local media progress + progressSyncResponsePayload.localProgressUpdates.forEach { localMediaProgress -> + DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress) + } + } + } + + cb(localSyncResultsPayload) + } + } else { + Log.d(tag, "No local media progress to sync") + cb(localSyncResultsPayload) + } + } +} diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index fd5eafc4..a99df0e9 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,7 +1,7 @@ - AudioBookshelf - AudioBookshelf + audiobookshelf + audiobookshelf com.audiobookshelf.app com.audiobookshelf.app diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index 590a60b1..5d0e5558 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -2,15 +2,15 @@ include ':capacitor-android' project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') -include ':capacitor-community-sqlite' -project(':capacitor-community-sqlite').projectDir = new File('../node_modules/@capacitor-community/sqlite/android') - include ':capacitor-app' project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android') include ':capacitor-dialog' project(':capacitor-dialog').projectDir = new File('../node_modules/@capacitor/dialog/android') +include ':capacitor-haptics' +project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/haptics/android') + include ':capacitor-network' project(':capacitor-network').projectDir = new File('../node_modules/@capacitor/network/android') @@ -22,6 +22,3 @@ project(':capacitor-storage').projectDir = new File('../node_modules/@capacitor/ include ':robingenz-capacitor-app-update' project(':robingenz-capacitor-app-update').projectDir = new File('../node_modules/@robingenz/capacitor-app-update/android') - -include ':capacitor-data-storage-sqlite' -project(':capacitor-data-storage-sqlite').projectDir = new File('../node_modules/capacitor-data-storage-sqlite/android') diff --git a/android/variables.gradle b/android/variables.gradle index 633d13f2..0a287a3c 100644 --- a/android/variables.gradle +++ b/android/variables.gradle @@ -1,5 +1,5 @@ ext { - minSdkVersion = 23 + minSdkVersion = 24 compileSdkVersion = 30 targetSdkVersion = 30 androidxActivityVersion = '1.2.0' diff --git a/components/app/Appbar.vue b/components/app/Appbar.vue index 9d8d24be..b7fe929d 100644 --- a/components/app/Appbar.vue +++ b/components/app/Appbar.vue @@ -7,16 +7,18 @@ arrow_back -
+
- + -

{{ currentLibraryName }}

+

{{ currentLibraryName }}

+ + search @@ -44,6 +46,7 @@ export default { return this.currentLibrary ? this.currentLibrary.name : 'Main' }, showBack() { + if (!this.$route.name) return true return this.$route.name !== 'index' && !this.$route.name.startsWith('bookshelf') }, user() { diff --git a/components/app/AudioPlayer.vue b/components/app/AudioPlayer.vue index 69f5def9..32cc9bb4 100644 --- a/components/app/AudioPlayer.vue +++ b/components/app/AudioPlayer.vue @@ -1,5 +1,5 @@ \ No newline at end of file diff --git a/components/app/SideDrawer.vue b/components/app/SideDrawer.vue index bcab1bc2..b62e11ab 100644 --- a/components/app/SideDrawer.vue +++ b/components/app/SideDrawer.vue @@ -3,25 +3,31 @@
-

+

Welcome, {{ username }}

+
-
-

{{ $config.version }}

-
-
-

Logout

- logout +
+
+

{{ serverConnectionConfig.address }}

+
+
+

{{ $config.version }}

+
+
+

Logout

+ logout +
@@ -62,6 +68,9 @@ export default { user() { return this.$store.state.user.user }, + serverConnectionConfig() { + return this.$store.state.user.serverConnectionConfig + }, username() { return this.user ? this.user.username : '' }, @@ -74,25 +83,9 @@ export default { icon: 'home', text: 'Home', to: '/bookshelf' - }, - { - icon: 'person', - text: 'Account', - to: '/account' - }, - { - icon: 'folder', - iconOutlined: true, - text: 'Downloads', - to: '/downloads' } - // { - // icon: 'settings', - // text: 'Settings', - // to: '/config' - // } ] - if (!this.socketConnected) { + if (!this.serverConnectionConfig) { items = [ { icon: 'cloud_off', @@ -100,8 +93,24 @@ export default { to: '/connect' } ].concat(items) + } else { + items.push({ + icon: 'person', + text: 'Account', + to: '/account' + }) } + + items.push({ + icon: 'folder', + iconOutlined: true, + text: 'Local Media', + to: '/localMedia/folders' + }) return items + }, + currentRoutePath() { + return this.$route.path } }, methods: { @@ -112,7 +121,9 @@ export default { await this.$axios.$post('/logout').catch((error) => { console.error(error) }) - this.$server.logout() + this.$socket.logout() + await this.$db.logout() + this.$store.commit('user/logout') this.$router.push('/connect') }, touchstart(e) { diff --git a/components/bookshelf/LazyBookshelf.vue b/components/bookshelf/LazyBookshelf.vue index 803a22d3..386b5fe4 100644 --- a/components/bookshelf/LazyBookshelf.vue +++ b/components/bookshelf/LazyBookshelf.vue @@ -1,8 +1,8 @@ - \ No newline at end of file diff --git a/components/covers/CollectionCover.vue b/components/covers/CollectionCover.vue index 91eb1db7..403024eb 100644 --- a/components/covers/CollectionCover.vue +++ b/components/covers/CollectionCover.vue @@ -9,8 +9,8 @@
- - + +
diff --git a/components/covers/GroupCover.vue b/components/covers/GroupCover.vue index 20fd1439..8af50fd0 100644 --- a/components/covers/GroupCover.vue +++ b/components/covers/GroupCover.vue @@ -17,6 +17,7 @@ export default { }, width: Number, height: Number, + groupTo: String, bookCoverAspectRatio: Number }, data() { @@ -31,7 +32,6 @@ export default { isFannedOut: false, isDetached: false, isAttaching: false, - windowWidth: 0, isInit: false } }, @@ -48,8 +48,11 @@ export default { }, computed: { sizeMultiplier() { - if (this.bookCoverAspectRatio === 1) return this.width / (100 * 1.6 * 2) - return this.width / 200 + if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2) + return this.width / 240 + }, + showExperimentalFeatures() { + return this.store.state.showExperimentalFeatures }, store() { return this.$store || this.$nuxt.$store @@ -59,44 +62,8 @@ export default { } }, methods: { - detchCoverWrapper() { - if (!this.coverWrapperEl || !this.$refs.wrapper || this.isDetached) return - - this.coverWrapperEl.remove() - - this.isDetached = true - document.body.appendChild(this.coverWrapperEl) - this.coverWrapperEl.addEventListener('mouseleave', this.mouseleaveCover) - - this.coverWrapperEl.style.position = 'absolute' - this.coverWrapperEl.style.zIndex = 40 - - this.updatePosition() - }, - attachCoverWrapper() { - if (!this.coverWrapperEl || !this.$refs.wrapper || !this.isDetached) return - - this.coverWrapperEl.remove() - this.coverWrapperEl.style.position = 'relative' - this.coverWrapperEl.style.left = 'unset' - this.coverWrapperEl.style.top = 'unset' - this.coverWrapperEl.style.width = this.$refs.wrapper.clientWidth + 'px' - - this.$refs.wrapper.appendChild(this.coverWrapperEl) - - this.isDetached = false - }, - updatePosition() { - var rect = this.$refs.wrapper.getBoundingClientRect() - this.coverWrapperEl.style.top = rect.top + window.scrollY + 'px' - - this.coverWrapperEl.style.left = rect.left + window.scrollX + 4 + 'px' - - this.coverWrapperEl.style.height = rect.height + 'px' - this.coverWrapperEl.style.width = rect.width + 'px' - }, getCoverUrl(book) { - return this.store.getters['audiobooks/getBookCoverSrc'](book, '') + return this.store.getters['globals/getLibraryItemCoverSrc'](book, '') }, async buildCoverImg(coverData, bgCoverWidth, offsetLeft, zIndex, forceCoverBg = false) { var src = coverData.coverUrl @@ -156,6 +123,22 @@ export default { imgdiv.appendChild(img) return imgdiv }, + createSeriesNameCover(offsetLeft) { + var imgdiv = document.createElement('div') + imgdiv.style.height = this.height + 'px' + imgdiv.style.width = this.height / this.bookCoverAspectRatio + 'px' + imgdiv.style.left = offsetLeft + 'px' + imgdiv.className = 'absolute top-0 box-shadow-book transition-transform flex items-center justify-center' + imgdiv.style.boxShadow = '4px 0px 4px #11111166' + imgdiv.style.backgroundColor = '#111' + + var innerP = document.createElement('p') + innerP.textContent = this.name + innerP.className = 'text-sm font-book text-white' + imgdiv.appendChild(innerP) + + return imgdiv + }, async init() { if (this.isInit) return this.isInit = true @@ -168,7 +151,6 @@ export default { .map((bookItem) => { return { id: bookItem.id, - volumeNumber: bookItem.book ? bookItem.book.volumeNumber : null, coverUrl: this.getCoverUrl(bookItem) } }) @@ -179,6 +161,8 @@ export default { } this.noValidCovers = false + validCovers = validCovers.slice(0, 10) + var coverWidth = this.width var widthPer = this.width if (validCovers.length > 1) { @@ -189,7 +173,7 @@ export default { this.offsetIncrement = widthPer var outerdiv = document.createElement('div') - outerdiv.id = `group-cover-${this.id || this.$encode(this.name)}` + outerdiv.id = `group-cover-${this.id}` this.coverWrapperEl = outerdiv outerdiv.className = 'w-full h-full relative box-shadow-book' @@ -211,9 +195,7 @@ export default { } } }, - mounted() { - this.windowWidth = window.innerWidth - }, + mounted() {}, beforeDestroy() { if (this.coverWrapperEl) this.coverWrapperEl.remove() if (this.coverImageEls && this.coverImageEls.length) { @@ -222,4 +204,4 @@ export default { if (this.coverDiv) this.coverDiv.remove() } } - \ No newline at end of file + diff --git a/components/home/BookshelfNavBar.vue b/components/home/BookshelfNavBar.vue index 54e1f94c..fe6fc5e8 100644 --- a/components/home/BookshelfNavBar.vue +++ b/components/home/BookshelfNavBar.vue @@ -1,17 +1,8 @@