Fix: download single track audiobooks #15, Change: check write permission when selecting folder #13, Add: Show folders and files list in user selected folder, Fix: Seek back only if audiobook was played #20

This commit is contained in:
advplyr 2021-10-28 09:37:31 -05:00
parent de4340487b
commit ebf628315c
17 changed files with 727 additions and 502 deletions

View file

@ -13,8 +13,8 @@ android {
applicationId "com.audiobookshelf.app" applicationId "com.audiobookshelf.app"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 27 versionCode 28
versionName "0.9.11-beta" versionName "0.9.12-beta"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View file

@ -2,28 +2,19 @@ package com.audiobookshelf.app
import android.app.DownloadManager import android.app.DownloadManager
import android.content.Context import android.content.Context
import android.database.Cursor
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment import android.os.Environment
import android.provider.MediaStore
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi
import androidx.documentfile.provider.DocumentFile 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.FileCallback
import com.anggrayudi.storage.callback.FolderPickerCallback
import com.anggrayudi.storage.callback.StorageAccessCallback
import com.anggrayudi.storage.file.* import com.anggrayudi.storage.file.*
import com.anggrayudi.storage.media.FileDescription import com.anggrayudi.storage.media.FileDescription
import com.getcapacitor.JSObject import com.getcapacitor.JSObject
import com.getcapacitor.Plugin import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin import com.getcapacitor.annotation.CapacitorPlugin
import org.json.JSONObject
import java.io.File import java.io.File
@ -34,219 +25,117 @@ class AudioDownloader : Plugin() {
lateinit var mainActivity:MainActivity lateinit var mainActivity:MainActivity
lateinit var downloadManager:DownloadManager lateinit var downloadManager:DownloadManager
data class AudiobookItem(val uri: Uri, val name: String, val size: Long, val coverUrl: String) { // data class AudiobookItem(val uri: Uri, val name: String, val size: Long, val coverUrl: String) {
fun toJSObject() : JSObject { // fun toJSObject() : JSObject {
var obj = JSObject() // var obj = JSObject()
obj.put("uri", this.uri) // obj.put("uri", this.uri)
obj.put("name", this.name) // obj.put("name", this.name)
obj.put("size", this.size) // obj.put("size", this.size)
obj.put("coverUrl", this.coverUrl) // obj.put("coverUrl", this.coverUrl)
return obj // return obj
} // }
} // }
override fun load() { override fun load() {
mainActivity = (activity as MainActivity) mainActivity = (activity as MainActivity)
downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
// storage = SimpleStorage(mainActivity)
var recieverEvent : (evt: String, id: Long) -> Unit = { evt: String, id: Long -> var recieverEvent: (evt: String, id: Long) -> Unit = { evt: String, id: Long ->
if (evt == "complete") {} if (evt == "complete") {
}
if (evt == "clicked") { if (evt == "clicked") {
Log.d(tag, "Clicked $id back in the audiodownloader") Log.d(tag, "Clicked $id back in the audiodownloader")
} }
} }
mainActivity.registerBroadcastReceiver(recieverEvent) mainActivity.registerBroadcastReceiver(recieverEvent)
setupSimpleStorage()
Log.d(tag, "Build SDK ${Build.VERSION.SDK_INT}") 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) // @PluginMethod
// var jsobj = JSObject() // fun load(call: PluginCall) {
// jsobj.put("value", "required") // var audiobookUrls = call.data.getJSONArray("audiobookUrls")
// notifyListeners("permission", jsobj) // var len = audiobookUrls?.length()
// } else { // if (len == null) {
// Log.d(tag, "Does not request permission") // len = 0
// } // }
} // Log.d(tag, "CALLED LOAD $len")
// var audiobookItems:MutableList<AudiobookItem> = mutableListOf()
private fun setupSimpleStorage() { //
mainActivity.storageHelper.onFolderSelected = { requestCode, folder -> // (0 until len).forEach {
Log.d(tag, "FOLDER SELECTED $requestCode ${folder.name} ${folder.uri}") // var jsobj = audiobookUrls.get(it) as JSONObject
var jsobj = JSObject() // var audiobookUrl = jsobj.get("contentUrl").toString()
jsobj.put("value", "granted") // var coverUrl = jsobj.get("coverUrl").toString()
jsobj.put("uri", folder.uri) // var storageId = ""
jsobj.put("absolutePath", folder.getAbsolutePath(context)) // if(jsobj.has("storageId")) jsobj.get("storageId").toString()
jsobj.put("storageId", folder.getStorageId(context)) //
jsobj.put("storageType", folder.getStorageType(context)) // var basePath = ""
jsobj.put("simplePath", folder.getSimplePath(context)) // if(jsobj.has("basePath")) jsobj.get("basePath").toString()
jsobj.put("basePath", folder.getBasePath(context)) //
notifyListeners("permission", jsobj) // var coverBasePath = ""
} // if(jsobj.has("coverBasePath")) jsobj.get("coverBasePath").toString()
//
mainActivity.storage.storageAccessCallback = object : StorageAccessCallback { // Log.d(tag, "LOOKUP $storageId $basePath $audiobookUrl")
override fun onRootPathNotSelected( //
requestCode: Int, // var audiobookFile: DocumentFile? = null
rootPath: String, // var coverFile: DocumentFile? = null
uri: Uri, //
selectedStorageType: StorageType, // // Android 9 OR Below use storage id and base path
expectedStorageType: StorageType // if (Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.P) {
) { // audiobookFile = DocumentFileCompat.fromSimplePath(context, storageId, basePath)
Log.d(tag, "STORAGE ACCESS CALLBACK") // if (coverUrl != null && coverUrl != "") {
} // coverFile = DocumentFileCompat.fromSimplePath(context, storageId, coverBasePath)
// }
override fun onCanceledByUser(requestCode: Int) { // } else {
Log.d(tag, "STORAGE ACCESS CALLBACK") // // Android 10 and up manually deleting will still load the file causing crash
} // var exists = checkUriExists(Uri.parse(audiobookUrl))
// if (exists) {
override fun onExpectedStorageNotSelected(requestCode: Int, selectedFolder: DocumentFile, selectedStorageType: StorageType, expectedBasePath: String, expectedStorageType: StorageType) { // Log.d(tag, "Audiobook exists")
Log.d(tag, "STORAGE ACCESS CALLBACK") // audiobookFile = DocumentFileCompat.fromUri(context, Uri.parse(audiobookUrl))
} // } else {
// Log.e(tag, "Audiobook does not exist")
override fun onStoragePermissionDenied(requestCode: Int) { // }
Log.d(tag, "STORAGE ACCESS CALLBACK") //
} // var coverExists = checkUriExists(Uri.parse(coverUrl))
// if (coverExists) {
override fun onRootPathPermissionGranted(requestCode: Int, root: DocumentFile) { // Log.d(tag, "Cover Exists")
Log.d(tag, "STORAGE ACCESS CALLBACK") // coverFile = DocumentFileCompat.fromUri(context, Uri.parse(coverUrl))
} // } else if (coverUrl != null && coverUrl != "") {
} // Log.e(tag, "Cover does not exist")
} // }
// }
@RequiresApi(Build.VERSION_CODES.R) //
@PluginMethod // if (audiobookFile == null) {
fun requestStoragePermission(call: PluginCall) { // Log.e(tag, "Audiobook was not found $audiobookUrl")
Log.d(tag, "Request Storage Permissions") // } else {
mainActivity.storageHelper.requestStorageAccess() // Log.d(tag, "Audiobook File Found StorageId:${audiobookFile.getStorageId(context)} | AbsolutePath:${audiobookFile.getAbsolutePath(context)} | BasePath:${audiobookFile.getBasePath(context)}")
call.resolve() //
} // var _name = audiobookFile.name
// if (_name == null) _name = ""
@PluginMethod //
fun checkStoragePermission(call: PluginCall) { // var size = audiobookFile.length()
var res = false //
// if (audiobookFile.uri.toString() !== audiobookUrl) {
if (Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.P) { // Log.d(tag, "Audiobook URI ${audiobookFile.uri} is different from $audiobookUrl => using the latter")
res = SimpleStorage.hasStoragePermission(context) // }
Log.d(tag, "Check Storage Access $res") //
} else { // // Use existing URI's - bug happening where new uri is different from initial
Log.d(tag, "Has permission on Android 10 or up") // var abItem = AudiobookItem(Uri.parse(audiobookUrl), _name, size, coverUrl)
res = true //
} // Log.d(tag, "Setting AB ITEM ${abItem.name} | ${abItem.size} | ${abItem.uri} | ${abItem.coverUrl}")
//
var jsobj = JSObject() // audiobookItems.add(abItem)
jsobj.put("value", res) // }
call.resolve(jsobj) // }
} //
// Log.d(tag, "Load Finished ${audiobookItems.size} found")
fun checkUriExists(uri: Uri?): Boolean { //
if (uri == null) return false // var audiobookObjs:List<JSObject> = audiobookItems.map{ it.toJSObject() }
val resolver = context.contentResolver // var mediaItemNoticePayload = JSObject()
//1. Check Uri // mediaItemNoticePayload.put("items", audiobookObjs)
var cursor: Cursor? = null // notifyListeners("onMediaLoaded", mediaItemNoticePayload)
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")
}
}
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<JSObject> = audiobookItems.map{ it.toJSObject() }
var mediaItemNoticePayload = JSObject()
mediaItemNoticePayload.put("items", audiobookObjs)
notifyListeners("onMediaLoaded", mediaItemNoticePayload)
}
@PluginMethod @PluginMethod
fun download(call: PluginCall) { fun download(call: PluginCall) {
@ -399,74 +288,6 @@ class AudioDownloader : Plugin() {
call.resolve(ret) 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 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 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()
}
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() { 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 val query: DownloadManager.Query = DownloadManager.Query()
private var totalBytes: Int = 0 private var totalBytes: Int = 0

View file

@ -43,6 +43,7 @@ class MainActivity : BridgeActivity() {
Log.d(tag, "onCreate") Log.d(tag, "onCreate")
registerPlugin(MyNativeAudio::class.java) registerPlugin(MyNativeAudio::class.java)
registerPlugin(AudioDownloader::class.java) registerPlugin(AudioDownloader::class.java)
registerPlugin(StorageManager::class.java)
var filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE).apply { var filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE).apply {
addAction(DownloadManager.ACTION_NOTIFICATION_CLICKED) addAction(DownloadManager.ACTION_NOTIFICATION_CLICKED)

View file

@ -263,7 +263,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
override fun getSupportedPrepareActions(): Long { override fun getSupportedPrepareActions(): Long {
Log.d(tag, "GET SUPORTED ACITONS")
return PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID or return PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID or
PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or
PlaybackStateCompat.ACTION_PREPARE_FROM_SEARCH or PlaybackStateCompat.ACTION_PREPARE_FROM_SEARCH or

View file

@ -0,0 +1,257 @@
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.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
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<MediaFile>) {
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))!!
Log.d(TAG, "Folder as DF ${df.isDirectory} | ${df.getSimplePath(context)} | ${df.getBasePath(context)} | ${df.name}")
var mediaFolders = mutableListOf<MediaFolder>()
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<MediaFile>()
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<MediaFile>()
var mediaFilesFound:List<DocumentFile> = 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
}
}
}

View file

@ -19,7 +19,7 @@
<div class="flex-grow" /> <div class="flex-grow" />
<!-- <ui-menu :label="username" :items="menuItems" @action="menuAction" class="ml-5" /> --> <!-- <ui-menu :label="username" :items="menuItems" @action="menuAction" class="ml-5" /> -->
<span class="material-icons cursor-pointer mx-4" @click="$store.commit('downloads/setShowModal', true)">source</span> <span class="material-icons cursor-pointer mx-4" :class="hasDownloadsFolder ? '' : 'text-warning'" @click="$store.commit('downloads/setShowModal', true)">source</span>
<widgets-connection-icon /> <widgets-connection-icon />
@ -74,6 +74,9 @@ export default {
} else { } else {
return process.env.IOS_APP_URL return process.env.IOS_APP_URL
} }
},
hasDownloadsFolder() {
return !!this.$store.state.downloadFolder
} }
}, },
methods: { methods: {

View file

@ -3,78 +3,100 @@
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false"> <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> <p class="absolute top-6 left-2 text-2xl">Downloads</p>
<div class="absolute top-16 left-0 right-0 w-full flex items-center px-2 py-1" :class="hasStoragePermission ? '' : 'text-error'"> <div class="absolute top-16 left-0 right-0 w-full px-2 py-1" :class="hasStoragePermission ? '' : 'text-error'">
<span class="material-icons" @click="changeDownloadFolderClick">{{ hasStoragePermission ? 'folder' : 'error' }}</span> <div class="flex items-center">
<p v-if="hasStoragePermission" class="text-sm px-4" @click="changeDownloadFolderClick">{{ downloadFolderSimplePath || 'No Download Folder Selected' }}</p> <span class="material-icons" @click="changeDownloadFolderClick">{{ hasStoragePermission ? 'folder' : 'error' }}</span>
<p v-else class="text-sm px-4" @click="changeDownloadFolderClick">No Storage Permissions. Click here</p> <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>
<!-- <p v-if="hasStoragePermission" class="text-xs text-gray-400 break-all max-w-full">{{ downloadFolderUri }}</p> -->
</div> </div>
<div v-if="totalSize" class="absolute bottom-0 left-0 right-0 w-full py-3 text-center"> <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> <p class="text-sm text-center text-gray-300">Total: {{ $bytesPretty(totalSize) }}</p>
</div> </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="downloadFolder && hasStoragePermission" class="w-full relative mt-10" @click.stop>
<div v-if="!totalDownloads" class="flex items-center justify-center h-40"> <div class="w-full h-10 relative">
<p>No Downloads</p> <div class="absolute top-px left-0 z-10 w-full h-full flex">
<div class="flex-grow h-full bg-primary rounded-t-md mr-px" @click="showingDownloads = true">
<div class="flex items-center justify-center rounded-t-md border-t border-l border-r border-white border-opacity-20 h-full" :class="showingDownloads ? 'text-gray-100' : 'border-b bg-black bg-opacity-20 text-gray-400'">
<p>Downloads</p>
</div>
</div>
<div class="flex-grow h-full bg-primary rounded-t-md ml-px" @click="showingDownloads = false">
<div class="flex items-center justify-center h-full rounded-t-md border-t border-l border-r border-white border-opacity-20" :class="!showingDownloads ? 'text-gray-100' : 'border-b bg-black bg-opacity-20 text-gray-400'">
<p>Files</p>
</div>
</div>
</div>
</div> </div>
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="download in downloadsDownloading">
<li :key="download.id" class="text-gray-400 select-none relative px-4 py-5 border-b border-white border-opacity-10 bg-black bg-opacity-10">
<div class="flex items-center justify-center">
<div class="w-3/4">
<span class="text-xs">({{ downloadingProgress[download.id] || 0 }}%) {{ download.isPreparing ? 'Preparing' : 'Downloading' }}...</span>
<p class="font-normal truncate text-sm">{{ download.audiobook.book.title }}</p>
</div>
<div class="flex-grow" />
<div class="shadow-sm text-white flex items-center justify-center rounded-full animate-spin"> <div class="list-content-body relative w-full overflow-x-hidden overflow-y-auto bg-primary rounded-b-lg border border-white border-opacity-20" style="max-height: 70vh; height: 70vh">
<span class="material-icons">refresh</span> <template v-if="showingDownloads">
</div> <div v-if="!totalDownloads" class="flex items-center justify-center h-40">
</div> <p>No Downloads</p>
</li> </div>
<ul v-else class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="download in downloadsDownloading">
<li :key="download.id" class="text-gray-400 select-none relative px-4 py-5 border-b border-white border-opacity-10 bg-black bg-opacity-10">
<div class="flex items-center justify-center">
<div class="w-3/4">
<span class="text-xs">({{ downloadingProgress[download.id] || 0 }}%) {{ download.isPreparing ? 'Preparing' : 'Downloading' }}...</span>
<p class="font-normal truncate text-sm">{{ download.audiobook.book.title }}</p>
</div>
<div class="flex-grow" />
<div class="shadow-sm text-white flex items-center justify-center rounded-full animate-spin">
<span class="material-icons">refresh</span>
</div>
</div>
</li>
</template>
<template v-for="download in downloadsReady">
<li :key="download.id" class="text-gray-50 select-none relative pr-4 pl-2 py-5 border-b border-white border-opacity-10" @click="jumpToAudiobook(download)">
<modals-downloads-download-item :download="download" @play="playDownload" @delete="clickDeleteDownload" />
</li>
</template>
</ul>
</template> </template>
<template v-for="download in downloadsReady"> <template v-else>
<li :key="download.id" class="text-gray-50 select-none relative pr-4 pl-2 py-5 border-b border-white border-opacity-10" @click="jumpToAudiobook(download)"> <div class="w-full h-full">
<div class="flex items-center justify-center"> <div class="w-full flex justify-around py-4 px-2">
<img v-if="download.cover" :src="download.cover" class="w-10 h-16 object-contain" /> <ui-btn small @click="searchFolder">Re-Scan</ui-btn>
<img v-else src="/book_placeholder.jpg" class="w-10 h-16 object-contain" /> <ui-btn small @click="changeDownloadFolderClick">Change Folder</ui-btn>
<div class="pl-2 w-2/3"> <ui-btn small color="error" @click="resetFolder">Reset</ui-btn>
<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">
<span class="material-icons">error_outline</span>
</div>
<button class="shadow-sm text-accent flex items-center justify-center rounded-full" @click.stop="clickedOption(download)">
<span class="material-icons" style="font-size: 2rem">play_arrow</span>
</button>
<div class="shadow-sm text-error flex items-center justify-center rounded-ful ml-4" @click.stop="clickDelete(download)">
<span class="material-icons" style="font-size: 1.2rem">delete</span>
</div>
</div> </div>
</li> <p v-if="isScanning" class="text-center my-8">Scanning Folder..</p>
<p v-else-if="!mediaScanResults" class="text-center my-8">No Files Found</p>
<template v-else>
<template v-for="mediaFolder in mediaScanResults.folders">
<div :key="mediaFolder.uri" class="w-full px-2 py-2">
<div class="flex items-center">
<span class="material-icons text-base text-white text-opacity-50">folder</span>
<p class="ml-1 py-0.5">{{ mediaFolder.name }}</p>
</div>
<div v-for="mediaFile in mediaFolder.files" :key="mediaFile.uri" class="ml-3 flex items-center">
<span class="material-icons text-base text-white text-opacity-50">{{ mediaFile.isAudio ? 'music_note' : 'image' }}</span>
<p class="ml-1 py-0.5">{{ mediaFile.name }}</p>
</div>
</div>
</template>
<template v-for="mediaFile in mediaScanResults.files">
<div :key="mediaFile.uri" class="w-full px-2 py-2">
<div class="flex items-center">
<span class="material-icons text-base text-white text-opacity-50">{{ mediaFile.isAudio ? 'music_note' : 'image' }}</span>
<p class="ml-1 py-0.5">{{ mediaFile.name }}</p>
</div>
</div>
</template>
</template>
</div>
</template> </template>
<template v-for="download in orphanDownloads"> </div>
<li :key="download.id" class="text-gray-50 select-none relative cursor-pointer px-4 py-5 border-b border-white border-opacity-10"> </div>
<div class="flex items-center justify-center"> <div v-else class="list-content-body relative w-full overflow-x-hidden overflow-y-auto bg-primary rounded-b-lg border border-white border-opacity-20 py-8 px-4" @click.stop>
<div class="w-3/4"> <ui-btn class="w-full" color="info" @click="changeDownloadFolderClick">Select Folder</ui-btn>
<span class="text-xs text-gray-400">Unknown Audio File</span>
<p class="font-normal truncate text-sm">{{ download.filename }}</p>
</div>
<!-- <span class="font-normal block truncate text-sm pr-2">{{ download.filename }}</span> -->
<div class="flex-grow" />
<div class="shadow-sm text-warning flex items-center justify-center rounded-full">
<span class="material-icons">error_outline</span>
</div>
<div class="shadow-sm text-error flex items-center justify-center rounded-ful ml-4" @click="clickDelete(download)">
<span class="material-icons" style="font-size: 1.2rem">delete</span>
</div>
</div>
</li>
</template>
</ul>
</div> </div>
</div> </div>
</modals-modal> </modals-modal>
@ -83,19 +105,21 @@
<script> <script>
import { Dialog } from '@capacitor/dialog' import { Dialog } from '@capacitor/dialog'
import AudioDownloader from '@/plugins/audio-downloader' import AudioDownloader from '@/plugins/audio-downloader'
import StorageManager from '@/plugins/storage-manager'
export default { export default {
data() { data() {
return { return {
downloadFolder: null,
downloadingProgress: {}, downloadingProgress: {},
totalSize: 0 totalSize: 0,
showingDownloads: true,
isScanning: false
} }
}, },
watch: { watch: {
async show(newValue) { async show(newValue) {
if (newValue) { if (newValue) {
this.downloadFolder = await this.$localStore.getDownloadFolder() await this.$localStore.getDownloadFolder()
this.setTotalSize() this.setTotalSize()
} }
} }
@ -112,11 +136,17 @@ export default {
hasStoragePermission() { hasStoragePermission() {
return this.$store.state.hasStoragePermission return this.$store.state.hasStoragePermission
}, },
downloadFolder() {
return this.$store.state.downloadFolder
},
downloadFolderSimplePath() { downloadFolderSimplePath() {
return this.downloadFolder ? this.downloadFolder.simplePath : null return this.downloadFolder ? this.downloadFolder.simplePath : null
}, },
downloadFolderUri() {
return this.downloadFolder ? this.downloadFolder.uri : null
},
totalDownloads() { totalDownloads() {
return this.downloadsReady.length + this.orphanDownloads.length + this.downloadsDownloading.length return this.downloadsReady.length + this.downloadsDownloading.length
}, },
downloadsDownloading() { downloadsDownloading() {
return this.downloads.filter((d) => d.isDownloading || d.isPreparing) return this.downloads.filter((d) => d.isDownloading || d.isPreparing)
@ -124,39 +154,11 @@ export default {
downloadsReady() { downloadsReady() {
return this.downloads.filter((d) => !d.isDownloading && !d.isPreparing) return this.downloads.filter((d) => !d.isDownloading && !d.isPreparing)
}, },
orphanDownloads() {
return this.$store.state.downloads.orphanDownloads
// return [
// {
// id: 'asdf',
// filename: 'Test Title 1 another long title on the downloading widget.jpg'
// }
// ]
},
downloads() { downloads() {
return this.$store.state.downloads.downloads return this.$store.state.downloads.downloads
// return [ },
// { mediaScanResults() {
// id: 'asdf1', return this.$store.state.mediaScanResults
// audiobook: {
// book: {
// title: 'Test Title 1 another long title on the downloading widget',
// author: 'Test Author 1'
// }
// },
// isDownloading: true
// },
// {
// id: 'asdf2',
// audiobook: {
// book: {
// title: 'Test Title 2',
// author: 'Test Author 2 long test author to test the overflow capabilities'
// }
// },
// isReady: true
// }
// ]
} }
}, },
methods: { methods: {
@ -170,15 +172,55 @@ export default {
async changeDownloadFolderClick() { async changeDownloadFolderClick() {
if (!this.hasStoragePermission) { if (!this.hasStoragePermission) {
console.log('Requesting Storage Permission') console.log('Requesting Storage Permission')
AudioDownloader.requestStoragePermission() StorageManager.requestStoragePermission()
} else { } else {
var folderObj = await AudioDownloader.selectFolder() var folderObj = await StorageManager.selectFolder()
if (folderObj.error) { if (folderObj.error) {
return this.$toast.error(`Error: ${folderObj.error || 'Unknown Error'}`) return this.$toast.error(`Error: ${folderObj.error || 'Unknown Error'}`)
} }
var permissionsGood = await StorageManager.checkFolderPermissions({ folderUrl: folderObj.uri })
console.log('Storage Permission check folder ' + permissionsGood)
if (!permissionsGood) {
this.$toast.error('Folder permissions failed')
return
} else {
this.$toast.success('Folder permission success')
}
await this.$localStore.setDownloadFolder(folderObj) await this.$localStore.setDownloadFolder(folderObj)
this.searchFolder()
} }
}, },
async searchFolder() {
this.isScanning = true
var response = await StorageManager.searchFolder({ folderUrl: this.downloadFolderUri })
var searchResults = response
searchResults.folders = JSON.parse(searchResults.folders)
searchResults.files = JSON.parse(searchResults.files)
if (searchResults.folders.length) {
console.log('Search results folders length', searchResults.folders.length)
searchResults.folders = searchResults.folders.map((sr) => {
if (sr.files) {
sr.files = JSON.parse(sr.files)
}
return sr
})
this.$store.commit('setMediaScanResults', searchResults)
} else {
this.$toast.warning('No audio or image files found')
}
this.isScanning = false
},
async resetFolder() {
await this.$localStore.setDownloadFolder(null)
this.$store.commit('setMediaScanResults', {})
this.$toast.info('Unlinked Folder')
},
updateDownloadProgress({ audiobookId, progress }) { updateDownloadProgress({ audiobookId, progress }) {
this.$set(this.downloadingProgress, audiobookId, progress) this.$set(this.downloadingProgress, audiobookId, progress)
}, },
@ -186,7 +228,7 @@ export default {
this.show = false this.show = false
this.$router.push(`/audiobook/${download.id}`) this.$router.push(`/audiobook/${download.id}`)
}, },
async clickDelete(download) { async clickDeleteDownload(download) {
const { value } = await Dialog.confirm({ const { value } = await Dialog.confirm({
title: 'Confirm', title: 'Confirm',
message: 'Delete this download?' message: 'Delete this download?'
@ -195,12 +237,18 @@ export default {
this.$emit('deleteDownload', download) this.$emit('deleteDownload', download)
} }
}, },
clickedOption(download) { playDownload(download) {
console.log('Clicked download', download) this.$store.commit('setPlayOnLoad', true)
this.$emit('selectDownload', download) this.$store.commit('setPlayingDownload', download)
this.show = false this.show = false
} }
}, },
mounted() {} mounted() {}
} }
</script> </script>
<style>
.list-content-body {
max-height: calc(75% - 40px);
}
</style>

View file

@ -0,0 +1,52 @@
<template>
<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-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 || download.isMissing" class="shadow-sm text-warning flex items-center justify-center rounded-full mr-4">
<span class="material-icons">error_outline</span>
</div>
<button v-if="!isMissing" class="shadow-sm text-accent flex items-center justify-center rounded-full" @click.stop="playDownload">
<span class="material-icons" style="font-size: 2rem">play_arrow</span>
</button>
<div class="shadow-sm text-error flex items-center justify-center rounded-ful ml-4" @click.stop="clickDelete">
<span class="material-icons" style="font-size: 1.2rem">delete</span>
</div>
</div>
</template>
<script>
export default {
props: {
download: {
type: Object,
default: () => {}
}
},
data() {
return {}
},
computed: {
isIncomplete() {
return this.download.isIncomplete
},
isMissing() {
return this.download.isMissing
}
},
methods: {
playDownload() {
this.$emit('play', this.download)
},
clickDelete() {
this.$emit('delete', this.download)
}
},
mounted() {}
}
</script>

View file

@ -5,7 +5,7 @@
<Nuxt /> <Nuxt />
</div> </div>
<app-stream-container ref="streamContainer" /> <app-stream-container ref="streamContainer" />
<modals-downloads-modal ref="downloadsModal" @selectDownload="selectDownload" @deleteDownload="deleteDownload" /> <modals-downloads-modal ref="downloadsModal" @deleteDownload="deleteDownload" />
<modals-libraries-modal /> <modals-libraries-modal />
<readers-reader /> <readers-reader />
</div> </div>
@ -17,6 +17,7 @@ import { Network } from '@capacitor/network'
import { AppUpdate } from '@robingenz/capacitor-app-update' import { AppUpdate } from '@robingenz/capacitor-app-update'
import AudioDownloader from '@/plugins/audio-downloader' import AudioDownloader from '@/plugins/audio-downloader'
import MyNativeAudio from '@/plugins/my-native-audio' import MyNativeAudio from '@/plugins/my-native-audio'
import StorageManager from '@/plugins/storage-manager'
export default { export default {
data() { data() {
@ -234,46 +235,100 @@ export default {
this.$localStore.setCurrent(null) this.$localStore.setCurrent(null)
} }
}, },
async onMediaLoaded(items) { async searchFolder(downloadFolder) {
var jsitems = JSON.parse(items) try {
jsitems = jsitems.map((item) => { var response = await StorageManager.searchFolder({ folderUrl: downloadFolder.uri })
return { var searchResults = response
filename: item.name, searchResults.folders = JSON.parse(searchResults.folders)
size: item.size, searchResults.files = JSON.parse(searchResults.files)
contentUrl: item.uri,
coverUrl: item.coverUrl || null console.log('Search folders results length', searchResults.folders.length)
} searchResults.folders = searchResults.folders.map((sr) => {
if (sr.files) {
sr.files = JSON.parse(sr.files)
}
return sr
})
return searchResults
} catch (error) {
console.error('Failed', error)
this.$toast.error('Failed to search downloads folder')
return {}
}
},
async syncDownloads(downloads, downloadFolder) {
console.log('Syncing downloads ' + downloads.length)
var mediaScanResults = await this.searchFolder(downloadFolder)
this.$store.commit('setMediaScanResults', mediaScanResults)
// Filter out media folders without any audio files
var mediaFolders = mediaScanResults.folders.filter((sr) => {
if (!sr.files) return false
var audioFiles = sr.files.filter((mf) => !!mf.isAudio)
return audioFiles.length
}) })
var downloads = await this.$sqlStore.getAllDownloads() downloads.forEach((download) => {
var mediaFolder = mediaFolders.find((mf) => mf.name === download.folderName)
if (mediaFolder) {
console.log('Found download ' + download.folderName)
if (download.isPreparing || download.isDownloading) {
download.isIncomplete = true
download.isPreparing = false
download.isDownloading = false
}
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('downloads/addUpdateDownload', download)
this.$store.commit('audiobooks/addUpdate', download.audiobook) this.$store.commit('audiobooks/addUpdate', download.audiobook)
} else { } else {
download.size = jsitem.size || 0 console.error('Download not found ' + download.folderName)
download.isMissing = true
download.isPreparing = false
download.isDownloading = false
this.$store.commit('downloads/addUpdateDownload', download) this.$store.commit('downloads/addUpdateDownload', download)
this.$store.commit('audiobooks/addUpdate', download.audiobook)
} }
} })
},
// async onMediaLoaded(items) {
// var jsitems = JSON.parse(items)
// jsitems = jsitems.map((item) => {
// return {
// filename: item.name,
// size: item.size,
// contentUrl: item.uri,
// coverUrl: item.coverUrl || null
// }
// })
this.checkLoadCurrent() // var downloads = await this.$sqlStore.getAllDownloads()
this.$store.dispatch('audiobooks/setNativeAudiobooks')
}, // for (let i = 0; i < downloads.length; i++) {
selectDownload(download) { // var download = downloads[i]
this.$store.commit('setPlayOnLoad', true) // var jsitem = jsitems.find((item) => item.contentUrl === download.contentUrl)
this.$store.commit('setPlayingDownload', download) // 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)
// this.$store.commit('audiobooks/addUpdate', download.audiobook)
// }
// }
// this.checkLoadCurrent()
// this.$store.dispatch('audiobooks/setNativeAudiobooks')
// },
async deleteDownload(download) { async deleteDownload(download) {
console.log('Delete download', download.filename) console.log('Delete download', download.filename)
@ -284,83 +339,63 @@ export default {
} }
} }
if (download.contentUrl) { if (download.contentUrl) {
await AudioDownloader.delete(download) await StorageManager.delete(download)
} }
this.$store.commit('downloads/removeDownload', download) this.$store.commit('downloads/removeDownload', download)
}, },
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() { async initMediaStore() {
// Request and setup listeners for media files on native // Request and setup listeners for media files on native
AudioDownloader.addListener('permission', (data) => {
this.onPermissionUpdate(data)
})
AudioDownloader.addListener('onDownloadComplete', (data) => { AudioDownloader.addListener('onDownloadComplete', (data) => {
this.onDownloadComplete(data) this.onDownloadComplete(data)
}) })
AudioDownloader.addListener('onDownloadFailed', (data) => { AudioDownloader.addListener('onDownloadFailed', (data) => {
this.onDownloadFailed(data) this.onDownloadFailed(data)
}) })
AudioDownloader.addListener('onMediaLoaded', (data) => { // AudioDownloader.addListener('onMediaLoaded', (data) => {
this.onMediaLoaded(data.items) // this.onMediaLoaded(data.items)
}) // })
AudioDownloader.addListener('onDownloadProgress', (data) => { AudioDownloader.addListener('onDownloadProgress', (data) => {
this.onDownloadProgress(data) this.onDownloadProgress(data)
}) })
await this.$localStore.loadUserAudiobooks() await this.$localStore.loadUserAudiobooks()
await this.$localStore.getDownloadFolder()
var downloads = await this.$sqlStore.getAllDownloads()
var downloadFolder = await this.$localStore.getDownloadFolder()
if (downloadFolder && downloads.length) {
await this.syncDownloads(downloads, downloadFolder)
}
var userSavedSettings = await this.$localStore.getUserSettings() var userSavedSettings = await this.$localStore.getUserSettings()
if (userSavedSettings) { if (userSavedSettings) {
this.$store.commit('user/setSettings', userSavedSettings) this.$store.commit('user/setSettings', userSavedSettings)
} }
var downloads = await this.$sqlStore.getAllDownloads() // if (downloads.length) {
if (downloads.length) { // var urls = downloads
var urls = downloads // .map((d) => {
.map((d) => { // return {
return { // contentUrl: d.contentUrl,
contentUrl: d.contentUrl, // coverUrl: d.coverUrl || '',
coverUrl: d.coverUrl || '', // storageId: d.storageId,
storageId: d.storageId, // basePath: d.basePath,
basePath: d.basePath, // coverBasePath: d.coverBasePath || ''
coverBasePath: d.coverBasePath || '' // }
} // })
}) // .filter((d) => {
.filter((d) => { // if (!d.contentUrl) {
if (!d.contentUrl) { // console.error('Invalid Download no Content URL', JSON.stringify(d))
console.error('Invalid Download no Content URL', JSON.stringify(d)) // return false
return false // }
} // return true
return true // })
}) // AudioDownloader.load({
// audiobookUrls: urls
// })
// }
AudioDownloader.load({ var checkPermission = await StorageManager.checkStoragePermission()
audiobookUrls: urls
})
}
var checkPermission = await AudioDownloader.checkStoragePermission()
console.log('Storage Permission is' + checkPermission.value) console.log('Storage Permission is' + checkPermission.value)
if (!checkPermission.value) { if (!checkPermission.value) {
console.log('Will require permissions') console.log('Will require permissions')

View file

@ -41,6 +41,7 @@ export default {
'@/plugins/axios.js', '@/plugins/axios.js',
'@/plugins/my-native-audio.js', '@/plugins/my-native-audio.js',
'@/plugins/audio-downloader.js', '@/plugins/audio-downloader.js',
'@/plugins/storage-manager.js',
'@/plugins/toast.js' '@/plugins/toast.js'
], ],

View file

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

View file

@ -51,6 +51,7 @@
import Path from 'path' import Path from 'path'
import { Dialog } from '@capacitor/dialog' import { Dialog } from '@capacitor/dialog'
import AudioDownloader from '@/plugins/audio-downloader' import AudioDownloader from '@/plugins/audio-downloader'
import StorageManager from '@/plugins/storage-manager'
export default { export default {
async asyncData({ store, params, redirect, app }) { async asyncData({ store, params, redirect, app }) {
@ -266,21 +267,17 @@ export default {
} }
if (!this.hasStoragePermission) { if (!this.hasStoragePermission) {
await AudioDownloader.requestStoragePermission() this.$store.commit('downloads/setShowModal', true)
return return
} }
// Download Path // Download Path
var dlFolder = this.$localStore.downloadFolder var dlFolder = this.$localStore.downloadFolder
if (!dlFolder) { if (!dlFolder) {
console.log('No download folder, request from ujser') console.log('No download folder, request from user')
var folderObj = await AudioDownloader.selectFolder() // User to select download folder from download modal to ensure permissions
this.$store.commit('downloads/setShowModal', true)
if (folderObj.error) { return
return this.$toast.error(`Error: ${folderObj.error || 'Unknown Error'}`)
}
dlFolder = folderObj
await this.$localStore.setDownloadFolder(folderObj)
} }
var downloadObject = { var downloadObject = {
@ -299,8 +296,11 @@ export default {
var track = audiobook.tracks[0] var track = audiobook.tracks[0]
var fileext = track.ext var fileext = track.ext
console.log('Download Single Track Path: ' + track.path)
var relTrackPath = track.path.replace('\\', '/').replace(this.audiobook.path.replace('\\', '/'), '') var relTrackPath = track.path.replace('\\', '/').replace(this.audiobook.path.replace('\\', '/'), '')
var url = `${this.$store.state.serverUrl}/s/book/${this.audiobookId}/${relTrackPath}?token=${this.userToken}`
var url = `${this.$store.state.serverUrl}/s/book/${this.audiobookId}${relTrackPath}?token=${this.userToken}`
this.startDownload(url, fileext, downloadObject) this.startDownload(url, fileext, downloadObject)
} else { } else {
// Multi-track merge // Multi-track merge

View file

@ -32,7 +32,7 @@ Vue.prototype.$secondsToTimestamp = (seconds) => {
_seconds -= _minutes * 60 _seconds -= _minutes * 60
var _hours = Math.floor(_minutes / 60) var _hours = Math.floor(_minutes / 60)
_minutes -= _hours * 60 _minutes -= _hours * 60
_seconds = Math.round(_seconds) _seconds = Math.floor(_seconds)
if (!_hours) { if (!_hours) {
return `${_minutes}:${_seconds.toString().padStart(2, '0')}` return `${_minutes}:${_seconds.toString().padStart(2, '0')}`
} }

View file

@ -0,0 +1,5 @@
import { registerPlugin } from '@capacitor/core';
const StorageManager = registerPlugin('StorageManager');
export default StorageManager;

View file

@ -441,7 +441,7 @@ class LocalStorage {
var val = (await Storage.get({ key: 'userAudiobooks' }) || {}).value || null var val = (await Storage.get({ key: 'userAudiobooks' }) || {}).value || null
this.userAudiobooks = val ? JSON.parse(val) : {} this.userAudiobooks = val ? JSON.parse(val) : {}
this.userAudiobooksLoaded = true this.userAudiobooksLoaded = true
this.vuexStore.commit('user/setLocalUserAudiobooks', this.userAudiobooks) this.vuexStore.commit('user/setLocalUserAudiobooks', { ...this.userAudiobooks })
} catch (error) { } catch (error) {
console.error('[LocalStorage] Failed to load user audiobooks', error) console.error('[LocalStorage] Failed to load user audiobooks', error)
} }
@ -458,7 +458,7 @@ class LocalStorage {
async setAllAudiobookProgress(progresses) { async setAllAudiobookProgress(progresses) {
this.userAudiobooks = progresses this.userAudiobooks = progresses
await this.saveUserAudiobooks() await this.saveUserAudiobooks()
this.vuexStore.commit('user/setLocalUserAudiobooks', this.userAudiobooks) this.vuexStore.commit('user/setLocalUserAudiobooks', { ...this.userAudiobooks })
} }
async updateUserAudiobookProgress(progressPayload) { async updateUserAudiobookProgress(progressPayload) {
@ -467,14 +467,14 @@ class LocalStorage {
} }
console.log('[LocalStorage] Updated User Audiobook Progress ' + progressPayload.audiobookId) console.log('[LocalStorage] Updated User Audiobook Progress ' + progressPayload.audiobookId)
await this.saveUserAudiobooks() await this.saveUserAudiobooks()
this.vuexStore.commit('user/setLocalUserAudiobooks', this.userAudiobooks) this.vuexStore.commit('user/setLocalUserAudiobooks', { ...this.userAudiobooks })
} }
async removeAudiobookProgress(audiobookId) { async removeAudiobookProgress(audiobookId) {
if (!this.userAudiobooks[audiobookId]) return if (!this.userAudiobooks[audiobookId]) return
delete this.userAudiobooks[audiobookId] delete this.userAudiobooks[audiobookId]
await this.saveUserAudiobooks() await this.saveUserAudiobooks()
this.vuexStore.commit('user/setLocalUserAudiobooks', this.userAudiobooks) this.vuexStore.commit('user/setLocalUserAudiobooks', { ...this.userAudiobooks })
} }
getUserAudiobook(audiobookId) { getUserAudiobook(audiobookId) {
@ -530,10 +530,13 @@ class LocalStorage {
if (folderObj) { if (folderObj) {
await Storage.set({ key: 'downloadFolder', value: JSON.stringify(folderObj) }) await Storage.set({ key: 'downloadFolder', value: JSON.stringify(folderObj) })
this.downloadFolder = folderObj this.downloadFolder = folderObj
this.vuexStore.commit('setDownloadFolder', { ...this.downloadFolder })
} else { } else {
await Storage.remove({ key: 'downloadFolder' }) await Storage.remove({ key: 'downloadFolder' })
this.downloadFolder = null this.downloadFolder = null
this.vuexStore.commit('setDownloadFolder', null)
} }
} catch (error) { } catch (error) {
console.error('[LocalStorage] Failed to set download folder', error) console.error('[LocalStorage] Failed to set download folder', error)
} }
@ -544,6 +547,7 @@ class LocalStorage {
var _value = (await Storage.get({ key: 'downloadFolder' }) || {}).value || null var _value = (await Storage.get({ key: 'downloadFolder' }) || {}).value || null
if (!_value) return null if (!_value) return null
this.downloadFolder = JSON.parse(_value) this.downloadFolder = JSON.parse(_value)
this.vuexStore.commit('setDownloadFolder', { ...this.downloadFolder })
return this.downloadFolder return this.downloadFolder
} catch (error) { } catch (error) {
console.error('[LocalStorage] Failed to get download folder', error) console.error('[LocalStorage] Failed to get download folder', error)

View file

@ -1,6 +1,5 @@
export const state = () => ({ export const state = () => ({
downloads: [], downloads: [],
orphanDownloads: [],
showModal: false showModal: false
}) })
@ -45,24 +44,16 @@ export const mutations = {
} }
}, },
addUpdateDownload(state, download) { addUpdateDownload(state, download) {
var key = download.isOrphan ? 'orphanDownloads' : 'downloads' var index = state.downloads.findIndex(d => d.id === download.id)
var index = state[key].findIndex(d => d.id === download.id)
if (index >= 0) { if (index >= 0) {
state[key].splice(index, 1, download) state.downloads.splice(index, 1, download)
} else { } else {
state[key].push(download) state.downloads.push(download)
}
if (key === 'downloads') {
this.$sqlStore.setDownload(download)
} }
this.$sqlStore.setDownload(download)
}, },
removeDownload(state, download) { removeDownload(state, download) {
var key = download.isOrphan ? 'orphanDownloads' : 'downloads' state.downloads = state.downloads.filter(d => d.id !== download.id)
state[key] = state[key].filter(d => d.id !== download.id) this.$sqlStore.removeDownload(download.id)
if (key === 'downloads') {
this.$sqlStore.removeDownload(download.id)
}
} }
} }

View file

@ -13,7 +13,9 @@ export const state = () => ({
isFirstLoad: true, isFirstLoad: true,
hasStoragePermission: false, hasStoragePermission: false,
selectedBook: null, selectedBook: null,
showReader: false showReader: false,
downloadFolder: null,
mediaScanResults: {}
}) })
export const getters = { export const getters = {
@ -89,5 +91,11 @@ export const mutations = {
}, },
setShowReader(state, val) { setShowReader(state, val) {
state.showReader = val state.showReader = val
},
setDownloadFolder(state, val) {
state.downloadFolder = val
},
setMediaScanResults(state, val) {
state.mediaScanResults = val
} }
} }