New downloader for downloading multiple tracks, android media player support for using multiple tracks

This commit is contained in:
advplyr 2022-04-02 19:43:43 -05:00
parent f70f707100
commit 7a091dd428
15 changed files with 767 additions and 457 deletions

View file

@ -7,6 +7,7 @@
<!-- Permissions --> <!-- Permissions -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

View file

@ -7,15 +7,29 @@ import android.os.Build
import android.os.Environment import android.os.Environment
import android.util.Log import android.util.Log
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.anggrayudi.storage.SimpleStorage
import com.anggrayudi.storage.callback.FileCallback import com.anggrayudi.storage.callback.FileCallback
import com.anggrayudi.storage.file.* import com.anggrayudi.storage.file.*
import com.anggrayudi.storage.media.FileDescription import com.anggrayudi.storage.media.FileDescription
import com.audiobookshelf.app.data.LibraryItem
import com.audiobookshelf.app.data.LocalFolder
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.device.FolderScanner
import com.audiobookshelf.app.server.ApiHandler
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.getcapacitor.JSObject import com.getcapacitor.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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.io.File import java.io.File
import java.io.FileOutputStream
import java.util.*
@CapacitorPlugin(name = "AudioDownloader") @CapacitorPlugin(name = "AudioDownloader")
@ -24,21 +38,46 @@ class AudioDownloader : Plugin() {
lateinit var mainActivity: MainActivity lateinit var mainActivity: MainActivity
lateinit var downloadManager: DownloadManager lateinit var downloadManager: DownloadManager
lateinit var apiHandler: ApiHandler
lateinit var folderScanner: FolderScanner
// data class AudiobookItem(val uri: Uri, val name: String, val size: Long, val coverUrl: String) { data class DownloadItemPart(
// fun toJSObject() : JSObject { val id: String,
// var obj = JSObject() val name: String,
// obj.put("uri", this.uri) val itemTitle: String,
// obj.put("name", this.name) val serverPath: String,
// obj.put("size", this.size) val folderName: String,
// obj.put("coverUrl", this.coverUrl) val localFolderId: String,
// return obj @JsonIgnore val uri: Uri,
// } @JsonIgnore val destinationUri: Uri,
// } var downloadId: Long?,
var progress: Long
) {
@JsonIgnore
fun getDownloadRequest(): DownloadManager.Request {
var dlRequest = DownloadManager.Request(uri)
dlRequest.setTitle(name)
dlRequest.setDescription("Downloading to $folderName for book $itemTitle")
dlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
dlRequest.setDestinationUri(destinationUri)
return dlRequest
}
}
data class DownloadItem(
val id: String,
val localFolder: LocalFolder,
val itemTitle: String,
val downloadItemParts: MutableList<DownloadItemPart>
)
var downloadQueue: MutableList<DownloadItem> = mutableListOf()
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
folderScanner = FolderScanner(mainActivity)
apiHandler = ApiHandler(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") {
@ -52,303 +91,355 @@ class AudioDownloader : Plugin() {
Log.d(tag, "Build SDK ${Build.VERSION.SDK_INT}") Log.d(tag, "Build SDK ${Build.VERSION.SDK_INT}")
} }
// @PluginMethod
// fun load(call: PluginCall) {
// var audiobookUrls = call.data.getJSONArray("audiobookUrls")
// var len = audiobookUrls?.length()
// if (len == null) {
// len = 0
// }
// Log.d(tag, "CALLED LOAD $len")
// var audiobookItems:MutableList<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 downloadLibraryItem(call: PluginCall) {
var audiobookId = call.data.getString("audiobookId", "audiobook").toString() var libraryItemId = call.data.getString("libraryItemId").toString()
var url = call.data.getString("downloadUrl", "unknown").toString() var localFolderId = call.data.getString("localFolderId").toString()
var coverDownloadUrl = call.data.getString("coverDownloadUrl", "").toString() Log.d(tag, "Download library item $libraryItemId to folder $localFolderId")
var title = call.data.getString("title", "Audiobook").toString()
var filename = call.data.getString("filename", "audiobook.mp3").toString()
var coverFilename = call.data.getString("coverFilename", "cover.png").toString()
var downloadFolderUrl = call.data.getString("downloadFolderUrl", "").toString()
var folder = DocumentFileCompat.fromUri(context, Uri.parse(downloadFolderUrl))!!
Log.d(tag, "Called download: $url | Folder: ${folder.name} | $downloadFolderUrl")
var dlfilename = audiobookId + "." + File(filename).extension apiHandler.getLibraryItem(libraryItemId) { libraryItem ->
var coverdlfilename = audiobookId + "." + File(coverFilename).extension Log.d(tag, "Got library item from server ${libraryItem.id}")
Log.d(tag, "DL Filename $dlfilename | Cover DL Filename $coverdlfilename") var localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
if (localFolder != null) {
var canWriteToFolder = folder.canWrite() startLibraryItemDownload(libraryItem, localFolder)
if (!canWriteToFolder) { call.resolve()
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)) call.resolve(JSObject("{\"error\":\"Library Item not found\"}"))
dlRequest.setTitle("Ab: $title")
dlRequest.setDescription("Downloading to ${folder.name}")
dlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
dlRequest.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, dlfilename)
var audiobookDownloadId = downloadManager.enqueue(dlRequest)
var coverDownloadId:Long? = null
if (coverDownloadUrl != "") {
var coverDlRequest = DownloadManager.Request(Uri.parse(coverDownloadUrl))
coverDlRequest.setTitle("Cover: $title")
coverDlRequest.setDescription("Downloading to ${folder.name}")
coverDlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_ONLY_COMPLETION)
coverDlRequest.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, coverdlfilename)
coverDownloadId = downloadManager.enqueue(coverDlRequest)
} }
var progressReceiver : (id:Long, prog: Long) -> Unit = { id:Long, prog: Long -> // Clean folder path so it can be used in URL
if (id == audiobookDownloadId) { fun cleanRelPath(relPath: String): String {
var cleanedRelPath = relPath.replace("\\", "/").replace("%", "%25").replace("#", "%23")
return if (cleanedRelPath.startsWith("/")) cleanedRelPath.substring(1) else cleanedRelPath
}
// Item filenames could be the same if they are in subfolders, this will make them unique
fun getFilenameFromRelPath(relPath: String): String {
var cleanedRelPath = relPath.replace("\\", "_").replace("/", "_")
return if (cleanedRelPath.startsWith("_")) cleanedRelPath.substring(1) else cleanedRelPath
}
fun getAbMetadataText(libraryItem:LibraryItem):String {
var bookMedia = libraryItem.media as com.audiobookshelf.app.data.Book
var fileString = ";ABMETADATA1\n"
fileString += "#libraryItemId=${libraryItem.id}\n"
fileString += "title=${bookMedia.metadata.title}\n"
fileString += "author=${bookMedia.metadata.authorName}\n"
fileString += "narrator=${bookMedia.metadata.narratorName}\n"
fileString += "series=${bookMedia.metadata.seriesName}\n"
return fileString
}
fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder) {
if (libraryItem.mediaType == "book") {
var bookMedia = libraryItem.media as com.audiobookshelf.app.data.Book
var bookTitle = bookMedia.metadata.title
var tracks = bookMedia.tracks ?: mutableListOf()
Log.d(tag, "Starting library item download with ${tracks.size} tracks")
var downloadItem = DownloadItem(libraryItem.id, localFolder, bookTitle, mutableListOf())
var itemFolderPath = localFolder.absolutePath + "/" + bookTitle
tracks.forEach { audioFile ->
var serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioFile.metadata.relPath)}"
var destinationFilename = getFilenameFromRelPath(audioFile.metadata.relPath)
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioFile.metadata.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName ${destinationFilename}")
var destinationFile = File("$itemFolderPath/$destinationFilename")
var destinationUri = Uri.fromFile(destinationFile)
var downloadUri = Uri.parse("${apiHandler.serverUrl}${serverPath}?token=${apiHandler.token}")
Log.d(tag, "Audio File Destination Uri $destinationUri | Download URI $downloadUri")
var downloadItemPart = DownloadItemPart(UUID.randomUUID().toString(), destinationFilename, bookTitle, serverPath, localFolder.name
?: "", localFolder.id, downloadUri, destinationUri, null, 0)
downloadItem.downloadItemParts.add(downloadItemPart)
var dlRequest = downloadItemPart.getDownloadRequest()
var downloadId = downloadManager.enqueue(dlRequest)
downloadItemPart.downloadId = downloadId
}
Log.d(tag, "Done queueing downloads ${downloadQueue.size}")
if (downloadItem.downloadItemParts.isNotEmpty()) {
// TODO: Cannot create new text file here but can download here... ??
// var abmetadataFile = File(itemFolderPath, "abmetadata.abs")
// abmetadataFile.createNewFileIfPossible()
// abmetadataFile.writeText(getAbMetadataText(libraryItem))
downloadQueue.add(downloadItem)
startWatchingDownloads(downloadItem)
}
} else {
// TODO: Download podcast episode(s)
}
}
fun startWatchingDownloads(downloadItem: DownloadItem) {
GlobalScope.launch(Dispatchers.IO) {
while (downloadItem.downloadItemParts.isNotEmpty()) {
checkDownloads(downloadItem)
notifyListeners("onItemDownloadUpdate", JSObject(jacksonObjectMapper().writeValueAsString(downloadItem)))
delay(500)
}
var folderScanResult = folderScanner.scanForMediaItems(downloadItem.localFolder, false)
Log.d(tag, "Item download complete ${downloadItem.itemTitle}")
var jsobj = JSObject() var jsobj = JSObject()
jsobj.put("audiobookId", audiobookId) jsobj.put("libraryItemId", downloadItem.id)
jsobj.put("progress", prog) jsobj.put("localFolderId", downloadItem.localFolder.id)
notifyListeners("onDownloadProgress", jsobj) jsobj.put("folderScanResult", JSObject(jacksonObjectMapper().writeValueAsString(folderScanResult)))
notifyListeners("onItemDownloadComplete", jsobj)
} }
} }
var coverDocFile:DocumentFile? = null fun checkDownloads(downloadItem: DownloadItem) {
var itemParts = downloadItem.downloadItemParts.map { it }
var doneReceiver : (id:Long, success: Boolean) -> Unit = { id:Long, success: Boolean -> Log.d(tag, "Check Downloads ${itemParts.size}")
Log.d(tag, "RECEIVER DONE $id, SUCCES? $success") for (downloadItemPart in itemParts) {
var docfile:DocumentFile? = null if (downloadItemPart.downloadId != null) {
var dlid = downloadItemPart.downloadId!!
// Download was complete, now find downloaded file val query = DownloadManager.Query().setFilterById(dlid)
if (id == coverDownloadId) { downloadManager.query(query).use {
docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, coverdlfilename)
Log.d(tag, "Move Cover File ${docfile?.name}")
// For unknown reason, Android 10 test was using the title set in "setTitle" for the dl manager as the filename
// check if this was the case
if (docfile?.name == null) {
docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, "Cover: $title")
Log.d(tag, "Cover File name attempt 2 ${docfile?.name}")
}
} else if (id == audiobookDownloadId) {
docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, dlfilename)
Log.d(tag, "Move Audiobook File ${docfile?.name}")
if (docfile?.name == null) {
docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, "Ab: $title")
Log.d(tag, "File name attempt 2 ${docfile?.name}")
}
}
// Callback for moving the downloaded file
var callback = object : FileCallback() {
override fun onPrepare() {
Log.d(tag, "PREPARING MOVE FILE")
}
override fun onFailed(errorCode:ErrorCode) {
Log.e(tag, "FAILED MOVE FILE $errorCode")
docfile?.delete()
coverDocFile?.delete()
if (id == audiobookDownloadId) {
var jsobj = JSObject()
jsobj.put("audiobookId", audiobookId)
jsobj.put("error", "Move failed")
notifyListeners("onDownloadFailed", jsobj)
}
}
override fun onCompleted(result:Any) {
var resultDocFile = result as DocumentFile
var simplePath = resultDocFile.getSimplePath(context)
var storageId = resultDocFile.getStorageId(context)
var size = resultDocFile.length()
Log.d(tag, "Finished Moving File, NAME: ${resultDocFile.name} | URI:${resultDocFile.uri} | AbsolutePath:${resultDocFile.getAbsolutePath(context)} | $storageId | SimplePath: $simplePath")
var abFolder = folder.findFolder(title)
var jsobj = JSObject()
jsobj.put("audiobookId", audiobookId)
jsobj.put("downloadId", id)
jsobj.put("storageId", storageId)
jsobj.put("storageType", resultDocFile.getStorageType(context))
jsobj.put("folderUrl", abFolder?.uri)
jsobj.put("folderName", abFolder?.name)
jsobj.put("downloadFolderUrl", downloadFolderUrl)
jsobj.put("contentUrl", resultDocFile.uri)
jsobj.put("basePath", resultDocFile.getBasePath(context))
jsobj.put("filename", filename)
jsobj.put("simplePath", simplePath)
jsobj.put("size", size)
if (resultDocFile.name == filename) {
Log.d(tag, "Audiobook Finishing Moving")
} else if (resultDocFile.name == coverFilename) {
coverDocFile = docfile
Log.d(tag, "Audiobook Cover Finished Moving")
jsobj.put("isCover", true)
}
notifyListeners("onDownloadComplete", jsobj)
}
}
// After file is downloaded, move the files into an audiobook directory inside the user selected folder
if (id == coverDownloadId) {
docfile?.moveFileTo(context, folder, FileDescription(coverFilename, title, MimeType.IMAGE), callback)
} else if (id == audiobookDownloadId) {
docfile?.moveFileTo(context, folder, FileDescription(filename, title, MimeType.AUDIO), callback)
}
}
var progressUpdater = DownloadProgressUpdater(downloadManager, audiobookDownloadId, progressReceiver, doneReceiver)
progressUpdater.run()
if (coverDownloadId != null) {
var coverProgressUpdater = DownloadProgressUpdater(downloadManager, coverDownloadId, progressReceiver, doneReceiver)
coverProgressUpdater.run()
}
val ret = JSObject()
ret.put("audiobookDownloadId", audiobookDownloadId)
ret.put("coverDownloadId", coverDownloadId)
call.resolve(ret)
}
internal class DownloadProgressUpdater(private val manager: DownloadManager, private val downloadId: Long, private var receiver: (Long, Long) -> Unit, private var doneReceiver: (Long, Boolean) -> Unit) : Thread() {
private val query: DownloadManager.Query = DownloadManager.Query()
private var totalBytes: Int = 0
private var TAG = "DownloadProgressUpdater"
init {
query.setFilterById(this.downloadId)
}
override fun run() {
Log.d(TAG, "RUN FOR ID $downloadId")
var keepRunning = true
var increment = 0
while (keepRunning) {
Thread.sleep(500)
increment++
if (increment % 4 == 0) {
Log.d(TAG, "Loop $increment : $downloadId")
}
manager.query(query).use {
if (it.moveToFirst()) { if (it.moveToFirst()) {
//get total bytes of the file val totalBytes = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES))
if (totalBytes <= 0) { Log.d(tag, "Download ${downloadItemPart.name} bytes $totalBytes")
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 downloadStatus = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_STATUS))
val bytesDownloadedSoFar = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)) 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) { if (downloadStatus == DownloadManager.STATUS_SUCCESSFUL) {
doneReceiver(downloadId, true) Log.d(tag, "Download ${downloadItemPart.name} Done")
} else { downloadItem.downloadItemParts.remove(downloadItemPart)
doneReceiver(downloadId, false) } else if (downloadStatus == DownloadManager.STATUS_FAILED) {
} Log.d(tag, "Download ${downloadItemPart.name} Failed")
keepRunning = false downloadItem.downloadItemParts.remove(downloadItemPart)
this.interrupt()
} else { } else {
//update progress //update progress
val percentProgress = ((bytesDownloadedSoFar * 100L) / totalBytes) val percentProgress = if (totalBytes > 0) ((bytesDownloadedSoFar * 100L) / totalBytes) else 0
receiver(downloadId, percentProgress) Log.d(tag, "${downloadItemPart.name} Progress = $percentProgress%")
downloadItemPart.progress = percentProgress
} }
} else { } else {
Log.e(TAG, "NOT FOUND IN QUERY") Log.d(tag, "Download ${downloadItemPart.name} not found in dlmanager")
keepRunning = false downloadItem.downloadItemParts.remove(downloadItemPart)
} }
} }
} }
} }
} }
} }
//
// @PluginMethod
// fun download(call: PluginCall) {
// var audiobookId = call.data.getString("audiobookId", "audiobook").toString()
// var url = call.data.getString("downloadUrl", "unknown").toString()
// var coverDownloadUrl = call.data.getString("coverDownloadUrl", "").toString()
// var title = call.data.getString("title", "Audiobook").toString()
// var filename = call.data.getString("filename", "audiobook.mp3").toString()
// var coverFilename = call.data.getString("coverFilename", "cover.png").toString()
// var downloadFolderUrl = call.data.getString("downloadFolderUrl", "").toString()
// var folder = DocumentFileCompat.fromUri(context, Uri.parse(downloadFolderUrl))!!
// Log.d(tag, "Called download: $url | Folder: ${folder.name} | $downloadFolderUrl")
//
// var dlfilename = audiobookId + "." + File(filename).extension
// var coverdlfilename = audiobookId + "." + File(coverFilename).extension
// Log.d(tag, "DL Filename $dlfilename | Cover DL Filename $coverdlfilename")
//
// var canWriteToFolder = folder.canWrite()
// if (!canWriteToFolder) {
// Log.e(tag, "Error Cannot Write to Folder ${folder.baseName}")
// val ret = JSObject()
// ret.put("error", "Cannot write to ${folder.baseName}")
// call.resolve(ret)
// return
// }
//
// var dlRequest = DownloadManager.Request(Uri.parse(url))
// dlRequest.setTitle("Ab: $title")
// dlRequest.setDescription("Downloading to ${folder.name}")
// dlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
// dlRequest.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, dlfilename)
//
// var audiobookDownloadId = downloadManager.enqueue(dlRequest)
// var coverDownloadId:Long? = null
//
// if (coverDownloadUrl != "") {
// var coverDlRequest = DownloadManager.Request(Uri.parse(coverDownloadUrl))
// coverDlRequest.setTitle("Cover: $title")
// coverDlRequest.setDescription("Downloading to ${folder.name}")
// coverDlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_ONLY_COMPLETION)
// coverDlRequest.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, coverdlfilename)
// coverDownloadId = downloadManager.enqueue(coverDlRequest)
// }
//
// var progressReceiver : (id:Long, prog: Long) -> Unit = { id:Long, prog: Long ->
// if (id == audiobookDownloadId) {
// var jsobj = JSObject()
// jsobj.put("audiobookId", audiobookId)
// jsobj.put("progress", prog)
// notifyListeners("onDownloadProgress", jsobj)
// }
// }
//
// var coverDocFile:DocumentFile? = null
//
// var doneReceiver : (id:Long, success: Boolean) -> Unit = { id:Long, success: Boolean ->
// Log.d(tag, "RECEIVER DONE $id, SUCCES? $success")
// var docfile:DocumentFile? = null
//
// // Download was complete, now find downloaded file
// if (id == coverDownloadId) {
// docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, coverdlfilename)
// Log.d(tag, "Move Cover File ${docfile?.name}")
//
// // For unknown reason, Android 10 test was using the title set in "setTitle" for the dl manager as the filename
// // check if this was the case
// if (docfile?.name == null) {
// docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, "Cover: $title")
// Log.d(tag, "Cover File name attempt 2 ${docfile?.name}")
// }
// } else if (id == audiobookDownloadId) {
// docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, dlfilename)
// Log.d(tag, "Move Audiobook File ${docfile?.name}")
//
// if (docfile?.name == null) {
// docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, "Ab: $title")
// Log.d(tag, "File name attempt 2 ${docfile?.name}")
// }
// }
//
// // Callback for moving the downloaded file
// var callback = object : FileCallback() {
// override fun onPrepare() {
// Log.d(tag, "PREPARING MOVE FILE")
// }
// override fun onFailed(errorCode:ErrorCode) {
// Log.e(tag, "FAILED MOVE FILE $errorCode")
//
// docfile?.delete()
// coverDocFile?.delete()
//
// if (id == audiobookDownloadId) {
// var jsobj = JSObject()
// jsobj.put("audiobookId", audiobookId)
// jsobj.put("error", "Move failed")
// notifyListeners("onDownloadFailed", jsobj)
// }
// }
// override fun onCompleted(result:Any) {
// var resultDocFile = result as DocumentFile
// var simplePath = resultDocFile.getSimplePath(context)
// var storageId = resultDocFile.getStorageId(context)
// var size = resultDocFile.length()
// Log.d(tag, "Finished Moving File, NAME: ${resultDocFile.name} | URI:${resultDocFile.uri} | AbsolutePath:${resultDocFile.getAbsolutePath(context)} | $storageId | SimplePath: $simplePath")
//
// var abFolder = folder.findFolder(title)
// var jsobj = JSObject()
// jsobj.put("audiobookId", audiobookId)
// jsobj.put("downloadId", id)
// jsobj.put("storageId", storageId)
// jsobj.put("storageType", resultDocFile.getStorageType(context))
// jsobj.put("folderUrl", abFolder?.uri)
// jsobj.put("folderName", abFolder?.name)
// jsobj.put("downloadFolderUrl", downloadFolderUrl)
// jsobj.put("contentUrl", resultDocFile.uri)
// jsobj.put("basePath", resultDocFile.getBasePath(context))
// jsobj.put("filename", filename)
// jsobj.put("simplePath", simplePath)
// jsobj.put("size", size)
//
// if (resultDocFile.name == filename) {
// Log.d(tag, "Audiobook Finishing Moving")
// } else if (resultDocFile.name == coverFilename) {
// coverDocFile = docfile
// Log.d(tag, "Audiobook Cover Finished Moving")
// jsobj.put("isCover", true)
// }
// notifyListeners("onDownloadComplete", jsobj)
// }
// }
//
// // After file is downloaded, move the files into an audiobook directory inside the user selected folder
// if (id == coverDownloadId) {
// docfile?.moveFileTo(context, folder, FileDescription(coverFilename, title, MimeType.IMAGE), callback)
// } else if (id == audiobookDownloadId) {
// docfile?.moveFileTo(context, folder, FileDescription(filename, title, MimeType.AUDIO), callback)
// }
// }
//
// var progressUpdater = DownloadProgressUpdater(downloadManager, audiobookDownloadId, progressReceiver, doneReceiver)
// progressUpdater.run()
// if (coverDownloadId != null) {
// var coverProgressUpdater = DownloadProgressUpdater(downloadManager, coverDownloadId, progressReceiver, doneReceiver)
// coverProgressUpdater.run()
// }
//
// val ret = JSObject()
// ret.put("audiobookDownloadId", audiobookDownloadId)
// ret.put("coverDownloadId", coverDownloadId)
// call.resolve(ret)
// }
//
//
//internal class DownloadProgressUpdater(private val manager: DownloadManager, private val downloadId: Long, private var receiver: (Long, Long) -> Unit, private var doneReceiver: (Long, Boolean) -> Unit) : Thread() {
// private val query: DownloadManager.Query = DownloadManager.Query()
// private var totalBytes: Int = 0
// private var TAG = "DownloadProgressUpdater"
//
// init {
// query.setFilterById(this.downloadId)
// }
//
// override fun run() {
// Log.d(TAG, "RUN FOR ID $downloadId")
// var keepRunning = true
// var increment = 0
// while (keepRunning) {
// Thread.sleep(500)
// increment++
//
// if (increment % 4 == 0) {
// Log.d(TAG, "Loop $increment : $downloadId")
// }
//
// manager.query(query).use {
// if (it.moveToFirst()) {
// //get total bytes of the file
// if (totalBytes <= 0) {
// totalBytes = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES))
// if (totalBytes <= 0) {
// Log.e(TAG, "Download Is 0 Bytes $downloadId")
// doneReceiver(downloadId, false)
// keepRunning = false
// this.interrupt()
// return
// }
// }
//
// val downloadStatus = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_STATUS))
// val bytesDownloadedSoFar = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR))
//
// if (increment % 4 == 0) {
// Log.d(TAG, "BYTES $increment : $downloadId : $bytesDownloadedSoFar : TOTAL: $totalBytes")
// }
//
// if (downloadStatus == DownloadManager.STATUS_SUCCESSFUL || downloadStatus == DownloadManager.STATUS_FAILED) {
// if (downloadStatus == DownloadManager.STATUS_SUCCESSFUL) {
// doneReceiver(downloadId, true)
// } else {
// doneReceiver(downloadId, false)
// }
// keepRunning = false
// this.interrupt()
// } else {
// //update progress
// val percentProgress = ((bytesDownloadedSoFar * 100L) / totalBytes)
// receiver(downloadId, percentProgress)
// }
// } else {
// Log.e(TAG, "NOT FOUND IN QUERY")
// keepRunning = false
// }
// }
// }
// }
// }
//}

View file

@ -52,6 +52,9 @@ class MainActivity : BridgeActivity() {
Log.d(tag, "onCreate") Log.d(tag, "onCreate")
// var ss = SimpleStorage(this)
// ss.requestFullStorageAccess()
var permission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) var permission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
if (permission != PackageManager.PERMISSION_GRANTED) { if (permission != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this, ActivityCompat.requestPermissions(this,

View file

@ -381,10 +381,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
.setMediaId(currentPlaybackSession!!.id) .setMediaId(currentPlaybackSession!!.id)
.setTitle(currentPlaybackSession!!.displayTitle) .setTitle(currentPlaybackSession!!.displayTitle)
.setSubtitle(currentPlaybackSession!!.displayAuthor) .setSubtitle(currentPlaybackSession!!.displayAuthor)
.setMediaUri(currentPlaybackSession!!.getContentUri())
.setIconUri(currentPlaybackSession!!.getCoverUri()) .setIconUri(currentPlaybackSession!!.getCoverUri())
return builder.build() return builder.build()
} }
// .setMediaUri(currentPlaybackSession!!.getContentUri())
} }
val myPlaybackPreparer:MediaSessionConnector.PlaybackPreparer = object :MediaSessionConnector.PlaybackPreparer { val myPlaybackPreparer:MediaSessionConnector.PlaybackPreparer = object :MediaSessionConnector.PlaybackPreparer {
@ -661,7 +661,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
} }
/* /*
User callable methods User callable methods
*/ */
@ -672,28 +671,44 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
var metadata = playbackSession.getMediaMetadataCompat() var metadata = playbackSession.getMediaMetadataCompat()
mediaSession.setMetadata(metadata) mediaSession.setMetadata(metadata)
var mediaMetadata = playbackSession.getExoMediaMetadata()
var mediaUri = playbackSession.getContentUri() var mediaItems = playbackSession.getMediaItems()
var mimeType = playbackSession.getMimeType()
var mediaItem = MediaItem.Builder().setUri(mediaUri).setMediaMetadata(mediaMetadata).setMimeType(mimeType).build()
if (mPlayer == currentPlayer) { if (mPlayer == currentPlayer) {
var mediaSource:MediaSource var mediaSource:MediaSource
if (!playbackSession.isHLS) { if (playbackSession.isLocal) {
Log.d(tag, "Playing Local File") Log.d(tag, "Playing Local Item")
var dataSourceFactory = DefaultDataSourceFactory(ctx, channelId) var dataSourceFactory = DefaultDataSourceFactory(ctx, channelId)
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem) mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0])
} else if (!playbackSession.isHLS) {
Log.d(tag, "Direct Playing Item")
var dataSourceFactory = DefaultDataSourceFactory(ctx, channelId)
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0])
} else { } else {
Log.d(tag, "Playing HLS File") Log.d(tag, "Playing HLS Item")
var dataSourceFactory = DefaultHttpDataSource.Factory() var dataSourceFactory = DefaultHttpDataSource.Factory()
dataSourceFactory.setUserAgent(channelId) dataSourceFactory.setUserAgent(channelId)
dataSourceFactory.setDefaultRequestProperties(hashMapOf("Authorization" to "Bearer ${playbackSession.token}")) dataSourceFactory.setDefaultRequestProperties(hashMapOf("Authorization" to "Bearer ${playbackSession.token}"))
mediaSource = HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem) mediaSource = HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0])
} }
Log.d(tag, "Playback Session CURRENT TIME ${playbackSession.currentTime} | ${playbackSession.currentTimeMs}") mPlayer.setMediaSource(mediaSource)
mPlayer.setMediaSource(mediaSource, playbackSession.currentTimeMs)
// Add remaining media items if multi-track
if (mediaItems.size > 1) {
mPlayer.addMediaItems(mediaItems.subList(1, mediaItems.size))
Log.d(tag, "mPlayer total media items ${mPlayer.mediaItemCount}")
var currentTrackIndex = playbackSession.getCurrentTrackIndex()
var currentTrackTime = playbackSession.getCurrentTrackTimeMs()
Log.d(tag, "mPlayer current track index $currentTrackIndex & current track time $currentTrackTime")
mPlayer.seekTo(currentTrackIndex, currentTrackTime)
} else {
mPlayer.seekTo(playbackSession.currentTimeMs)
}
} else if (castPlayer != null) { } else if (castPlayer != null) {
//// var mediaQueue = currentAudiobookStreamData!!.getCastQueue() //// var mediaQueue = currentAudiobookStreamData!!.getCastQueue()
// // TODO: Start position will need to be adjusted if using multi-track queue // // TODO: Start position will need to be adjusted if using multi-track queue
@ -784,12 +799,24 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
fun getCurrentTime() : Long { fun getCurrentTime() : Long {
if (currentPlayer.mediaItemCount > 1) {
var windowIndex = currentPlayer.currentWindowIndex
var currentTrackStartOffset = currentPlaybackSession?.getTrackStartOffsetMs(windowIndex) ?: 0L
return currentPlayer.currentPosition + currentTrackStartOffset
} else {
return currentPlayer.currentPosition return currentPlayer.currentPosition
} }
}
fun getBufferedTime() : Long { fun getBufferedTime() : Long {
if (currentPlayer.mediaItemCount > 1) {
var windowIndex = currentPlayer.currentWindowIndex
var currentTrackStartOffset = currentPlaybackSession?.getTrackStartOffsetMs(windowIndex) ?: 0L
return currentPlayer.bufferedPosition + currentTrackStartOffset
} else {
return currentPlayer.bufferedPosition return currentPlayer.bufferedPosition
} }
}
fun getTheLastPauseTime() : Long { fun getTheLastPauseTime() : Long {
return lastPauseTime return lastPauseTime
@ -867,8 +894,15 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
fun seekPlayer(time: Long) { fun seekPlayer(time: Long) {
if (currentPlayer.mediaItemCount > 1) {
currentPlaybackSession?.currentTime = time / 1000.0
var newWindowIndex = currentPlaybackSession?.getCurrentTrackIndex() ?: 0
var newTimeOffset = currentPlaybackSession?.getCurrentTrackTimeMs() ?: 0
currentPlayer.seekTo(newWindowIndex, newTimeOffset)
} else {
currentPlayer.seekTo(time) currentPlayer.seekTo(time)
} }
}
fun seekForward(amount: Long) { fun seekForward(amount: Long) {
currentPlayer.seekTo(mPlayer.currentPosition + amount) currentPlayer.seekTo(mPlayer.currentPosition + amount)
@ -883,9 +917,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
fun terminateStream() { fun terminateStream() {
// if (currentPlayer.playbackState == Player.STATE_READY) {
// currentPlayer.clearMediaItems()
// }
currentPlayer.clearMediaItems() currentPlayer.clearMediaItems()
currentPlaybackSession = null currentPlaybackSession = null
lastPauseTime = 0 lastPauseTime = 0
@ -894,15 +925,13 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
fun sendClientMetadata(stateName: String) { fun sendClientMetadata(stateName: String) {
var metadata = JSObject() var metadata = JSObject()
var duration = mPlayer.duration var duration = currentPlaybackSession?.getTotalDuration() ?: 0
if (duration < 0) duration = 0
metadata.put("duration", duration) metadata.put("duration", duration)
metadata.put("currentTime", mPlayer.currentPosition) metadata.put("currentTime", getCurrentTime())
metadata.put("stateName", stateName) metadata.put("stateName", stateName)
clientEventEmitter?.onMetadata(metadata) clientEventEmitter?.onMetadata(metadata)
} }
// //
// MEDIA BROWSER STUFF (ANDROID AUTO) // MEDIA BROWSER STUFF (ANDROID AUTO)
// //

View file

@ -47,7 +47,10 @@ data class Book(
var coverPath:String?, var coverPath:String?,
var tags:MutableList<String>, var tags:MutableList<String>,
var audioFiles:MutableList<AudioFile>, var audioFiles:MutableList<AudioFile>,
var chapters:MutableList<BookChapter> var chapters:MutableList<BookChapter>,
var tracks:MutableList<AudioFile>?,
var size:Long?,
var duration:Double?
) : MediaType() ) : MediaType()
// This auto-detects whether it is a Book or Podcast // This auto-detects whether it is a Book or Podcast
@ -154,7 +157,15 @@ data class AudioTrack(
var isLocal:Boolean, var isLocal:Boolean,
var localFileId:String?, var localFileId:String?,
var audioProbeResult:AudioProbeResult? var audioProbeResult:AudioProbeResult?
) ) {
@get:JsonIgnore
val startOffsetMs get() = (startOffset * 1000L).toLong()
@get:JsonIgnore
val durationMs get() = (duration * 1000L).toLong()
@get:JsonIgnore
val endOffsetMs get() = startOffsetMs + durationMs
}
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class BookChapter( data class BookChapter(

View file

@ -1,6 +1,7 @@
package com.audiobookshelf.app.data package com.audiobookshelf.app.data
import android.util.Log import android.util.Log
import androidx.documentfile.provider.DocumentFile
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.getcapacitor.JSObject import com.getcapacitor.JSObject
import com.getcapacitor.Plugin import com.getcapacitor.Plugin
@ -12,6 +13,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.json.JSONObject import org.json.JSONObject
import java.io.File
@CapacitorPlugin(name = "DbManager") @CapacitorPlugin(name = "DbManager")
class DbManager : Plugin() { class DbManager : Plugin() {
@ -25,14 +27,28 @@ class DbManager : Plugin() {
Paper.book("device").write("data", deviceData) Paper.book("device").write("data", deviceData)
} }
fun loadLocalMediaItems():List<LocalMediaItem> { fun loadLocalMediaItems():MutableList<LocalMediaItem> {
var localMediaItems:MutableList<LocalMediaItem> = mutableListOf() var localMediaItems:MutableList<LocalMediaItem> = mutableListOf()
Paper.book("localMediaItems").allKeys.forEach { Paper.book("localMediaItems").allKeys.forEach {
var localMediaItem:LocalMediaItem? = Paper.book("localMediaItems").read(it) var localMediaItem:LocalMediaItem? = Paper.book("localMediaItems").read(it)
if (localMediaItem != null) { if (localMediaItem != null) {
// if (localMediaItem.coverContentUrl != null) {
// var file = DocumentFile.fromSingleUri(ctx)
// if (!file.exists()) {
// Log.e(tag, "Local media item cover url does not exist ${localMediaItem.coverContentUrl}")
// removeLocalMediaItem(localMediaItem.id)
// } else {
// localMediaItems.add(localMediaItem)
// }
// } else {
localMediaItems.add(localMediaItem) localMediaItems.add(localMediaItem)
// }
} }
} }
// localMediaItems = localMediaItems.filter {
//
// file.exists()
// }
return localMediaItems return localMediaItems
} }

View file

@ -29,7 +29,8 @@ data class LocalMediaItem(
var absolutePath:String, var absolutePath:String,
var audioTracks:MutableList<AudioTrack>, var audioTracks:MutableList<AudioTrack>,
var localFiles:MutableList<LocalFile>, var localFiles:MutableList<LocalFile>,
var coverContentUrl:String? var coverContentUrl:String?,
var coverAbsolutePath:String?
) { ) {
@JsonIgnore @JsonIgnore

View file

@ -2,9 +2,11 @@ package com.audiobookshelf.app.data
import android.net.Uri import android.net.Uri
import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.MediaMetadataCompat
import android.util.Log
import com.audiobookshelf.app.R import com.audiobookshelf.app.R
import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.MediaMetadata import com.google.android.exoplayer2.MediaMetadata
// TODO: enum or something in kotlin? // TODO: enum or something in kotlin?
@ -39,6 +41,37 @@ class PlaybackSession(
val isLocal get() = playMethod == PLAYMETHOD_LOCAL val isLocal get() = playMethod == PLAYMETHOD_LOCAL
val currentTimeMs get() = (currentTime * 1000L).toLong() val currentTimeMs get() = (currentTime * 1000L).toLong()
@JsonIgnore
fun getCurrentTrackIndex():Int {
for (i in 0..(audioTracks.size - 1)) {
var track = audioTracks[i]
if (currentTimeMs >= track.startOffsetMs && (track.endOffsetMs) > currentTimeMs) {
return i
}
}
return audioTracks.size - 1
}
@JsonIgnore
fun getCurrentTrackTimeMs():Long {
var currentTrack = audioTracks[this.getCurrentTrackIndex()]
var time = currentTime - currentTrack.startOffset
return (time * 1000L).toLong()
}
@JsonIgnore
fun getTrackStartOffsetMs(index:Int):Long {
var currentTrack = audioTracks[index]
return (currentTrack.startOffset * 1000L).toLong()
}
@JsonIgnore
fun getTotalDuration():Double {
var total = 0.0
audioTracks.forEach { total += it.duration }
return total
}
@JsonIgnore @JsonIgnore
fun getCoverUri(): Uri { fun getCoverUri(): Uri {
if (localMediaItem?.coverContentUrl != null) return Uri.parse(localMediaItem?.coverContentUrl) ?: Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon) if (localMediaItem?.coverContentUrl != null) return Uri.parse(localMediaItem?.coverContentUrl) ?: Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
@ -48,18 +81,11 @@ class PlaybackSession(
} }
@JsonIgnore @JsonIgnore
fun getContentUri(): Uri { fun getContentUri(audioTrack:AudioTrack): Uri {
var audioTrack = audioTracks[0]
if (isLocal) return Uri.parse(audioTrack.contentUrl) // Local content url if (isLocal) return Uri.parse(audioTrack.contentUrl) // Local content url
return Uri.parse("$serverUrl${audioTrack.contentUrl}?token=$token") return Uri.parse("$serverUrl${audioTrack.contentUrl}?token=$token")
} }
@JsonIgnore
fun getMimeType():String {
var audioTrack = audioTracks[0]
return audioTrack.mimeType
}
@JsonIgnore @JsonIgnore
fun getMediaMetadataCompat(): MediaMetadataCompat { fun getMediaMetadataCompat(): MediaMetadataCompat {
var metadataBuilder = MediaMetadataCompat.Builder() var metadataBuilder = MediaMetadataCompat.Builder()
@ -74,7 +100,7 @@ class PlaybackSession(
} }
@JsonIgnore @JsonIgnore
fun getExoMediaMetadata(): MediaMetadata { fun getExoMediaMetadata(audioTrack:AudioTrack): MediaMetadata {
var metadataBuilder = MediaMetadata.Builder() var metadataBuilder = MediaMetadata.Builder()
.setTitle(displayTitle) .setTitle(displayTitle)
.setDisplayTitle(displayTitle) .setDisplayTitle(displayTitle)
@ -82,9 +108,23 @@ class PlaybackSession(
.setAlbumArtist(displayAuthor) .setAlbumArtist(displayAuthor)
.setSubtitle(displayAuthor) .setSubtitle(displayAuthor)
var contentUri = this.getContentUri() var contentUri = this.getContentUri(audioTrack)
metadataBuilder.setMediaUri(contentUri) metadataBuilder.setMediaUri(contentUri)
return metadataBuilder.build() return metadataBuilder.build()
} }
@JsonIgnore
fun getMediaItems():List<MediaItem> {
var mediaItems:MutableList<MediaItem> = mutableListOf()
for (audioTrack in audioTracks) {
var mediaMetadata = this.getExoMediaMetadata(audioTrack)
var mediaUri = this.getContentUri(audioTrack)
var mimeType = audioTrack.mimeType
var mediaItem = MediaItem.Builder().setUri(mediaUri).setMediaMetadata(mediaMetadata).setMimeType(mimeType).build()
mediaItems.add(mediaItem)
}
return mediaItems
}
} }

View file

@ -73,6 +73,7 @@ class FolderScanner(var ctx: Context) {
var index = 1 var index = 1
var startOffset = 0.0 var startOffset = 0.0
var coverContentUrl:String? = null var coverContentUrl:String? = null
var coverAbsolutePath:String? = null
var filesInFolder = it.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*")) var filesInFolder = it.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*"))
@ -157,6 +158,7 @@ class FolderScanner(var ctx: Context) {
// First image file use as cover path // First image file use as cover path
if (coverContentUrl == null) { if (coverContentUrl == null) {
coverContentUrl = localFile.contentUrl coverContentUrl = localFile.contentUrl
coverAbsolutePath = localFile.absolutePath
} }
} }
} }
@ -173,7 +175,7 @@ class FolderScanner(var ctx: Context) {
else mediaItemsAdded++ else mediaItemsAdded++
Log.d(tag, "Found local media item named $itemFolderName with ${audioTracks.size} tracks and ${localFiles.size} local files") Log.d(tag, "Found local media item named $itemFolderName with ${audioTracks.size} tracks and ${localFiles.size} local files")
var localMediaItem = LocalMediaItem(itemId, itemFolderName, localFolder.mediaType, localFolder.id, it.uri.toString(), it.getSimplePath(ctx), it.getAbsolutePath(ctx),audioTracks,localFiles,coverContentUrl) var localMediaItem = LocalMediaItem(itemId, itemFolderName, localFolder.mediaType, localFolder.id, it.uri.toString(), it.getSimplePath(ctx), it.getAbsolutePath(ctx),audioTracks,localFiles,coverContentUrl,coverAbsolutePath)
mediaItems.add(localMediaItem) mediaItems.add(localMediaItem)
} }
} }

View file

@ -94,14 +94,20 @@ class ApiHandler {
} }
} }
fun getLibraryItem(libraryItemId:String, cb: (LibraryItem) -> Unit) {
getRequest("/api/items/$libraryItemId?expanded=1") {
val libraryItem = jacksonObjectMapper().readValue<LibraryItem>(it.toString())
cb(libraryItem)
}
}
fun getLibraryItems(libraryId:String, cb: (List<LibraryItem>) -> Unit) { fun getLibraryItems(libraryId:String, cb: (List<LibraryItem>) -> Unit) {
val mapper = jacksonObjectMapper()
getRequest("/api/libraries/$libraryId/items") { getRequest("/api/libraries/$libraryId/items") {
val items = mutableListOf<LibraryItem>() val items = mutableListOf<LibraryItem>()
if (it.has("results")) { if (it.has("results")) {
var array = it.getJSONArray("results") var array = it.getJSONArray("results")
for (i in 0 until array.length()) { for (i in 0 until array.length()) {
val item = mapper.readValue<LibraryItem>(array.get(i).toString()) val item = jacksonObjectMapper().readValue<LibraryItem>(array.get(i).toString())
items.add(item) items.add(item)
} }
} }

View file

@ -291,8 +291,8 @@ export default {
return this.restart() return this.restart()
} }
// If 1 second or less into current chapter, then go to previous // If 4 seconds or less into current chapter, then go to previous
if (this.currentTime - this.currentChapter.start <= 1) { if (this.currentTime - this.currentChapter.start <= 4) {
var currChapterIndex = this.chapters.findIndex((ch) => Number(ch.start) <= this.currentTime && Number(ch.end) >= this.currentTime) var currChapterIndex = this.chapters.findIndex((ch) => Number(ch.start) <= this.currentTime && Number(ch.end) >= this.currentTime)
if (currChapterIndex > 0) { if (currChapterIndex > 0) {
var prevChapter = this.chapters[currChapterIndex - 1] var prevChapter = this.chapters[currChapterIndex - 1]
@ -509,8 +509,8 @@ export default {
console.log('onMetadata', JSON.stringify(data)) console.log('onMetadata', JSON.stringify(data))
this.isLoading = false this.isLoading = false
this.totalDuration = Number((data.duration / 1000).toFixed(2)) // this.totalDuration = Number((data.duration / 1000).toFixed(2))
this.$emit('setTotalDuration', this.totalDuration) this.totalDuration = Number(data.duration.toFixed(2))
this.currentTime = Number((data.currentTime / 1000).toFixed(2)) this.currentTime = Number((data.currentTime / 1000).toFixed(2))
this.stateName = data.stateName this.stateName = data.stateName

View file

@ -1,19 +1,7 @@
<template> <template>
<div> <div>
<div id="streamContainer"> <div id="streamContainer">
<app-audio-player <app-audio-player ref="audioPlayer" :playing.sync="isPlaying" :bookmarks="bookmarks" :sleep-timer-running="isSleepTimerRunning" :sleep-time-remaining="sleepTimeRemaining" @selectPlaybackSpeed="showPlaybackSpeedModal = true" @updateTime="(t) => (currentTime = t)" @showSleepTimer="showSleepTimer" @showBookmarks="showBookmarks" @hook:mounted="audioPlayerMounted" />
ref="audioPlayer"
:playing.sync="isPlaying"
:bookmarks="bookmarks"
:sleep-timer-running="isSleepTimerRunning"
:sleep-time-remaining="sleepTimeRemaining"
@setTotalDuration="setTotalDuration"
@selectPlaybackSpeed="showPlaybackSpeedModal = true"
@updateTime="(t) => (currentTime = t)"
@showSleepTimer="showSleepTimer"
@showBookmarks="showBookmarks"
@hook:mounted="audioPlayerMounted"
/>
</div> </div>
<modals-playback-speed-modal v-model="showPlaybackSpeedModal" :playback-rate.sync="playbackSpeed" @update:playbackRate="updatePlaybackSpeed" @change="changePlaybackSpeed" /> <modals-playback-speed-modal v-model="showPlaybackSpeedModal" :playback-rate.sync="playbackSpeed" @update:playbackRate="updatePlaybackSpeed" @change="changePlaybackSpeed" />
@ -43,8 +31,7 @@ export default {
onSleepTimerEndedListener: null, onSleepTimerEndedListener: null,
onSleepTimerSetListener: null, onSleepTimerSetListener: null,
sleepInterval: null, sleepInterval: null,
currentEndOfChapterTime: 0, currentEndOfChapterTime: 0
totalDuration: 0
} }
}, },
watch: { watch: {
@ -121,9 +108,6 @@ export default {
console.log('Canceling sleep timer') console.log('Canceling sleep timer')
await MyNativeAudio.cancelSleepTimer() await MyNativeAudio.cancelSleepTimer()
}, },
setTotalDuration(duration) {
this.totalDuration = duration
},
streamClosed() { streamClosed() {
console.log('Stream Closed') console.log('Stream Closed')
}, },

View file

@ -0,0 +1,62 @@
<template>
<modals-modal v-model="show" :width="300" height="100%">
<template #outer>
<div class="absolute top-7 left-4 z-40" style="max-width: 80%">
<p class="text-white text-lg truncate">Select Local Folder</p>
</div>
</template>
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
<div ref="container" 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>
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="folder in localFolders">
<li :key="folder.id" :id="`folder-${folder.id}`" class="text-gray-50 select-none relative py-4" role="option" @click="clickedOption(folder)">
<div class="relative flex items-center pl-3" style="padding-right: 4.5rem">
<p class="font-normal block truncate text-sm text-white text-opacity-80">{{ folder.name }}</p>
</div>
</li>
</template>
</ul>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
mediaType: String
},
data() {
return {
localFolders: []
}
},
watch: {
value(newVal) {
this.$nextTick(this.init)
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
}
},
methods: {
clickedOption(folder) {
this.$emit('select', folder)
},
async init() {
var localFolders = (await this.$db.loadFolders()) || []
this.localFolders = localFolders.filter((lf) => lf.mediaType == this.mediaType)
}
},
mounted() {}
}
</script>

View file

@ -253,17 +253,26 @@ export default {
await this.$store.dispatch('downloads/linkOrphanDownloads') await this.$store.dispatch('downloads/linkOrphanDownloads')
} }
}, },
onItemDownloadUpdate(data) {
console.log('ON ITEM DOWNLOAD UPDATE', JSON.stringify(data))
},
onItemDownloadComplete(data) {
console.log('ON ITEM DOWNLOAD COMPLETE', JSON.stringify(data))
},
async initMediaStore() { async initMediaStore() {
// Request and setup listeners for media files on native // Request and setup listeners for media files on native
AudioDownloader.addListener('onDownloadComplete', (data) => { AudioDownloader.addListener('onItemDownloadUpdate', (data) => {
this.onDownloadComplete(data) this.onItemDownloadUpdate(data)
}) })
AudioDownloader.addListener('onDownloadFailed', (data) => { AudioDownloader.addListener('onItemDownloadComplete', (data) => {
this.onDownloadFailed(data) this.onItemDownloadComplete(data)
})
AudioDownloader.addListener('onDownloadProgress', (data) => {
this.onDownloadProgress(data)
}) })
// AudioDownloader.addListener('onDownloadFailed', (data) => {
// this.onDownloadFailed(data)
// })
// AudioDownloader.addListener('onDownloadProgress', (data) => {
// this.onDownloadProgress(data)
// })
var downloads = await this.$store.dispatch('downloads/loadFromStorage') var downloads = await this.$store.dispatch('downloads/loadFromStorage')
var downloadFolder = await this.$localStore.getDownloadFolder() var downloadFolder = await this.$localStore.getDownloadFolder()

View file

@ -45,6 +45,8 @@
<div class="w-full py-4"> <div class="w-full py-4">
<p>{{ description }}</p> <p>{{ description }}</p>
</div> </div>
<modals-select-local-folder-modal v-model="showSelectLocalFolder" :media-type="mediaType" @select="selectedLocalFolder" />
</div> </div>
</template> </template>
@ -81,7 +83,8 @@ export default {
}, },
data() { data() {
return { return {
resettingProgress: false resettingProgress: false,
showSelectLocalFolder: false
} }
}, },
computed: { computed: {
@ -97,6 +100,9 @@ export default {
libraryItemId() { libraryItemId() {
return this.libraryItem.id return this.libraryItem.id
}, },
mediaType() {
return this.libraryItem.mediaType
},
media() { media() {
return this.libraryItem.media || {} return this.libraryItem.media || {}
}, },
@ -235,17 +241,6 @@ export default {
this.resettingProgress = false this.resettingProgress = false
}) })
} }
// if (value) {
// this.resettingProgress = true
// this.$store.dispatch('user/updateUserAudiobookData', {
// libraryItemId: this.libraryItemId,
// currentTime: 0,
// totalDuration: this.duration,
// progress: 0,
// lastUpdate: Date.now(),
// isRead: false
// })
// }
}, },
itemUpdated(libraryItem) { itemUpdated(libraryItem) {
if (libraryItem.id === this.libraryItemId) { if (libraryItem.id === this.libraryItemId) {
@ -253,14 +248,74 @@ export default {
this.libraryItem = libraryItem this.libraryItem = libraryItem
} }
}, },
async selectFolder() {
// Select and save the local folder for media type
var folderObj = await StorageManager.selectFolder({ mediaType: this.mediaType })
if (folderObj.error) {
return this.$toast.error(`Error: ${folderObj.error || 'Unknown Error'}`)
}
return folderObj
},
selectedLocalFolder(localFolder) {
this.showSelectLocalFolder = false
this.download(localFolder)
},
downloadClick() { downloadClick() {
this.download()
},
async download(selectedLocalFolder = null) {
console.log('downloadClick ' + this.$server.connected + ' | ' + !!this.downloadObj) console.log('downloadClick ' + this.$server.connected + ' | ' + !!this.downloadObj)
if (!this.$server.connected) return if (!this.$server.connected) return
if (this.downloadObj) { if (!this.numTracks || this.downloadObj) {
console.log('Already downloaded', this.downloadObj) return
}
// Get the local folder to download to
var localFolder = selectedLocalFolder
if (!localFolder) {
var localFolders = (await this.$db.loadFolders()) || []
console.log('Local folders loaded', localFolders.length)
var foldersWithMediaType = localFolders.filter((lf) => {
console.log('Checking local folder', lf.mediaType)
return lf.mediaType == this.mediaType
})
console.log('Folders with media type', this.mediaType, foldersWithMediaType.length)
if (!foldersWithMediaType.length) {
// No local folders or no local folders with this media type
localFolder = await this.selectFolder()
} else if (foldersWithMediaType.length == 1) {
console.log('Only 1 local folder with this media type - auto select it')
localFolder = foldersWithMediaType[0]
} else { } else {
this.prepareDownload() console.log('Multiple folders with media type')
this.showSelectLocalFolder = true
return
}
if (!localFolder) {
return this.$toast.error('Invalid download folder')
}
}
console.log('Local folder', JSON.stringify(localFolder))
var startDownloadMessage = `Start download for "${this.title}" with ${this.numTracks} audio track${this.numTracks == 1 ? '' : 's'} to folder ${localFolder.name}?`
const { value } = await Dialog.confirm({
title: 'Confirm',
message: startDownloadMessage
})
if (value) {
this.startDownload(localFolder)
}
},
async startDownload(localFolder) {
console.log('Starting download to local folder', localFolder.name)
var downloadRes = await AudioDownloader.downloadLibraryItem({ libraryItemId: this.libraryItemId, localFolderId: localFolder.id })
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)
} }
}, },
async changeDownloadFolderClick() { async changeDownloadFolderClick() {
@ -286,103 +341,103 @@ export default {
await this.$localStore.setDownloadFolder(folderObj) await this.$localStore.setDownloadFolder(folderObj)
} }
}, },
async prepareDownload() { // async prepareDownload() {
var audiobook = this.libraryItem // var audiobook = this.libraryItem
if (!audiobook) { // if (!audiobook) {
return // return
} // }
// Download Path // // Download Path
var dlFolder = this.$localStore.downloadFolder // var dlFolder = this.$localStore.downloadFolder
console.log('Prepare download: ' + this.hasStoragePermission + ' | ' + dlFolder) // console.log('Prepare download: ' + this.hasStoragePermission + ' | ' + dlFolder)
if (!this.hasStoragePermission || !dlFolder) { // if (!this.hasStoragePermission || !dlFolder) {
console.log('No download folder, request from user') // console.log('No download folder, request from user')
// User to select download folder from download modal to ensure permissions // // User to select download folder from download modal to ensure permissions
// this.$store.commit('downloads/setShowModal', true) // // this.$store.commit('downloads/setShowModal', true)
this.changeDownloadFolderClick() // this.changeDownloadFolderClick()
return // return
} else { // } else {
console.log('Has Download folder: ' + JSON.stringify(dlFolder)) // console.log('Has Download folder: ' + JSON.stringify(dlFolder))
} // }
var downloadObject = { // var downloadObject = {
id: this.libraryItemId, // id: this.libraryItemId,
downloadFolderUrl: dlFolder.uri, // downloadFolderUrl: dlFolder.uri,
audiobook: { // audiobook: {
...audiobook // ...audiobook
}, // },
isPreparing: true, // isPreparing: true,
isDownloading: false, // isDownloading: false,
toastId: this.$toast(`Preparing download for "${this.title}"`, { timeout: false }) // toastId: this.$toast(`Preparing download for "${this.title}"`, { timeout: false })
} // }
if (audiobook.tracks.length === 1) { // if (audiobook.tracks.length === 1) {
// Single track should not need preparation // // Single track should not need preparation
console.log('Single track, start download no prep needed') // console.log('Single track, start download no prep needed')
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) // console.log('Download Single Track Path: ' + track.path)
var relTrackPath = track.path.replace('\\', '/').replace(this.libraryItem.path.replace('\\', '/'), '') // var relTrackPath = track.path.replace('\\', '/').replace(this.libraryItem.path.replace('\\', '/'), '')
var url = `${this.$store.state.serverUrl}/s/book/${this.libraryItemId}${relTrackPath}?token=${this.userToken}` // var url = `${this.$store.state.serverUrl}/s/book/${this.libraryItemId}${relTrackPath}?token=${this.userToken}`
this.startDownload(url, fileext, downloadObject) // this.startDownload(url, fileext, downloadObject)
} else { // } else {
// Multi-track merge // // Multi-track merge
this.$store.commit('downloads/addUpdateDownload', downloadObject) // this.$store.commit('downloads/addUpdateDownload', downloadObject)
var prepareDownloadPayload = { // var prepareDownloadPayload = {
audiobookId: this.libraryItemId, // audiobookId: this.libraryItemId,
audioFileType: 'same', // audioFileType: 'same',
type: 'singleAudio' // type: 'singleAudio'
} // }
this.$server.socket.emit('download', prepareDownloadPayload) // this.$server.socket.emit('download', prepareDownloadPayload)
} // }
}, // },
getCoverUrlForDownload() { // getCoverUrlForDownload() {
if (!this.book || !this.book.cover) return null // if (!this.book || !this.book.cover) return null
var cover = this.book.cover // var cover = this.book.cover
if (cover.startsWith('http')) return cover // if (cover.startsWith('http')) return cover
var coverSrc = this.$store.getters['global/getLibraryItemCoverSrc'](this.libraryItem) // var coverSrc = this.$store.getters['global/getLibraryItemCoverSrc'](this.libraryItem)
return coverSrc // return coverSrc
}, // },
async startDownload(url, fileext, download) { // async startDownload(url, fileext, download) {
this.$toast.update(download.toastId, { content: `Downloading "${download.audiobook.book.title}"...` }) // this.$toast.update(download.toastId, { content: `Downloading "${download.audiobook.book.title}"...` })
var coverDownloadUrl = this.getCoverUrlForDownload() // var coverDownloadUrl = this.getCoverUrlForDownload()
var coverFilename = null // var coverFilename = null
if (coverDownloadUrl) { // if (coverDownloadUrl) {
var coverNoQueryString = coverDownloadUrl.split('?')[0] // var coverNoQueryString = coverDownloadUrl.split('?')[0]
var coverExt = Path.extname(coverNoQueryString) || '.jpg' // var coverExt = Path.extname(coverNoQueryString) || '.jpg'
coverFilename = `cover-${download.id}${coverExt}` // coverFilename = `cover-${download.id}${coverExt}`
} // }
download.isDownloading = true // download.isDownloading = true
download.isPreparing = false // download.isPreparing = false
download.filename = `${download.audiobook.book.title}${fileext}` // download.filename = `${download.audiobook.book.title}${fileext}`
this.$store.commit('downloads/addUpdateDownload', download) // this.$store.commit('downloads/addUpdateDownload', download)
console.log('Starting Download URL', url) // console.log('Starting Download URL', url)
var downloadRequestPayload = { // var downloadRequestPayload = {
audiobookId: download.id, // audiobookId: download.id,
filename: download.filename, // filename: download.filename,
coverFilename, // coverFilename,
coverDownloadUrl, // coverDownloadUrl,
downloadUrl: url, // downloadUrl: url,
title: download.audiobook.book.title, // title: download.audiobook.book.title,
downloadFolderUrl: download.downloadFolderUrl // downloadFolderUrl: download.downloadFolderUrl
} // }
var downloadRes = await AudioDownloader.download(downloadRequestPayload) // var downloadRes = await AudioDownloader.download(downloadRequestPayload)
if (downloadRes.error) { // if (downloadRes.error) {
var errorMsg = downloadRes.error || 'Unknown error' // var errorMsg = downloadRes.error || 'Unknown error'
console.error('Download error', errorMsg) // console.error('Download error', errorMsg)
this.$toast.update(download.toastId, { content: `Error: ${errorMsg}`, options: { timeout: 5000, type: 'error' } }) // this.$toast.update(download.toastId, { content: `Error: ${errorMsg}`, options: { timeout: 5000, type: 'error' } })
this.$store.commit('downloads/removeDownload', download) // this.$store.commit('downloads/removeDownload', download)
} // }
}, // },
downloadReady(prepareDownload) { downloadReady(prepareDownload) {
var download = this.$store.getters['downloads/getDownload'](prepareDownload.audiobookId) var download = this.$store.getters['downloads/getDownload'](prepareDownload.audiobookId)
if (download) { if (download) {