Support for android 8+, selectable download location, permissions check, recursive delete

This commit is contained in:
advplyr 2021-09-19 18:44:10 -05:00
parent b4ffe0fc83
commit e124d3858f
23 changed files with 798 additions and 387 deletions

View file

@ -36,7 +36,7 @@ class Server extends EventEmitter {
this.user = user
this.store.commit('user/setUser', user)
if (user) {
this.store.commit('user/setSettings', user.settings)
// this.store.commit('user/setSettings', user.settings)
Storage.set({ key: 'token', value: user.token })
} else {
Storage.remove({ key: 'token' })

View file

@ -5,13 +5,16 @@ plugins {
}
android {
kotlinOptions {
freeCompilerArgs = ['-Xjvm-default=all']
}
compileSdkVersion rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "com.audiobookshelf.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 13
versionName "0.8.4-beta"
versionCode 15
versionName "0.9.0-beta"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@ -35,6 +38,7 @@ repositories {
}
dependencies {
implementation "com.anggrayudi:storage:0.13.0"
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation project(':capacitor-android')

View file

@ -6,6 +6,9 @@
<!-- Permissions -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.INTERNET" />
<!-- <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />-->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<application
android:allowBackup="true"
@ -14,7 +17,8 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true">
android:usesCleartextTraffic="true"
android:requestLegacyExternalStorage="true">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"

View file

@ -1,18 +1,32 @@
package com.audiobookshelf.app
import android.app.DownloadManager
import android.content.ContentUris
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.documentfile.provider.DocumentFile
import com.anggrayudi.storage.SimpleStorage
import com.anggrayudi.storage.SimpleStorageHelper
import com.anggrayudi.storage.callback.FileCallback
import com.anggrayudi.storage.callback.FolderPickerCallback
import com.anggrayudi.storage.callback.StorageAccessCallback
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 org.json.JSONObject
import java.io.File
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
@CapacitorPlugin(name = "AudioDownloader")
@ -22,281 +36,415 @@ class AudioDownloader : Plugin() {
lateinit var mainActivity:MainActivity
lateinit var downloadManager:DownloadManager
data class AudiobookDownload(val url: String, val filename: String, val downloadId: Long)
var downloads:MutableList<AudiobookDownload> = mutableListOf()
data class CoverItem(val name: String, val coverUrl: String)
data class AudiobookItem(val id: Long, val uri: Uri, val name: String, val size: Int, val duration: Int, val coverUrl: String) {
data class AudiobookItem(val uri: Uri, val name: String, val size: Long, val coverUrl: String) {
fun toJSObject() : JSObject {
var obj = JSObject()
obj.put("id", this.id)
obj.put("uri", this.uri)
obj.put("name", this.name)
obj.put("size", this.size)
obj.put("duration", this.duration)
obj.put("coverUrl", this.coverUrl)
return obj
}
}
var audiobookItems:MutableList<AudiobookItem> = mutableListOf()
override fun load() {
mainActivity = (activity as MainActivity)
downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
// storage = SimpleStorage(mainActivity)
var recieverEvent : (evt: String, id: Long) -> Unit = { evt: String, id: Long ->
Log.d(tag, "RECEIVE EVT $evt $id")
if (evt == "complete") {
var path = downloadManager.getUriForDownloadedFile(id)
var download = downloads.find { it.downloadId == id }
var filename = download?.filename
var jsobj = JSObject()
jsobj.put("downloadId", id)
jsobj.put("contentUrl", path)
jsobj.put("filename", filename)
notifyListeners("onDownloadComplete", jsobj)
downloads = downloads.filter { it.downloadId != id } as MutableList<AudiobookDownload>
}
if (evt == "complete") {}
if (evt == "clicked") {
Log.d(tag, "Clicked $id back in the audiodownloader")
}
}
mainActivity.registerBroadcastReceiver(recieverEvent)
setupSimpleStorage()
Log.d(tag, "Build SDK ${Build.VERSION.SDK_INT}")
// Android 9 OR Below Request Permissions
// if (Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.P) {
// Log.d(tag, "Requires Permission")
//// storage.requestStorageAccess(9)
// var jsobj = JSObject()
// jsobj.put("value", "required")
// notifyListeners("permission", jsobj)
// } else {
// Log.d(tag, "Does not request permission")
// }
}
fun loadAudiobooks() {
var covers = loadCovers()
val projection = arrayOf(
MediaStore.Audio.Media._ID,
MediaStore.Audio.Media.DISPLAY_NAME,
MediaStore.Audio.Media.DURATION,
MediaStore.Audio.Media.SIZE,
MediaStore.Audio.Media.IS_AUDIOBOOK,
MediaStore.Audio.Media.RELATIVE_PATH
)
var _audiobookItems:MutableList<AudiobookItem> = mutableListOf()
val selection = "${MediaStore.Audio.Media.IS_AUDIOBOOK} == ?"
val selectionArgs = arrayOf("1")
val sortOrder = "${MediaStore.Audio.Media.DISPLAY_NAME} ASC"
activity.applicationContext.contentResolver.query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
projection,
selection,
selectionArgs,
sortOrder
)?.use { cursor ->
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)
val isAudiobookColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.IS_AUDIOBOOK)
var relativePathColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.RELATIVE_PATH)
while (cursor.moveToNext()) {
val id = cursor.getLong(idColumn)
val name = cursor.getString(nameColumn)
val duration = cursor.getInt(durationColumn)
val size = cursor.getInt(sizeColumn)
var isAudiobook = cursor.getInt(isAudiobookColumn)
var relativePath = cursor.getString(relativePathColumn)
if (isAudiobook == 1) {
val contentUri: Uri = ContentUris.withAppendedId(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
id
)
Log.d(tag, "Got Content FRom MEdia STORE $id $contentUri, Name: $name, Dur: $duration, Size: $size, relativePath: $relativePath")
var audiobookId = File(name).nameWithoutExtension
var coverItem:CoverItem? = covers.find{it.name == audiobookId}
var coverUrl = coverItem?.coverUrl ?: ""
_audiobookItems.add(AudiobookItem(id, contentUri, name, duration, size, coverUrl))
private fun setupSimpleStorage() {
mainActivity.storageHelper.onFolderSelected = { requestCode, folder ->
Log.d(tag, "FOLDER SELECTED $requestCode ${folder.name} ${folder.uri}")
var jsobj = JSObject()
jsobj.put("value", "granted")
jsobj.put("uri", folder.uri)
jsobj.put("absolutePath", folder.getAbsolutePath(context))
jsobj.put("storageId", folder.getStorageId(context))
jsobj.put("storageType", folder.getStorageType(context))
jsobj.put("simplePath", folder.getSimplePath(context))
jsobj.put("basePath", folder.getBasePath(context))
notifyListeners("permission", jsobj)
}
mainActivity.storage.storageAccessCallback = object : StorageAccessCallback {
override fun onRootPathNotSelected(
requestCode: Int,
rootPath: String,
uri: Uri,
selectedStorageType: StorageType,
expectedStorageType: StorageType
) {
Log.d(tag, "STORAGE ACCESS CALLBACK")
}
audiobookItems = _audiobookItems
var audiobookObjs:List<JSObject> = _audiobookItems.map{ it.toJSObject() }
override fun onCanceledByUser(requestCode: Int) {
Log.d(tag, "STORAGE ACCESS CALLBACK")
}
var mediaItemNoticePayload = JSObject()
mediaItemNoticePayload.put("items", audiobookObjs)
notifyListeners("onMediaLoaded", mediaItemNoticePayload)
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")
}
}
}
fun loadCovers() : MutableList<CoverItem> {
val projection = arrayOf(
MediaStore.Images.Media._ID,
MediaStore.Images.Media.DISPLAY_NAME
)
val sortOrder = "${MediaStore.Images.Media.DISPLAY_NAME} ASC"
var coverItems:MutableList<CoverItem> = mutableListOf()
activity.applicationContext.contentResolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
projection,
null,
null,
sortOrder
)?.use { cursor ->
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
val nameColumn =
cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)
while (cursor.moveToNext()) {
val id = cursor.getLong(idColumn)
val filename = cursor.getString(nameColumn)
val contentUri: Uri = ContentUris.withAppendedId(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
id
)
var name = File(filename).nameWithoutExtension
Log.d(tag, "Got IMAGE FRom Media STORE $id $contentUri, Name: $name")
var coverItem = CoverItem(name, contentUri.toString())
coverItems.add(coverItem)
}
}
return coverItems
}
@RequiresApi(Build.VERSION_CODES.R)
@PluginMethod
fun load(call: PluginCall) {
loadAudiobooks()
fun requestStoragePermission(call: PluginCall) {
Log.d(tag, "Request Storage Permissions")
mainActivity.storageHelper.requestStorageAccess()
call.resolve()
}
@PluginMethod
fun downloadCover(call: PluginCall) {
var audiobookId = call.data.getString("audiobookId", "audiobook").toString()
var url = call.data.getString("downloadUrl", "unknown").toString()
var title = call.data.getString("title", "Cover").toString()
var filename = call.data.getString("filename", "audiobook.jpg").toString()
fun checkStoragePermission(call: PluginCall) {
var res = false
Log.d(tag, "Called download cover: $url")
var dlRequest = DownloadManager.Request(Uri.parse(url))
dlRequest.setTitle("Cover Art: $title")
dlRequest.setDescription("Cover art for audiobook")
dlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_ONLY_COMPLETION)
var file:File = File(audiobookId, filename)
Log.d(tag, "FILE ${file.path} | ${file.canonicalPath}")
dlRequest.setDestinationInExternalPublicDir(Environment.DIRECTORY_AUDIOBOOKS, file.path)
var downloadId = downloadManager.enqueue(dlRequest)
var progressReceiver : (prog: Long) -> Unit = { prog: Long ->
//
}
var doneReceiver : (success: Boolean) -> Unit = { success: Boolean ->
var jsobj = JSObject()
if (success) {
var path = downloadManager.getUriForDownloadedFile(downloadId)
jsobj.put("url", path)
call.resolve(jsobj)
if (Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.P) {
res = SimpleStorage.hasStoragePermission(context)
Log.d(tag, "Check Storage Access $res")
} else {
jsobj.put("failed", true)
Log.d(tag, "Has permission on Android 10 or up")
res = true
}
var jsobj = JSObject()
jsobj.put("value", res)
call.resolve(jsobj)
}
fun checkUriExists(uri: Uri?): Boolean {
if (uri == null) return false
val resolver = context.contentResolver
//1. Check Uri
var cursor: Cursor? = null
val isUriExist: Boolean = 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
}
return isUriExist
}
@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<AudiobookItem> = 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")
}
}
var progressUpdater = DownloadProgressUpdater(downloadManager, downloadId, progressReceiver, doneReceiver)
progressUpdater.run()
if (audiobookFile == null) {
Log.e(tag, "Audiobook was not found $audiobookUrl")
} else {
var _name = audiobookFile.name
if (_name == null) _name = ""
var _coverUrl = ""
if (coverFile != null) _coverUrl = coverFile.uri.toString()
var size = audiobookFile.length()
var abItem = AudiobookItem(audiobookFile.uri, _name, size, _coverUrl)
audiobookItems.add(abItem)
}
}
var audiobookObjs:List<JSObject> = 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")
Log.d(tag, "Called download: $url")
var dlfilename = audiobookId + "." + File(filename).extension
var coverdlfilename = audiobookId + "." + File(coverFilename).extension
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(title)
dlRequest.setDescription("Downloading to Audiobooks directory")
dlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_ONLY_COMPLETION)
dlRequest.setDescription("Downloading to ${folder.name}")
dlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
dlRequest.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, dlfilename)
var file:File = File(audiobookId, filename)
Log.d(tag, "FILE ${file.path} | ${file.canonicalPath}")
dlRequest.setDestinationInExternalPublicDir(Environment.DIRECTORY_AUDIOBOOKS, file.path)
var audiobookDownloadId = downloadManager.enqueue(dlRequest)
var coverDownloadId:Long? = null
var downloadId = downloadManager.enqueue(dlRequest)
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 download = AudiobookDownload(url, filename, downloadId)
downloads.add(download)
var progressReceiver : (prog: Long) -> Unit = { prog: Long ->
var progressReceiver : (id:Long, prog: Long) -> Unit = { id:Long, prog: Long ->
if (id == audiobookDownloadId) {
var jsobj = JSObject()
jsobj.put("filename", filename)
jsobj.put("downloadId", downloadId)
jsobj.put("audiobookId", audiobookId)
jsobj.put("progress", prog)
notifyListeners("onDownloadProgress", jsobj)
}
var doneReceiver : (success: Boolean) -> Unit = { success: Boolean ->
Log.d(tag, "RECIEVER DONE, SUCCES? $success")
}
var progressUpdater = DownloadProgressUpdater(downloadManager, downloadId, progressReceiver, doneReceiver)
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
if (id == coverDownloadId) {
docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, coverdlfilename)
Log.d(tag, "Move Cover File ${docfile?.name}")
} else if (id == audiobookDownloadId) {
docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, dlfilename)
Log.d(tag, "Move Audiobook File ${docfile?.name}")
}
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} | $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)
}
}
val executorService: ExecutorService = Executors.newFixedThreadPool(4)
executorService.execute {
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("value", downloadId)
ret.put("audiobookDownloadId", audiobookDownloadId)
ret.put("coverDownloadId", coverDownloadId)
call.resolve(ret)
}
@PluginMethod
fun selectFolder(call: PluginCall) {
mainActivity.storage.folderPickerCallback = object : FolderPickerCallback {
override fun onFolderSelected(requestCode: Int, folder: DocumentFile) {
Log.d(tag, "ONF OLDER SELECRTED ${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)
}
@PluginMethod
fun delete(call: PluginCall) {
var audiobookId = call.data.getString("audiobookId", "audiobook").toString()
var filename = call.data.getString("filename", "audiobook.mp3").toString()
var url = call.data.getString("url", "").toString()
var coverUrl = call.data.getString("coverUrl", "").toString()
var folderUrl = call.data.getString("folderUrl", "").toString()
// Does Not Work
// var audiobookDirRoot = activity.applicationContext.getExternalFilesDir(Environment.DIRECTORY_AUDIOBOOKS)
// Log.d(tag, "AUDIOBOOK DIR ROOT $audiobookDirRoot")
// var result = audiobookDirRoot?.deleteRecursively()
// Log.d(tag, "DONE DELETING FOLDER $result")
// Does Not Work
// var audiobookDir = File(audiobookDirRoot, audiobookId + "/")
// Log.d(tag, "Delete Audiobook DIR ${audiobookDir.path} is dir ${audiobookDir.isDirectory}")
// var result = audiobookDir.deleteRecursively()
//
// Does Not Work
// var audiobookDir = activity.applicationContext.getExternalFilesDir(Environment.DIRECTORY_AUDIOBOOKS)
// Log.d(tag, "AUDIOBOOK DIR ${audiobookDir?.path}")
// var dir = File(audiobookDir, "$audiobookId/")
// Log.d(tag, "DIR DIR ${dir.path}")
// var res = dir.delete()
// Log.d(tag, "DELETED $res")
var contentResolver = activity.applicationContext.contentResolver
contentResolver.delete(Uri.parse(url), null, null)
if (coverUrl != "") {
contentResolver.delete(Uri.parse(coverUrl), null, null)
}
if (folderUrl != "") {
Log.d(tag, "CALLED DELETE FIOLDER: $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()
}
internal class DownloadProgressUpdater(private val manager: DownloadManager, private val downloadId: Long, private var receiver: (Long) -> Unit, private var doneReceiver: (Boolean) -> Unit) : Thread() {
var coverExists = checkUriExists(Uri.parse(coverUrl))
if (coverExists) {
var coverfile = DocumentFileCompat.fromUri(context, Uri.parse(coverUrl))
coverfile?.delete()
}
}
}
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"
@ -308,34 +456,49 @@ class AudioDownloader : Plugin() {
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(true)
doneReceiver(downloadId, true)
} else {
doneReceiver(false)
doneReceiver(downloadId, false)
}
keepRunning = false
this.interrupt()
} else {
//update progress
val percentProgress = ((bytesDownloadedSoFar * 100L) / totalBytes)
receiver(percentProgress)
receiver(downloadId, percentProgress)
}
} else {
Log.e(TAG, "NOT FOUND IN QUERY")
keepRunning = false
@ -343,6 +506,5 @@ class AudioDownloader : Plugin() {
}
}
}
}
}

View file

@ -2,12 +2,10 @@ package com.audiobookshelf.app
import android.app.DownloadManager
import android.content.*
import android.os.Bundle
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.*
import android.util.Log
import android.widget.Toast
import com.anggrayudi.storage.SimpleStorage
import com.anggrayudi.storage.SimpleStorageHelper
import com.getcapacitor.BridgeActivity
@ -21,23 +19,19 @@ class MainActivity : BridgeActivity() {
lateinit var pluginCallback : () -> Unit
lateinit var downloaderCallback : (String, Long) -> Unit
val storageHelper = SimpleStorageHelper(this)
val storage = SimpleStorage(this)
val broadcastReceiver = object: BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
DownloadManager.ACTION_DOWNLOAD_COMPLETE -> {
var thisdlid = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0L)
downloaderCallback("complete", thisdlid)
Log.d(tag, "DOWNNLAOD COMPELTE $thisdlid")
Toast.makeText(this@MainActivity, "Download Completed $thisdlid", Toast.LENGTH_SHORT)
}
DownloadManager.ACTION_NOTIFICATION_CLICKED -> {
var thisdlid = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0L)
downloaderCallback("clicked", thisdlid)
Log.d(tag, "CLICKED NOTFIFICAIONT $thisdlid")
Toast.makeText(this@MainActivity, "Download CLICKED $thisdlid", Toast.LENGTH_SHORT)
}
}
}
@ -45,6 +39,7 @@ class MainActivity : BridgeActivity() {
public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.d(tag, "onCreate")
registerPlugin(MyNativeAudio::class.java)
registerPlugin(AudioDownloader::class.java)
@ -57,7 +52,7 @@ class MainActivity : BridgeActivity() {
override fun onDestroy() {
super.onDestroy()
// unregisterReceiver(broadcastReceiver)
unregisterReceiver(broadcastReceiver)
}
override fun onPostCreate(savedInstanceState: Bundle?) {
@ -101,4 +96,29 @@ class MainActivity : BridgeActivity() {
fun registerBroadcastReceiver(cb: (String, Long) -> Unit) {
downloaderCallback = cb
}
override fun onSaveInstanceState(outState: Bundle) {
storageHelper.onSaveInstanceState(outState)
super.onSaveInstanceState(outState)
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
storageHelper.onRestoreInstanceState(savedInstanceState)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
// Mandatory for Activity, but not for Fragment & ComponentActivity
storageHelper.storage.onActivityResult(requestCode, resultCode, data)
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
Log.d(tag, "onRequestPermissionResult $requestCode")
permissions.forEach { Log.d(tag, "PERMISSION $it") }
grantResults.forEach { Log.d(tag, "GRANTREUSLTS $it") }
// Mandatory for Activity, but not for Fragment & ComponentActivity
storageHelper.onRequestPermissionsResult(requestCode, permissions, grantResults)
}
}

View file

@ -1,5 +1,5 @@
ext {
minSdkVersion = 29
minSdkVersion = 23
compileSdkVersion = 30
targetSdkVersion = 30
androidxActivityVersion = '1.2.0'

View file

@ -3,12 +3,7 @@
<template v-for="(shelf, index) in groupedBooks">
<div :key="index" class="border-b border-opacity-10 w-full bookshelfRow py-4 flex justify-around relative">
<template v-for="audiobook in shelf">
<!-- <div :key="audiobook.id" class="relative px-4"> -->
<cards-book-card :key="audiobook.id" :audiobook="audiobook" :width="cardWidth" :user-progress="userAudiobooks[audiobook.id]" :local-user-progress="localUserAudiobooks[audiobook.id]" />
<!-- <nuxt-link :to="`/audiobook/${audiobook.id}`">
<cards-book-cover :audiobook="audiobook" :width="cardWidth" class="mx-auto -mb-px" style="box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166" />
</nuxt-link> -->
<!-- </div> -->
</template>
<div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" />
</div>
@ -93,16 +88,22 @@ export default {
} else {
console.log('Disconnected - Reset to local storage')
this.$store.commit('audiobooks/reset')
this.$store.dispatch('downloads/loadFromStorage')
this.$store.dispatch('audiobooks/useDownloaded')
// this.calcShelves()
// this.$store.dispatch('downloads/loadFromStorage')
}
}
},
mounted() {
this.$store.commit('audiobooks/addListener', { id: 'bookshelf', meth: this.audiobooksUpdated })
this.$store.commit('user/addSettingsListener', { id: 'bookshelf', meth: this.settingsUpdated })
window.addEventListener('resize', this.resize)
if (!this.$server) {
console.error('Bookshelf mounted no server')
return
}
this.$server.on('connected', this.socketConnected)
if (this.$server.connected) {
this.$store.dispatch('audiobooks/load')
@ -115,6 +116,12 @@ export default {
this.$store.commit('audiobooks/removeListener', 'bookshelf')
this.$store.commit('user/removeSettingsListener', 'bookshelf')
window.removeEventListener('resize', this.resize)
if (!this.$server) {
console.error('Bookshelf beforeDestroy no server')
return
}
this.$server.off('connected', this.socketConnected)
}
}
</script>

View file

@ -128,6 +128,7 @@ export default {
if (this.$refs.audioPlayerMini) {
this.$refs.audioPlayerMini.terminateStream()
}
this.download = null
this.$store.commit('setPlayingDownload', null)
this.$localStore.setCurrent(null)
@ -167,12 +168,6 @@ export default {
}
if (this.$server.connected) {
// var updateObj = {
// audiobookId: this.download.id,
// totalDuration: this.download.audiobook.duration,
// clientCurrentTime: currentTime,
// clientProgress: Number((currentTime / this.download.audiobook.duration).toFixed(3))
// }
this.$server.socket.emit('progress_update', progressUpdate)
}
this.$localStore.updateUserAudiobookProgress(progressUpdate).then(() => {
@ -184,7 +179,6 @@ export default {
closeStream() {},
streamClosed(audiobookId) {
console.log('Stream Closed')
if (this.stream.audiobook.id === audiobookId || audiobookId === 'n/a') {
this.$store.commit('setStreamAudiobook', null)
}
@ -305,23 +299,15 @@ export default {
if (this.playingDownload) {
console.log('[StreamContainer] Play download on audio mount')
if (!this.download) {
this.download = { ...this.playingDownload }
}
this.playDownload()
} else if (this.$server.stream) {
console.log('[StreamContainer] Open stream on audio mount')
this.streamOpen(this.$server.stream)
}
},
setListeners() {
if (!this.$server.socket) {
console.error('Invalid server socket not set')
return
}
this.$server.socket.on('stream_open', this.streamOpen)
this.$server.socket.on('stream_closed', this.streamClosed)
this.$server.socket.on('stream_progress', this.streamProgress)
this.$server.socket.on('stream_ready', this.streamReady)
this.$server.socket.on('stream_reset', this.streamReset)
},
changePlaybackSpeed(speed) {
this.$store.dispatch('user/updateUserSettings', { playbackRate: speed })
},
@ -342,6 +328,17 @@ export default {
this.cancelStream()
}
}
},
setListeners() {
if (!this.$server.socket) {
console.error('Invalid server socket not set')
return
}
this.$server.socket.on('stream_open', this.streamOpen)
this.$server.socket.on('stream_closed', this.streamClosed)
this.$server.socket.on('stream_progress', this.streamProgress)
this.$server.socket.on('stream_ready', this.streamReady)
this.$server.socket.on('stream_reset', this.streamReset)
}
},
mounted() {

View file

@ -110,6 +110,7 @@ export default {
return this.download ? this.download.cover : null
},
download() {
return null
return this.$store.getters['downloads/getDownloadIfReady'](this.audiobookId)
},
errorText() {

View file

@ -1,8 +1,16 @@
<template>
<div class="relative rounded-sm overflow-hidden" :style="{ height: width * 1.6 + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }">
<div class="w-full h-full relative">
<div v-if="showCoverBg" class="bg-primary absolute top-0 left-0 w-full h-full">
<div class="w-full h-full z-0" ref="coverBg" />
<div class="bg-primary absolute top-0 left-0 w-full h-full">
<!-- Blurred background for covers that dont fill -->
<div v-if="showCoverBg" class="w-full h-full z-0" ref="coverBg" />
<!-- Image Loading indicator -->
<div v-if="!isImageLoaded" class="w-full h-full flex items-center justify-center text-white">
<svg class="animate-spin w-12 h-12" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>
</div>
</div>
<img ref="cover" :src="fullCoverUrl" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-cover'" />
</div>
@ -42,7 +50,8 @@ export default {
data() {
return {
imageFailed: false,
showCoverBg: false
showCoverBg: false,
isImageLoaded: false
}
},
watch: {
@ -142,10 +151,11 @@ export default {
this.showCoverBg = false
}
}
this.isImageLoaded = true
},
imageError(err) {
console.error('ImgError', err)
this.imageFailed = true
console.error('ImgError', err, `SET IMAGE FAILED ${this.imageFailed}`)
}
}
}

View file

@ -3,7 +3,17 @@
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
<p class="absolute top-6 left-2 text-2xl">Downloads</p>
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
<div class="absolute top-16 left-0 right-0 w-full flex items-center px-2 py-1" :class="hasStoragePermission ? '' : 'text-error'">
<span class="material-icons" @click="changeDownloadFolderClick">{{ hasStoragePermission ? 'folder' : 'error' }}</span>
<p v-if="hasStoragePermission" class="text-sm px-4" @click="changeDownloadFolderClick">{{ downloadFolderSimplePath || 'No Download Folder Selected' }}</p>
<p v-else class="text-sm px-4" @click="changeDownloadFolderClick">No Storage Permissions. Click here</p>
</div>
<div v-if="totalSize" class="absolute bottom-0 left-0 right-0 w-full py-3 text-center">
<p class="text-sm text-center text-gray-300">Total: {{ $bytesPretty(totalSize) }}</p>
</div>
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20 mt-10" style="max-height: 75%" @click.stop>
<div v-if="!totalDownloads" class="flex items-center justify-center h-40">
<p>No Downloads</p>
</div>
@ -28,9 +38,10 @@
<div class="flex items-center justify-center">
<img v-if="download.cover" :src="download.cover" class="w-10 h-16 object-contain" />
<img v-else src="/book_placeholder.jpg" class="w-10 h-16 object-contain" />
<div class="pl-2 w-1/2">
<div class="pl-2 w-2/3">
<p class="font-normal truncate text-sm">{{ download.audiobook.book.title }}</p>
<p class="font-normal truncate text-xs text-gray-400">{{ download.audiobook.book.author }}</p>
<p class="font-normal truncate text-xs text-gray-400">{{ $bytesPretty(download.size) }}</p>
</div>
<div class="flex-grow" />
<div v-if="download.isIncomplete" class="shadow-sm text-warning flex items-center justify-center rounded-full mr-4">
@ -71,11 +82,22 @@
<script>
import { Dialog } from '@capacitor/dialog'
import AudioDownloader from '@/plugins/audio-downloader'
export default {
data() {
return {
downloadingProgress: {}
downloadFolder: null,
downloadingProgress: {},
totalSize: 0
}
},
watch: {
async show(newValue) {
if (newValue) {
this.downloadFolder = await this.$localStore.getDownloadFolder()
this.setTotalSize()
}
}
},
computed: {
@ -87,6 +109,12 @@ export default {
this.$store.commit('downloads/setShowModal', val)
}
},
hasStoragePermission() {
return this.$store.state.hasStoragePermission
},
downloadFolderSimplePath() {
return this.downloadFolder ? this.downloadFolder.simplePath : null
},
totalDownloads() {
return this.downloadsReady.length + this.orphanDownloads.length + this.downloadsDownloading.length
},
@ -132,6 +160,25 @@ export default {
}
},
methods: {
setTotalSize() {
var totalSize = 0
this.downloadsReady.forEach((dl) => {
totalSize += dl.size && !isNaN(dl.size) ? Number(dl.size) : 0
})
this.totalSize = totalSize
},
async changeDownloadFolderClick() {
if (!this.hasStoragePermission) {
console.log('Requesting Storage Permission')
AudioDownloader.requestStoragePermission()
} else {
var folderObj = await AudioDownloader.selectFolder()
if (folderObj.error) {
return this.$toast.error(`Error: ${folderObj.error || 'Unknown Error'}`)
}
await this.$localStore.setDownloadFolder(folderObj)
}
},
updateDownloadProgress({ audiobookId, progress }) {
this.$set(this.downloadingProgress, audiobookId, progress)
},

View file

@ -43,6 +43,10 @@ export default {
text: 'Added At',
value: 'addedAt'
},
{
text: 'Volume #',
value: 'book.volumeNumber'
},
{
text: 'Duration',
value: 'duration'

View file

@ -55,14 +55,16 @@ export default {
if (localAudiobooks[audiobookId].lastUpdate > userAudiobooks[audiobookId].lastUpdate) {
// Local progress is more recent than user progress
this.updateAudiobookProgressOnServer(localAudiobooks[audiobookId])
} else {
} else if (localAudiobooks[audiobookId].lastUpdate < userAudiobooks[audiobookId].lastUpdate) {
// Server is more recent than local
newestLocal[audiobookId] = userAudiobooks[audiobookId]
console.log('SYNCUSERPROGRESS Server IS MORE RECENT for', audiobookId)
localHasUpdates = true
}
} else {
// Not on local yet - store on local
newestLocal[audiobookId] = userAudiobooks[audiobookId]
console.log('SYNCUSERPROGRESS LOCAL Is NOT Stored YET for', audiobookId, JSON.stringify(newestLocal[audiobookId]))
localHasUpdates = true
}
}
@ -111,22 +113,19 @@ export default {
this.$store.commit('setAppUpdateInfo', result)
if (result.updateAvailability === 2) {
setTimeout(() => {
this.$toast.info(`Update is available!`, {
this.$toast.info(`Update is available! Click to update.`, {
draggable: false,
hideProgressBar: false,
timeout: 4000,
closeButton: false,
onClick: this.clickUpdateToast()
timeout: 20000,
closeButton: true,
onClick: this.clickUpdateToast
})
// this.showUpdateToast(result.availableVersion, !!result.immediateUpdateAllowed)
}, 5000)
}
},
onDownloadProgress(data) {
// var downloadId = data.downloadId
var progress = data.progress
var filename = data.filename
var audiobookId = filename ? Path.basename(filename, Path.extname(filename)) : ''
var audiobookId = data.audiobookId
var downloadObj = this.$store.getters['downloads/getDownload'](audiobookId)
if (downloadObj) {
@ -136,31 +135,70 @@ export default {
this.$toast.update(downloadObj.toastId, { content: `${progress}% Downloading ${downloadObj.audiobook.book.title}` })
}
},
onDownloadFailed(data) {
if (!data.audiobookId) {
console.error('Download failed invalid audiobook id', data)
return
}
var downloadObj = this.$store.getters['downloads/getDownload'](data.audiobookId)
if (!downloadObj) {
console.error('Failed to find download for audiobook', data.audiobookId)
return
}
var message = data.error || 'Unknown Error'
this.$toast.update(downloadObj.toastId, { content: `Failed. ${message}.`, options: { timeout: 5000, type: 'error' } }, true)
this.$store.commit('downloads/removeDownload', downloadObj)
},
onDownloadComplete(data) {
if (!data.audiobookId) {
console.error('Download compelte invalid audiobook id', data)
return
}
var downloadId = data.downloadId
var contentUrl = data.contentUrl
var folderUrl = data.folderUrl
var folderName = data.folderName
var storageId = data.storageId
var storageType = data.storageType
var simplePath = data.simplePath
var filename = data.filename
var audiobookId = filename ? Path.basename(filename, Path.extname(filename)) : ''
var audiobookId = data.audiobookId
var size = data.size || 0
var isCover = !!data.isCover
if (audiobookId) {
console.log('Download complete', filename, downloadId, contentUrl, 'AudiobookId:', audiobookId, 'Is Cover:', isCover)
var downloadObj = this.$store.getters['downloads/getDownload'](audiobookId)
if (!downloadObj) {
console.error('Failed to find download for audiobook', audiobookId)
return
}
if (!isCover) {
// Notify server to remove prepared download
if (this.$server.socket) {
this.$server.socket.emit('remove_download', audiobookId)
}
console.log('Download complete', filename, downloadId, contentUrl, 'AudiobookId:', audiobookId)
var downloadObj = this.$store.getters['downloads/getDownload'](audiobookId)
if (!downloadObj) {
console.error('Could not find download...')
} else {
this.$toast.update(downloadObj.toastId, { content: `Success! ${downloadObj.audiobook.book.title} downloaded.`, options: { timeout: 5000, type: 'success' } }, true)
console.log('Found download, update with content url')
delete downloadObj.isDownloading
delete downloadObj.isPreparing
downloadObj.contentUrl = contentUrl
downloadObj.simplePath = simplePath
downloadObj.folderUrl = folderUrl
downloadObj.folderName = folderName
downloadObj.storageType = storageType
downloadObj.storageId = storageId
downloadObj.basePath = data.basePath || null
downloadObj.size = size
this.$store.commit('downloads/addUpdateDownload', downloadObj)
} else {
downloadObj.coverUrl = contentUrl
downloadObj.cover = Capacitor.convertFileSrc(contentUrl)
downloadObj.coverSize = size
downloadObj.coverBasePath = data.basePath || null
console.log('Updating download with cover', downloadObj.cover)
this.$store.commit('downloads/addUpdateDownload', downloadObj)
}
}
},
async checkLoadCurrent() {
@ -176,59 +214,38 @@ export default {
this.$localStore.setCurrent(null)
}
},
onMediaLoaded(items) {
async onMediaLoaded(items) {
var jsitems = JSON.parse(items)
jsitems = jsitems.map((item) => {
return {
id: item.id,
size: item.size,
duration: item.duration,
filename: item.name,
audiobookId: item.name ? Path.basename(item.name, Path.extname(item.name)) : '',
contentUrl: item.uri.replace(/\\\//g, '/'),
size: item.size,
contentUrl: item.uri,
coverUrl: item.coverUrl || null
}
})
jsitems.forEach((item) => {
var download = this.$store.getters['downloads/getDownload'](item.audiobookId)
if (!download) {
console.error(`Unknown media item found for filename ${item.filename}`)
console.log('onMediaLoaded Items', JSON.stringify(jsitems))
var orphanDownload = {
id: `orphan-${item.id}`,
contentUrl: item.contentUrl,
coverUrl: item.coverUrl,
cover: item.coverUrl ? Capacitor.convertFileSrc(item.coverUrl) : null,
mediaId: item.id,
filename: item.filename,
size: item.size,
duration: item.duration,
isOrphan: true
}
this.$store.commit('downloads/addUpdateDownload', orphanDownload)
} else {
console.log(`Found media item for audiobook ${download.audiobook.book.title} (${item.audiobookId})`)
download.contentUrl = item.contentUrl
download.coverUrl = item.coverUrl
download.cover = item.coverUrl ? Capacitor.convertFileSrc(item.coverUrl) : null
download.size = item.size
download.duration = item.duration
var downloads = await this.$sqlStore.getAllDownloads()
for (let i = 0; i < downloads.length; i++) {
var download = downloads[i]
var jsitem = jsitems.find((item) => item.contentUrl === download.contentUrl)
if (!jsitem) {
console.error('Removing download was not found', JSON.stringify(download))
await this.$sqlStore.removeDownload(download.id)
} else if (download.coverUrl && !jsitem.coverUrl) {
console.error('Removing cover for download was not found')
download.cover = null
download.coverUrl = null
download.size = jsitem.size || 0
this.$store.commit('downloads/addUpdateDownload', download)
this.$store.commit('audiobooks/addUpdate', download.audiobook)
} else {
download.size = jsitem.size || 0
this.$store.commit('downloads/addUpdateDownload', download)
download.audiobook.isDownloaded = true
this.$store.commit('audiobooks/addUpdate', download.audiobook)
}
})
var downloads = this.$store.state.downloads.downloads
downloads.forEach((download) => {
var matchingItem = jsitems.find((item) => item.audiobookId === download.id)
if (!matchingItem) {
console.error(`Saved download not in media store ${download.audiobook.book.title} (${download.id})`)
this.$store.commit('downloads/removeDownload', download)
}
})
this.checkLoadCurrent()
},
@ -245,32 +262,95 @@ export default {
this.$refs.streamContainer.cancelStream()
}
}
if (download.contentUrl) {
await AudioDownloader.delete({ audiobookId: download.id, filename: download.filename, url: download.contentUrl, coverUrl: download.coverUrl })
await AudioDownloader.delete(download)
}
this.$store.commit('downloads/removeDownload', download)
},
async initMediaStore() {
// Load local database of downloads
await this.$store.dispatch('downloads/loadFromStorage')
await this.$localStore.loadUserAudiobooks()
async onPermissionUpdate(data) {
var val = data.value
console.log('Permission Update', val)
if (val === 'required') {
console.log('Permission Required - Making Request')
this.$store.commit('setHasStoragePermission', false)
} else if (val === 'canceled' || val === 'denied') {
console.error('Permission denied by user')
this.$store.commit('setHasStoragePermission', false)
} else if (val === 'granted') {
console.log('User Granted permission')
this.$store.commit('setHasStoragePermission', true)
var folder = data
delete folder.value
console.log('Setting folder', JSON.stringify(folder))
await this.$localStore.setDownloadFolder(folder)
} else {
console.warn('Other permisiso update', val)
}
},
async initMediaStore() {
// Request and setup listeners for media files on native
console.log('Permissino SET LISTENER')
AudioDownloader.addListener('permission', (data) => {
this.onPermissionUpdate(data)
})
AudioDownloader.addListener('onDownloadComplete', (data) => {
this.onDownloadComplete(data)
})
AudioDownloader.addListener('onDownloadFailed', (data) => {
this.onDownloadFailed(data)
})
AudioDownloader.addListener('onMediaLoaded', (data) => {
this.onMediaLoaded(data.items)
})
AudioDownloader.addListener('onDownloadProgress', (data) => {
this.onDownloadProgress(data)
})
AudioDownloader.load()
await this.$localStore.loadUserAudiobooks()
await this.$localStore.getDownloadFolder()
var userSavedSettings = await this.$localStore.getUserSettings()
if (userSavedSettings) {
this.$store.commit('user/setSettings', userSavedSettings)
}
var downloads = await this.$sqlStore.getAllDownloads()
if (downloads.length) {
var urls = downloads
.map((d) => {
return {
contentUrl: d.contentUrl,
coverUrl: d.coverUrl || '',
storageId: d.storageId,
basePath: d.basePath,
coverBasePath: d.coverBasePath || ''
}
})
.filter((d) => {
if (!d.contentUrl) {
console.error('Invalid Download no Content URL', JSON.stringify(d))
return false
}
return true
})
AudioDownloader.load({
audiobookUrls: urls
})
}
var checkPermission = await AudioDownloader.checkStoragePermission()
console.log('Storage Permission is', checkPermission.value)
if (!checkPermission.value) {
console.log('Will require permissions')
} else {
console.log('Has Storage Permission')
this.$store.commit('setHasStoragePermission', true)
}
},
async setupNetworkListener() {
var status = await Network.getStatus()
console.log('Network status', status.connected, status.connectionType)
this.$store.commit('setNetworkStatus', status)
Network.addListener('networkStatusChange', (status) => {
@ -282,13 +362,27 @@ export default {
mounted() {
if (!this.$server) return console.error('No Server')
console.log('Default Mounted')
this.$server.on('connected', this.connected)
this.$server.on('initialStream', this.initialStream)
if (this.$store.state.isFirstLoad) {
this.$store.commit('setIsFirstLoad', false)
this.setupNetworkListener()
this.checkForUpdate()
this.initMediaStore()
}
},
beforeDestroy() {
if (!this.$server) {
console.error('No Server beforeDestroy')
return
}
console.log('Default Before Destroy remove listeners')
this.$server.off('connected', this.connected)
this.$server.off('initialStream', this.initialStream)
}
}
</script>
@ -297,7 +391,6 @@ export default {
height: calc(100vh - 64px);
}
#content.playerOpen {
/* height: calc(100vh - 204px); */
height: calc(100vh - 240px);
}
</style>

View file

@ -1,6 +1,6 @@
{
"name": "audiobookshelf-app",
"version": "v0.8.4-beta",
"version": "v0.9.0-beta",
"author": "advplyr",
"scripts": {
"dev": "nuxt --hostname localhost --port 1337",

View file

@ -68,6 +68,9 @@ export default {
})
this.$server.logout()
this.$router.push('/connect')
this.$store.commit('audiobooks/reset')
this.$store.dispatch('audiobooks/useDownloaded')
},
openAppStore() {
AppUpdate.openAppStore()

View file

@ -162,6 +162,9 @@ export default {
},
downloadObj() {
return this.$store.getters['downloads/getDownload'](this.audiobookId)
},
hasStoragePermission() {
return this.$store.state.hasStoragePermission
}
},
methods: {
@ -229,17 +232,32 @@ export default {
}
},
async prepareDownload() {
// var audiobook = await this.$axios.$get(`/api/audiobook/${this.audiobookId}`).catch((error) => {
// console.error('Failed', error)
// return false
// })
var audiobook = this.audiobook
if (!audiobook) {
return
}
if (!this.hasStoragePermission) {
await AudioDownloader.requestStoragePermission()
return
}
// Download Path
var dlFolder = this.$localStore.downloadFolder
if (!dlFolder) {
console.log('No download folder, request from ujser')
var folderObj = await AudioDownloader.selectFolder()
if (folderObj.error) {
return this.$toast.error(`Error: ${folderObj.error || 'Unknown Error'}`)
}
dlFolder = folderObj
await this.$localStore.setDownloadFolder(folderObj)
}
var downloadObject = {
id: this.audiobookId,
downloadFolderUrl: dlFolder.uri,
audiobook: {
...audiobook
},
@ -278,59 +296,37 @@ export default {
}
return _clean
},
async downloadCover(download) {
var coverUrl = this.getCoverUrlForDownload()
if (!coverUrl) {
return null
}
var extname = Path.extname(coverUrl) || '.jpg'
var downloadRequestPayload = {
audiobookId: download.id,
downloadUrl: coverUrl,
filename: `${download.id}${extname}`,
title: download.audiobook.book.title
}
console.log('Starting cover download', coverUrl, downloadRequestPayload.filename)
var downloadRes = await AudioDownloader.downloadCover(downloadRequestPayload)
if (downloadRes && downloadRes.url) {
console.log('Cover art downloaded', downloadRes.url)
return downloadRes.url
} else {
console.error('Cover art failed to download')
return null
}
},
async startDownload(url, fileext, download) {
this.$toast.update(download.toastId, { content: `Downloading "${download.audiobook.book.title}"...` })
var coverDownloadUrl = this.getCoverUrlForDownload()
var coverFilename = null
if (coverDownloadUrl) {
var coverExt = Path.extname(coverDownloadUrl) || '.jpg'
coverFilename = `cover-${download.id}${coverExt}`
}
download.isDownloading = true
download.isPreparing = false
download.filename = `${download.id}${fileext}`
download.filename = `${download.audiobook.book.title}${fileext}`
this.$store.commit('downloads/addUpdateDownload', download)
console.log('Starting Download URL', url)
var downloadRequestPayload = {
audiobookId: download.id,
filename: download.filename,
coverFilename,
coverDownloadUrl,
downloadUrl: url,
title: download.audiobook.book.title
title: download.audiobook.book.title,
downloadFolderUrl: download.downloadFolderUrl
}
var downloadRes = await AudioDownloader.download(downloadRequestPayload)
var downloadId = downloadRes.value
if (!downloadId) {
console.error('Invalid download, removing')
this.$toast.update(download.toastId, { content: `Failed download "${download.audiobook.book.title}"`, options: { timeout: 5000, type: 'error' } })
if (downloadRes.error) {
var errorMsg = downloadRes.error || 'Unknown error'
console.error('Download error', errorMsg)
this.$toast.update(download.toastId, { content: `Error: ${errorMsg}`, options: { timeout: 5000, type: 'error' } })
this.$store.commit('downloads/removeDownload', download)
} else {
console.log('Download cover now')
var coverUrl = await this.downloadCover(download)
if (coverUrl) {
console.log('Got cover url', coverUrl)
download.coverUrl = coverUrl
download.cover = Capacitor.convertFileSrc(coverUrl)
this.$store.commit('downloads/addUpdateDownload', download)
}
}
},
downloadReady(prepareDownload) {
@ -363,16 +359,24 @@ export default {
}
},
mounted() {
if (!this.$server.socket) {
console.warn('Audiobook Page mounted: Server socket not set')
} else {
this.$server.socket.on('download_ready', this.downloadReady)
this.$server.socket.on('download_killed', this.downloadKilled)
this.$server.socket.on('download_failed', this.downloadFailed)
}
this.$store.commit('audiobooks/addListener', { id: 'audiobook', audiobookId: this.audiobookId, meth: this.audiobookUpdated })
},
beforeDestroy() {
if (!this.$server.socket) {
console.warn('Audiobook Page beforeDestroy: Server socket not set')
} else {
this.$server.socket.off('download_ready', this.downloadReady)
this.$server.socket.off('download_killed', this.downloadKilled)
this.$server.socket.off('download_failed', this.downloadFailed)
}
this.$store.commit('audiobooks/removeListener', 'audiobook')
}

View file

@ -179,6 +179,13 @@ export default {
},
mounted() {
this.init()
},
beforeDestroy() {
if (!this.$server) {
console.error('Connected beforeDestroy: No Server')
return
}
this.$server.off('connected', this.socketConnected)
}
}
</script>

View file

@ -2,6 +2,7 @@ import Vue from 'vue'
Vue.prototype.$isDev = process.env.NODE_ENV !== 'production'
Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
if (isNaN(bytes) || bytes === null) return 'Invalid Bytes'
if (bytes === 0) {
return '0 Bytes'
}

View file

@ -408,6 +408,7 @@ class LocalStorage {
this.vuexStore = vuexStore
this.userAudiobooksLoaded = false
this.downloadFolder = null
this.userAudiobooks = {}
}
@ -502,6 +503,32 @@ class LocalStorage {
}
}
async setDownloadFolder(folderObj) {
try {
if (folderObj) {
await Storage.set({ key: 'downloadFolder', value: JSON.stringify(folderObj) })
this.downloadFolder = folderObj
} else {
await Storage.remove({ key: 'downloadFolder' })
this.downloadFolder = null
}
} catch (error) {
console.error('[LocalStorage] Failed to set download folder', error)
}
}
async getDownloadFolder() {
try {
var _value = (await Storage.get({ key: 'downloadFolder' }) || {}).value || null
if (!_value) return null
this.downloadFolder = JSON.parse(_value)
return this.downloadFolder
} catch (error) {
console.error('[LocalStorage] Failed to get download folder', error)
return null
}
}
async getServerUrl() {
try {
return (await Storage.get({ key: 'serverUrl' }) || {}).value || null

View file

@ -15,7 +15,11 @@ export const getters = {
getAudiobook: state => id => {
return state.audiobooks.find(ab => ab.id === id)
},
getFiltered: (state, getters, rootState) => () => {
getFiltered: (state, getters, rootState, rootGetters) => () => {
// var isDisc = !rootState.networkConnected || !rootState.socketConnected
// console.log('GET FILETERED', isDisc)
// var filtered = isDisc ? rootGetters['downloads/getAudiobooks'] : state.audiobooks
// console.log('FILTERED LEN', filtered.length)
var filtered = state.audiobooks
var settings = rootState.user.settings || {}
var filterBy = settings.filterBy || ''
@ -36,9 +40,12 @@ export const getters = {
var direction = settings.orderDesc ? 'desc' : 'asc'
var filtered = getters.getFiltered()
var orderByNumber = settings.orderBy === 'book.volumeNumber'
return sort(filtered)[direction]((ab) => {
// Supports dot notation strings i.e. "book.title"
return settings.orderBy.split('.').reduce((a, b) => a[b], ab)
var value = settings.orderBy.split('.').reduce((a, b) => a[b], ab)
if (orderByNumber && !isNaN(value)) return Number(value)
return value
})
},
getUniqueAuthors: (state) => {
@ -63,6 +70,9 @@ export const actions = {
.catch((error) => {
console.error('Failed', error)
})
},
useDownloaded({ commit, rootGetters }) {
commit('set', rootGetters['downloads/getAudiobooks'])
}
}

View file

@ -11,6 +11,9 @@ export const getters = {
getDownloadIfReady: (state) => id => {
var download = state.downloads.find(d => d.id === id)
return !!download && !download.isDownloading && !download.isPreparing ? download : null
},
getAudiobooks: (state) => {
return state.downloads.map(dl => dl.audiobook)
}
}

View file

@ -8,8 +8,10 @@ export const state = () => ({
appUpdateInfo: null,
socketConnected: false,
networkConnected: false,
networkConnectionType: 'unknown',
streamListener: null
networkConnectionType: null,
streamListener: null,
isFirstLoad: true,
hasStoragePermission: false
})
export const getters = {
@ -27,6 +29,12 @@ export const getters = {
export const actions = {}
export const mutations = {
setHasStoragePermission(state, val) {
state.hasStoragePermission = val
},
setIsFirstLoad(state, val) {
state.isFirstLoad = val
},
setAppUpdateInfo(state, info) {
state.appUpdateInfo = info
},

View file

@ -1,4 +1,3 @@
import { Storage } from '@capacitor/storage'
import Vue from 'vue'
export const state = () => ({