Compare commits

...
46 changed files with 3519 additions and 301 deletions
+26 -6
View File
@@ -1,4 +1,5 @@
import { io } from 'socket.io-client' import { io } from 'socket.io-client'
import { Storage } from '@capacitor/storage'
import axios from 'axios' import axios from 'axios'
import EventEmitter from 'events' import EventEmitter from 'events'
@@ -26,6 +27,7 @@ class Server extends EventEmitter {
} }
getServerUrl(url) { getServerUrl(url) {
if (!url) return null
var urlObject = new URL(url) var urlObject = new URL(url)
return `${urlObject.protocol}//${urlObject.hostname}:${urlObject.port}` return `${urlObject.protocol}//${urlObject.hostname}:${urlObject.port}`
} }
@@ -34,24 +36,35 @@ class Server extends EventEmitter {
this.user = user this.user = user
this.store.commit('user/setUser', user) this.store.commit('user/setUser', user)
if (user) { if (user) {
this.store.commit('user/setSettings', user.settings) // this.store.commit('user/setSettings', user.settings)
localStorage.setItem('userToken', user.token) Storage.set({ key: 'token', value: user.token })
} else { } else {
localStorage.removeItem('userToken') Storage.remove({ key: 'token' })
} }
} }
setServerUrl(url) { setServerUrl(url) {
this.url = url this.url = url
localStorage.setItem('serverUrl', url)
this.store.commit('setServerUrl', url) this.store.commit('setServerUrl', url)
if (url) {
Storage.set({ key: 'serverUrl', value: url })
} else {
Storage.remove({ key: 'serverUrl' })
}
} }
async connect(url, token) { async connect(url, token) {
if (!url) {
console.error('Invalid url to connect')
return false
}
var serverUrl = this.getServerUrl(url) var serverUrl = this.getServerUrl(url)
var res = await this.ping(serverUrl) var res = await this.ping(serverUrl)
if (!res || !res.success) { if (!res || !res.success) {
this.url = null this.setServerUrl(null)
return false return false
} }
var authRes = await this.authorize(serverUrl, token) var authRes = await this.authorize(serverUrl, token)
@@ -60,7 +73,7 @@ class Server extends EventEmitter {
} }
this.setServerUrl(serverUrl) this.setServerUrl(serverUrl)
console.warn('Connect setting auth user', authRes)
this.setUser(authRes.user) this.setUser(authRes.user)
this.connectSocket() this.connectSocket()
@@ -103,6 +116,9 @@ class Server extends EventEmitter {
logout() { logout() {
this.setUser(null) this.setUser(null)
if (this.socket) {
this.socket.disconnect()
}
} }
authorize(serverUrl, token) { authorize(serverUrl, token) {
@@ -138,9 +154,13 @@ class Server extends EventEmitter {
this.connected = true this.connected = true
this.emit('connected', true) this.emit('connected', true)
this.store.commit('setSocketConnected', true)
}) })
this.socket.on('disconnect', () => { this.socket.on('disconnect', () => {
console.log('[Server] Socket Disconnected') console.log('[Server] Socket Disconnected')
this.connected = false
this.emit('connected', false)
this.store.commit('setSocketConnected', false)
}) })
this.socket.on('init', (data) => { this.socket.on('init', (data) => {
console.log('[Server] Initial socket data received', data) console.log('[Server] Initial socket data received', data)
+6 -2
View File
@@ -5,13 +5,16 @@ plugins {
} }
android { android {
kotlinOptions {
freeCompilerArgs = ['-Xjvm-default=all']
}
compileSdkVersion rootProject.ext.compileSdkVersion compileSdkVersion rootProject.ext.compileSdkVersion
defaultConfig { defaultConfig {
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 5 versionCode 15
versionName "0.2.1-beta" versionName "0.9.0-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.
@@ -35,6 +38,7 @@ repositories {
} }
dependencies { dependencies {
implementation "com.anggrayudi:storage:0.13.0"
implementation fileTree(include: ['*.jar'], dir: 'libs') implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation project(':capacitor-android') implementation project(':capacitor-android')
+4
View File
@@ -9,8 +9,12 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies { dependencies {
implementation project(':capacitor-community-sqlite')
implementation project(':capacitor-dialog') implementation project(':capacitor-dialog')
implementation project(':capacitor-network')
implementation project(':capacitor-storage')
implementation project(':robingenz-capacitor-app-update') implementation project(':robingenz-capacitor-app-update')
implementation project(':capacitor-data-storage-sqlite')
} }
+5 -1
View File
@@ -6,6 +6,9 @@
<!-- 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.MANAGE_EXTERNAL_STORAGE" />-->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<application <application
android:allowBackup="true" android:allowBackup="true"
@@ -14,7 +17,8 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/AppTheme" android:theme="@style/AppTheme"
android:usesCleartextTraffic="true"> android:usesCleartextTraffic="true"
android:requestLegacyExternalStorage="true">
<activity <activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
@@ -1,10 +1,26 @@
[ [
{
"pkg": "@capacitor-community/sqlite",
"classpath": "com.getcapacitor.community.database.sqlite.CapacitorSQLitePlugin"
},
{ {
"pkg": "@capacitor/dialog", "pkg": "@capacitor/dialog",
"classpath": "com.capacitorjs.plugins.dialog.DialogPlugin" "classpath": "com.capacitorjs.plugins.dialog.DialogPlugin"
}, },
{
"pkg": "@capacitor/network",
"classpath": "com.capacitorjs.plugins.network.NetworkPlugin"
},
{
"pkg": "@capacitor/storage",
"classpath": "com.capacitorjs.plugins.storage.StoragePlugin"
},
{ {
"pkg": "@robingenz/capacitor-app-update", "pkg": "@robingenz/capacitor-app-update",
"classpath": "dev.robingenz.capacitor.appupdate.AppUpdatePlugin" "classpath": "dev.robingenz.capacitor.appupdate.AppUpdatePlugin"
},
{
"pkg": "capacitor-data-storage-sqlite",
"classpath": "com.jeep.plugin.capacitor.capacitordatastoragesqlite.CapacitorDataStorageSqlitePlugin"
} }
] ]
@@ -0,0 +1,510 @@
package com.audiobookshelf.app
import android.app.DownloadManager
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.documentfile.provider.DocumentFile
import com.anggrayudi.storage.SimpleStorage
import com.anggrayudi.storage.SimpleStorageHelper
import com.anggrayudi.storage.callback.FileCallback
import com.anggrayudi.storage.callback.FolderPickerCallback
import com.anggrayudi.storage.callback.StorageAccessCallback
import com.anggrayudi.storage.file.*
import com.anggrayudi.storage.media.FileDescription
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import org.json.JSONObject
import java.io.File
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
@CapacitorPlugin(name = "AudioDownloader")
class AudioDownloader : Plugin() {
private val tag = "AudioDownloader"
lateinit var mainActivity:MainActivity
lateinit var downloadManager:DownloadManager
data class AudiobookItem(val uri: Uri, val name: String, val size: Long, val coverUrl: String) {
fun toJSObject() : JSObject {
var obj = JSObject()
obj.put("uri", this.uri)
obj.put("name", this.name)
obj.put("size", this.size)
obj.put("coverUrl", this.coverUrl)
return obj
}
}
override fun load() {
mainActivity = (activity as MainActivity)
downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
// storage = SimpleStorage(mainActivity)
var recieverEvent : (evt: String, id: Long) -> Unit = { evt: String, id: Long ->
if (evt == "complete") {}
if (evt == "clicked") {
Log.d(tag, "Clicked $id back in the audiodownloader")
}
}
mainActivity.registerBroadcastReceiver(recieverEvent)
setupSimpleStorage()
Log.d(tag, "Build SDK ${Build.VERSION.SDK_INT}")
// Android 9 OR Below Request Permissions
// if (Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.P) {
// Log.d(tag, "Requires Permission")
//// storage.requestStorageAccess(9)
// var jsobj = JSObject()
// jsobj.put("value", "required")
// notifyListeners("permission", jsobj)
// } else {
// Log.d(tag, "Does not request permission")
// }
}
private fun setupSimpleStorage() {
mainActivity.storageHelper.onFolderSelected = { requestCode, folder ->
Log.d(tag, "FOLDER SELECTED $requestCode ${folder.name} ${folder.uri}")
var jsobj = JSObject()
jsobj.put("value", "granted")
jsobj.put("uri", folder.uri)
jsobj.put("absolutePath", folder.getAbsolutePath(context))
jsobj.put("storageId", folder.getStorageId(context))
jsobj.put("storageType", folder.getStorageType(context))
jsobj.put("simplePath", folder.getSimplePath(context))
jsobj.put("basePath", folder.getBasePath(context))
notifyListeners("permission", jsobj)
}
mainActivity.storage.storageAccessCallback = object : StorageAccessCallback {
override fun onRootPathNotSelected(
requestCode: Int,
rootPath: String,
uri: Uri,
selectedStorageType: StorageType,
expectedStorageType: StorageType
) {
Log.d(tag, "STORAGE ACCESS CALLBACK")
}
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")
}
}
}
@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)
}
fun checkUriExists(uri: Uri?): Boolean {
if (uri == null) return false
val resolver = context.contentResolver
//1. Check Uri
var cursor: Cursor? = null
val isUriExist: Boolean = try {
cursor = resolver.query(uri, null, null, null, null)
//cursor null: content Uri was invalid or some other error occurred
//cursor.moveToFirst() false: Uri was ok but no entry found.
(cursor != null && cursor.moveToFirst())
} catch (t: Throwable) {
false
} finally {
try {
cursor?.close()
} catch (t: Throwable) {
}
false
}
return isUriExist
}
@PluginMethod
fun load(call: PluginCall) {
var audiobookUrls = call.data.getJSONArray("audiobookUrls")
var len = audiobookUrls?.length()
if (len == null) {
len = 0
}
Log.d(tag, "CALLED LOAD $len")
var audiobookItems:MutableList<AudiobookItem> = mutableListOf()
(0 until len).forEach {
var jsobj = audiobookUrls.get(it) as JSONObject
var audiobookUrl = jsobj.get("contentUrl").toString()
var coverUrl = jsobj.get("coverUrl").toString()
var storageId = ""
if(jsobj.has("storageId")) jsobj.get("storageId").toString()
var basePath = ""
if(jsobj.has("basePath")) jsobj.get("basePath").toString()
var coverBasePath = ""
if(jsobj.has("coverBasePath")) jsobj.get("coverBasePath").toString()
Log.d(tag, "LOOKUP $storageId $basePath $audiobookUrl")
var audiobookFile: DocumentFile? = null
var coverFile: DocumentFile? = null
// Android 9 OR Below use storage id and base path
if (Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.P) {
audiobookFile = DocumentFileCompat.fromSimplePath(context, storageId, basePath)
if (coverUrl != null && coverUrl != "") {
coverFile = DocumentFileCompat.fromSimplePath(context, storageId, coverBasePath)
}
} else {
// Android 10 and up manually deleting will still load the file causing crash
var exists = checkUriExists(Uri.parse(audiobookUrl))
if (exists) {
Log.d(tag, "Audiobook exists")
audiobookFile = DocumentFileCompat.fromUri(context, Uri.parse(audiobookUrl))
} else {
Log.e(tag, "Audiobook does not exist")
}
var coverExists = checkUriExists(Uri.parse(coverUrl))
if (coverExists) {
Log.d(tag, "Cover Exists")
coverFile = DocumentFileCompat.fromUri(context, Uri.parse(coverUrl))
} else if (coverUrl != null && coverUrl != "") {
Log.e(tag, "Cover does not exist")
}
}
if (audiobookFile == null) {
Log.e(tag, "Audiobook was not found $audiobookUrl")
} else {
var _name = audiobookFile.name
if (_name == null) _name = ""
var _coverUrl = ""
if (coverFile != null) _coverUrl = coverFile.uri.toString()
var size = audiobookFile.length()
var abItem = AudiobookItem(audiobookFile.uri, _name, size, _coverUrl)
audiobookItems.add(abItem)
}
}
var audiobookObjs:List<JSObject> = audiobookItems.map{ it.toJSObject() }
var mediaItemNoticePayload = JSObject()
mediaItemNoticePayload.put("items", audiobookObjs)
notifyListeners("onMediaLoaded", mediaItemNoticePayload)
}
@PluginMethod
fun download(call: PluginCall) {
var audiobookId = call.data.getString("audiobookId", "audiobook").toString()
var url = call.data.getString("downloadUrl", "unknown").toString()
var coverDownloadUrl = call.data.getString("coverDownloadUrl", "").toString()
var title = call.data.getString("title", "Audiobook").toString()
var filename = call.data.getString("filename", "audiobook.mp3").toString()
var coverFilename = call.data.getString("coverFilename", "cover.png").toString()
var downloadFolderUrl = call.data.getString("downloadFolderUrl", "").toString()
var folder = DocumentFileCompat.fromUri(context, Uri.parse(downloadFolderUrl))!!
Log.d(tag, "Called download: $url | Folder: ${folder.name} | $downloadFolderUrl")
var dlfilename = audiobookId + "." + File(filename).extension
var coverdlfilename = audiobookId + "." + File(coverFilename).extension
var canWriteToFolder = folder.canWrite()
if (!canWriteToFolder) {
Log.e(tag, "Error Cannot Write to Folder ${folder.baseName}")
val ret = JSObject()
ret.put("error", "Cannot write to ${folder.baseName}")
call.resolve(ret)
return
}
var dlRequest = DownloadManager.Request(Uri.parse(url))
dlRequest.setTitle(title)
dlRequest.setDescription("Downloading to ${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
if (id == coverDownloadId) {
docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, coverdlfilename)
Log.d(tag, "Move Cover File ${docfile?.name}")
} else if (id == audiobookDownloadId) {
docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, dlfilename)
Log.d(tag, "Move Audiobook File ${docfile?.name}")
}
var callback = object : FileCallback() {
override fun onPrepare() {
Log.d(tag, "PREPARING MOVE FILE")
}
override fun onFailed(errorCode:ErrorCode) {
Log.e(tag, "FAILED MOVE FILE $errorCode")
docfile?.delete()
coverDocFile?.delete()
if (id == audiobookDownloadId) {
var jsobj = JSObject()
jsobj.put("audiobookId", audiobookId)
jsobj.put("error", "Move failed")
notifyListeners("onDownloadFailed", jsobj)
}
}
override fun onCompleted(result:Any) {
var resultDocFile = result as DocumentFile
var simplePath = resultDocFile.getSimplePath(context)
var storageId = resultDocFile.getStorageId(context)
var size = resultDocFile.length()
Log.d(tag, "Finished Moving File, NAME: ${resultDocFile.name} | $storageId | SimplePath: $simplePath")
var abFolder = folder.findFolder(title)
var jsobj = JSObject()
jsobj.put("audiobookId", audiobookId)
jsobj.put("downloadId", id)
jsobj.put("storageId", storageId)
jsobj.put("storageType", resultDocFile.getStorageType(context))
jsobj.put("folderUrl", abFolder?.uri)
jsobj.put("folderName", abFolder?.name)
jsobj.put("downloadFolderUrl", downloadFolderUrl)
jsobj.put("contentUrl", resultDocFile.uri)
jsobj.put("basePath", resultDocFile.getBasePath(context))
jsobj.put("filename", filename)
jsobj.put("simplePath", simplePath)
jsobj.put("size", size)
if (resultDocFile.name == filename) {
Log.d(tag, "Audiobook Finishing Moving")
} else if (resultDocFile.name == coverFilename) {
coverDocFile = docfile
Log.d(tag, "Audiobook Cover Finished Moving")
jsobj.put("isCover", true)
}
notifyListeners("onDownloadComplete", jsobj)
}
}
val executorService: ExecutorService = Executors.newFixedThreadPool(4)
executorService.execute {
if (id == coverDownloadId) {
docfile?.moveFileTo(context, folder, FileDescription(coverFilename, title, MimeType.IMAGE), callback)
} else if (id == audiobookDownloadId) {
docfile?.moveFileTo(context, folder, FileDescription(filename, title, MimeType.AUDIO), callback)
}
}
}
var progressUpdater = DownloadProgressUpdater(downloadManager, audiobookDownloadId, progressReceiver, doneReceiver)
progressUpdater.run()
if (coverDownloadId != null) {
var coverProgressUpdater = DownloadProgressUpdater(downloadManager, coverDownloadId, progressReceiver, doneReceiver)
coverProgressUpdater.run()
}
val ret = JSObject()
ret.put("audiobookDownloadId", audiobookDownloadId)
ret.put("coverDownloadId", coverDownloadId)
call.resolve(ret)
}
@PluginMethod
fun selectFolder(call: PluginCall) {
mainActivity.storage.folderPickerCallback = object : FolderPickerCallback {
override fun onFolderSelected(requestCode: Int, folder: DocumentFile) {
Log.d(tag, "ONF OLDER SELECRTED ${folder.uri} ${folder.name}")
var absolutePath = folder.getAbsolutePath(activity)
var storageId = folder.getStorageId(activity)
var storageType = folder.getStorageType(activity)
var simplePath = folder.getSimplePath(activity)
var basePath = folder.getBasePath(activity)
var jsobj = JSObject()
jsobj.put("uri", folder.uri)
jsobj.put("absolutePath", absolutePath)
jsobj.put("storageId", storageId)
jsobj.put("storageType", storageType)
jsobj.put("simplePath", simplePath)
jsobj.put("basePath", basePath)
call.resolve(jsobj)
}
override fun onStorageAccessDenied(requestCode: Int, folder: DocumentFile?, storageType: StorageType) {
Log.e(tag, "STORAGE ACCESS DENIED")
var jsobj = JSObject()
jsobj.put("error", "Access Denied")
call.resolve(jsobj)
}
override fun onStoragePermissionDenied(requestCode: Int) {
Log.d(tag, "STORAGE PERMISSION DENIED $requestCode")
var jsobj = JSObject()
jsobj.put("error", "Permission Denied")
call.resolve(jsobj)
}
}
mainActivity.storage.openFolderPicker(6)
}
@PluginMethod
fun delete(call: PluginCall) {
var 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() {
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
}
}
}
}
}
}
@@ -16,10 +16,14 @@ class Audiobook {
var playbackSpeed:Float = 1f var playbackSpeed:Float = 1f
var duration:Long = 0 var duration:Long = 0
var isLocal:Boolean = false
var contentUrl:String = ""
var hasPlayerLoaded:Boolean = false var hasPlayerLoaded:Boolean = false
val playlistUri:Uri var playlistUri:Uri = Uri.EMPTY
val coverUri:Uri var coverUri:Uri = Uri.EMPTY
var contentUri:Uri = Uri.EMPTY // For Local only
constructor(jsondata:JSObject) { constructor(jsondata:JSObject) {
id = jsondata.getString("id", "audiobook").toString() id = jsondata.getString("id", "audiobook").toString()
@@ -34,7 +38,22 @@ class Audiobook {
playbackSpeed = jsondata.getDouble("playbackSpeed")!!.toFloat() playbackSpeed = jsondata.getDouble("playbackSpeed")!!.toFloat()
duration = jsondata.getString("duration", "0")!!.toLong() duration = jsondata.getString("duration", "0")!!.toLong()
playlistUri = Uri.parse(playlistUrl) // Local data
coverUri = Uri.parse(cover) isLocal = jsondata.getBoolean("isLocal", false) == true
contentUrl = jsondata.getString("contentUrl", "").toString()
if (playlistUrl != "") {
playlistUri = Uri.parse(playlistUrl)
}
if (cover != "") {
coverUri = Uri.parse(cover)
} else {
coverUri = Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
cover = coverUri.toString()
}
if (contentUrl != "") {
contentUri = Uri.parse(contentUrl)
}
} }
} }
@@ -1,15 +1,14 @@
package com.audiobookshelf.app package com.audiobookshelf.app
import android.content.ComponentName import android.app.DownloadManager
import android.content.Context import android.content.*
import android.content.Intent import android.os.*
import android.content.ServiceConnection
import android.os.Bundle
import android.os.IBinder
import android.util.Log import android.util.Log
import com.example.myapp.MyNativeAudio import com.anggrayudi.storage.SimpleStorage
import com.anggrayudi.storage.SimpleStorageHelper
import com.getcapacitor.BridgeActivity import com.getcapacitor.BridgeActivity
class MainActivity : BridgeActivity() { class MainActivity : BridgeActivity() {
private val tag = "MainActivity" private val tag = "MainActivity"
@@ -18,11 +17,42 @@ class MainActivity : BridgeActivity() {
private lateinit var mConnection : ServiceConnection private lateinit var mConnection : ServiceConnection
lateinit var pluginCallback : () -> Unit lateinit var pluginCallback : () -> Unit
lateinit var downloaderCallback : (String, Long) -> Unit
val storageHelper = SimpleStorageHelper(this)
val storage = SimpleStorage(this)
val broadcastReceiver = object: BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
DownloadManager.ACTION_DOWNLOAD_COMPLETE -> {
var thisdlid = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0L)
downloaderCallback("complete", thisdlid)
}
DownloadManager.ACTION_NOTIFICATION_CLICKED -> {
var thisdlid = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0L)
downloaderCallback("clicked", thisdlid)
}
}
}
}
public override fun onCreate(savedInstanceState: Bundle?) { public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
Log.d(tag, "onCreate") Log.d(tag, "onCreate")
registerPlugin(MyNativeAudio::class.java) registerPlugin(MyNativeAudio::class.java)
registerPlugin(AudioDownloader::class.java)
var filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE).apply {
addAction(DownloadManager.ACTION_NOTIFICATION_CLICKED)
}
registerReceiver(broadcastReceiver, filter)
}
override fun onDestroy() {
super.onDestroy()
unregisterReceiver(broadcastReceiver)
} }
override fun onPostCreate(savedInstanceState: Bundle?) { override fun onPostCreate(savedInstanceState: Bundle?) {
@@ -62,4 +92,33 @@ class MainActivity : BridgeActivity() {
val stopIntent = Intent(this, PlayerNotificationService::class.java) val stopIntent = Intent(this, PlayerNotificationService::class.java)
stopService(stopIntent) stopService(stopIntent)
} }
fun registerBroadcastReceiver(cb: (String, Long) -> Unit) {
downloaderCallback = cb
}
override fun onSaveInstanceState(outState: Bundle) {
storageHelper.onSaveInstanceState(outState)
super.onSaveInstanceState(outState)
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
storageHelper.onRestoreInstanceState(savedInstanceState)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
// Mandatory for Activity, but not for Fragment & ComponentActivity
storageHelper.storage.onActivityResult(requestCode, resultCode, data)
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
Log.d(tag, "onRequestPermissionResult $requestCode")
permissions.forEach { Log.d(tag, "PERMISSION $it") }
grantResults.forEach { Log.d(tag, "GRANTREUSLTS $it") }
// Mandatory for Activity, but not for Fragment & ComponentActivity
storageHelper.onRequestPermissionsResult(requestCode, permissions, grantResults)
}
} }
@@ -1,13 +1,10 @@
package com.example.myapp package com.audiobookshelf.app
import android.content.Intent import android.content.Intent
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.audiobookshelf.app.Audiobook
import com.audiobookshelf.app.MainActivity
import com.audiobookshelf.app.PlayerNotificationService
import com.getcapacitor.JSObject import com.getcapacitor.JSObject
import com.getcapacitor.Plugin import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall import com.getcapacitor.PluginCall
@@ -56,12 +53,20 @@ class MyNativeAudio : Plugin() {
} else { } else {
Log.w(tag, "Service already started --") Log.w(tag, "Service already started --")
} }
var jsobj = JSObject()
var audiobook:Audiobook = Audiobook(call.data) var audiobook:Audiobook = Audiobook(call.data)
if (audiobook.playlistUrl == "" && audiobook.contentUrl == "") {
Log.e(tag, "Invalid URL for init audio player")
jsobj.put("success", false)
return call.resolve(jsobj)
}
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post() {
playerNotificationService.initPlayer(audiobook) playerNotificationService.initPlayer(audiobook)
call.resolve() jsobj.put("success", true)
call.resolve(jsobj)
} }
} }
@@ -109,6 +114,7 @@ class MyNativeAudio : Plugin() {
call.resolve() call.resolve()
} }
} }
@PluginMethod @PluginMethod
fun seekBackward(call: PluginCall) { fun seekBackward(call: PluginCall) {
var amount:Long = call.getString("amount", "0")!!.toLong() var amount:Long = call.getString("amount", "0")!!.toLong()
@@ -117,6 +123,7 @@ class MyNativeAudio : Plugin() {
call.resolve() call.resolve()
} }
} }
@PluginMethod @PluginMethod
fun setPlaybackSpeed(call: PluginCall) { fun setPlaybackSpeed(call: PluginCall) {
var playbackSpeed:Float = call.getFloat("speed", 1.0f)!! var playbackSpeed:Float = call.getFloat("speed", 1.0f)!!
@@ -126,6 +133,7 @@ class MyNativeAudio : Plugin() {
call.resolve() call.resolve()
} }
} }
@PluginMethod @PluginMethod
fun terminateStream(call: PluginCall) { fun terminateStream(call: PluginCall) {
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post() {
@@ -9,6 +9,7 @@ import android.net.Uri
import android.os.Binder import android.os.Binder
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.provider.MediaStore
import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaControllerCompat import android.support.v4.media.session.MediaControllerCompat
@@ -21,12 +22,18 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.RequestOptions
import com.getcapacitor.JSObject import com.getcapacitor.JSObject
import com.google.android.exoplayer2.* import com.google.android.exoplayer2.*
import com.google.android.exoplayer2.audio.AudioAttributes
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator
import com.google.android.exoplayer2.source.DefaultMediaSourceFactory
import com.google.android.exoplayer2.source.MediaSource
import com.google.android.exoplayer2.source.ProgressiveMediaExtractor
import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.source.hls.HlsMediaSource import com.google.android.exoplayer2.source.hls.HlsMediaSource
import com.google.android.exoplayer2.ui.PlayerNotificationManager import com.google.android.exoplayer2.ui.PlayerNotificationManager
import com.google.android.exoplayer2.upstream.* import com.google.android.exoplayer2.upstream.*
import kotlinx.coroutines.* import kotlinx.coroutines.*
import java.io.File
const val NOTIFICATION_LARGE_ICON_SIZE = 144 // px const val NOTIFICATION_LARGE_ICON_SIZE = 144 // px
@@ -133,7 +140,16 @@ class PlayerNotificationService : Service() {
createNotificationChannel(channelId, channelName) createNotificationChannel(channelId, channelName)
} else "" } else ""
mPlayer = SimpleExoPlayer.Builder(this).build()
var simpleExoPlayerBuilder = SimpleExoPlayer.Builder(this)
simpleExoPlayerBuilder.setSeekBackIncrementMs(10000)
simpleExoPlayerBuilder.setSeekForwardIncrementMs(10000)
mPlayer = simpleExoPlayerBuilder.build()
mPlayer.setHandleAudioBecomingNoisy(true)
var audioAttributes:AudioAttributes = AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.CONTENT_TYPE_SPEECH).build()
mPlayer.setAudioAttributes(audioAttributes, true)
setPlayerListeners() setPlayerListeners()
val sessionActivityPendingIntent = val sessionActivityPendingIntent =
@@ -207,13 +223,13 @@ class PlayerNotificationService : Service() {
mediaSessionConnector = MediaSessionConnector(mediaSession) mediaSessionConnector = MediaSessionConnector(mediaSession)
val queueNavigator: TimelineQueueNavigator = object : TimelineQueueNavigator(mediaSession) { val queueNavigator: TimelineQueueNavigator = object : TimelineQueueNavigator(mediaSession) {
override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat { override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat {
return MediaDescriptionCompat.Builder() var builder = MediaDescriptionCompat.Builder()
.setMediaId(currentAudiobook!!.id) .setMediaId(currentAudiobook!!.id)
.setTitle(currentAudiobook!!.title) .setTitle(currentAudiobook!!.title)
.setSubtitle(currentAudiobook!!.author) .setSubtitle(currentAudiobook!!.author)
.setMediaUri(currentAudiobook!!.playlistUri) .setMediaUri(currentAudiobook!!.playlistUri)
.setIconUri(currentAudiobook!!.coverUri) .setIconUri(currentAudiobook!!.coverUri)
.build() return builder.build()
} }
} }
mediaSessionConnector.setQueueNavigator(queueNavigator) mediaSessionConnector.setQueueNavigator(queueNavigator)
@@ -270,6 +286,9 @@ class PlayerNotificationService : Service() {
} }
} }
private fun setPlayerListeners() { private fun setPlayerListeners() {
mPlayer.addListener(object : Player.Listener { mPlayer.addListener(object : Player.Listener {
override fun onPlayerError(error: PlaybackException) { override fun onPlayerError(error: PlaybackException) {
@@ -341,28 +360,42 @@ class PlayerNotificationService : Service() {
Log.d(tag, "Init Player audiobook already playing") Log.d(tag, "Init Player audiobook already playing")
} }
val metadata = MediaMetadataCompat.Builder() var metadataBuilder = MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, currentAudiobook!!.title) .putString(MediaMetadataCompat.METADATA_KEY_TITLE, currentAudiobook!!.title)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, currentAudiobook!!.title) .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, currentAudiobook!!.title)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, currentAudiobook!!.author) .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, currentAudiobook!!.author)
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, currentAudiobook!!.author) .putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, currentAudiobook!!.author)
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, currentAudiobook!!.author) .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, currentAudiobook!!.author)
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, currentAudiobook!!.series) .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, currentAudiobook!!.series)
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, currentAudiobook!!.cover)
.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, currentAudiobook!!.cover)
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, currentAudiobook!!.id) .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, currentAudiobook!!.id)
.build()
if (currentAudiobook!!.cover != "") {
metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, currentAudiobook!!.cover)
metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, currentAudiobook!!.cover)
}
var metadata = metadataBuilder.build()
mediaSession.setMetadata(metadata) mediaSession.setMetadata(metadata)
var mediaMetadata = MediaMetadata.Builder().build() var mediaMetadata = MediaMetadata.Builder().build()
var mediaItem = MediaItem.Builder().setUri(currentAudiobook!!.playlistUri).setMediaMetadata(mediaMetadata).build()
var dataSourceFactory = DefaultHttpDataSource.Factory()
dataSourceFactory.setUserAgent(channelId)
dataSourceFactory.setDefaultRequestProperties(hashMapOf("Authorization" to "Bearer ${currentAudiobook!!.token}"))
var mediaSource = HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem) var mediaSource:MediaSource
if (currentAudiobook!!.isLocal) {
Log.d(tag, "Playing Local File")
var mediaItem = MediaItem.Builder().setUri(currentAudiobook!!.contentUri).setMediaMetadata(mediaMetadata).build()
var dataSourceFactory = DefaultDataSourceFactory(ctx, channelId)
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem)
} else {
Log.d(tag, "Playing HLS File")
var mediaItem = MediaItem.Builder().setUri(currentAudiobook!!.playlistUri).setMediaMetadata(mediaMetadata).build()
var dataSourceFactory = DefaultHttpDataSource.Factory()
dataSourceFactory.setUserAgent(channelId)
dataSourceFactory.setDefaultRequestProperties(hashMapOf("Authorization" to "Bearer ${currentAudiobook!!.token}"))
mediaSource = HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem)
}
mPlayer.setMediaSource(mediaSource, true) mPlayer.setMediaSource(mediaSource, true)
mPlayer.prepare() mPlayer.prepare()
+12
View File
@@ -2,8 +2,20 @@
include ':capacitor-android' include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
include ':capacitor-community-sqlite'
project(':capacitor-community-sqlite').projectDir = new File('../node_modules/@capacitor-community/sqlite/android')
include ':capacitor-dialog' include ':capacitor-dialog'
project(':capacitor-dialog').projectDir = new File('../node_modules/@capacitor/dialog/android') project(':capacitor-dialog').projectDir = new File('../node_modules/@capacitor/dialog/android')
include ':capacitor-network'
project(':capacitor-network').projectDir = new File('../node_modules/@capacitor/network/android')
include ':capacitor-storage'
project(':capacitor-storage').projectDir = new File('../node_modules/@capacitor/storage/android')
include ':robingenz-capacitor-app-update' include ':robingenz-capacitor-app-update'
project(':robingenz-capacitor-app-update').projectDir = new File('../node_modules/@robingenz/capacitor-app-update/android') project(':robingenz-capacitor-app-update').projectDir = new File('../node_modules/@robingenz/capacitor-app-update/android')
include ':capacitor-data-storage-sqlite'
project(':capacitor-data-storage-sqlite').projectDir = new File('../node_modules/capacitor-data-storage-sqlite/android')
+1 -1
View File
@@ -1,5 +1,5 @@
ext { ext {
minSdkVersion = 26 minSdkVersion = 23
compileSdkVersion = 30 compileSdkVersion = 30
targetSdkVersion = 30 targetSdkVersion = 30
androidxActivityVersion = '1.2.0' androidxActivityVersion = '1.2.0'
+2
View File
@@ -1,3 +1,5 @@
@import "./fonts.css";
.box-shadow-md { .box-shadow-md {
box-shadow: 2px 8px 6px #111111aa; box-shadow: 2px 8px 6px #111111aa;
} }
+41
View File
@@ -0,0 +1,41 @@
/* fallback */
@font-face {
font-family: 'Material Icons';
font-style: normal;
font-weight: 400;
src: url(/material-icons.woff2) format('woff2');
}
.material-icons {
font-family: 'Material Icons';
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
}
@font-face {
font-family: 'Gentium Book Basic';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/GentiumBookBasic.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Gentium Book Basic';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/GentiumBookBasic.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
+22 -2
View File
@@ -202,7 +202,17 @@ export default {
this.isResetting = false this.isResetting = false
this.initObject = { ...audiobookStreamData } this.initObject = { ...audiobookStreamData }
this.currentPlaybackRate = this.initObject.playbackSpeed this.currentPlaybackRate = this.initObject.playbackSpeed
MyNativeAudio.initPlayer(this.initObject) MyNativeAudio.initPlayer(this.initObject).then((res) => {
if (res && res.success) {
console.log('Success init audio player')
} else {
console.error('Failed to init audio player')
}
})
if (audiobookStreamData.isLocal) {
this.setStreamReady()
}
}, },
setFromObj() { setFromObj() {
if (!this.initObject) { if (!this.initObject) {
@@ -210,7 +220,17 @@ export default {
return return
} }
this.isResetting = false this.isResetting = false
MyNativeAudio.initPlayer(this.initObject) MyNativeAudio.initPlayer(this.initObject).then((res) => {
if (res && res.success) {
console.log('Success init audio player')
} else {
console.error('Failed to init audio player')
}
})
if (audiobookStreamData.isLocal) {
this.setStreamReady()
}
}, },
play() { play() {
MyNativeAudio.playPlayer() MyNativeAudio.playPlayer()
+49 -2
View File
@@ -13,14 +13,18 @@
<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" /> -->
<nuxt-link to="/account" class="relative w-28 bg-fg border border-gray-500 rounded shadow-sm ml-5 pl-3 pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer hover:bg-bg hover:bg-opacity-40" aria-haspopup="listbox" aria-expanded="true"> <span class="material-icons cursor-pointer mx-4" @click="$store.commit('downloads/setShowModal', true)">source</span>
<widgets-connection-icon />
<!-- <nuxt-link to="/account" class="relative w-28 bg-fg border border-gray-500 rounded shadow-sm ml-5 pl-3 pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer hover:bg-bg hover:bg-opacity-40" aria-haspopup="listbox" aria-expanded="true">
<span class="flex items-center"> <span class="flex items-center">
<span class="block truncate">{{ username }}</span> <span class="block truncate">{{ username }}</span>
</span> </span>
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none"> <span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<span class="material-icons text-gray-100">person</span> <span class="material-icons text-gray-100">person</span>
</span> </span>
</nuxt-link> </nuxt-link> -->
</div> </div>
</div> </div>
</template> </template>
@@ -89,4 +93,47 @@ export default {
#appbar { #appbar {
box-shadow: 0px 5px 5px #11111155; box-shadow: 0px 5px 5px #11111155;
} }
.loader-dots div {
animation-timing-function: cubic-bezier(0, 1, 1, 0);
}
.loader-dots div:nth-child(1) {
left: 0px;
animation: loader-dots1 0.6s infinite;
}
.loader-dots div:nth-child(2) {
left: 0px;
animation: loader-dots2 0.6s infinite;
}
.loader-dots div:nth-child(3) {
left: 10px;
animation: loader-dots2 0.6s infinite;
}
.loader-dots div:nth-child(4) {
left: 20px;
animation: loader-dots3 0.6s infinite;
}
@keyframes loader-dots1 {
0% {
transform: scale(0);
}
100% {
transform: scale(1);
}
}
@keyframes loader-dots3 {
0% {
transform: scale(1);
}
100% {
transform: scale(0);
}
}
@keyframes loader-dots2 {
0% {
transform: translate(0, 0);
}
100% {
transform: translate(10px, 0);
}
}
</style> </style>
+34 -13
View File
@@ -3,12 +3,7 @@
<template v-for="(shelf, index) in groupedBooks"> <template v-for="(shelf, index) in groupedBooks">
<div :key="index" class="border-b border-opacity-10 w-full bookshelfRow py-4 flex justify-around relative"> <div :key="index" class="border-b border-opacity-10 w-full bookshelfRow py-4 flex justify-around relative">
<template v-for="audiobook in shelf"> <template v-for="audiobook in shelf">
<!-- <div :key="audiobook.id" class="relative px-4"> --> <cards-book-card :key="audiobook.id" :audiobook="audiobook" :width="cardWidth" :user-progress="userAudiobooks[audiobook.id]" :local-user-progress="localUserAudiobooks[audiobook.id]" />
<cards-book-card :key="audiobook.id" :audiobook="audiobook" :width="cardWidth" :user-progress="userAudiobooks[audiobook.id]" />
<!-- <nuxt-link :to="`/audiobook/${audiobook.id}`">
<cards-book-cover :audiobook="audiobook" :width="cardWidth" class="mx-auto -mb-px" style="box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166" />
</nuxt-link> -->
<!-- </div> -->
</template> </template>
<div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" /> <div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" />
</div> </div>
@@ -44,6 +39,9 @@ export default {
}, },
userAudiobooks() { userAudiobooks() {
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {} return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
},
localUserAudiobooks() {
return this.$store.state.user.localUserAudiobooks || {}
} }
}, },
methods: { methods: {
@@ -52,11 +50,6 @@ export default {
filterBy: 'all' filterBy: 'all'
}) })
}, },
playAudiobook(audiobook) {
console.log('Play Audiobook', audiobook)
this.$store.commit('setStreamAudiobook', audiobook)
this.$server.socket.emit('open_stream', audiobook.id)
},
calcShelves() { calcShelves() {
var booksPerShelf = Math.floor(this.pageWidth / (this.cardWidth + 32)) var booksPerShelf = Math.floor(this.pageWidth / (this.cardWidth + 32))
var groupedBooks = [] var groupedBooks = []
@@ -87,20 +80,48 @@ export default {
if (this.currFilterOrderKey !== this.filterOrderKey) { if (this.currFilterOrderKey !== this.filterOrderKey) {
this.calcShelves() this.calcShelves()
} }
},
socketConnected(isConnected) {
if (isConnected) {
console.log('Connected - Load from server')
this.$store.dispatch('audiobooks/load')
} else {
console.log('Disconnected - Reset to local storage')
this.$store.commit('audiobooks/reset')
this.$store.dispatch('audiobooks/useDownloaded')
// this.calcShelves()
// this.$store.dispatch('downloads/loadFromStorage')
}
} }
}, },
mounted() { mounted() {
this.$store.commit('audiobooks/addListener', { id: 'bookshelf', meth: this.audiobooksUpdated }) this.$store.commit('audiobooks/addListener', { id: 'bookshelf', meth: this.audiobooksUpdated })
this.$store.commit('user/addSettingsListener', { id: 'bookshelf', meth: this.settingsUpdated }) this.$store.commit('user/addSettingsListener', { id: 'bookshelf', meth: this.settingsUpdated })
window.addEventListener('resize', this.resize) window.addEventListener('resize', this.resize)
this.$store.dispatch('audiobooks/load')
if (!this.$server) {
console.error('Bookshelf mounted no server')
return
}
this.$server.on('connected', this.socketConnected)
if (this.$server.connected) {
this.$store.dispatch('audiobooks/load')
} else {
console.log('Bookshelf - Server not connected using downloaded')
}
this.init() this.init()
}, },
beforeDestroy() { beforeDestroy() {
this.$store.commit('audiobooks/removeListener', 'bookshelf') this.$store.commit('audiobooks/removeListener', 'bookshelf')
this.$store.commit('user/removeSettingsListener', 'bookshelf') this.$store.commit('user/removeSettingsListener', 'bookshelf')
window.removeEventListener('resize', this.resize) window.removeEventListener('resize', this.resize)
if (!this.$server) {
console.error('Bookshelf beforeDestroy no server')
return
}
this.$server.off('connected', this.socketConnected)
} }
} }
</script> </script>
+199 -44
View File
@@ -1,20 +1,24 @@
<template> <template>
<div class="w-full p-4 pointer-events-none fixed bottom-0 left-0 right-0 z-20"> <div class="w-full p-4 pointer-events-none fixed bottom-0 left-0 right-0 z-20">
<div v-if="streamAudiobook" class="w-full bg-primary absolute bottom-0 left-0 right-0 z-50 p-2 pointer-events-auto" @click.stop @mousedown.stop @mouseup.stop> <div v-if="audiobook" class="w-full bg-primary absolute bottom-0 left-0 right-0 z-50 p-2 pointer-events-auto" @click.stop @mousedown.stop @mouseup.stop>
<div class="pl-16 pr-2 flex items-center pb-2"> <div class="pl-16 pr-2 flex items-center pb-2">
<div> <div>
<p class="px-2">{{ title }}</p> <p class="px-2">{{ title }}</p>
<p class="px-2 text-xs text-gray-400">by {{ author }}</p> <p class="px-2 text-xs text-gray-400">by {{ author }}</p>
</div> </div>
<div class="flex-grow" /> <div class="flex-grow" />
<div v-if="chapters.length" class="cursor-pointer flex items-center justify-center mr-6 w-6 text-center" :class="chapters.length ? 'text-gray-300' : 'text-gray-400'" @mousedown.prevent @mouseup.prevent @click="clickChapterBtn">
<span class="material-icons text-2xl">format_list_bulleted</span>
</div>
<span class="material-icons" @click="cancelStream">close</span> <span class="material-icons" @click="cancelStream">close</span>
</div> </div>
<div class="absolute left-2 -top-10"> <div class="absolute left-2 -top-10">
<cards-book-cover :audiobook="streamAudiobook" :width="64" /> <cards-book-cover :audiobook="audiobook" :download-cover="downloadedCover" :width="64" />
</div> </div>
<audio-player-mini ref="audioPlayerMini" :loading="!stream || currStreamAudiobookId !== streamAudiobookId" @updateTime="updateTime" @selectPlaybackSpeed="showPlaybackSpeedModal = true" @hook:mounted="audioPlayerMounted" /> <audio-player-mini ref="audioPlayerMini" :loading="isLoading" @updateTime="updateTime" @selectPlaybackSpeed="showPlaybackSpeedModal = true" @hook:mounted="audioPlayerMounted" />
</div> </div>
<modals-playback-speed-modal v-model="showPlaybackSpeedModal" :playback-speed.sync="playbackSpeed" @change="changePlaybackSpeed" /> <modals-playback-speed-modal v-model="showPlaybackSpeedModal" :playback-speed.sync="playbackSpeed" @change="changePlaybackSpeed" />
<modals-chapters-modal v-model="showChapterModal" :chapters="chapters" @select="selectChapter" />
</div> </div>
</template> </template>
@@ -26,23 +30,45 @@ export default {
return { return {
audioPlayerReady: false, audioPlayerReady: false,
stream: null, stream: null,
lastServerUpdateSentSeconds: 0, download: null,
lastProgressTimeUpdate: 0,
showPlaybackSpeedModal: false, showPlaybackSpeedModal: false,
playbackSpeed: 1 playbackSpeed: 1,
showChapterModal: false
}
},
watch: {
socketConnected(newVal) {
if (newVal) {
console.log('Socket Connected set listeners')
this.setListeners()
}
} }
}, },
computed: { computed: {
socketConnected() {
return this.$store.state.socketConnected
},
isLoading() {
if (this.playingDownload) return false
if (!this.streamAudiobook) return false
return !this.stream || this.streamAudiobook.id !== this.stream.audiobook.id
},
playingDownload() {
return this.$store.state.playingDownload
},
audiobook() {
if (this.playingDownload) return this.playingDownload.audiobook
return this.streamAudiobook
},
audiobookId() {
return this.audiobook ? this.audiobook.id : null
},
streamAudiobook() { streamAudiobook() {
return this.$store.state.streamAudiobook return this.$store.state.streamAudiobook
}, },
streamAudiobookId() {
return this.streamAudiobook ? this.streamAudiobook.id : null
},
currStreamAudiobookId() {
return this.stream ? this.stream.audiobook.id : null
},
book() { book() {
return this.streamAudiobook ? this.streamAudiobook.book || {} : {} return this.audiobook ? this.audiobook.book || {} : {}
}, },
title() { title() {
return this.book ? this.book.title : '' return this.book ? this.book.title : ''
@@ -53,9 +79,15 @@ export default {
cover() { cover() {
return this.book ? this.book.cover : '' return this.book ? this.book.cover : ''
}, },
downloadedCover() {
return this.download ? this.download.cover : null
},
series() { series() {
return this.book ? this.book.series : '' return this.book ? this.book.series : ''
}, },
chapters() {
return this.audiobook ? this.audiobook.chapters || [] : []
},
volumeNumber() { volumeNumber() {
return this.book ? this.book.volumeNumber : '' return this.book ? this.book.volumeNumber : ''
}, },
@@ -65,7 +97,7 @@ export default {
return `${this.series} #${this.volumeNumber}` return `${this.series} #${this.volumeNumber}`
}, },
duration() { duration() {
return this.streamAudiobook ? this.streamAudiobook.duration || 0 : 0 return this.audiobook ? this.audiobook.duration || 0 : 0
}, },
coverForNative() { coverForNative() {
if (!this.cover) { if (!this.cover) {
@@ -81,34 +113,72 @@ export default {
} }
}, },
methods: { methods: {
clickChapterBtn() {
if (!this.chapters.length) return
this.showChapterModal = true
},
selectChapter(chapter) {
if (this.$refs.audioPlayerMini) {
this.$refs.audioPlayerMini.seek(chapter.start)
}
this.showChapterModal = false
},
async cancelStream() { async cancelStream() {
const { value } = await Dialog.confirm({ if (this.download) {
title: 'Confirm',
message: 'Cancel this stream?'
})
if (value) {
this.$server.socket.emit('close_stream')
this.$store.commit('setStreamAudiobook', null)
if (this.$refs.audioPlayerMini) { if (this.$refs.audioPlayerMini) {
this.$refs.audioPlayerMini.terminateStream() this.$refs.audioPlayerMini.terminateStream()
} }
this.download = null
this.$store.commit('setPlayingDownload', null)
this.$localStore.setCurrent(null)
} else {
const { value } = await Dialog.confirm({
title: 'Confirm',
message: 'Cancel this stream?'
})
if (value) {
this.$server.socket.emit('close_stream')
this.$store.commit('setStreamAudiobook', null)
if (this.$refs.audioPlayerMini) {
this.$refs.audioPlayerMini.terminateStream()
}
}
} }
}, },
updateTime(currentTime) { updateTime(currentTime) {
var diff = currentTime - this.lastServerUpdateSentSeconds var diff = currentTime - this.lastProgressTimeUpdate
if (diff > 4 || diff < 0) { if (diff > 4 || diff < 0) {
this.lastServerUpdateSentSeconds = currentTime this.lastProgressTimeUpdate = currentTime
var updatePayload = { if (this.stream) {
currentTime, var updatePayload = {
streamId: this.stream.id currentTime,
streamId: this.stream.id
}
this.$server.socket.emit('stream_update', updatePayload)
} else if (this.download) {
var progressUpdate = {
audiobookId: this.download.id,
currentTime: currentTime,
totalDuration: this.download.audiobook.duration,
progress: Number((currentTime / this.download.audiobook.duration).toFixed(3)),
lastUpdate: Date.now(),
isRead: false
}
if (this.$server.connected) {
this.$server.socket.emit('progress_update', progressUpdate)
}
this.$localStore.updateUserAudiobookProgress(progressUpdate).then(() => {
console.log('Updated user audiobook progress', currentTime)
})
} }
this.$server.socket.emit('stream_update', updatePayload)
} }
}, },
closeStream() {}, closeStream() {},
streamClosed(audiobookId) { streamClosed(audiobookId) {
console.log('Stream Closed') console.log('Stream Closed')
if (this.stream.audiobook.id === audiobookId || audiobookId === 'n/a') { if (this.stream.audiobook.id === audiobookId || audiobookId === 'n/a') {
this.$store.commit('setStreamAudiobook', null) this.$store.commit('setStreamAudiobook', null)
} }
@@ -133,8 +203,67 @@ export default {
} }
} }
}, },
async getDownloadStartTime() {
var userAudiobook = await this.$localStore.getMostRecentUserAudiobook(this.audiobookId)
if (!userAudiobook) {
console.log('[StreamContainer] getDownloadStartTime no user audiobook record found')
return 0
}
return userAudiobook.currentTime
},
async playDownload() {
if (this.stream) {
if (this.$refs.audioPlayerMini) {
this.$refs.audioPlayerMini.terminateStream()
}
this.stream = null
}
this.lastProgressTimeUpdate = 0
console.log('[StreamContainer] Playing local', this.playingDownload)
if (!this.$refs.audioPlayerMini) {
console.error('No Audio Player Mini')
return
}
var playOnLoad = this.$store.state.playOnLoad
if (playOnLoad) this.$store.commit('setPlayOnLoad', false)
var currentTime = await this.getDownloadStartTime()
if (isNaN(currentTime) || currentTime === null) currentTime = 0
// Update local current time
this.$localStore.setCurrent({
audiobookId: this.download.id,
lastUpdate: Date.now()
})
var audiobookStreamData = {
title: this.title,
author: this.author,
playWhenReady: !!playOnLoad,
startTime: String(Math.floor(currentTime * 1000)),
playbackSpeed: this.playbackSpeed || 1,
cover: this.download.coverUrl || null,
duration: String(Math.floor(this.duration * 1000)),
series: this.seriesTxt,
token: this.$store.getters['user/getToken'],
contentUrl: this.playingDownload.contentUrl,
isLocal: true
}
this.$refs.audioPlayerMini.set(audiobookStreamData)
},
streamOpen(stream) { streamOpen(stream) {
console.log('[StreamContainer] Stream Open', stream) if (this.download) {
if (this.$refs.audioPlayerMini) {
this.$refs.audioPlayerMini.terminateStream()
}
}
this.lastProgressTimeUpdate = 0
console.log('[StreamContainer] Stream Open: ' + this.title)
if (!this.$refs.audioPlayerMini) { if (!this.$refs.audioPlayerMini) {
console.error('No Audio Player Mini') console.error('No Audio Player Mini')
return return
@@ -142,6 +271,9 @@ export default {
this.stream = stream this.stream = stream
// Update local remove current
this.$localStore.setCurrent(null)
var playlistUrl = stream.clientPlaylistUri var playlistUrl = stream.clientPlaylistUri
var currentTime = stream.clientCurrentTime || 0 var currentTime = stream.clientCurrentTime || 0
var playOnLoad = this.$store.state.playOnLoad var playOnLoad = this.$store.state.playOnLoad
@@ -159,16 +291,44 @@ export default {
playlistUrl: this.$server.url + playlistUrl, playlistUrl: this.$server.url + playlistUrl,
token: this.$store.getters['user/getToken'] token: this.$store.getters['user/getToken']
} }
this.$refs.audioPlayerMini.set(audiobookStreamData) this.$refs.audioPlayerMini.set(audiobookStreamData)
}, },
audioPlayerMounted() { audioPlayerMounted() {
console.log('Audio Player Mounted', this.$server.stream) console.log('Audio Player Mounted', this.$server.stream)
this.audioPlayerReady = true this.audioPlayerReady = true
if (this.$server.stream) {
if (this.playingDownload) {
console.log('[StreamContainer] Play download on audio mount')
if (!this.download) {
this.download = { ...this.playingDownload }
}
this.playDownload()
} else if (this.$server.stream) {
console.log('[StreamContainer] Open stream on audio mount')
this.streamOpen(this.$server.stream) this.streamOpen(this.$server.stream)
} }
}, },
changePlaybackSpeed(speed) {
this.$store.dispatch('user/updateUserSettings', { playbackRate: speed })
},
settingsUpdated(settings) {
if (this.$refs.audioPlayerMini && this.$refs.audioPlayerMini.currentPlaybackRate !== settings.playbackRate) {
this.playbackSpeed = settings.playbackRate
this.$refs.audioPlayerMini.updatePlaybackRate()
}
},
streamUpdated(type, data) {
if (type === 'download') {
if (data) {
this.download = { ...data }
if (this.audioPlayerReady) {
this.playDownload()
}
} else if (this.download) {
this.cancelStream()
}
}
},
setListeners() { setListeners() {
if (!this.$server.socket) { if (!this.$server.socket) {
console.error('Invalid server socket not set') console.error('Invalid server socket not set')
@@ -179,15 +339,6 @@ export default {
this.$server.socket.on('stream_progress', this.streamProgress) this.$server.socket.on('stream_progress', this.streamProgress)
this.$server.socket.on('stream_ready', this.streamReady) this.$server.socket.on('stream_ready', this.streamReady)
this.$server.socket.on('stream_reset', this.streamReset) this.$server.socket.on('stream_reset', this.streamReset)
},
changePlaybackSpeed(speed) {
this.$store.dispatch('user/updateUserSettings', { playbackRate: speed })
},
settingsUpdated(settings) {
if (this.$refs.audioPlayerMini && this.$refs.audioPlayerMini.currentPlaybackRate !== settings.playbackRate) {
this.playbackSpeed = settings.playbackRate
this.$refs.audioPlayerMini.updatePlaybackRate()
}
} }
}, },
mounted() { mounted() {
@@ -195,15 +346,19 @@ export default {
this.setListeners() this.setListeners()
this.$store.commit('user/addSettingsListener', { id: 'streamContainer', meth: this.settingsUpdated }) this.$store.commit('user/addSettingsListener', { id: 'streamContainer', meth: this.settingsUpdated })
this.$store.commit('setStreamListener', this.streamUpdated)
}, },
beforeDestroy() { beforeDestroy() {
this.$server.socket.off('stream_open', this.streamOpen) if (this.$server.socket) {
this.$server.socket.off('stream_closed', this.streamClosed) this.$server.socket.off('stream_open', this.streamOpen)
this.$server.socket.off('stream_progress', this.streamProgress) this.$server.socket.off('stream_closed', this.streamClosed)
this.$server.socket.off('stream_ready', this.streamReady) this.$server.socket.off('stream_progress', this.streamProgress)
this.$server.socket.off('stream_reset', this.streamReset) this.$server.socket.off('stream_ready', this.streamReady)
this.$server.socket.off('stream_reset', this.streamReset)
}
this.$store.commit('user/removeSettingsListener', 'streamContainer') this.$store.commit('user/removeSettingsListener', 'streamContainer')
this.$store.commit('removeStreamListener')
} }
} }
</script> </script>
+23 -8
View File
@@ -11,7 +11,11 @@
<div class="rounded-sm h-full overflow-hidden relative box-shadow-book"> <div class="rounded-sm h-full overflow-hidden relative box-shadow-book">
<nuxt-link :to="`/audiobook/${audiobookId}`" class="cursor-pointer"> <nuxt-link :to="`/audiobook/${audiobookId}`" class="cursor-pointer">
<div class="w-full relative" :style="{ height: height + 'px' }"> <div class="w-full relative" :style="{ height: height + 'px' }">
<cards-book-cover :audiobook="audiobook" :author-override="authorFormat" :width="width" /> <cards-book-cover :audiobook="audiobook" :download-cover="downloadCover" :author-override="authorFormat" :width="width" />
<div v-if="download" class="absolute" :style="{ top: 0.5 * sizeMultiplier + 'rem', right: 0.5 * sizeMultiplier + 'rem' }">
<span class="material-icons text-success" :style="{ fontSize: 1.1 * sizeMultiplier + 'rem' }">download_done</span>
</div>
<div class="absolute bottom-0 left-0 h-1.5 bg-yellow-400 shadow-sm" :style="{ width: width * userProgressPercent + 'px' }"></div> <div class="absolute bottom-0 left-0 h-1.5 bg-yellow-400 shadow-sm" :style="{ width: width * userProgressPercent + 'px' }"></div>
@@ -35,6 +39,10 @@ export default {
type: Object, type: Object,
default: () => null default: () => null
}, },
localUserProgress: {
type: Object,
default: () => null
},
width: { width: {
type: Number, type: Number,
default: 140 default: 140
@@ -81,8 +89,13 @@ export default {
orderBy() { orderBy() {
return this.$store.getters['user/getUserSetting']('orderBy') return this.$store.getters['user/getUserSetting']('orderBy')
}, },
mostRecentUserProgress() {
if (!this.localUserProgress) return this.userProgress
if (!this.userProgress) return this.localUserProgress
return this.localUserProgress.lastUpdate > this.userProgress.lastUpdate ? this.localUserProgress : this.userProgress
},
userProgressPercent() { userProgressPercent() {
return this.userProgress ? this.userProgress.progress || 0 : 0 return this.mostRecentUserProgress ? this.mostRecentUserProgress.progress || 0 : 0
}, },
showError() { showError() {
return this.hasMissingParts || this.hasInvalidParts return this.hasMissingParts || this.hasInvalidParts
@@ -93,6 +106,13 @@ export default {
hasInvalidParts() { hasInvalidParts() {
return this.audiobook.hasInvalidParts return this.audiobook.hasInvalidParts
}, },
downloadCover() {
return this.download ? this.download.cover : null
},
download() {
return null
return this.$store.getters['downloads/getDownloadIfReady'](this.audiobookId)
},
errorText() { errorText() {
var txt = '' var txt = ''
if (this.hasMissingParts) { if (this.hasMissingParts) {
@@ -105,12 +125,7 @@ export default {
return txt || 'Unknown Error' return txt || 'Unknown Error'
} }
}, },
methods: { methods: {},
play() {
this.$store.commit('setStreamAudiobook', this.audiobook)
this.$root.socket.emit('open_stream', this.audiobookId)
}
},
mounted() {} mounted() {}
} }
</script> </script>
+22 -4
View File
@@ -1,8 +1,16 @@
<template> <template>
<div class="relative rounded-sm overflow-hidden" :style="{ height: width * 1.6 + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }"> <div class="relative rounded-sm overflow-hidden" :style="{ height: width * 1.6 + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }">
<div class="w-full h-full relative"> <div class="w-full h-full relative">
<div v-if="showCoverBg" class="bg-primary absolute top-0 left-0 w-full h-full"> <div class="bg-primary absolute top-0 left-0 w-full h-full">
<div class="w-full h-full z-0" ref="coverBg" /> <!-- Blurred background for covers that dont fill -->
<div v-if="showCoverBg" class="w-full h-full z-0" ref="coverBg" />
<!-- Image Loading indicator -->
<div v-if="!isImageLoaded" class="w-full h-full flex items-center justify-center text-white">
<svg class="animate-spin w-12 h-12" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>
</div>
</div> </div>
<img ref="cover" :src="fullCoverUrl" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-cover'" /> <img ref="cover" :src="fullCoverUrl" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-cover'" />
</div> </div>
@@ -32,6 +40,7 @@ export default {
type: Object, type: Object,
default: () => {} default: () => {}
}, },
downloadCover: String,
authorOverride: String, authorOverride: String,
width: { width: {
type: Number, type: Number,
@@ -41,7 +50,8 @@ export default {
data() { data() {
return { return {
imageFailed: false, imageFailed: false,
showCoverBg: false showCoverBg: false,
isImageLoaded: false
} }
}, },
watch: { watch: {
@@ -78,7 +88,13 @@ export default {
serverUrl() { serverUrl() {
return this.$store.state.serverUrl return this.$store.state.serverUrl
}, },
networkConnected() {
return this.$store.state.networkConnected
},
fullCoverUrl() { fullCoverUrl() {
if (this.downloadCover) return this.downloadCover
else if (!this.networkConnected) return this.placeholderUrl
if (this.cover.startsWith('http')) return this.cover if (this.cover.startsWith('http')) return this.cover
var _clean = this.cover.replace(/\\/g, '/') var _clean = this.cover.replace(/\\/g, '/')
if (_clean.startsWith('/local')) { if (_clean.startsWith('/local')) {
@@ -91,6 +107,7 @@ export default {
return this.book.cover || this.placeholderUrl return this.book.cover || this.placeholderUrl
}, },
hasCover() { hasCover() {
if (!this.networkConnected && !this.downloadCover) return false
return !!this.book.cover return !!this.book.cover
}, },
sizeMultiplier() { sizeMultiplier() {
@@ -134,10 +151,11 @@ export default {
this.showCoverBg = false this.showCoverBg = false
} }
} }
this.isImageLoaded = true
}, },
imageError(err) { imageError(err) {
console.error('ImgError', err)
this.imageFailed = true this.imageFailed = true
console.error('ImgError', err, `SET IMAGE FAILED ${this.imageFailed}`)
} }
} }
} }
+50
View File
@@ -0,0 +1,50 @@
<template>
<modals-modal v-model="show" :width="300" height="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 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="chapter in chapters">
<li :key="chapter.id" class="text-gray-50 select-none relative py-3 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption(chapter)">
<div class="flex items-center justify-center px-3">
<span class="font-normal block truncate text-lg">{{ chapter.title }}</span>
<div class="flex-grow" />
<span class="font-mono text-gray-300">{{ $secondsToTimestamp(chapter.start) }}</span>
</div>
</li>
</template>
</ul>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
chapters: {
type: Array,
default: () => []
}
},
data() {
return {}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
}
},
methods: {
clickedOption(chapter) {
this.$emit('select', chapter)
}
},
mounted() {}
}
</script>
+206
View File
@@ -0,0 +1,206 @@
<template>
<modals-modal v-model="show" width="100%" height="100%">
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
<p class="absolute top-6 left-2 text-2xl">Downloads</p>
<div class="absolute top-16 left-0 right-0 w-full flex items-center px-2 py-1" :class="hasStoragePermission ? '' : 'text-error'">
<span class="material-icons" @click="changeDownloadFolderClick">{{ hasStoragePermission ? 'folder' : 'error' }}</span>
<p v-if="hasStoragePermission" class="text-sm px-4" @click="changeDownloadFolderClick">{{ downloadFolderSimplePath || 'No Download Folder Selected' }}</p>
<p v-else class="text-sm px-4" @click="changeDownloadFolderClick">No Storage Permissions. Click here</p>
</div>
<div v-if="totalSize" class="absolute bottom-0 left-0 right-0 w-full py-3 text-center">
<p class="text-sm text-center text-gray-300">Total: {{ $bytesPretty(totalSize) }}</p>
</div>
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20 mt-10" style="max-height: 75%" @click.stop>
<div v-if="!totalDownloads" class="flex items-center justify-center h-40">
<p>No Downloads</p>
</div>
<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">
<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)">
<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" 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>
</li>
</template>
<template v-for="download in orphanDownloads">
<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 class="flex items-center justify-center">
<div class="w-3/4">
<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>
</modals-modal>
</template>
<script>
import { Dialog } from '@capacitor/dialog'
import AudioDownloader from '@/plugins/audio-downloader'
export default {
data() {
return {
downloadFolder: null,
downloadingProgress: {},
totalSize: 0
}
},
watch: {
async show(newValue) {
if (newValue) {
this.downloadFolder = await this.$localStore.getDownloadFolder()
this.setTotalSize()
}
}
},
computed: {
show: {
get() {
return this.$store.state.downloads.showModal
},
set(val) {
this.$store.commit('downloads/setShowModal', val)
}
},
hasStoragePermission() {
return this.$store.state.hasStoragePermission
},
downloadFolderSimplePath() {
return this.downloadFolder ? this.downloadFolder.simplePath : null
},
totalDownloads() {
return this.downloadsReady.length + this.orphanDownloads.length + this.downloadsDownloading.length
},
downloadsDownloading() {
return this.downloads.filter((d) => d.isDownloading || d.isPreparing)
},
downloadsReady() {
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() {
return this.$store.state.downloads.downloads
// return [
// {
// id: 'asdf1',
// 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: {
setTotalSize() {
var totalSize = 0
this.downloadsReady.forEach((dl) => {
totalSize += dl.size && !isNaN(dl.size) ? Number(dl.size) : 0
})
this.totalSize = totalSize
},
async changeDownloadFolderClick() {
if (!this.hasStoragePermission) {
console.log('Requesting Storage Permission')
AudioDownloader.requestStoragePermission()
} else {
var folderObj = await AudioDownloader.selectFolder()
if (folderObj.error) {
return this.$toast.error(`Error: ${folderObj.error || 'Unknown Error'}`)
}
await this.$localStore.setDownloadFolder(folderObj)
}
},
updateDownloadProgress({ audiobookId, progress }) {
this.$set(this.downloadingProgress, audiobookId, progress)
},
jumpToAudiobook(download) {
this.show = false
this.$router.push(`/audiobook/${download.id}`)
},
async clickDelete(download) {
const { value } = await Dialog.confirm({
title: 'Confirm',
message: 'Delete this download?'
})
if (value) {
this.$emit('deleteDownload', download)
}
},
clickedOption(download) {
console.log('Clicked download', download)
this.$emit('selectDownload', download)
this.show = false
}
},
mounted() {}
}
</script>
+18 -14
View File
@@ -31,13 +31,13 @@
</li> </li>
<li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="option"> <li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="option">
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<span class="font-normal block truncate py-3">No {{ sublist }}</span> <span class="font-normal block truncate py-5 text-lg">No {{ sublist }} items</span>
</div> </div>
</li> </li>
<template v-for="item in sublistItems"> <template v-for="item in sublistItems">
<li :key="item" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" :class="`${sublist}.${item}` === selected ? 'bg-bg bg-opacity-50' : ''" role="option" @click="clickedSublistOption(item)"> <li :key="item.value" class="text-gray-50 select-none relative px-4 cursor-pointer hover:bg-black-400" :class="`${sublist}.${item.value}` === selected ? 'bg-bg bg-opacity-50' : ''" role="option" @click="clickedSublistOption(item.value)">
<div class="flex items-center"> <div class="flex items-center">
<span class="font-normal truncate py-3 text-base">{{ snakeToNormal(item) }}</span> <span class="font-normal truncate py-3 text-base">{{ item.text }}</span>
</div> </div>
</li> </li>
</template> </template>
@@ -75,6 +75,11 @@ export default {
text: 'Series', text: 'Series',
value: 'series', value: 'series',
sublist: true sublist: true
},
{
text: 'Authors',
value: 'authors',
sublist: true
} }
] ]
} }
@@ -108,7 +113,7 @@ export default {
return this.selected && this.selected.includes('.') ? this.selected.split('.')[0] : false return this.selected && this.selected.includes('.') ? this.selected.split('.')[0] : false
}, },
genres() { genres() {
return this.$store.state.audiobooks.genres return this.$store.getters['audiobooks/getGenresUsed']
}, },
tags() { tags() {
return this.$store.state.audiobooks.tags return this.$store.state.audiobooks.tags
@@ -116,8 +121,16 @@ export default {
series() { series() {
return this.$store.state.audiobooks.series return this.$store.state.audiobooks.series
}, },
authors() {
return this.$store.getters['audiobooks/getUniqueAuthors']
},
sublistItems() { sublistItems() {
return this[this.sublist] || [] return (this[this.sublist] || []).map((item) => {
return {
text: item,
value: this.$encode(item)
}
})
} }
}, },
methods: { methods: {
@@ -126,15 +139,6 @@ export default {
this.show = false this.show = false
this.$nextTick(() => this.$emit('change', 'all')) this.$nextTick(() => this.$emit('change', 'all'))
}, },
snakeToNormal(kebab) {
if (!kebab) {
return 'err'
}
return String(kebab)
.split('_')
.map((t) => t.slice(0, 1).toUpperCase() + t.slice(1))
.join(' ')
},
clickedSublistOption(item) { clickedSublistOption(item) {
this.clickedOption({ value: `${this.sublist}.${item}` }) this.clickedOption({ value: `${this.sublist}.${item}` })
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
<template> <template>
<div ref="wrapper" class="modal modal-bg w-full h-full max-h-screen fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-30 opacity-0"> <div ref="wrapper" class="modal modal-bg w-full h-full max-h-screen fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-30 opacity-0">
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" /> <div class="absolute top-0 left-0 w-full h-36 bg-gradient-to-b from-black to-transparent opacity-70 pointer-events-none" />
<div class="absolute z-40 top-4 right-4 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="show = false"> <div class="absolute z-40 top-4 right-4 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="show = false">
<span class="material-icons text-4xl">close</span> <span class="material-icons text-4xl">close</span>
+4
View File
@@ -43,6 +43,10 @@ export default {
text: 'Added At', text: 'Added At',
value: 'addedAt' value: 'addedAt'
}, },
{
text: 'Volume #',
value: 'book.volumeNumber'
},
{ {
text: 'Duration', text: 'Duration',
value: 'duration' value: 'duration'
+100
View File
@@ -0,0 +1,100 @@
<template>
<modals-modal v-model="show" width="90%" height="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 overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20 p-8" style="max-height: 75%" @click.stop>
<ui-text-input ref="input" v-model="search" @input="updateSearch" placeholder="Search" class="w-full text-lg" />
<div v-show="isFetching" class="w-full py-8 flex justify-center">
<p class="text-lg text-gray-400">Fetching...</p>
</div>
<div v-if="!isFetching && lastSearch && !items.length" class="w-full py-8 flex justify-center">
<p class="text-lg text-gray-400">Nothing found</p>
</div>
<template v-for="item in items">
<div class="py-2 border-b border-bg flex" :key="item.id" @click="clickItem(item)">
<cards-book-cover :audiobook="item.data" :width="50" />
<div class="flex-grow px-4 h-full">
<div class="w-full h-full">
<p class="text-base truncate">{{ item.data.book.title }}</p>
<p class="text-sm text-gray-400 truncate">{{ item.data.book.author }}</p>
</div>
</div>
</div>
</template>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean
},
data() {
return {
search: null,
searchTimeout: null,
lastSearch: null,
isFetching: false,
items: []
}
},
watch: {
value(newVal) {
if (newVal) {
this.$nextTick(this.setFocus())
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
}
},
methods: {
clickItem(item) {
this.show = false
this.$router.push(`/audiobook/${item.id}`)
},
async runSearch(value) {
this.lastSearch = value
if (!this.lastSearch) {
this.items = []
return
}
this.isFetching = true
var results = await this.$axios.$get(`/api/audiobooks?q=${value}`).catch((error) => {
console.error('Search error', error)
return []
})
this.isFetching = false
this.items = results.map((res) => {
return {
id: res.id,
data: res,
type: 'audiobook'
}
})
},
updateSearch(val) {
clearTimeout(this.searchTimeout)
this.searchTimeout = setTimeout(() => {
this.runSearch(val)
}, 500)
},
setFocus() {
setTimeout(() => {
if (this.$refs.input) {
this.$refs.input.focus()
}
}, 100)
}
},
mounted() {}
}
</script>
+14 -2
View File
@@ -1,5 +1,5 @@
<template> <template>
<input v-model="input" :type="type" :disabled="disabled" autocorrect="off" autocapitalize="none" autocomplete="off" :placeholder="placeholder" class="px-2 py-1 bg-bg border border-gray-600 outline-none rounded-sm" :class="disabled ? 'text-gray-300' : 'text-white'" /> <input v-model="input" ref="input" autofocus :type="type" :disabled="disabled" autocorrect="off" autocapitalize="none" autocomplete="off" :placeholder="placeholder" class="px-2 py-1 bg-bg border border-gray-600 outline-none rounded-sm" :class="disabled ? 'text-gray-300' : 'text-white'" @keyup="keyup" />
</template> </template>
<script> <script>
@@ -23,7 +23,19 @@ export default {
} }
} }
}, },
methods: {}, methods: {
focus() {
if (this.$refs.input) {
this.$refs.input.focus()
this.$refs.input.click()
}
},
keyup() {
if (this.$refs.input) {
this.input = this.$refs.input.value
}
}
},
mounted() {} mounted() {}
} }
</script> </script>
+103
View File
@@ -0,0 +1,103 @@
<template>
<div>
<nuxt-link v-if="isConnected" to="/account" class="p-2 bg-white bg-opacity-10 border border-white border-opacity-40 rounded-full h-11 w-11 flex items-center justify-center">
<span class="material-icons">person</span>
</nuxt-link>
<div v-else-if="processing" class="relative p-2 bg-warning bg-opacity-10 border border-warning border-opacity-40 rounded-full h-11 w-11 flex items-center justify-center">
<div class="loader-dots block relative w-10 h-2.5">
<div class="absolute top-0 mt-0.5 w-1.5 h-1.5 rounded-full bg-warning"></div>
<div class="absolute top-0 mt-0.5 w-1.5 h-1.5 rounded-full bg-warning"></div>
<div class="absolute top-0 mt-0.5 w-1.5 h-1.5 rounded-full bg-warning"></div>
<div class="absolute top-0 mt-0.5 w-1.5 h-1.5 rounded-full bg-warning"></div>
</div>
</div>
<nuxt-link v-else to="/connect" class="relative p-2 bg-warning bg-opacity-10 border border-warning border-opacity-40 rounded-full h-11 w-11 flex items-center justify-center">
<span class="material-icons">{{ networkIcon }}</span>
<!-- <div class="absolute top-0 left-0"> -->
<!-- <div class="absolute -top-5 -right-5 overflow-hidden">
<svg class="w-20 h-20 animate-spin" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<path clip-rule="evenodd" d="M15.165 8.53a.5.5 0 01-.404.58A7 7 0 1023 16a.5.5 0 011 0 8 8 0 11-9.416-7.874.5.5 0 01.58.404z" fill="currentColor" fill-rule="evenodd" />
</svg>
</div> -->
</nuxt-link>
</div>
</template>
<script>
export default {
props: {},
data() {
return {
processing: false,
serverUrl: null,
isConnected: false
}
},
watch: {
networkConnected(newVal) {
if (newVal) {
this.init()
}
}
},
computed: {
user() {
return this.$store.state.user.user
},
networkIcon() {
if (!this.networkConnected) return 'signal_wifi_connected_no_internet_4'
return 'cloud_off'
},
networkConnected() {
return this.$store.state.networkConnected
}
},
methods: {
socketConnected(val) {
this.processing = false
this.isConnected = val
},
async init() {
if (this.isConnected) {
return
}
if (!this.$server) {
console.error('Invalid server not initialized')
return
}
if (!this.networkConnected) return
this.$server.on('connected', this.socketConnected)
var localServerUrl = await this.$localStore.getServerUrl()
var localUserToken = await this.$localStore.getToken()
if (localServerUrl) {
this.serverUrl = localServerUrl
// Server and Token are stored
if (localUserToken) {
this.processing = true
var success = await this.$server.connect(localServerUrl, localUserToken)
if (!success && !this.$server.url) {
this.processing = false
this.serverUrl = null
} else if (!success) {
this.processing = false
}
} else {
// Server only is stored
var success = await this.$server.check(this.serverUrl)
if (!success) {
console.error('Invalid server')
this.$server.setServerUrl(null)
}
}
}
}
},
mounted() {
this.init()
}
}
</script>
+322 -87
View File
@@ -1,24 +1,28 @@
<template> <template>
<div class="w-full min-h-screen h-full bg-bg text-white"> <div class="w-full min-h-screen h-full bg-bg text-white">
<app-appbar /> <app-appbar />
<div id="content" class="overflow-hidden" :class="streaming ? 'streaming' : ''"> <div id="content" class="overflow-hidden" :class="playerIsOpen ? 'playerOpen' : ''">
<Nuxt /> <Nuxt />
</div> </div>
<app-stream-container ref="streamContainer" /> <app-stream-container ref="streamContainer" />
<modals-downloads-modal ref="downloadsModal" @selectDownload="selectDownload" @deleteDownload="deleteDownload" />
</div> </div>
</template> </template>
<script> <script>
import Path from 'path'
import { Capacitor } from '@capacitor/core'
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'
export default { export default {
middleware: 'authenticated',
data() { data() {
return {} return {}
}, },
computed: { computed: {
streaming() { playerIsOpen() {
return this.$store.state.streamAudiobook return this.$store.getters['playerIsOpen']
}, },
routeName() { routeName() {
return this.$route.name return this.$route.name
@@ -30,11 +34,52 @@ export default {
if (isConnected) { if (isConnected) {
this.$router.push('/') this.$router.push('/')
} }
} else { }
if (!isConnected) { this.syncUserProgress()
this.$router.push('/connect') },
updateAudiobookProgressOnServer(audiobookProgress) {
if (this.$server.socket) {
this.$server.socket.emit('progress_update', audiobookProgress)
}
},
syncUserProgress() {
if (!this.$store.state.user.user) return
var userAudiobooks = this.$store.state.user.user.audiobooks
var localAudiobooks = this.$store.state.user.localUserAudiobooks
var localHasUpdates = false
var newestLocal = { ...localAudiobooks }
for (const audiobookId in userAudiobooks) {
if (localAudiobooks[audiobookId]) {
if (localAudiobooks[audiobookId].lastUpdate > userAudiobooks[audiobookId].lastUpdate) {
// Local progress is more recent than user progress
this.updateAudiobookProgressOnServer(localAudiobooks[audiobookId])
} else if (localAudiobooks[audiobookId].lastUpdate < userAudiobooks[audiobookId].lastUpdate) {
// Server is more recent than local
newestLocal[audiobookId] = userAudiobooks[audiobookId]
console.log('SYNCUSERPROGRESS Server IS MORE RECENT for', audiobookId)
localHasUpdates = true
}
} else {
// Not on local yet - store on local
newestLocal[audiobookId] = userAudiobooks[audiobookId]
console.log('SYNCUSERPROGRESS LOCAL Is NOT Stored YET for', audiobookId, JSON.stringify(newestLocal[audiobookId]))
localHasUpdates = true
} }
} }
for (const audiobookId in localAudiobooks) {
if (!userAudiobooks[audiobookId]) {
// Local progress is not on server
this.updateAudiobookProgressOnServer(localAudiobooks[audiobookId])
}
}
if (localHasUpdates) {
console.log('Local audiobook progress has updates from server')
this.$localStore.setAllAudiobookProgress(newestLocal)
}
}, },
initialStream(stream) { initialStream(stream) {
if (this.$refs.streamContainer && this.$refs.streamContainer.audioPlayerReady) { if (this.$refs.streamContainer && this.$refs.streamContainer.audioPlayerReady) {
@@ -49,16 +94,16 @@ export default {
await AppUpdate.openAppStore() await AppUpdate.openAppStore()
} }
}, },
showUpdateToast(availableVersion, immediateUpdateAllowed) { // showUpdateToast(availableVersion, immediateUpdateAllowed) {
var toastText = immediateUpdateAllowed ? `Click here to update` : `Click here to open app store` // var toastText = immediateUpdateAllowed ? `Click here to update` : `Click here to open app store`
this.$toast.info(`Update is available for v${availableVersion}! ${toastText}`, { // this.$toast.info(`Update is available for v${availableVersion}! ${toastText}`, {
draggable: false, // draggable: false,
hideProgressBar: false, // hideProgressBar: false,
timeout: 10000, // timeout: 10000,
closeButton: false, // closeButton: false,
onClick: this.clickUpdateToast() // onClick: this.clickUpdateToast()
}) // })
}, // },
async checkForUpdate() { async checkForUpdate() {
const result = await AppUpdate.getAppUpdateInfo() const result = await AppUpdate.getAppUpdateInfo()
if (!result) { if (!result) {
@@ -66,87 +111,277 @@ export default {
return return
} }
this.$store.commit('setAppUpdateInfo', result) this.$store.commit('setAppUpdateInfo', result)
if (result.updateAvailability === 2) { if (result.updateAvailability === 2) {
setTimeout(() => { setTimeout(() => {
this.showUpdateToast(result.availableVersion, !!result.immediateUpdateAllowed) this.$toast.info(`Update is available! Click to update.`, {
draggable: false,
hideProgressBar: false,
timeout: 20000,
closeButton: true,
onClick: this.clickUpdateToast
})
}, 5000) }, 5000)
} }
} },
// parseSemver(ver) { onDownloadProgress(data) {
// if (!ver) return null var progress = data.progress
// var groups = ver.match(/^v((([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)$/) var audiobookId = data.audiobookId
// if (groups && groups.length > 6) {
// var total = Number(groups[3]) * 100 + Number(groups[4]) * 10 + Number(groups[5])
// if (isNaN(total)) {
// console.warn('Invalid version total', groups[3], groups[4], groups[5])
// return null
// }
// return {
// total,
// version: groups[2],
// major: Number(groups[3]),
// minor: Number(groups[4]),
// patch: Number(groups[5]),
// preRelease: groups[6] || null
// }
// } else {
// console.warn('Invalid semver string', ver)
// }
// return null
// },
// checkForUpdateWebVersion() {
// if (!this.$config.version) {
// return
// }
// var currVerObj = this.parseSemver(this.$config.version)
// if (!currVerObj) {
// console.error('Invalid version', this.$config.version)
// return
// }
// console.log('Check for update, your version:', currVerObj.version)
// this.$store.commit('setCurrentVersion', currVerObj)
// var largestVer = null var downloadObj = this.$store.getters['downloads/getDownload'](audiobookId)
// this.$axios.$get(`https://api.github.com/repos/advplyr/audiobookshelf-app/tags`).then((tags) => { if (downloadObj) {
// if (tags && tags.length) { if (this.$refs.downloadsModal) {
// tags.forEach((tag) => { this.$refs.downloadsModal.updateDownloadProgress({ audiobookId, progress })
// var verObj = this.parseSemver(tag.name) }
// if (verObj) { this.$toast.update(downloadObj.toastId, { content: `${progress}% Downloading ${downloadObj.audiobook.book.title}` })
// if (!largestVer || largestVer.total < verObj.total) { }
// largestVer = verObj },
// } onDownloadFailed(data) {
// } if (!data.audiobookId) {
// }) console.error('Download failed invalid audiobook id', data)
// } return
// }) }
// if (!largestVer) { var downloadObj = this.$store.getters['downloads/getDownload'](data.audiobookId)
// console.error('No valid version tags to compare with') if (!downloadObj) {
// return console.error('Failed to find download for audiobook', data.audiobookId)
// } return
// this.$store.commit('setLatestVersion', largestVer) }
// if (largestVer.total > currVerObj.total) { var message = data.error || 'Unknown Error'
// console.log('Has Update!', largestVer.version) this.$toast.update(downloadObj.toastId, { content: `Failed. ${message}.`, options: { timeout: 5000, type: 'error' } }, true)
// this.$store.commit('setHasUpdate', true) this.$store.commit('downloads/removeDownload', downloadObj)
// } },
// } onDownloadComplete(data) {
if (!data.audiobookId) {
console.error('Download compelte invalid audiobook id', data)
return
}
var downloadId = data.downloadId
var contentUrl = data.contentUrl
var folderUrl = data.folderUrl
var folderName = data.folderName
var storageId = data.storageId
var storageType = data.storageType
var simplePath = data.simplePath
var filename = data.filename
var audiobookId = data.audiobookId
var size = data.size || 0
var isCover = !!data.isCover
console.log('Download complete', filename, downloadId, contentUrl, 'AudiobookId:', audiobookId, 'Is Cover:', isCover)
var downloadObj = this.$store.getters['downloads/getDownload'](audiobookId)
if (!downloadObj) {
console.error('Failed to find download for audiobook', audiobookId)
return
}
if (!isCover) {
// Notify server to remove prepared download
if (this.$server.socket) {
this.$server.socket.emit('remove_download', audiobookId)
}
this.$toast.update(downloadObj.toastId, { content: `Success! ${downloadObj.audiobook.book.title} downloaded.`, options: { timeout: 5000, type: 'success' } }, true)
delete downloadObj.isDownloading
delete downloadObj.isPreparing
downloadObj.contentUrl = contentUrl
downloadObj.simplePath = simplePath
downloadObj.folderUrl = folderUrl
downloadObj.folderName = folderName
downloadObj.storageType = storageType
downloadObj.storageId = storageId
downloadObj.basePath = data.basePath || null
downloadObj.size = size
this.$store.commit('downloads/addUpdateDownload', downloadObj)
} else {
downloadObj.coverUrl = contentUrl
downloadObj.cover = Capacitor.convertFileSrc(contentUrl)
downloadObj.coverSize = size
downloadObj.coverBasePath = data.basePath || null
console.log('Updating download with cover', downloadObj.cover)
this.$store.commit('downloads/addUpdateDownload', downloadObj)
}
},
async checkLoadCurrent() {
var currentObj = await this.$localStore.getCurrent()
if (!currentObj) return
console.log('Has Current playing', currentObj.audiobookId)
var download = this.$store.getters['downloads/getDownload'](currentObj.audiobookId)
if (download) {
this.$store.commit('setPlayingDownload', download)
} else {
console.warn('Download not available for previous current playing', currentObj.audiobookId)
this.$localStore.setCurrent(null)
}
},
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
}
})
console.log('onMediaLoaded Items', JSON.stringify(jsitems))
var downloads = await this.$sqlStore.getAllDownloads()
for (let i = 0; i < downloads.length; i++) {
var download = downloads[i]
var jsitem = jsitems.find((item) => item.contentUrl === download.contentUrl)
if (!jsitem) {
console.error('Removing download was not found', JSON.stringify(download))
await this.$sqlStore.removeDownload(download.id)
} else if (download.coverUrl && !jsitem.coverUrl) {
console.error('Removing cover for download was not found')
download.cover = null
download.coverUrl = null
download.size = jsitem.size || 0
this.$store.commit('downloads/addUpdateDownload', download)
this.$store.commit('audiobooks/addUpdate', download.audiobook)
} else {
download.size = jsitem.size || 0
this.$store.commit('downloads/addUpdateDownload', download)
this.$store.commit('audiobooks/addUpdate', download.audiobook)
}
}
this.checkLoadCurrent()
},
selectDownload(download) {
this.$store.commit('setPlayOnLoad', true)
this.$store.commit('setPlayingDownload', download)
},
async deleteDownload(download) {
console.log('Delete download', download.filename)
if (this.$store.state.playingDownload && this.$store.state.playingDownload.id === download.id) {
console.warn('Deleting download when currently playing download - terminate play')
if (this.$refs.streamContainer) {
this.$refs.streamContainer.cancelStream()
}
}
if (download.contentUrl) {
await AudioDownloader.delete(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() {
// Request and setup listeners for media files on native
console.log('Permissino SET LISTENER')
AudioDownloader.addListener('permission', (data) => {
this.onPermissionUpdate(data)
})
AudioDownloader.addListener('onDownloadComplete', (data) => {
this.onDownloadComplete(data)
})
AudioDownloader.addListener('onDownloadFailed', (data) => {
this.onDownloadFailed(data)
})
AudioDownloader.addListener('onMediaLoaded', (data) => {
this.onMediaLoaded(data.items)
})
AudioDownloader.addListener('onDownloadProgress', (data) => {
this.onDownloadProgress(data)
})
await this.$localStore.loadUserAudiobooks()
await this.$localStore.getDownloadFolder()
var userSavedSettings = await this.$localStore.getUserSettings()
if (userSavedSettings) {
this.$store.commit('user/setSettings', userSavedSettings)
}
var downloads = await this.$sqlStore.getAllDownloads()
if (downloads.length) {
var urls = downloads
.map((d) => {
return {
contentUrl: d.contentUrl,
coverUrl: d.coverUrl || '',
storageId: d.storageId,
basePath: d.basePath,
coverBasePath: d.coverBasePath || ''
}
})
.filter((d) => {
if (!d.contentUrl) {
console.error('Invalid Download no Content URL', JSON.stringify(d))
return false
}
return true
})
AudioDownloader.load({
audiobookUrls: urls
})
}
var checkPermission = await AudioDownloader.checkStoragePermission()
console.log('Storage Permission is', checkPermission.value)
if (!checkPermission.value) {
console.log('Will require permissions')
} else {
console.log('Has Storage Permission')
this.$store.commit('setHasStoragePermission', true)
}
},
async setupNetworkListener() {
var status = await Network.getStatus()
this.$store.commit('setNetworkStatus', status)
Network.addListener('networkStatusChange', (status) => {
console.log('Network status changed', status.connected, status.connectionType)
this.$store.commit('setNetworkStatus', status)
})
}
}, },
mounted() { mounted() {
if (!this.$server) return console.error('No Server') if (!this.$server) return console.error('No Server')
console.log('Default Mounted')
this.$server.on('connected', this.connected) this.$server.on('connected', this.connected)
this.$server.on('initialStream', this.initialStream) this.$server.on('initialStream', this.initialStream)
if (!this.$server.connected) { if (this.$store.state.isFirstLoad) {
this.$router.push('/connect') this.$store.commit('setIsFirstLoad', false)
this.setupNetworkListener()
this.checkForUpdate()
this.initMediaStore()
} }
},
this.checkForUpdate() beforeDestroy() {
if (!this.$server) {
// var checkForUpdateFlag = localStorage.getItem('checkForUpdate') console.error('No Server beforeDestroy')
// if (!checkForUpdateFlag || checkForUpdateFlag !== '1') { return
// this.checkForUpdate() }
// } console.log('Default Before Destroy remove listeners')
this.$server.off('connected', this.connected)
this.$server.off('initialStream', this.initialStream)
} }
} }
</script> </script>
@@ -155,7 +390,7 @@ export default {
#content { #content {
height: calc(100vh - 64px); height: calc(100vh - 64px);
} }
#content.streaming { #content.playerOpen {
height: calc(100vh - 204px); height: calc(100vh - 240px);
} }
</style> </style>
+3 -2
View File
@@ -26,8 +26,7 @@ export default {
], ],
link: [ link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }, { rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Fira+Mono&family=Ubuntu+Mono&family=Open+Sans:wght@400;600&family=Gentium+Book+Basic' }, { rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Ubuntu+Mono&family=Open+Sans:wght@400;600' },
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/icon?family=Material+Icons' }
] ]
}, },
@@ -37,9 +36,11 @@ export default {
plugins: [ plugins: [
'@/plugins/server.js', '@/plugins/server.js',
'@/plugins/store.js',
'@/plugins/init.client.js', '@/plugins/init.client.js',
'@/plugins/axios.js', '@/plugins/axios.js',
'@/plugins/my-native-audio.js', '@/plugins/my-native-audio.js',
'@/plugins/audio-downloader.js',
'@/plugins/toast.js' '@/plugins/toast.js'
], ],
+478 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-app", "name": "audiobookshelf-app",
"version": "v0.2.1-beta", "version": "v0.4.0-beta",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {
@@ -1000,6 +1000,11 @@
"to-fast-properties": "^2.0.0" "to-fast-properties": "^2.0.0"
} }
}, },
"@capacitor-community/sqlite": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@capacitor-community/sqlite/-/sqlite-3.2.0.tgz",
"integrity": "sha512-0tD+XKrtXS44DpeVLV0LR+UZafTBmLNHy3nQBr1lVMsefiunaU75q/BiYGhVdUIdp7x397y16s2RyTqroj5mbg=="
},
"@capacitor/android": { "@capacitor/android": {
"version": "3.2.2", "version": "3.2.2",
"resolved": "https://registry.npmjs.org/@capacitor/android/-/android-3.2.2.tgz", "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-3.2.2.tgz",
@@ -1061,11 +1066,62 @@
"resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-3.2.2.tgz", "resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-3.2.2.tgz",
"integrity": "sha512-Eq17Y+UDHFmYGaZcObvxHAcHw0fF9TCBAg1f5f6qdV8ab3cKKEUB9xMvoCSZAueBfxFARrD18TsZJKoxh2YsLA==" "integrity": "sha512-Eq17Y+UDHFmYGaZcObvxHAcHw0fF9TCBAg1f5f6qdV8ab3cKKEUB9xMvoCSZAueBfxFARrD18TsZJKoxh2YsLA=="
}, },
"@capacitor/network": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@capacitor/network/-/network-1.0.3.tgz",
"integrity": "sha512-DgRusTC0UkTJE9IQIAMgqBnRnTaj8nFeGH7dwRldfVBZAtHBTkU8wCK/tU1oWtaY2Wam+iyVKXUAhYDO7yeD9Q=="
},
"@capacitor/storage": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@capacitor/storage/-/storage-1.1.0.tgz",
"integrity": "sha512-Fi5R542sHWfkzBcYeRl4zde+4zPqUpLk7S0LzrkCwn9eAy90v8yY6Pa6dXfADXvkPKDdPckmyCQ7CmM+YImsrQ=="
},
"@csstools/convert-colors": { "@csstools/convert-colors": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/@csstools/convert-colors/-/convert-colors-1.4.0.tgz", "resolved": "https://registry.npmjs.org/@csstools/convert-colors/-/convert-colors-1.4.0.tgz",
"integrity": "sha512-5a6wqoJV/xEdbRNKVo6I4hO3VjyDq//8q2f9I6PBAvMesJHFauXDorcNCsr9RzvsZnaWi5NYCcfyqP1QeFHFbw==" "integrity": "sha512-5a6wqoJV/xEdbRNKVo6I4hO3VjyDq//8q2f9I6PBAvMesJHFauXDorcNCsr9RzvsZnaWi5NYCcfyqP1QeFHFbw=="
}, },
"@electron/get": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/@electron/get/-/get-1.13.0.tgz",
"integrity": "sha512-+SjZhRuRo+STTO1Fdhzqnv9D2ZhjxXP6egsJ9kiO8dtP68cDx7dFCwWi64dlMQV7sWcfW1OYCW4wviEBzmRsfQ==",
"requires": {
"debug": "^4.1.1",
"env-paths": "^2.2.0",
"fs-extra": "^8.1.0",
"global-agent": "^2.0.2",
"global-tunnel-ng": "^2.7.1",
"got": "^9.6.0",
"progress": "^2.0.3",
"semver": "^6.2.0",
"sumchecker": "^3.0.1"
},
"dependencies": {
"fs-extra": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
"requires": {
"graceful-fs": "^4.2.0",
"jsonfile": "^4.0.0",
"universalify": "^0.1.0"
}
},
"jsonfile": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
"integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=",
"requires": {
"graceful-fs": "^4.1.6"
}
},
"universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="
}
}
},
"@ionic/cli-framework-output": { "@ionic/cli-framework-output": {
"version": "2.2.2", "version": "2.2.2",
"resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.2.tgz", "resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.2.tgz",
@@ -2619,6 +2675,19 @@
"resolved": "https://registry.npmjs.org/@robingenz/capacitor-app-update/-/capacitor-app-update-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@robingenz/capacitor-app-update/-/capacitor-app-update-1.0.0.tgz",
"integrity": "sha512-pK8Yi7VgG/O/R4kJ3JtLpdeQgJzRIDPGM61bJhofTqu/+i26h8GhQdq4MB2OLJWk06Ht8MYDIIW/E0nxatrrnA==" "integrity": "sha512-pK8Yi7VgG/O/R4kJ3JtLpdeQgJzRIDPGM61bJhofTqu/+i26h8GhQdq4MB2OLJWk06Ht8MYDIIW/E0nxatrrnA=="
}, },
"@sindresorhus/is": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz",
"integrity": "sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ=="
},
"@szmarczak/http-timer": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz",
"integrity": "sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA==",
"requires": {
"defer-to-connect": "^1.0.1"
}
},
"@types/component-emitter": { "@types/component-emitter": {
"version": "1.2.10", "version": "1.2.10",
"resolved": "https://registry.npmjs.org/@types/component-emitter/-/component-emitter-1.2.10.tgz", "resolved": "https://registry.npmjs.org/@types/component-emitter/-/component-emitter-1.2.10.tgz",
@@ -3523,6 +3592,12 @@
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24=" "integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24="
}, },
"boolean": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.1.4.tgz",
"integrity": "sha512-3hx0kwU3uzG6ReQ3pnaFQPSktpBw6RHN3/ivDKEuU8g1XSfafowyvDnadjv1xp8IZqhtSukxlwv9bF6FhX8m0w==",
"optional": true
},
"boxen": { "boxen": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/boxen/-/boxen-5.0.1.tgz", "resolved": "https://registry.npmjs.org/boxen/-/boxen-5.0.1.tgz",
@@ -3875,6 +3950,40 @@
"schema-utils": "^2.0.0" "schema-utils": "^2.0.0"
} }
}, },
"cacheable-request": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz",
"integrity": "sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg==",
"requires": {
"clone-response": "^1.0.2",
"get-stream": "^5.1.0",
"http-cache-semantics": "^4.0.0",
"keyv": "^3.0.0",
"lowercase-keys": "^2.0.0",
"normalize-url": "^4.1.0",
"responselike": "^1.0.2"
},
"dependencies": {
"get-stream": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
"integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
"requires": {
"pump": "^3.0.0"
}
},
"lowercase-keys": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
"integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="
},
"normalize-url": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.1.tgz",
"integrity": "sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA=="
}
}
},
"call-bind": { "call-bind": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
@@ -3941,6 +4050,15 @@
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001249.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001249.tgz",
"integrity": "sha512-vcX4U8lwVXPdqzPWi6cAJ3FnQaqXbBqy/GZseKNQzRj37J7qZdGcBtxq/QLFNLLlfsoXLUdHw8Iwenri86Tagw==" "integrity": "sha512-vcX4U8lwVXPdqzPWi6cAJ3FnQaqXbBqy/GZseKNQzRj37J7qZdGcBtxq/QLFNLLlfsoXLUdHw8Iwenri86Tagw=="
}, },
"capacitor-data-storage-sqlite": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/capacitor-data-storage-sqlite/-/capacitor-data-storage-sqlite-3.2.0.tgz",
"integrity": "sha512-+8uyd9O+k/UleSGAKI7rNkF5AAAMeLzvzIqBle7sYjLRuhRiuCLapJ5b5GLAbzdtyUp72YoZX29XCKk3ywDAyg==",
"requires": {
"electron": "~13.1.4",
"localforage": "^1.9.0"
}
},
"chalk": { "chalk": {
"version": "2.4.2", "version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
@@ -4167,6 +4285,14 @@
} }
} }
}, },
"clone-response": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz",
"integrity": "sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws=",
"requires": {
"mimic-response": "^1.0.0"
}
},
"co": { "co": {
"version": "4.6.0", "version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@@ -4296,6 +4422,16 @@
"typedarray": "^0.0.6" "typedarray": "^0.0.6"
} }
}, },
"config-chain": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
"integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==",
"optional": true,
"requires": {
"ini": "^1.3.4",
"proto-list": "~1.2.1"
}
},
"connect": { "connect": {
"version": "3.7.0", "version": "3.7.0",
"resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz",
@@ -5016,6 +5152,14 @@
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
"integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=" "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU="
}, },
"decompress-response": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz",
"integrity": "sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M=",
"requires": {
"mimic-response": "^1.0.0"
}
},
"deep-equal": { "deep-equal": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz",
@@ -5027,6 +5171,11 @@
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz",
"integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==" "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg=="
}, },
"defer-to-connect": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-1.1.3.tgz",
"integrity": "sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ=="
},
"define-properties": { "define-properties": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz",
@@ -5118,6 +5267,12 @@
"resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-5.0.0.tgz", "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-5.0.0.tgz",
"integrity": "sha1-OHHMCmoALow+Wzz38zYmRnXwa50=" "integrity": "sha1-OHHMCmoALow+Wzz38zYmRnXwa50="
}, },
"detect-node": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
"optional": true
},
"detective": { "detective": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/detective/-/detective-5.2.0.tgz", "resolved": "https://registry.npmjs.org/detective/-/detective-5.2.0.tgz",
@@ -5256,6 +5411,11 @@
"resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==" "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="
}, },
"duplexer3": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz",
"integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI="
},
"duplexify": { "duplexify": {
"version": "3.7.1", "version": "3.7.1",
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz",
@@ -5272,6 +5432,23 @@
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0="
}, },
"electron": {
"version": "13.1.9",
"resolved": "https://registry.npmjs.org/electron/-/electron-13.1.9.tgz",
"integrity": "sha512-By4Zb72XNQLrPb70BXdIW3NtEHFwybP5DIQjohnCxOYONq5vojuHjNcTuWnBgMvwQ2qwykk6Tw5EwF2Pt0CWjA==",
"requires": {
"@electron/get": "^1.0.1",
"@types/node": "^14.6.2",
"extract-zip": "^1.0.3"
},
"dependencies": {
"@types/node": {
"version": "14.17.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.17.15.tgz",
"integrity": "sha512-D1sdW0EcSCmNdLKBGMYb38YsHUS6JcM7yQ6sLQ9KuZ35ck7LYCKE7kYFHOO59ayFOY3zobWVZxf4KXhYHcHYFA=="
}
}
},
"electron-to-chromium": { "electron-to-chromium": {
"version": "1.3.796", "version": "1.3.796",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.796.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.796.tgz",
@@ -5445,6 +5622,12 @@
"is-symbol": "^1.0.2" "is-symbol": "^1.0.2"
} }
}, },
"es6-error": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
"integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
"optional": true
},
"escalade": { "escalade": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
@@ -5726,6 +5909,32 @@
} }
} }
}, },
"extract-zip": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-1.7.0.tgz",
"integrity": "sha512-xoh5G1W/PB0/27lXgMQyIhP5DSY/LhoCsOyZgb+6iMmRtCwVBo55uKaMoEYrDCKQhWvqEip5ZPKAc6eFNyf/MA==",
"requires": {
"concat-stream": "^1.6.2",
"debug": "^2.6.9",
"mkdirp": "^0.5.4",
"yauzl": "^2.10.0"
},
"dependencies": {
"debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"requires": {
"ms": "2.0.0"
}
},
"ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
}
}
},
"fast-deep-equal": { "fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -6070,11 +6279,58 @@
"is-glob": "^4.0.1" "is-glob": "^4.0.1"
} }
}, },
"global-agent": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/global-agent/-/global-agent-2.2.0.tgz",
"integrity": "sha512-+20KpaW6DDLqhG7JDiJpD1JvNvb8ts+TNl7BPOYcURqCrXqnN1Vf+XVOrkKJAFPqfX+oEhsdzOj1hLWkBTdNJg==",
"optional": true,
"requires": {
"boolean": "^3.0.1",
"core-js": "^3.6.5",
"es6-error": "^4.1.1",
"matcher": "^3.0.0",
"roarr": "^2.15.3",
"semver": "^7.3.2",
"serialize-error": "^7.0.1"
},
"dependencies": {
"semver": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz",
"integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==",
"optional": true,
"requires": {
"lru-cache": "^6.0.0"
}
}
}
},
"global-tunnel-ng": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/global-tunnel-ng/-/global-tunnel-ng-2.7.1.tgz",
"integrity": "sha512-4s+DyciWBV0eK148wqXxcmVAbFVPqtc3sEtUE/GTQfuU80rySLcMhUmHKSHI7/LDj8q0gDYI1lIhRRB7ieRAqg==",
"optional": true,
"requires": {
"encodeurl": "^1.0.2",
"lodash": "^4.17.10",
"npm-conf": "^1.1.3",
"tunnel": "^0.0.6"
}
},
"globals": { "globals": {
"version": "11.12.0", "version": "11.12.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
"integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==" "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="
}, },
"globalthis": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.2.tgz",
"integrity": "sha512-ZQnSFO1la8P7auIOQECnm0sSuoMeaSq0EEdXMBFF2QJO4uNcwbyhSgG3MruWNbFTqCLmxVwGOl7LZ9kASvHdeQ==",
"optional": true,
"requires": {
"define-properties": "^1.1.3"
}
},
"globby": { "globby": {
"version": "11.0.4", "version": "11.0.4",
"resolved": "https://registry.npmjs.org/globby/-/globby-11.0.4.tgz", "resolved": "https://registry.npmjs.org/globby/-/globby-11.0.4.tgz",
@@ -6088,6 +6344,34 @@
"slash": "^3.0.0" "slash": "^3.0.0"
} }
}, },
"got": {
"version": "9.6.0",
"resolved": "https://registry.npmjs.org/got/-/got-9.6.0.tgz",
"integrity": "sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q==",
"requires": {
"@sindresorhus/is": "^0.14.0",
"@szmarczak/http-timer": "^1.1.2",
"cacheable-request": "^6.0.0",
"decompress-response": "^3.3.0",
"duplexer3": "^0.1.4",
"get-stream": "^4.1.0",
"lowercase-keys": "^1.0.1",
"mimic-response": "^1.0.1",
"p-cancelable": "^1.0.0",
"to-readable-stream": "^1.0.0",
"url-parse-lax": "^3.0.0"
},
"dependencies": {
"get-stream": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz",
"integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==",
"requires": {
"pump": "^3.0.0"
}
}
}
},
"graceful-fs": { "graceful-fs": {
"version": "4.2.6", "version": "4.2.6",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.6.tgz", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.6.tgz",
@@ -6511,6 +6795,11 @@
"http-errors": "~1.7.2" "http-errors": "~1.7.2"
} }
}, },
"http-cache-semantics": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz",
"integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ=="
},
"http-errors": { "http-errors": {
"version": "1.7.3", "version": "1.7.3",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz",
@@ -6611,6 +6900,11 @@
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz",
"integrity": "sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==" "integrity": "sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw=="
}, },
"immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha1-nbHb0Pr43m++D13V5Wu2BigN5ps="
},
"import-cwd": { "import-cwd": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-2.1.0.tgz", "resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-2.1.0.tgz",
@@ -7100,6 +7394,11 @@
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",
"integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==" "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA=="
}, },
"json-buffer": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz",
"integrity": "sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg="
},
"json-parse-better-errors": { "json-parse-better-errors": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz",
@@ -7116,6 +7415,12 @@
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="
}, },
"json-stringify-safe": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
"integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=",
"optional": true
},
"json5": { "json5": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.0.tgz", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.0.tgz",
@@ -7142,6 +7447,14 @@
"tsscmp": "1.0.6" "tsscmp": "1.0.6"
} }
}, },
"keyv": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz",
"integrity": "sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA==",
"requires": {
"json-buffer": "3.0.0"
}
},
"kind-of": { "kind-of": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
@@ -7297,6 +7610,14 @@
"launch-editor": "^2.2.1" "launch-editor": "^2.2.1"
} }
}, },
"lie": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz",
"integrity": "sha1-mkNrLMd0bKWd56QfpGmz77dr2H4=",
"requires": {
"immediate": "~3.0.5"
}
},
"lilconfig": { "lilconfig": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.3.tgz", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.3.tgz",
@@ -7334,6 +7655,14 @@
} }
} }
}, },
"localforage": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz",
"integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==",
"requires": {
"lie": "3.1.1"
}
},
"locate-path": { "locate-path": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
@@ -7409,6 +7738,11 @@
"tslib": "^2.0.3" "tslib": "^2.0.3"
} }
}, },
"lowercase-keys": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz",
"integrity": "sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA=="
},
"lru-cache": { "lru-cache": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@@ -7446,6 +7780,23 @@
"object-visit": "^1.0.0" "object-visit": "^1.0.0"
} }
}, },
"matcher": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
"integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
"optional": true,
"requires": {
"escape-string-regexp": "^4.0.0"
},
"dependencies": {
"escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"optional": true
}
}
},
"md5.js": { "md5.js": {
"version": "1.3.5", "version": "1.3.5",
"resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
@@ -7572,6 +7923,11 @@
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-3.1.0.tgz", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-3.1.0.tgz",
"integrity": "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==" "integrity": "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ=="
}, },
"mimic-response": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
"integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="
},
"minimalistic-assert": { "minimalistic-assert": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
@@ -7949,6 +8305,24 @@
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-3.3.0.tgz", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-3.3.0.tgz",
"integrity": "sha512-U+JJi7duF1o+u2pynbp2zXDW2/PADgC30f0GsHZtRh+HOcXHnw137TrNlyxxRvWW5fjKd3bcLHPxofWuCjaeZg==" "integrity": "sha512-U+JJi7duF1o+u2pynbp2zXDW2/PADgC30f0GsHZtRh+HOcXHnw137TrNlyxxRvWW5fjKd3bcLHPxofWuCjaeZg=="
}, },
"npm-conf": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/npm-conf/-/npm-conf-1.1.3.tgz",
"integrity": "sha512-Yic4bZHJOt9RCFbRP3GgpqhScOY4HH3V2P8yBj6CeYq118Qr+BLXqT2JvpJ00mryLESpgOxf5XlFv4ZjXxLScw==",
"optional": true,
"requires": {
"config-chain": "^1.1.11",
"pify": "^3.0.0"
},
"dependencies": {
"pify": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz",
"integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=",
"optional": true
}
}
},
"npm-run-path": { "npm-run-path": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
@@ -8173,6 +8547,11 @@
"resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
"integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=" "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ="
}, },
"p-cancelable": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-1.1.0.tgz",
"integrity": "sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw=="
},
"p-defer": { "p-defer": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/p-defer/-/p-defer-1.0.0.tgz", "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-1.0.0.tgz",
@@ -10998,6 +11377,11 @@
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
}, },
"progress": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="
},
"promise-inflight": { "promise-inflight": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz",
@@ -11029,6 +11413,12 @@
"signal-exit": "^3.0.2" "signal-exit": "^3.0.2"
} }
}, },
"proto-list": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
"integrity": "sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk=",
"optional": true
},
"protocols": { "protocols": {
"version": "1.4.8", "version": "1.4.8",
"resolved": "https://registry.npmjs.org/protocols/-/protocols-1.4.8.tgz", "resolved": "https://registry.npmjs.org/protocols/-/protocols-1.4.8.tgz",
@@ -11550,6 +11940,14 @@
"resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz",
"integrity": "sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=" "integrity": "sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo="
}, },
"responselike": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz",
"integrity": "sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec=",
"requires": {
"lowercase-keys": "^1.0.0"
}
},
"restore-cursor": { "restore-cursor": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz",
@@ -11601,6 +11999,28 @@
"inherits": "^2.0.1" "inherits": "^2.0.1"
} }
}, },
"roarr": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
"integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==",
"optional": true,
"requires": {
"boolean": "^3.0.1",
"detect-node": "^2.0.4",
"globalthis": "^1.0.1",
"json-stringify-safe": "^5.0.1",
"semver-compare": "^1.0.0",
"sprintf-js": "^1.1.2"
},
"dependencies": {
"sprintf-js": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz",
"integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==",
"optional": true
}
}
},
"run-async": { "run-async": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz",
@@ -11680,6 +12100,12 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
"integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw=="
}, },
"semver-compare": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
"integrity": "sha1-De4hahyUGrN+nvsXiPavxf9VN/w=",
"optional": true
},
"send": { "send": {
"version": "0.17.1", "version": "0.17.1",
"resolved": "https://registry.npmjs.org/send/-/send-0.17.1.tgz", "resolved": "https://registry.npmjs.org/send/-/send-0.17.1.tgz",
@@ -11727,6 +12153,23 @@
} }
} }
}, },
"serialize-error": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
"integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
"optional": true,
"requires": {
"type-fest": "^0.13.1"
},
"dependencies": {
"type-fest": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
"integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
"optional": true
}
}
},
"serialize-javascript": { "serialize-javascript": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-5.0.1.tgz", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-5.0.1.tgz",
@@ -12361,6 +12804,14 @@
} }
} }
}, },
"sumchecker": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
"integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==",
"requires": {
"debug": "^4.1.0"
}
},
"supports-color": { "supports-color": {
"version": "5.5.0", "version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
@@ -12847,6 +13298,11 @@
} }
} }
}, },
"to-readable-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-1.0.0.tgz",
"integrity": "sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q=="
},
"to-regex": { "to-regex": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/to-regex/-/to-regex-3.0.2.tgz", "resolved": "https://registry.npmjs.org/to-regex/-/to-regex-3.0.2.tgz",
@@ -12902,6 +13358,12 @@
"resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz", "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz",
"integrity": "sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY=" "integrity": "sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY="
}, },
"tunnel": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz",
"integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==",
"optional": true
},
"type-fest": { "type-fest": {
"version": "0.21.3", "version": "0.21.3",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz",
@@ -13141,6 +13603,21 @@
} }
} }
}, },
"url-parse-lax": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz",
"integrity": "sha1-FrXK/Afb42dsGxmZF3gj1lA6yww=",
"requires": {
"prepend-http": "^2.0.0"
},
"dependencies": {
"prepend-http": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz",
"integrity": "sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc="
}
}
},
"use": { "use": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",
+6 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-app", "name": "audiobookshelf-app",
"version": "v0.2.1-beta", "version": "v0.9.0-beta",
"author": "advplyr", "author": "advplyr",
"scripts": { "scripts": {
"dev": "nuxt --hostname localhost --port 1337", "dev": "nuxt --hostname localhost --port 1337",
@@ -10,14 +10,18 @@
"icons-android": "cordova-res android --skip-config --copy" "icons-android": "cordova-res android --skip-config --copy"
}, },
"dependencies": { "dependencies": {
"@capacitor-community/sqlite": "^3.2.0",
"@capacitor/android": "^3.2.2", "@capacitor/android": "^3.2.2",
"@capacitor/cli": "^3.1.2", "@capacitor/cli": "^3.1.2",
"@capacitor/core": "^3.2.2", "@capacitor/core": "^3.2.2",
"@capacitor/dialog": "^1.0.3", "@capacitor/dialog": "^1.0.3",
"@capacitor/ios": "^3.2.2", "@capacitor/ios": "^3.2.2",
"@capacitor/network": "^1.0.3",
"@capacitor/storage": "^1.1.0",
"@nuxtjs/axios": "^5.13.6", "@nuxtjs/axios": "^5.13.6",
"@robingenz/capacitor-app-update": "^1.0.0", "@robingenz/capacitor-app-update": "^1.0.0",
"axios": "^0.21.1", "axios": "^0.21.1",
"capacitor-data-storage-sqlite": "^3.2.0",
"core-js": "^3.15.1", "core-js": "^3.15.1",
"hls.js": "^1.0.9", "hls.js": "^1.0.9",
"nuxt": "^2.15.7", "nuxt": "^2.15.7",
@@ -30,4 +34,4 @@
"@nuxtjs/tailwindcss": "^4.2.0", "@nuxtjs/tailwindcss": "^4.2.0",
"postcss": "^8.3.5" "postcss": "^8.3.5"
} }
} }
+11 -3
View File
@@ -22,7 +22,9 @@
<p class="font-mono pt-1 pb-4">{{ $config.version }}</p> <p class="font-mono pt-1 pb-4">{{ $config.version }}</p>
<ui-btn v-if="isUpdateAvailable" class="w-full my-4" color="success" @click="clickUpdate"> Version {{ availableVersion }} is available! {{ immediateUpdateAllowed ? 'Update now' : 'Get update from app store' }} </ui-btn> <ui-btn v-if="isUpdateAvailable" class="w-full my-4" color="success" @click="clickUpdate">Update is available</ui-btn>
<ui-btn v-if="!isUpdateAvailable || immediateUpdateAllowed" class="w-full my-4" color="primary" @click="openAppStore">Open app store</ui-btn>
</div> </div>
</template> </template>
@@ -66,12 +68,18 @@ export default {
}) })
this.$server.logout() this.$server.logout()
this.$router.push('/connect') this.$router.push('/connect')
this.$store.commit('audiobooks/reset')
this.$store.dispatch('audiobooks/useDownloaded')
},
openAppStore() {
AppUpdate.openAppStore()
}, },
async clickUpdate() { async clickUpdate() {
if (this.immediateUpdateAllowed) { if (this.immediateUpdateAllowed) {
await AppUpdate.performImmediateUpdate() AppUpdate.performImmediateUpdate()
} else { } else {
await AppUpdate.openAppStore() AppUpdate.openAppStore()
} }
} }
}, },
+254 -37
View File
@@ -3,17 +3,19 @@
<div class="flex"> <div class="flex">
<div class="w-32"> <div class="w-32">
<div class="relative"> <div class="relative">
<cards-book-cover :audiobook="audiobook" :width="128" /> <cards-book-cover :audiobook="audiobook" :download-cover="downloadedCover" :width="128" />
<div class="absolute bottom-0 left-0 h-1.5 bg-yellow-400 shadow-sm" :style="{ width: 128 * progressPercent + 'px' }"></div> <div class="absolute bottom-0 left-0 h-1.5 bg-yellow-400 shadow-sm" :style="{ width: 128 * progressPercent + 'px' }"></div>
</div> </div>
<!-- <cards-book-cover :audiobook="audiobook" :width="128" /> --> <div class="flex my-4">
<p class="text-sm">{{ numTracks }} Tracks</p>
</div>
</div> </div>
<div class="flex-grow px-3"> <div class="flex-grow px-3">
<h1 class="text-lg">{{ title }}</h1> <h1 class="text-lg">{{ title }}</h1>
<h3 v-if="series" class="font-book text-gray-300 text-lg leading-7">{{ seriesText }}</h3> <h3 v-if="series" class="font-book text-gray-300 text-lg leading-7">{{ seriesText }}</h3>
<p class="text-sm text-gray-400">by {{ author }}</p> <p class="text-sm text-gray-400">by {{ author }}</p>
<p class="text-gray-300 text-sm my-1"> <p class="text-gray-300 text-sm my-1">
{{ durationPretty }}<span class="px-4">{{ sizePretty }}</span> {{ $elapsedPretty(duration) }}<span class="px-4">{{ $bytesPretty(size) }}</span>
</p> </p>
<div v-if="progressPercent > 0" class="px-4 py-2 bg-primary text-sm font-semibold rounded-md text-gray-200 mt-4 relative" :class="resettingProgress ? 'opacity-25' : ''"> <div v-if="progressPercent > 0" class="px-4 py-2 bg-primary text-sm font-semibold rounded-md text-gray-200 mt-4 relative" :class="resettingProgress ? 'opacity-25' : ''">
@@ -24,11 +26,15 @@
</div> </div>
</div> </div>
<ui-btn color="success" :disabled="streaming" class="flex items-center justify-center w-full mt-4" :padding-x="4" @click="playClick"> <div v-if="isConnected || isDownloadPlayable" class="flex mt-4">
<span v-show="!streaming" class="material-icons">play_arrow</span> <ui-btn color="success" :disabled="isPlaying" class="flex items-center justify-center w-full mr-2" :padding-x="4" @click="playClick">
<span class="px-1">{{ streaming ? 'Streaming' : 'Play' }}</span> <span v-show="!isPlaying" class="material-icons">play_arrow</span>
</ui-btn> <span class="px-1">{{ isPlaying ? (isStreaming ? 'Streaming' : 'Playing') : isDownloadPlayable ? 'Play local' : 'Play stream' }}</span>
<div class="flex my-4"></div> </ui-btn>
<ui-btn v-if="isConnected" color="primary" :disabled="isPlaying" class="flex items-center justify-center" :padding-x="2" @click="downloadClick">
<span class="material-icons" :class="isDownloaded ? 'animate-pulse' : ''">{{ downloadObj ? (isDownloading || isDownloadPreparing ? 'downloading' : 'download_done') : 'download' }}</span>
</ui-btn>
</div>
</div> </div>
</div> </div>
<div class="w-full py-4"> <div class="w-full py-4">
@@ -38,15 +44,25 @@
</template> </template>
<script> <script>
import Path from 'path'
import { Dialog } from '@capacitor/dialog' import { Dialog } from '@capacitor/dialog'
import { Capacitor } from '@capacitor/core'
import AudioDownloader from '@/plugins/audio-downloader'
export default { export default {
async asyncData({ params, redirect, app }) { async asyncData({ store, params, redirect, app }) {
var audiobookId = params.id var audiobookId = params.id
var audiobook = await app.$axios.$get(`/api/audiobook/${audiobookId}`).catch((error) => { var audiobook = null
console.error('Failed', error)
return false if (app.$server.connected) {
}) audiobook = await app.$axios.$get(`/api/audiobook/${audiobookId}`).catch((error) => {
console.error('Failed', error)
return false
})
} else {
audiobook = store.getters['audiobooks/getAudiobook'](audiobookId)
}
if (!audiobook) { if (!audiobook) {
console.error('No audiobook...', params.id) console.error('No audiobook...', params.id)
return redirect('/') return redirect('/')
@@ -61,6 +77,9 @@ export default {
} }
}, },
computed: { computed: {
isConnected() {
return this.$store.state.socketConnected
},
audiobookId() { audiobookId() {
return this.audiobook.id return this.audiobook.id
}, },
@@ -87,14 +106,11 @@ export default {
if (!this.volumeNumber) return this.series if (!this.volumeNumber) return this.series
return `${this.series} #${this.volumeNumber}` return `${this.series} #${this.volumeNumber}`
}, },
durationPretty() {
return this.audiobook.durationPretty
},
duration() { duration() {
return this.audiobook.duration return this.audiobook.duration
}, },
sizePretty() { size() {
return this.audiobook.sizePretty return this.audiobook.size
}, },
userAudiobooks() { userAudiobooks() {
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {} return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
@@ -102,27 +118,68 @@ export default {
userAudiobook() { userAudiobook() {
return this.userAudiobooks[this.audiobookId] || null return this.userAudiobooks[this.audiobookId] || null
}, },
localUserAudiobooks() {
return this.$store.state.user.localUserAudiobooks || {}
},
localUserAudiobook() {
return this.localUserAudiobooks[this.audiobookId] || null
},
mostRecentUserAudiobook() {
if (!this.localUserAudiobook) return this.userAudiobook
if (!this.userAudiobook) return this.localUserAudiobook
return this.localUserAudiobook.lastUpdate > this.userAudiobook.lastUpdate ? this.localUserAudiobook : this.userAudiobook
},
userCurrentTime() { userCurrentTime() {
return this.userAudiobook ? this.userAudiobook.currentTime : 0 return this.mostRecentUserAudiobook ? this.mostRecentUserAudiobook.currentTime : 0
}, },
userTimeRemaining() { userTimeRemaining() {
return this.duration - this.userCurrentTime return this.duration - this.userCurrentTime
}, },
progressPercent() { progressPercent() {
return this.userAudiobook ? this.userAudiobook.progress : 0 return this.mostRecentUserAudiobook ? this.mostRecentUserAudiobook.progress : 0
},
streamAudiobook() {
return this.$store.state.streamAudiobook
}, },
isStreaming() { isStreaming() {
return this.streamAudiobook && this.streamAudiobook.id === this.audiobookId return this.$store.getters['isAudiobookStreaming'](this.audiobookId)
},
isPlaying() {
return this.$store.getters['isAudiobookPlaying'](this.audiobookId)
},
numTracks() {
if (this.audiobook.tracks) return this.audiobook.tracks.length
return this.audiobook.numTracks || 0
},
isDownloading() {
return this.downloadObj ? this.downloadObj.isDownloading : false
},
isDownloadPreparing() {
return this.downloadObj ? this.downloadObj.isPreparing : false
},
isDownloadPlayable() {
return this.downloadObj && !this.isDownloading && !this.isDownloadPreparing
},
downloadedCover() {
return this.downloadObj ? this.downloadObj.cover : null
},
downloadObj() {
return this.$store.getters['downloads/getDownload'](this.audiobookId)
},
hasStoragePermission() {
return this.$store.state.hasStoragePermission
} }
}, },
methods: { methods: {
playClick() { playClick() {
this.$store.commit('setPlayOnLoad', true) this.$store.commit('setPlayOnLoad', true)
this.$store.commit('setStreamAudiobook', this.audiobook) if (!this.isDownloadPlayable) {
this.$server.socket.emit('open_stream', this.audiobook.id) // Stream
console.log('[PLAYCLICK] Set Playing STREAM ' + this.title)
this.$store.commit('setStreamAudiobook', this.audiobook)
this.$server.socket.emit('open_stream', this.audiobook.id)
} else {
// Local
console.log('[PLAYCLICK] Set Playing Local Download ' + this.title)
this.$store.commit('setPlayingDownload', this.downloadObj)
}
}, },
async clearProgressClick() { async clearProgressClick() {
const { value } = await Dialog.confirm({ const { value } = await Dialog.confirm({
@@ -132,17 +189,26 @@ export default {
if (value) { if (value) {
this.resettingProgress = true this.resettingProgress = true
this.$axios if (this.$server.connected) {
.$delete(`/api/user/audiobook/${this.audiobookId}`) await this.$axios
.then(() => { .$delete(`/api/user/audiobook/${this.audiobookId}`)
console.log('Progress reset complete') .then(() => {
this.$toast.success(`Your progress was reset`) console.log('Progress reset complete')
this.resettingProgress = false this.$toast.success(`Your progress was reset`)
}) })
.catch((error) => { .catch((error) => {
console.error('Progress reset failed', error) console.error('Progress reset failed', error)
this.resettingProgress = false })
}) }
this.$localStore.updateUserAudiobookProgress({
audiobookId: this.audiobookId,
currentTime: 0,
totalDuration: this.duration,
progress: 0,
lastUpdate: Date.now(),
isRead: false
})
this.resettingProgress = false
} }
}, },
audiobookUpdated() { audiobookUpdated() {
@@ -155,12 +221,163 @@ export default {
.catch((error) => { .catch((error) => {
console.error('Failed', error) console.error('Failed', error)
}) })
},
downloadClick() {
if (!this.$server.connected) return
if (this.downloadObj) {
console.log('Already downloaded', this.downloadObj)
} else {
this.prepareDownload()
}
},
async prepareDownload() {
var audiobook = this.audiobook
if (!audiobook) {
return
}
if (!this.hasStoragePermission) {
await AudioDownloader.requestStoragePermission()
return
}
// Download Path
var dlFolder = this.$localStore.downloadFolder
if (!dlFolder) {
console.log('No download folder, request from ujser')
var folderObj = await AudioDownloader.selectFolder()
if (folderObj.error) {
return this.$toast.error(`Error: ${folderObj.error || 'Unknown Error'}`)
}
dlFolder = folderObj
await this.$localStore.setDownloadFolder(folderObj)
}
var downloadObject = {
id: this.audiobookId,
downloadFolderUrl: dlFolder.uri,
audiobook: {
...audiobook
},
isPreparing: true,
isDownloading: false,
toastId: this.$toast(`Preparing download for "${this.title}"`, { timeout: false })
}
if (audiobook.tracks.length === 1) {
// Single track should not need preparation
console.log('Single track, start download no prep needed')
var track = audiobook.tracks[0]
var fileext = track.ext
var url = `${this.$store.state.serverUrl}/local/${track.path}`
this.startDownload(url, fileext, downloadObject)
} else {
// Multi-track merge
this.$store.commit('downloads/addUpdateDownload', downloadObject)
var prepareDownloadPayload = {
audiobookId: this.audiobookId,
audioFileType: 'same',
type: 'singleAudio'
}
this.$server.socket.emit('download', prepareDownloadPayload)
}
},
getCoverUrlForDownload() {
if (!this.book || !this.book.cover) return null
var cover = this.book.cover
if (cover.startsWith('http')) return cover
var _clean = cover.replace(/\\/g, '/')
if (_clean.startsWith('/local')) {
var _cover = process.env.NODE_ENV !== 'production' && process.env.PROD !== '1' ? _clean.replace('/local', '') : _clean
return `${this.$store.state.serverUrl}${_cover}`
}
return _clean
},
async startDownload(url, fileext, download) {
this.$toast.update(download.toastId, { content: `Downloading "${download.audiobook.book.title}"...` })
var coverDownloadUrl = this.getCoverUrlForDownload()
var coverFilename = null
if (coverDownloadUrl) {
var coverExt = Path.extname(coverDownloadUrl) || '.jpg'
coverFilename = `cover-${download.id}${coverExt}`
}
download.isDownloading = true
download.isPreparing = false
download.filename = `${download.audiobook.book.title}${fileext}`
this.$store.commit('downloads/addUpdateDownload', download)
console.log('Starting Download URL', url)
var downloadRequestPayload = {
audiobookId: download.id,
filename: download.filename,
coverFilename,
coverDownloadUrl,
downloadUrl: url,
title: download.audiobook.book.title,
downloadFolderUrl: download.downloadFolderUrl
}
var downloadRes = await AudioDownloader.download(downloadRequestPayload)
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)
}
},
downloadReady(prepareDownload) {
var download = this.$store.getters['downloads/getDownload'](prepareDownload.audiobookId)
if (download) {
var fileext = prepareDownload.ext
var url = `${this.$store.state.serverUrl}/downloads/${prepareDownload.id}/${prepareDownload.filename}`
this.startDownload(url, fileext, download)
} else {
console.error('Prepare download killed but download not found', prepareDownload)
}
},
downloadKilled(prepareDownload) {
var download = this.$store.getters['downloads/getDownload'](prepareDownload.audiobookId)
if (download) {
this.$toast.update(download.toastId, { content: `Prepare download killed for "${download.audiobook.book.title}"`, options: { timeout: 5000, type: 'error' } })
this.$store.commit('downloads/removeDownload', download)
} else {
console.error('Prepare download killed but download not found', prepareDownload)
}
},
downloadFailed(prepareDownload) {
var download = this.$store.getters['downloads/getDownload'](prepareDownload.audiobookId)
if (download) {
this.$toast.update(download.toastId, { content: `Prepare download failed for "${download.audiobook.book.title}"`, options: { timeout: 5000, type: 'error' } })
this.$store.commit('downloads/removeDownload', download)
} else {
console.error('Prepare download failed but download not found', prepareDownload)
}
} }
}, },
mounted() { mounted() {
if (!this.$server.socket) {
console.warn('Audiobook Page mounted: Server socket not set')
} else {
this.$server.socket.on('download_ready', this.downloadReady)
this.$server.socket.on('download_killed', this.downloadKilled)
this.$server.socket.on('download_failed', this.downloadFailed)
}
this.$store.commit('audiobooks/addListener', { id: 'audiobook', audiobookId: this.audiobookId, meth: this.audiobookUpdated }) this.$store.commit('audiobooks/addListener', { id: 'audiobook', audiobookId: this.audiobookId, meth: this.audiobookUpdated })
}, },
beforeDestroy() { beforeDestroy() {
if (!this.$server.socket) {
console.warn('Audiobook Page beforeDestroy: Server socket not set')
} else {
this.$server.socket.off('download_ready', this.downloadReady)
this.$server.socket.off('download_killed', this.downloadKilled)
this.$server.socket.off('download_failed', this.downloadFailed)
}
this.$store.commit('audiobooks/removeListener', 'audiobook') this.$store.commit('audiobooks/removeListener', 'audiobook')
} }
} }
+30 -6
View File
@@ -1,11 +1,14 @@
<template> <template>
<div class="w-full h-full"> <div class="w-full h-full">
<div class="relative flex items-center justify-center min-h-screen sm:pt-0"> <div class="relative flex items-center justify-center min-h-screen sm:pt-0">
<nuxt-link to="/" class="absolute top-2 left-2 z-20">
<span class="material-icons text-4xl">arrow_back</span>
</nuxt-link>
<div class="absolute top-0 left-0 w-full p-6 flex items-center flex-col justify-center z-0 short:hidden"> <div class="absolute top-0 left-0 w-full p-6 flex items-center flex-col justify-center z-0 short:hidden">
<img src="/Logo.png" class="h-20 w-20 mb-2" /> <img src="/Logo.png" class="h-20 w-20 mb-2" />
<h1 class="text-2xl font-book">AudioBookshelf</h1> <h1 class="text-2xl font-book">AudioBookshelf</h1>
</div> </div>
<p class="hidden absolute short:block top-0 left-0 p-2 font-book text-xl">AudioBookshelf</p> <p class="hidden absolute short:block top-1.5 left-12 p-2 font-book text-xl">AudioBookshelf</p>
<div class="max-w-sm mx-auto sm:px-6 lg:px-8 z-10"> <div class="max-w-sm mx-auto sm:px-6 lg:px-8 z-10">
<div v-show="loggedIn" class="mt-8 bg-primary overflow-hidden shadow rounded-lg p-6 text-center"> <div v-show="loggedIn" class="mt-8 bg-primary overflow-hidden shadow rounded-lg p-6 text-center">
@@ -15,8 +18,8 @@
<div v-show="!loggedIn" class="mt-8 bg-primary overflow-hidden shadow rounded-lg p-6"> <div v-show="!loggedIn" class="mt-8 bg-primary overflow-hidden shadow rounded-lg p-6">
<h2 class="text-xl leading-7 mb-4">Enter an <span class="font-book font-normal">AudioBookshelf</span><br />server address:</h2> <h2 class="text-xl leading-7 mb-4">Enter an <span class="font-book font-normal">AudioBookshelf</span><br />server address:</h2>
<form v-show="!showAuth" @submit.prevent="submit" novalidate> <form v-show="!showAuth" @submit.prevent="submit" novalidate>
<ui-text-input v-model="serverUrl" :disabled="processing" placeholder="http://55.55.55.55:13378" type="url" class="w-60 sm:w-72 h-10" /> <ui-text-input v-model="serverUrl" :disabled="processing || !networkConnected" placeholder="http://55.55.55.55:13378" type="url" class="w-60 sm:w-72 h-10" />
<ui-btn :disabled="processing" type="submit" :padding-x="3" class="h-10">Submit</ui-btn> <ui-btn :disabled="processing || !networkConnected" type="submit" :padding-x="3" class="h-10">{{ networkConnected ? 'Submit' : 'No Internet' }}</ui-btn>
</form> </form>
<template v-if="showAuth"> <template v-if="showAuth">
<div class="flex items-center"> <div class="flex items-center">
@@ -29,7 +32,7 @@
<ui-text-input v-model="username" :disabled="processing" placeholder="username" class="w-full my-1 text-lg" /> <ui-text-input v-model="username" :disabled="processing" placeholder="username" class="w-full my-1 text-lg" />
<ui-text-input v-model="password" type="password" :disabled="processing" placeholder="password" class="w-full my-1 text-lg" /> <ui-text-input v-model="password" type="password" :disabled="processing" placeholder="password" class="w-full my-1 text-lg" />
<ui-btn :disabled="processing" type="submit" class="mt-1 h-10">Submit</ui-btn> <ui-btn :disabled="processing || !networkConnected" type="submit" class="mt-1 h-10">{{ networkConnected ? 'Submit' : 'No Internet' }}</ui-btn>
</form> </form>
</template> </template>
@@ -77,8 +80,16 @@ export default {
loggedIn: false loggedIn: false
} }
}, },
computed: {
networkConnected() {
return this.$store.state.networkConnected
}
},
methods: { methods: {
async submit() { async submit() {
if (!this.networkConnected) {
return
}
if (!this.serverUrl.startsWith('http')) { if (!this.serverUrl.startsWith('http')) {
this.serverUrl = 'http://' + this.serverUrl this.serverUrl = 'http://' + this.serverUrl
} }
@@ -94,6 +105,9 @@ export default {
} }
}, },
async submitAuth() { async submitAuth() {
if (!this.networkConnected) {
return
}
if (!this.username) { if (!this.username) {
this.error = 'Invalid username' this.error = 'Invalid username'
return return
@@ -137,8 +151,11 @@ export default {
} }
this.$server.on('connected', this.socketConnected) this.$server.on('connected', this.socketConnected)
var localServerUrl = localStorage.getItem('serverUrl') var localServerUrl = await this.$localStore.getServerUrl()
var localUserToken = localStorage.getItem('userToken') var localUserToken = await this.$localStore.getToken()
if (!this.networkConnected) return
if (localServerUrl) { if (localServerUrl) {
this.serverUrl = localServerUrl this.serverUrl = localServerUrl
if (localUserToken) { if (localUserToken) {
@@ -162,6 +179,13 @@ export default {
}, },
mounted() { mounted() {
this.init() this.init()
},
beforeDestroy() {
if (!this.$server) {
console.error('Connected beforeDestroy: No Server')
return
}
this.$server.off('connected', this.socketConnected)
} }
} }
</script> </script>
+4
View File
@@ -2,7 +2,9 @@
<div class="w-full h-full"> <div class="w-full h-full">
<div class="w-full h-12 relative z-20"> <div class="w-full h-12 relative z-20">
<div id="toolbar" class="asolute top-0 left-0 w-full h-full bg-bg flex items-center px-2"> <div id="toolbar" class="asolute top-0 left-0 w-full h-full bg-bg flex items-center px-2">
<span class="material-icons px-2" @click="showSearchModal = true">search</span>
<p class="font-book">{{ numAudiobooks }} Audiobooks</p> <p class="font-book">{{ numAudiobooks }} Audiobooks</p>
<div class="flex-grow" /> <div class="flex-grow" />
<span class="material-icons px-2" @click="showFilterModal = true">filter_alt</span> <span class="material-icons px-2" @click="showFilterModal = true">filter_alt</span>
<span class="material-icons px-2" @click="showSortModal = true">sort</span> <span class="material-icons px-2" @click="showSortModal = true">sort</span>
@@ -12,6 +14,7 @@
<modals-order-modal v-model="showSortModal" :order-by.sync="settings.orderBy" :descending.sync="settings.orderDesc" @change="updateOrder" /> <modals-order-modal v-model="showSortModal" :order-by.sync="settings.orderBy" :descending.sync="settings.orderDesc" @change="updateOrder" />
<modals-filter-modal v-model="showFilterModal" :filter-by.sync="settings.filterBy" @change="updateFilter" /> <modals-filter-modal v-model="showFilterModal" :filter-by.sync="settings.filterBy" @change="updateFilter" />
<modals-search-modal v-model="showSearchModal" />
</div> </div>
</template> </template>
@@ -21,6 +24,7 @@ export default {
return { return {
showSortModal: false, showSortModal: false,
showFilterModal: false, showFilterModal: false,
showSearchModal: false,
settings: {} settings: {}
} }
}, },
+4
View File
@@ -0,0 +1,4 @@
import { registerPlugin } from '@capacitor/core';
const AudioDownloader = registerPlugin('AudioDownloader');
export default AudioDownloader;
+12 -2
View File
@@ -2,6 +2,7 @@ import Vue from 'vue'
Vue.prototype.$isDev = process.env.NODE_ENV !== 'production' Vue.prototype.$isDev = process.env.NODE_ENV !== 'production'
Vue.prototype.$bytesPretty = (bytes, decimals = 2) => { Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
if (isNaN(bytes) || bytes === null) return 'Invalid Bytes'
if (bytes === 0) { if (bytes === 0) {
return '0 Bytes' return '0 Bytes'
} }
@@ -38,7 +39,6 @@ Vue.prototype.$secondsToTimestamp = (seconds) => {
return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}` return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}`
} }
function isClickedOutsideEl(clickEvent, elToCheckOutside, ignoreSelectors = [], ignoreElems = []) { function isClickedOutsideEl(clickEvent, elToCheckOutside, ignoreSelectors = [], ignoreElems = []) {
const isDOMElement = (element) => { const isDOMElement = (element) => {
return element instanceof Element || element instanceof HTMLDocument return element instanceof Element || element instanceof HTMLDocument
@@ -74,4 +74,14 @@ Vue.directive('click-outside', {
document.removeEventListener('click', el['__click_outside__'], false) document.removeEventListener('click', el['__click_outside__'], false)
delete el['__click_outside__'] delete el['__click_outside__']
} }
}) })
const encode = (text) => encodeURIComponent(Buffer.from(text).toString('base64'))
Vue.prototype.$encode = encode
const decode = (text) => Buffer.from(decodeURIComponent(text), 'base64').toString()
Vue.prototype.$decode = decode
export {
encode,
decode
}
+2 -4
View File
@@ -1,7 +1,5 @@
import Vue from 'vue'
import Server from '../Server' import Server from '../Server'
Vue.prototype.$server = null export default function ({ store }, inject) {
export default function ({ store }) { inject('server', new Server(store))
Vue.prototype.$server = new Server(store)
} }
+585
View File
@@ -0,0 +1,585 @@
import { Capacitor } from '@capacitor/core';
import { CapacitorDataStorageSqlite } from 'capacitor-data-storage-sqlite';
import { Storage } from '@capacitor/storage'
class StoreService {
store
isService = false
platform
isOpen = false
tableName = 'downloads'
constructor() {
this.init()
}
/**
* Plugin Initialization
*/
init() {
this.platform = Capacitor.getPlatform()
this.store = CapacitorDataStorageSqlite
this.isService = true
console.log('in init ', this.platform, this.isService)
}
/**
* Open a Store
* @param _dbName string optional
* @param _table string optional
* @param _encrypted boolean optional
* @param _mode string optional
*/
async openStore(_dbName, _table, _encrypted, _mode) {
if (this.isService && this.store != null) {
const database = _dbName ? _dbName : "storage"
const table = _table ? _table : "storage_table"
const encrypted = _encrypted ? _encrypted : false
const mode = _mode ? _mode : "no-encryption"
this.isOpen = false
try {
await this.store.openStore({ database, table, encrypted, mode })
// return Promise.resolve()
this.isOpen = true
return true
} catch (err) {
// return Promise.reject(err)
return false
}
} else {
// return Promise.reject(new Error("openStore: Store not opened"))
return false
}
}
/**
* Close a store
* @param dbName
* @returns
*/
async closeStore(dbName) {
if (this.isService && this.store != null) {
try {
await this.store.closeStore({ database: dbName })
return Promise.resolve()
} catch (err) {
return Promise.reject(err)
}
} else {
return Promise.reject(new Error("close: Store not opened"))
}
}
/**
* Check if a store is opened
* @param dbName
* @returns
*/
async isStoreOpen(dbName) {
if (this.isService && this.store != null) {
try {
const ret = await this.store.isStoreOpen({ database: dbName })
return Promise.resolve(ret)
} catch (err) {
return Promise.reject(err)
}
} else {
return Promise.reject(new Error("isStoreOpen: Store not opened"))
}
}
/**
* Check if a store already exists
* @param dbName
* @returns
*/
async isStoreExists(dbName) {
if (this.isService && this.store != null) {
try {
const ret = await this.store.isStoreExists({ database: dbName })
return Promise.resolve(ret)
} catch (err) {
return Promise.reject(err)
}
} else {
return Promise.reject(new Error("isStoreExists: Store not opened"))
}
}
/**
* Create/Set a Table
* @param table string
*/
async setTable(table) {
if (this.isService && this.store != null) {
try {
await this.store.setTable({ table })
return Promise.resolve()
} catch (err) {
return Promise.reject(err)
}
} else {
return Promise.reject(new Error("setTable: Store not opened"))
}
}
/**
* Set of Key
* @param key string
* @param value string
*/
async setItem(key, value) {
if (this.isService && this.store != null) {
if (key.length > 0) {
try {
await this.store.set({ key, value });
return Promise.resolve();
} catch (err) {
return Promise.reject(err);
}
} else {
return Promise.reject(new Error("setItem: Must give a key"));
}
} else {
return Promise.reject(new Error("setItem: Store not opened"));
}
}
/**
* Get the Value for a given Key
* @param key string
*/
async getItem(key) {
if (this.isService && this.store != null) {
if (key.length > 0) {
try {
const { value } = await this.store.get({ key });
console.log("in getItem value ", value)
return Promise.resolve(value);
} catch (err) {
console.error(`in getItem key: ${key} err: ${JSON.stringify(err)}`)
return Promise.reject(err);
}
} else {
return Promise.reject(new Error("getItem: Must give a key"));
}
} else {
return Promise.reject(new Error("getItem: Store not opened"));
}
}
async isKey(key) {
if (this.isService && this.store != null) {
if (key.length > 0) {
try {
const { result } = await this.store.iskey({ key });
return Promise.resolve(result);
} catch (err) {
return Promise.reject(err);
}
} else {
return Promise.reject(new Error("isKey: Must give a key"));
}
} else {
return Promise.reject(new Error("isKey: Store not opened"));
}
}
async getAllKeys() {
if (this.isService && this.store != null) {
try {
const { keys } = await this.store.keys();
return Promise.resolve(keys);
} catch (err) {
return Promise.reject(err);
}
} else {
return Promise.reject(new Error("getAllKeys: Store not opened"));
}
}
async getAllValues() {
if (this.isService && this.store != null) {
try {
const { values } = await this.store.values();
return Promise.resolve(values);
} catch (err) {
return Promise.reject(err);
}
} else {
return Promise.reject(new Error("getAllValues: Store not opened"));
}
}
async getFilterValues(filter) {
if (this.isService && this.store != null) {
try {
const { values } = await this.store.filtervalues({ filter });
return Promise.resolve(values);
} catch (err) {
return Promise.reject(err);
}
} else {
return Promise.reject(new Error("getFilterValues: Store not opened"));
}
}
async getAllKeysValues() {
if (this.isService && this.store != null) {
try {
const { keysvalues } = await this.store.keysvalues();
return Promise.resolve(keysvalues);
} catch (err) {
return Promise.reject(err);
}
} else {
return Promise.reject(new Error("getAllKeysValues: Store not opened"));
}
}
async removeItem(key) {
if (this.isService && this.store != null) {
if (key.length > 0) {
try {
await this.store.remove({ key });
return Promise.resolve();
} catch (err) {
return Promise.reject(err);
}
} else {
return Promise.reject(new Error("removeItem: Must give a key"));
}
} else {
return Promise.reject(new Error("removeItem: Store not opened"));
}
}
async clear() {
if (this.isService && this.store != null) {
try {
await this.store.clear();
return Promise.resolve();
} catch (err) {
return Promise.reject(err.message);
}
} else {
return Promise.reject(new Error("clear: Store not opened"));
}
}
async deleteStore(_dbName) {
const database = _dbName ? _dbName : "storage"
if (this.isService && this.store != null) {
try {
await this.store.deleteStore({ database })
return Promise.resolve();
} catch (err) {
return Promise.reject(err.message)
}
} else {
return Promise.reject(new Error("deleteStore: Store not opened"));
}
}
async isTable(table) {
if (this.isService && this.store != null) {
if (table.length > 0) {
try {
const { result } = await this.store.isTable({ table });
return Promise.resolve(result);
} catch (err) {
return Promise.reject(err);
}
} else {
return Promise.reject(new Error("isTable: Must give a table"));
}
} else {
return Promise.reject(new Error("isTable: Store not opened"));
}
}
async getAllTables() {
if (this.isService && this.store != null) {
try {
const { tables } = await this.store.tables();
return Promise.resolve(tables);
} catch (err) {
return Promise.reject(err);
}
} else {
return Promise.reject(new Error("getAllTables: Store not opened"));
}
}
async deleteTable(table) {
if (this.isService && this.store != null) {
if (table.length > 0) {
try {
await this.store.deleteTable({ table });
return Promise.resolve();
} catch (err) {
return Promise.reject(err);
}
} else {
return Promise.reject(new Error("deleteTable: Must give a table"));
}
} else {
return Promise.reject(new Error("deleteTable: Store not opened"));
}
}
async getDownload(id) {
if (!this.isOpen) {
var success = await this.openStore('storage', this.tableName)
if (!success) {
console.error('Store failed to open')
return null
}
}
try {
var value = await this.getItem(id)
return JSON.parse(value)
} catch (error) {
console.error('Failed to get download from store', error)
return null
}
}
async setDownload(download) {
if (!this.isOpen) {
var success = await this.openStore('storage', this.tableName)
if (!success) {
console.error('Store failed to open')
return false
}
}
try {
await this.setItem(download.id, JSON.stringify(download))
console.log(`[STORE] Set Download ${download.id}`)
return true
} catch (error) {
console.error('Failed to set download in store', error)
return false
}
}
async removeDownload(id) {
if (!this.isOpen) {
var success = await this.openStore('storage', this.tableName)
if (!success) {
console.error('Store failed to open')
return false
}
}
try {
await this.removeItem(id)
console.log(`[STORE] Removed download ${id}`)
return true
} catch (error) {
console.error('Failed to remove download in store', error)
return false
}
}
async getAllDownloads() {
if (!this.isOpen) {
var success = await this.openStore('storage', this.tableName)
if (!success) {
console.error('Store failed to open')
return []
}
}
var keysvalues = await this.getAllKeysValues()
var downloads = []
for (let i = 0; i < keysvalues.length; i++) {
try {
var download = JSON.parse(keysvalues[i].value)
downloads.push(download)
} catch (error) {
console.error('Failed to parse download', error)
await this.removeItem(keysvalues[i].key)
}
}
return downloads
}
}
class LocalStorage {
constructor(vuexStore) {
this.vuexStore = vuexStore
this.userAudiobooksLoaded = false
this.downloadFolder = null
this.userAudiobooks = {}
}
async getMostRecentUserAudiobook(audiobookId) {
if (!this.userAudiobooksLoaded) {
await this.loadUserAudiobooks()
}
var local = this.getUserAudiobook(audiobookId)
var server = this.vuexStore.getters['user/getUserAudiobook'](audiobookId)
if (local && server) {
if (local.lastUpdate > server.lastUpdate) {
console.log('[LocalStorage] Most recent user audiobook is from LOCAL')
return local
}
console.log('[LocalStorage] Most recent user audiobook is from SERVER')
return server
} else if (local) {
console.log('[LocalStorage] Most recent user audiobook is from LOCAL')
return local
} else if (server) {
console.log('[LocalStorage] Most recent user audiobook is from SERVER')
return server
}
return null
}
async loadUserAudiobooks() {
try {
var val = (await Storage.get({ key: 'userAudiobooks' }) || {}).value || null
this.userAudiobooks = val ? JSON.parse(val) : {}
this.userAudiobooksLoaded = true
this.vuexStore.commit('user/setLocalUserAudiobooks', this.userAudiobooks)
console.log('[LocalStorage] Loaded Local USER Audiobooks ' + JSON.stringify(this.userAudiobooks))
} catch (error) {
console.error('[LocalStorage] Failed to load user audiobooks', error)
}
}
async saveUserAudiobooks() {
try {
await Storage.set({ key: 'userAudiobooks', value: JSON.stringify(this.userAudiobooks) })
} catch (error) {
console.error('[LocalStorage] Failed to set user audiobooks', error)
}
}
async setAllAudiobookProgress(progresses) {
this.userAudiobooks = progresses
await this.saveUserAudiobooks()
this.vuexStore.commit('user/setLocalUserAudiobooks', this.userAudiobooks)
}
async updateUserAudiobookProgress(progressPayload) {
this.userAudiobooks[progressPayload.audiobookId] = {
...progressPayload
}
console.log('[LocalStorage] Updated User Audiobook Progress ' + progressPayload.audiobookId)
await this.saveUserAudiobooks()
this.vuexStore.commit('user/setLocalUserAudiobooks', this.userAudiobooks)
}
async removeAudiobookProgress(audiobookId) {
if (!this.userAudiobooks[audiobookId]) return
delete this.userAudiobooks[audiobookId]
await this.saveUserAudiobooks()
this.vuexStore.commit('user/setLocalUserAudiobooks', this.userAudiobooks)
}
getUserAudiobook(audiobookId) {
return this.userAudiobooks[audiobookId] || null
}
async setToken(token) {
try {
if (token) {
await Storage.set({ key: 'token', value: token })
} else {
await Storage.remove({ key: 'token' })
}
} catch (error) {
console.error('[LocalStorage] Failed to set token', error)
}
}
async getToken() {
try {
return (await Storage.get({ key: 'token' }) || {}).value || null
} catch (error) {
console.error('[LocalStorage] Failed to get token', error)
return null
}
}
async setDownloadFolder(folderObj) {
try {
if (folderObj) {
await Storage.set({ key: 'downloadFolder', value: JSON.stringify(folderObj) })
this.downloadFolder = folderObj
} else {
await Storage.remove({ key: 'downloadFolder' })
this.downloadFolder = null
}
} catch (error) {
console.error('[LocalStorage] Failed to set download folder', error)
}
}
async getDownloadFolder() {
try {
var _value = (await Storage.get({ key: 'downloadFolder' }) || {}).value || null
if (!_value) return null
this.downloadFolder = JSON.parse(_value)
return this.downloadFolder
} catch (error) {
console.error('[LocalStorage] Failed to get download folder', error)
return null
}
}
async getServerUrl() {
try {
return (await Storage.get({ key: 'serverUrl' }) || {}).value || null
} catch (error) {
console.error('[LocalStorage] Failed to get serverUrl', error)
return null
}
}
async setUserSettings(settings) {
try {
await Storage.set({ key: 'userSettings', value: JSON.stringify(settings) })
} catch (error) {
console.error('[LocalStorage] Failed to update user settings', error)
}
}
async getUserSettings() {
try {
var settingsObj = await Storage.get({ key: 'userSettings' }) || {}
return settingsObj.value ? JSON.parse(settingsObj.value) : null
} catch (error) {
console.error('[LocalStorage] Failed to get user settings', error)
return null
}
}
async setCurrent(current) {
try {
if (current) {
await Storage.set({ key: 'current', value: JSON.stringify(current) })
} else {
await Storage.remove({ key: 'current' })
}
} catch (error) {
console.error('[LocalStorage] Failed to set current', error)
}
}
async getCurrent() {
try {
var currentObj = await Storage.get({ key: 'current' }) || {}
return currentObj.value ? JSON.parse(currentObj.value) : null
} catch (error) {
console.error('[LocalStorage] Failed to get current', error)
return null
}
}
}
export default ({ app, store }, inject) => {
inject('sqlStore', new StoreService())
inject('localStore', new LocalStorage(store))
}
Binary file not shown.
Binary file not shown.
+38 -6
View File
@@ -1,4 +1,5 @@
import { sort } from '@/assets/fastSort' import { sort } from '@/assets/fastSort'
import { decode } from '@/plugins/init.client'
const STANDARD_GENRES = ['adventure', 'autobiography', 'biography', 'childrens', 'comedy', 'crime', 'dystopian', 'fantasy', 'fiction', 'health', 'history', 'horror', 'mystery', 'new_adult', 'nonfiction', 'philosophy', 'politics', 'religion', 'romance', 'sci-fi', 'self-help', 'short_story', 'technology', 'thriller', 'true_crime', 'western', 'young_adult'] const STANDARD_GENRES = ['adventure', 'autobiography', 'biography', 'childrens', 'comedy', 'crime', 'dystopian', 'fantasy', 'fiction', 'health', 'history', 'horror', 'mystery', 'new_adult', 'nonfiction', 'philosophy', 'politics', 'religion', 'romance', 'sci-fi', 'self-help', 'short_story', 'technology', 'thriller', 'true_crime', 'western', 'young_adult']
@@ -11,7 +12,14 @@ export const state = () => ({
}) })
export const getters = { export const getters = {
getFiltered: (state, getters, rootState) => () => { getAudiobook: state => id => {
return state.audiobooks.find(ab => ab.id === id)
},
getFiltered: (state, getters, rootState, rootGetters) => () => {
// var isDisc = !rootState.networkConnected || !rootState.socketConnected
// console.log('GET FILETERED', isDisc)
// var filtered = isDisc ? rootGetters['downloads/getAudiobooks'] : state.audiobooks
// console.log('FILTERED LEN', filtered.length)
var filtered = state.audiobooks var filtered = state.audiobooks
var settings = rootState.user.settings || {} var settings = rootState.user.settings || {}
var filterBy = settings.filterBy || '' var filterBy = settings.filterBy || ''
@@ -19,7 +27,7 @@ export const getters = {
var searchGroups = ['genres', 'tags', 'series', 'authors'] var searchGroups = ['genres', 'tags', 'series', 'authors']
var group = searchGroups.find(_group => filterBy.startsWith(_group + '.')) var group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
if (group) { if (group) {
var filter = filterBy.replace(`${group}.`, '') var filter = decode(filterBy.replace(`${group}.`, ''))
if (group === 'genres') filtered = filtered.filter(ab => ab.book && ab.book.genres.includes(filter)) if (group === 'genres') filtered = filtered.filter(ab => ab.book && ab.book.genres.includes(filter))
else if (group === 'tags') filtered = filtered.filter(ab => ab.tags.includes(filter)) else if (group === 'tags') filtered = filtered.filter(ab => ab.tags.includes(filter))
else if (group === 'series') filtered = filtered.filter(ab => ab.book && ab.book.series === filter) else if (group === 'series') filtered = filtered.filter(ab => ab.book && ab.book.series === filter)
@@ -32,20 +40,28 @@ export const getters = {
var direction = settings.orderDesc ? 'desc' : 'asc' var direction = settings.orderDesc ? 'desc' : 'asc'
var filtered = getters.getFiltered() var filtered = getters.getFiltered()
var orderByNumber = settings.orderBy === 'book.volumeNumber'
return sort(filtered)[direction]((ab) => { return sort(filtered)[direction]((ab) => {
// Supports dot notation strings i.e. "book.title" // Supports dot notation strings i.e. "book.title"
return settings.orderBy.split('.').reduce((a, b) => a[b], ab) var value = settings.orderBy.split('.').reduce((a, b) => a[b], ab)
if (orderByNumber && !isNaN(value)) return Number(value)
return value
}) })
}, },
getUniqueAuthors: (state) => { getUniqueAuthors: (state) => {
var _authors = state.audiobooks.filter(ab => !!(ab.book && ab.book.author)).map(ab => ab.book.author) var _authors = state.audiobooks.filter(ab => !!(ab.book && ab.book.author)).map(ab => ab.book.author)
return [...new Set(_authors)] return [...new Set(_authors)]
},
getGenresUsed: (state) => {
var _genres = []
state.audiobooks.filter(ab => !!(ab.book && ab.book.genres)).forEach(ab => _genres = _genres.concat(ab.book.genres))
return [...new Set(_genres)].sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
} }
} }
export const actions = { export const actions = {
load({ commit }) { load({ commit }) {
this.$axios return this.$axios
.$get(`/api/audiobooks`) .$get(`/api/audiobooks`)
.then((data) => { .then((data) => {
console.log('Audiobooks request data', data) console.log('Audiobooks request data', data)
@@ -53,12 +69,20 @@ export const actions = {
}) })
.catch((error) => { .catch((error) => {
console.error('Failed', error) console.error('Failed', error)
commit('set', [])
}) })
}, },
useDownloaded({ commit, rootGetters }) {
commit('set', rootGetters['downloads/getAudiobooks'])
}
} }
export const mutations = { export const mutations = {
reset(state) {
state.audiobooks = []
state.genres = [...STANDARD_GENRES]
state.tags = []
state.series = []
},
set(state, audiobooks) { set(state, audiobooks) {
// GENRES // GENRES
var genres = [...state.genres] var genres = [...state.genres]
@@ -86,7 +110,15 @@ export const mutations = {
state.series = series state.series = series
state.series.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1) state.series.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
state.audiobooks = audiobooks audiobooks.forEach((ab) => {
var indexOf = state.audiobooks.findIndex(_ab => _ab.id === ab.id)
if (indexOf >= 0) {
state.audiobooks.splice(indexOf, 1, ab)
} else {
state.audiobooks.push(ab)
}
})
// state.audiobooks = audiobooks
state.listeners.forEach((listener) => { state.listeners.forEach((listener) => {
listener.meth() listener.meth()
}) })
+68
View File
@@ -0,0 +1,68 @@
export const state = () => ({
downloads: [],
orphanDownloads: [],
showModal: false
})
export const getters = {
getDownload: (state) => id => {
return state.downloads.find(d => d.id === id)
},
getDownloadIfReady: (state) => id => {
var download = state.downloads.find(d => d.id === id)
return !!download && !download.isDownloading && !download.isPreparing ? download : null
},
getAudiobooks: (state) => {
return state.downloads.map(dl => dl.audiobook)
}
}
export const actions = {
async loadFromStorage({ commit }) {
var downloads = await this.$sqlStore.getAllDownloads()
downloads.forEach(ab => {
if (ab.isDownloading || ab.isPreparing) {
ab.isIncomplete = true
}
ab.isDownloading = false
ab.isPreparing = false
commit('setDownload', ab)
})
}
}
export const mutations = {
setShowModal(state, val) {
state.showModal = val
},
setDownload(state, download) {
var index = state.downloads.findIndex(d => d.id === download.id)
if (index >= 0) {
state.downloads.splice(index, 1, download)
} else {
state.downloads.push(download)
}
},
addUpdateDownload(state, download) {
var key = download.isOrphan ? 'orphanDownloads' : 'downloads'
var index = state[key].findIndex(d => d.id === download.id)
if (index >= 0) {
state[key].splice(index, 1, download)
} else {
state[key].push(download)
}
if (key === 'downloads') {
this.$sqlStore.setDownload(download)
}
},
removeDownload(state, download) {
var key = download.isOrphan ? 'orphanDownloads' : 'downloads'
state[key] = state[key].filter(d => d.id !== download.id)
if (key === 'downloads') {
this.$sqlStore.removeDownload(download.id)
}
}
}
+55 -5
View File
@@ -1,15 +1,40 @@
import Vue from 'vue'
export const state = () => ({ export const state = () => ({
streamAudiobook: null, streamAudiobook: null,
playingDownload: null,
playOnLoad: false, playOnLoad: false,
serverUrl: null, serverUrl: null,
user: null, appUpdateInfo: null,
appUpdateInfo: null socketConnected: false,
networkConnected: false,
networkConnectionType: null,
streamListener: null,
isFirstLoad: true,
hasStoragePermission: false
}) })
export const getters = {
playerIsOpen: (state) => {
return state.streamAudiobook || state.playingDownload
},
isAudiobookStreaming: (state) => id => {
return (state.streamAudiobook && state.streamAudiobook.id === id)
},
isAudiobookPlaying: (state) => id => {
return (state.playingDownload && state.playingDownload.id === id) || (state.streamAudiobook && state.streamAudiobook.id === id)
}
}
export const actions = {} export const actions = {}
export const mutations = { export const mutations = {
setHasStoragePermission(state, val) {
state.hasStoragePermission = val
},
setIsFirstLoad(state, val) {
state.isFirstLoad = val
},
setAppUpdateInfo(state, info) { setAppUpdateInfo(state, info) {
state.appUpdateInfo = info state.appUpdateInfo = info
}, },
@@ -23,12 +48,37 @@ export const mutations = {
state.playOnLoad = val state.playOnLoad = val
}, },
setStreamAudiobook(state, audiobook) { setStreamAudiobook(state, audiobook) {
state.streamAudiobook = audiobook if (audiobook) {
state.playingDownload = null
}
Vue.set(state, 'streamAudiobook', audiobook)
if (state.streamListener) {
state.streamListener('stream', audiobook)
}
},
setPlayingDownload(state, download) {
if (download) {
state.streamAudiobook = null
}
Vue.set(state, 'playingDownload', download)
if (state.streamListener) {
state.streamListener('download', download)
}
}, },
setServerUrl(state, url) { setServerUrl(state, url) {
state.serverUrl = url state.serverUrl = url
}, },
setUser(state, user) { setSocketConnected(state, val) {
state.user = user state.socketConnected = val
},
setNetworkStatus(state, val) {
state.networkConnected = val.connected
state.networkConnectionType = val.connectionType
},
setStreamListener(state, val) {
state.streamListener = val
},
removeStreamListener(state) {
state.streamListener = null
} }
} }
+31 -17
View File
@@ -1,6 +1,8 @@
import Vue from 'vue'
export const state = () => ({ export const state = () => ({
user: null, user: null,
localUserAudiobooks: {},
settings: { settings: {
orderBy: 'book.title', orderBy: 'book.title',
orderDesc: false, orderDesc: false,
@@ -28,33 +30,42 @@ export const getters = {
} }
export const actions = { export const actions = {
updateUserSettings({ commit }, payload) { async updateUserSettings({ commit }, payload) {
var updatePayload = {
...payload if (Vue.prototype.$server.connected) {
} var updatePayload = {
return this.$axios.$patch('/api/user/settings', updatePayload).then((result) => { ...payload
if (result.success) {
commit('setSettings', result.settings)
console.log('Settings updated', result.settings)
return true
} else {
return false
} }
}).catch((error) => { return this.$axios.$patch('/api/user/settings', updatePayload).then((result) => {
console.error('Failed to update settings', error) if (result.success) {
return false commit('setSettings', result.settings)
}) console.log('Settings updated', result.settings)
return true
} else {
return false
}
}).catch((error) => {
console.error('Failed to update settings', error)
return false
})
} else {
console.log('Update settings without server')
commit('setSettings', payload)
}
} }
} }
export const mutations = { export const mutations = {
setLocalUserAudiobooks(state, userAudiobooks) {
state.localUserAudiobooks = userAudiobooks
},
setUser(state, user) { setUser(state, user) {
state.user = user state.user = user
if (user) { if (user) {
if (user.token) localStorage.setItem('token', user.token) if (user.token) this.$localStore.setToken(user.token)
console.log('setUser', user.username) console.log('setUser', user.username)
} else { } else {
localStorage.removeItem('token') this.$localStore.setToken(null)
console.warn('setUser cleared') console.warn('setUser cleared')
} }
}, },
@@ -69,6 +80,9 @@ export const mutations = {
} }
} }
if (hasChanges) { if (hasChanges) {
console.log('Update settings in local storage')
this.$localStore.setUserSettings({ ...state.settings })
state.settingsListeners.forEach((listener) => { state.settingsListeners.forEach((listener) => {
listener.meth(state.settings) listener.meth(state.settings)
}) })