Offline support, downloading, syncing progress

This commit is contained in:
advplyr 2021-09-12 18:37:08 -05:00
parent e97218f2e8
commit a412c9d359
37 changed files with 2836 additions and 201 deletions

View file

@ -1,4 +1,5 @@
import { io } from 'socket.io-client'
import { Storage } from '@capacitor/storage'
import axios from 'axios'
import EventEmitter from 'events'
@ -26,6 +27,7 @@ class Server extends EventEmitter {
}
getServerUrl(url) {
if (!url) return null
var urlObject = new URL(url)
return `${urlObject.protocol}//${urlObject.hostname}:${urlObject.port}`
}
@ -35,23 +37,34 @@ class Server extends EventEmitter {
this.store.commit('user/setUser', user)
if (user) {
this.store.commit('user/setSettings', user.settings)
localStorage.setItem('userToken', user.token)
Storage.set({ key: 'token', value: user.token })
} else {
localStorage.removeItem('userToken')
Storage.remove({ key: 'token' })
}
}
setServerUrl(url) {
this.url = url
localStorage.setItem('serverUrl', url)
this.store.commit('setServerUrl', url)
if (url) {
Storage.set({ key: 'serverUrl', value: url })
} else {
Storage.remove({ key: 'serverUrl' })
}
}
async connect(url, token) {
if (!url) {
console.error('Invalid url to connect')
return false
}
var serverUrl = this.getServerUrl(url)
var res = await this.ping(serverUrl)
if (!res || !res.success) {
this.url = null
this.setServerUrl(null)
return false
}
var authRes = await this.authorize(serverUrl, token)
@ -60,7 +73,7 @@ class Server extends EventEmitter {
}
this.setServerUrl(serverUrl)
console.warn('Connect setting auth user', authRes)
this.setUser(authRes.user)
this.connectSocket()
@ -103,6 +116,9 @@ class Server extends EventEmitter {
logout() {
this.setUser(null)
if (this.socket) {
this.socket.disconnect()
}
}
authorize(serverUrl, token) {
@ -138,9 +154,13 @@ class Server extends EventEmitter {
this.connected = true
this.emit('connected', true)
this.store.commit('setSocketConnected', true)
})
this.socket.on('disconnect', () => {
console.log('[Server] Socket Disconnected')
this.connected = false
this.emit('connected', false)
this.store.commit('setSocketConnected', false)
})
this.socket.on('init', (data) => {
console.log('[Server] Initial socket data received', data)

View file

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

View file

@ -9,8 +9,12 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-community-sqlite')
implementation project(':capacitor-dialog')
implementation project(':capacitor-network')
implementation project(':capacitor-storage')
implementation project(':robingenz-capacitor-app-update')
implementation project(':capacitor-data-storage-sqlite')
}

View file

@ -1,10 +1,26 @@
[
{
"pkg": "@capacitor-community/sqlite",
"classpath": "com.getcapacitor.community.database.sqlite.CapacitorSQLitePlugin"
},
{
"pkg": "@capacitor/dialog",
"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",
"classpath": "dev.robingenz.capacitor.appupdate.AppUpdatePlugin"
},
{
"pkg": "capacitor-data-storage-sqlite",
"classpath": "com.jeep.plugin.capacitor.capacitordatastoragesqlite.CapacitorDataStorageSqlitePlugin"
}
]

View file

@ -0,0 +1,320 @@
package com.audiobookshelf.app
import android.app.DownloadManager
import android.content.ContentUris
import android.content.Context
import android.net.Uri
import android.os.Environment
import android.provider.MediaStore
import android.util.Log
import android.widget.Toast
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import java.io.File
@CapacitorPlugin(name = "AudioDownloader")
class AudioDownloader : Plugin() {
private val tag = "AudioDownloader"
lateinit var mainActivity:MainActivity
lateinit var downloadManager:DownloadManager
data class AudiobookDownload(val url: String, val filename:String, val downloadId: Long)
var downloads:MutableList<AudiobookDownload> = mutableListOf()
data class CoverItem(val name:String, val coverUrl:String)
data class AudiobookItem(val id: Long, val uri: Uri, val name: String, val size: Int, val duration: Int, val coverUrl: String) {
fun toJSObject() : JSObject {
var obj = JSObject()
obj.put("id", this.id)
obj.put("uri", this.uri)
obj.put("name", this.name)
obj.put("size", this.size)
obj.put("duration", this.duration)
obj.put("coverUrl", this.coverUrl)
return obj
}
}
var audiobookItems:MutableList<AudiobookItem> = mutableListOf()
override fun load() {
mainActivity = (activity as MainActivity)
downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
var recieverEvent : (evt: String, id: Long) -> Unit = { evt: String, id: Long ->
Log.d(tag, "RECEIVE EVT $evt $id")
if (evt == "complete") {
var path = downloadManager.getUriForDownloadedFile(id)
var download = downloads.find { it.downloadId == id }
var filename = download?.filename
var jsobj = JSObject()
jsobj.put("downloadId", id)
jsobj.put("contentUrl", path)
jsobj.put("filename", filename)
notifyListeners("onDownloadComplete", jsobj)
downloads = downloads.filter { it.downloadId != id } as MutableList<AudiobookDownload>
}
if (evt == "clicked") {
Log.d(tag, "Clicked $id back in the audiodownloader")
}
}
mainActivity.registerBroadcastReceiver(recieverEvent)
}
fun loadAudiobooks() {
var covers = loadCovers()
val projection = arrayOf(
MediaStore.Audio.Media._ID,
MediaStore.Audio.Media.DISPLAY_NAME,
MediaStore.Audio.Media.DURATION,
MediaStore.Audio.Media.SIZE,
MediaStore.Audio.Media.IS_AUDIOBOOK
)
var _audiobookItems:MutableList<AudiobookItem> = mutableListOf()
val selection = "${MediaStore.Audio.Media.IS_AUDIOBOOK} == ?"
val selectionArgs = arrayOf("1")
val sortOrder = "${MediaStore.Audio.Media.DISPLAY_NAME} ASC"
activity.applicationContext.contentResolver.query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
projection,
selection,
selectionArgs,
sortOrder
)?.use { cursor ->
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID)
val nameColumn =
cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME)
val durationColumn =
cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION)
val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.SIZE)
val isAudiobookColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.IS_AUDIOBOOK)
while (cursor.moveToNext()) {
val id = cursor.getLong(idColumn)
val name = cursor.getString(nameColumn)
val duration = cursor.getInt(durationColumn)
val size = cursor.getInt(sizeColumn)
var isAudiobook = cursor.getInt(isAudiobookColumn)
if (isAudiobook == 1) {
val contentUri: Uri = ContentUris.withAppendedId(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
id
)
Log.d(tag, "Got Content FRom MEdia STORE $id $contentUri, Name: $name, Dur: $duration, Size: $size")
var audiobookId = File(name).nameWithoutExtension
var coverItem:CoverItem? = covers.find{it.name == audiobookId}
var coverUrl = coverItem?.coverUrl ?: ""
_audiobookItems.add(AudiobookItem(id, contentUri, name, duration, size, coverUrl))
}
}
audiobookItems = _audiobookItems
var audiobookObjs:List<JSObject> = _audiobookItems.map{ it.toJSObject() }
var mediaItemNoticePayload = JSObject()
mediaItemNoticePayload.put("items", audiobookObjs)
notifyListeners("onMediaLoaded", mediaItemNoticePayload)
}
}
fun loadCovers() : MutableList<CoverItem> {
val projection = arrayOf(
MediaStore.Images.Media._ID,
MediaStore.Images.Media.DISPLAY_NAME
)
val sortOrder = "${MediaStore.Images.Media.DISPLAY_NAME} ASC"
var coverItems:MutableList<CoverItem> = mutableListOf()
activity.applicationContext.contentResolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
projection,
null,
null,
sortOrder
)?.use { cursor ->
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
val nameColumn =
cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)
while (cursor.moveToNext()) {
val id = cursor.getLong(idColumn)
val filename = cursor.getString(nameColumn)
val contentUri: Uri = ContentUris.withAppendedId(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
id
)
var name = File(filename).nameWithoutExtension
Log.d(tag, "Got IMAGE FRom Media STORE $id $contentUri, Name: $name")
var coverItem = CoverItem(name, contentUri.toString())
coverItems.add(coverItem)
}
}
return coverItems
}
@PluginMethod
fun load(call: PluginCall) {
loadAudiobooks()
call.resolve()
}
@PluginMethod
fun downloadCover(call: PluginCall) {
var url = call.data.getString("downloadUrl", "unknown").toString()
var title = call.data.getString("title", "Cover").toString()
var filename = call.data.getString("filename", "audiobook.jpg").toString()
Log.d(tag, "Called download cover: $url")
var dlRequest = DownloadManager.Request(Uri.parse(url))
dlRequest.setTitle("Cover Art: $title")
dlRequest.setDescription("Cover art for audiobook")
dlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_ONLY_COMPLETION)
dlRequest.setDestinationInExternalPublicDir(Environment.DIRECTORY_AUDIOBOOKS, filename)
var downloadId = downloadManager.enqueue(dlRequest)
var progressReceiver : (prog: Long) -> Unit = { prog: Long ->
//
}
var doneReceiver : (success: Boolean) -> Unit = { success: Boolean ->
var jsobj = JSObject()
if (success) {
var path = downloadManager.getUriForDownloadedFile(downloadId)
jsobj.put("url", path)
call.resolve(jsobj)
} else {
jsobj.put("failed", true)
call.resolve(jsobj)
}
}
var progressUpdater = DownloadProgressUpdater(downloadManager, downloadId, progressReceiver, doneReceiver)
progressUpdater.run()
}
@PluginMethod
fun download(call: PluginCall) {
var url = call.data.getString("downloadUrl", "unknown").toString()
var title = call.data.getString("title", "Audiobook").toString()
var filename = call.data.getString("filename", "audiobook.mp3").toString()
Log.d(tag, "Called download: $url")
var dlRequest = DownloadManager.Request(Uri.parse(url))
dlRequest.setTitle(title)
dlRequest.setDescription("Downloading to Audiobooks directory")
dlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_ONLY_COMPLETION)
dlRequest.setDestinationInExternalPublicDir(Environment.DIRECTORY_AUDIOBOOKS, filename)
var downloadId = downloadManager.enqueue(dlRequest)
var download = AudiobookDownload(url, filename, downloadId)
downloads.add(download)
var progressReceiver : (prog: Long) -> Unit = { prog: Long ->
var jsobj = JSObject()
jsobj.put("filename", filename)
jsobj.put("downloadId", downloadId)
jsobj.put("progress", prog)
notifyListeners("onDownloadProgress", jsobj)
}
var doneReceiver : (success: Boolean) -> Unit = { success: Boolean ->
Log.d(tag, "RECIEVER DONE, SUCCES? $success")
}
var progressUpdater = DownloadProgressUpdater(downloadManager, downloadId, progressReceiver, doneReceiver)
progressUpdater.run()
val ret = JSObject()
ret.put("value", downloadId)
call.resolve(ret)
}
@PluginMethod
fun delete(call:PluginCall) {
var filename = call.data.getString("filename", "audiobook.mp3").toString()
var url = call.data.getString("url", "").toString()
var coverUrl = call.data.getString("coverUrl", "").toString()
Log.d(tag, "Called delete file $filename $url")
var contentResolver = activity.applicationContext.contentResolver
contentResolver.delete(Uri.parse(url), null, null)
if (coverUrl != "") {
contentResolver.delete(Uri.parse(coverUrl), null, null)
}
call.resolve()
}
internal class DownloadProgressUpdater(private val manager: DownloadManager, private val downloadId: Long, private var receiver:(Long) -> Unit, private var doneReceiver:(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
while (keepRunning) {
Thread.sleep(500)
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))
}
val downloadStatus = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_STATUS))
val bytesDownloadedSoFar = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR))
if (downloadStatus == DownloadManager.STATUS_SUCCESSFUL || downloadStatus == DownloadManager.STATUS_FAILED) {
if (downloadStatus == DownloadManager.STATUS_SUCCESSFUL) {
doneReceiver(true)
} else {
doneReceiver(false)
}
keepRunning = false
this.interrupt()
} else {
//update progress
val percentProgress = ((bytesDownloadedSoFar * 100L) / totalBytes)
receiver(percentProgress)
}
} else {
Log.e(TAG, "NOT FOUND IN QUERY")
keepRunning = false
}
}
}
}
}
}

View file

@ -16,10 +16,14 @@ class Audiobook {
var playbackSpeed:Float = 1f
var duration:Long = 0
var isLocal:Boolean = false
var contentUrl:String = ""
var hasPlayerLoaded:Boolean = false
val playlistUri:Uri
val coverUri:Uri
var playlistUri:Uri = Uri.EMPTY
var coverUri:Uri = Uri.EMPTY
var contentUri:Uri = Uri.EMPTY // For Local only
constructor(jsondata:JSObject) {
id = jsondata.getString("id", "audiobook").toString()
@ -34,7 +38,22 @@ class Audiobook {
playbackSpeed = jsondata.getDouble("playbackSpeed")!!.toFloat()
duration = jsondata.getString("duration", "0")!!.toLong()
// Local data
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)
}
}
}

View file

@ -1,15 +1,16 @@
package com.audiobookshelf.app
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.app.DownloadManager
import android.content.*
import android.os.Bundle
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.util.Log
import com.example.myapp.MyNativeAudio
import android.widget.Toast
import com.getcapacitor.BridgeActivity
class MainActivity : BridgeActivity() {
private val tag = "MainActivity"
@ -18,11 +19,45 @@ class MainActivity : BridgeActivity() {
private lateinit var mConnection : ServiceConnection
lateinit var pluginCallback : () -> Unit
lateinit var downloaderCallback : (String, Long) -> Unit
val broadcastReceiver = object: BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
DownloadManager.ACTION_DOWNLOAD_COMPLETE -> {
var thisdlid = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0L)
downloaderCallback("complete", thisdlid)
Log.d(tag, "DOWNNLAOD COMPELTE $thisdlid")
Toast.makeText(this@MainActivity, "Download Completed $thisdlid", Toast.LENGTH_SHORT)
}
DownloadManager.ACTION_NOTIFICATION_CLICKED -> {
var thisdlid = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0L)
downloaderCallback("clicked", thisdlid)
Log.d(tag, "CLICKED NOTFIFICAIONT $thisdlid")
Toast.makeText(this@MainActivity, "Download CLICKED $thisdlid", Toast.LENGTH_SHORT)
}
}
}
}
public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.d(tag, "onCreate")
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?) {
@ -62,4 +97,8 @@ class MainActivity : BridgeActivity() {
val stopIntent = Intent(this, PlayerNotificationService::class.java)
stopService(stopIntent)
}
fun registerBroadcastReceiver(cb: (String, Long) -> Unit) {
downloaderCallback = cb
}
}

View file

@ -1,13 +1,10 @@
package com.example.myapp
package com.audiobookshelf.app
import android.content.Intent
import android.os.Handler
import android.os.Looper
import android.util.Log
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.Plugin
import com.getcapacitor.PluginCall
@ -56,12 +53,20 @@ class MyNativeAudio : Plugin() {
} else {
Log.w(tag, "Service already started --")
}
var jsobj = JSObject()
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() {
playerNotificationService.initPlayer(audiobook)
call.resolve()
jsobj.put("success", true)
call.resolve(jsobj)
}
}
@ -109,6 +114,7 @@ class MyNativeAudio : Plugin() {
call.resolve()
}
}
@PluginMethod
fun seekBackward(call: PluginCall) {
var amount:Long = call.getString("amount", "0")!!.toLong()
@ -117,6 +123,7 @@ class MyNativeAudio : Plugin() {
call.resolve()
}
}
@PluginMethod
fun setPlaybackSpeed(call: PluginCall) {
var playbackSpeed:Float = call.getFloat("speed", 1.0f)!!
@ -126,6 +133,7 @@ class MyNativeAudio : Plugin() {
call.resolve()
}
}
@PluginMethod
fun terminateStream(call: PluginCall) {
Handler(Looper.getMainLooper()).post() {

View file

@ -9,6 +9,7 @@ import android.net.Uri
import android.os.Binder
import android.os.Build
import android.os.IBinder
import android.provider.MediaStore
import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaControllerCompat
@ -24,10 +25,15 @@ 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.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.ui.PlayerNotificationManager
import com.google.android.exoplayer2.upstream.*
import kotlinx.coroutines.*
import java.io.File
const val NOTIFICATION_LARGE_ICON_SIZE = 144 // px
@ -217,13 +223,13 @@ class PlayerNotificationService : Service() {
mediaSessionConnector = MediaSessionConnector(mediaSession)
val queueNavigator: TimelineQueueNavigator = object : TimelineQueueNavigator(mediaSession) {
override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat {
return MediaDescriptionCompat.Builder()
var builder = MediaDescriptionCompat.Builder()
.setMediaId(currentAudiobook!!.id)
.setTitle(currentAudiobook!!.title)
.setSubtitle(currentAudiobook!!.author)
.setMediaUri(currentAudiobook!!.playlistUri)
.setIconUri(currentAudiobook!!.coverUri)
.build()
return builder.build()
}
}
mediaSessionConnector.setQueueNavigator(queueNavigator)
@ -280,6 +286,9 @@ class PlayerNotificationService : Service() {
}
}
private fun setPlayerListeners() {
mPlayer.addListener(object : Player.Listener {
override fun onPlayerError(error: PlaybackException) {
@ -351,28 +360,42 @@ class PlayerNotificationService : Service() {
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_DISPLAY_TITLE, currentAudiobook!!.title)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, currentAudiobook!!.author)
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, currentAudiobook!!.author)
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, currentAudiobook!!.author)
.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)
.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)
var mediaMetadata = MediaMetadata.Builder().build()
var mediaItem = MediaItem.Builder().setUri(currentAudiobook!!.playlistUri).setMediaMetadata(mediaMetadata).build()
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}"))
var mediaSource = HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem)
mediaSource = HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem)
}
mPlayer.setMediaSource(mediaSource, true)
mPlayer.prepare()

View file

@ -2,8 +2,20 @@
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
include ':capacitor-community-sqlite'
project(':capacitor-community-sqlite').projectDir = new File('../node_modules/@capacitor-community/sqlite/android')
include ':capacitor-dialog'
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'
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')

View file

@ -1,3 +1,5 @@
@import "./fonts.css";
.box-shadow-md {
box-shadow: 2px 8px 6px #111111aa;
}

41
assets/fonts.css Normal file
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;
}

View file

@ -202,7 +202,17 @@ export default {
this.isResetting = false
this.initObject = { ...audiobookStreamData }
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() {
if (!this.initObject) {
@ -210,7 +220,17 @@ export default {
return
}
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() {
MyNativeAudio.playPlayer()

View file

@ -13,14 +13,18 @@
<div class="flex-grow" />
<!-- <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="block truncate">{{ username }}</span>
</span>
<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>
</nuxt-link>
</nuxt-link> -->
</div>
</div>
</template>
@ -89,4 +93,47 @@ export default {
#appbar {
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>

View file

@ -4,7 +4,7 @@
<div :key="index" class="border-b border-opacity-10 w-full bookshelfRow py-4 flex justify-around relative">
<template v-for="audiobook in shelf">
<!-- <div :key="audiobook.id" class="relative px-4"> -->
<cards-book-card :key="audiobook.id" :audiobook="audiobook" :width="cardWidth" :user-progress="userAudiobooks[audiobook.id]" />
<cards-book-card :key="audiobook.id" :audiobook="audiobook" :width="cardWidth" :user-progress="userAudiobooks[audiobook.id]" :local-user-progress="localUserAudiobooks[audiobook.id]" />
<!-- <nuxt-link :to="`/audiobook/${audiobook.id}`">
<cards-book-cover :audiobook="audiobook" :width="cardWidth" class="mx-auto -mb-px" style="box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166" />
</nuxt-link> -->
@ -44,6 +44,9 @@ export default {
},
userAudiobooks() {
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
},
localUserAudiobooks() {
return this.$store.state.user.localUserAudiobooks || {}
}
},
methods: {
@ -52,11 +55,6 @@ export default {
filterBy: 'all'
})
},
playAudiobook(audiobook) {
console.log('Play Audiobook', audiobook)
this.$store.commit('setStreamAudiobook', audiobook)
this.$server.socket.emit('open_stream', audiobook.id)
},
calcShelves() {
var booksPerShelf = Math.floor(this.pageWidth / (this.cardWidth + 32))
var groupedBooks = []
@ -87,6 +85,16 @@ export default {
if (this.currFilterOrderKey !== this.filterOrderKey) {
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('downloads/loadFromStorage')
}
}
},
mounted() {
@ -94,7 +102,13 @@ export default {
this.$store.commit('user/addSettingsListener', { id: 'bookshelf', meth: this.settingsUpdated })
window.addEventListener('resize', this.resize)
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()
},
beforeDestroy() {

View file

@ -1,21 +1,21 @@
<template>
<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>
<p class="px-2">{{ title }}</p>
<p class="px-2 text-xs text-gray-400">by {{ author }}</p>
</div>
<div class="flex-grow" />
<div 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">
<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>
</div>
<div class="absolute left-2 -top-10">
<cards-book-cover :audiobook="streamAudiobook" :width="64" />
<cards-book-cover :audiobook="audiobook" :width="64" />
</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>
<modals-playback-speed-modal v-model="showPlaybackSpeedModal" :playback-speed.sync="playbackSpeed" @change="changePlaybackSpeed" />
<modals-chapters-modal v-model="showChapterModal" :chapters="chapters" @select="selectChapter" />
@ -30,24 +30,62 @@ export default {
return {
audioPlayerReady: false,
stream: null,
lastServerUpdateSentSeconds: 0,
download: null,
lastProgressTimeUpdate: 0,
showPlaybackSpeedModal: false,
playbackSpeed: 1,
showChapterModal: false
}
},
watch: {
// playingDownload: {
// handler(newVal, oldVal) {
// console.log('[StreamContainer] Download AUDIOBOOK Changed ' + newVal + '|' + oldVal)
// if (newVal) {
// if (this.audioPlayerReady) {
// this.playDownload()
// }
// } else if (this.download) {
// this.download = null
// }
// }
// },
// streamAudiobook(newVal, oldval) {
// console.log('[StreamContainer] Stream AUDIOBOOK Changed ' + newVal + '|' + oldVal)
// }
},
watch: {
socketConnected(newVal) {
if (newVal) {
console.log('Socket Connected set listeners')
this.setListeners()
}
}
},
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() {
return this.$store.state.streamAudiobook
},
streamAudiobookId() {
return this.streamAudiobook ? this.streamAudiobook.id : null
},
currStreamAudiobookId() {
return this.stream ? this.stream.audiobook.id : null
},
book() {
return this.streamAudiobook ? this.streamAudiobook.book || {} : {}
return this.audiobook ? this.audiobook.book || {} : {}
},
title() {
return this.book ? this.book.title : ''
@ -62,7 +100,7 @@ export default {
return this.book ? this.book.series : ''
},
chapters() {
return this.streamAudiobook ? this.streamAudiobook.chapters || [] : []
return this.audiobook ? this.audiobook.chapters || [] : []
},
volumeNumber() {
return this.book ? this.book.volumeNumber : ''
@ -73,7 +111,7 @@ export default {
return `${this.series} #${this.volumeNumber}`
},
duration() {
return this.streamAudiobook ? this.streamAudiobook.duration || 0 : 0
return this.audiobook ? this.audiobook.duration || 0 : 0
},
coverForNative() {
if (!this.cover) {
@ -100,6 +138,14 @@ export default {
this.showChapterModal = false
},
async cancelStream() {
if (this.download) {
if (this.$refs.audioPlayerMini) {
this.$refs.audioPlayerMini.terminateStream()
}
this.$store.commit('setPlayingDownload', null)
this.$localStore.setCurrent(null)
} else {
const { value } = await Dialog.confirm({
title: 'Confirm',
message: 'Cancel this stream?'
@ -111,16 +157,42 @@ export default {
this.$refs.audioPlayerMini.terminateStream()
}
}
}
},
updateTime(currentTime) {
var diff = currentTime - this.lastServerUpdateSentSeconds
var diff = currentTime - this.lastProgressTimeUpdate
if (diff > 4 || diff < 0) {
this.lastServerUpdateSentSeconds = currentTime
this.lastProgressTimeUpdate = currentTime
if (this.stream) {
var updatePayload = {
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) {
// var updateObj = {
// audiobookId: this.download.id,
// totalDuration: this.download.audiobook.duration,
// clientCurrentTime: currentTime,
// clientProgress: Number((currentTime / this.download.audiobook.duration).toFixed(3))
// }
this.$server.socket.emit('progress_update', progressUpdate)
}
this.$localStore.updateUserAudiobookProgress(progressUpdate).then(() => {
console.log('Updated user audiobook progress', currentTime)
})
}
}
},
closeStream() {},
@ -151,8 +223,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) {
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) {
console.error('No Audio Player Mini')
return
@ -160,6 +291,9 @@ export default {
this.stream = stream
// Update local remove current
this.$localStore.setCurrent(null)
var playlistUrl = stream.clientPlaylistUri
var currentTime = stream.clientCurrentTime || 0
var playOnLoad = this.$store.state.playOnLoad
@ -177,13 +311,17 @@ export default {
playlistUrl: this.$server.url + playlistUrl,
token: this.$store.getters['user/getToken']
}
this.$refs.audioPlayerMini.set(audiobookStreamData)
},
audioPlayerMounted() {
console.log('Audio Player Mounted', this.$server.stream)
this.audioPlayerReady = true
if (this.$server.stream) {
if (this.playingDownload) {
console.log('[StreamContainer] Play download on audio mount')
this.playDownload()
} else if (this.$server.stream) {
console.log('[StreamContainer] Open stream on audio mount')
this.streamOpen(this.$server.stream)
}
},
@ -206,6 +344,26 @@ export default {
this.playbackSpeed = settings.playbackRate
this.$refs.audioPlayerMini.updatePlaybackRate()
}
},
streamUpdated(type, data) {
if (type === 'download') {
if (data) {
console.log('START DOWNLOAD PLAY')
this.download = { ...data }
if (this.audioPlayerReady) {
this.playDownload()
}
} else if (this.download) {
console.log('STOP DOWNLOAD PLAY')
this.cancelStream()
}
} else {
if (data) {
console.log('STARTING STREAM')
} else {
console.log('STOPPING STREAM')
}
}
}
},
mounted() {
@ -213,15 +371,19 @@ export default {
this.setListeners()
this.$store.commit('user/addSettingsListener', { id: 'streamContainer', meth: this.settingsUpdated })
this.$store.commit('setStreamListener', this.streamUpdated)
},
beforeDestroy() {
if (this.$server.socket) {
this.$server.socket.off('stream_open', this.streamOpen)
this.$server.socket.off('stream_closed', this.streamClosed)
this.$server.socket.off('stream_progress', this.streamProgress)
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('removeStreamListener')
}
}
</script>

View file

@ -11,7 +11,11 @@
<div class="rounded-sm h-full overflow-hidden relative box-shadow-book">
<nuxt-link :to="`/audiobook/${audiobookId}`" class="cursor-pointer">
<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>
@ -35,6 +39,10 @@ export default {
type: Object,
default: () => null
},
localUserProgress: {
type: Object,
default: () => null
},
width: {
type: Number,
default: 140
@ -81,8 +89,13 @@ export default {
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() {
return this.userProgress ? this.userProgress.progress || 0 : 0
return this.mostRecentUserProgress ? this.mostRecentUserProgress.progress || 0 : 0
},
showError() {
return this.hasMissingParts || this.hasInvalidParts
@ -93,6 +106,12 @@ export default {
hasInvalidParts() {
return this.audiobook.hasInvalidParts
},
downloadCover() {
return this.download ? this.download.cover : null
},
download() {
return this.$store.getters['downloads/getDownloadIfReady'](this.audiobookId)
},
errorText() {
var txt = ''
if (this.hasMissingParts) {
@ -105,12 +124,7 @@ export default {
return txt || 'Unknown Error'
}
},
methods: {
play() {
this.$store.commit('setStreamAudiobook', this.audiobook)
this.$root.socket.emit('open_stream', this.audiobookId)
}
},
methods: {},
mounted() {}
}
</script>

View file

@ -32,6 +32,7 @@ export default {
type: Object,
default: () => {}
},
downloadCover: String,
authorOverride: String,
width: {
type: Number,
@ -78,7 +79,13 @@ export default {
serverUrl() {
return this.$store.state.serverUrl
},
networkConnected() {
return this.$store.state.networkConnected
},
fullCoverUrl() {
if (this.downloadCover) return this.downloadCover
else if (!this.networkConnected) return this.placeholderUrl
if (this.cover.startsWith('http')) return this.cover
var _clean = this.cover.replace(/\\/g, '/')
if (_clean.startsWith('/local')) {
@ -91,6 +98,7 @@ export default {
return this.book.cover || this.placeholderUrl
},
hasCover() {
if (!this.networkConnected && !this.downloadCover) return false
return !!this.book.cover
},
sizeMultiplier() {

View file

@ -0,0 +1,147 @@
<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="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
<div 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>
<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">
<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>
</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">
<span class="font-normal block truncate text-sm">{{ 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'
export default {
data() {
return {
downloadingProgress: {}
}
},
computed: {
show: {
get() {
return this.$store.state.downloads.showModal
},
set(val) {
this.$store.commit('downloads/setShowModal', val)
}
},
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
},
downloads() {
return this.$store.state.downloads.downloads
// return [
// {
// id: 'asdf1',
// title: 'Test Title 1',
// author: 'Test Author 1',
// isDownloading: true
// },
// {
// id: 'asdf2',
// title: 'Test Title 2',
// author: 'Author 2',
// isReady: true
// },
// {
// id: 'asdf3',
// name: 'asdf.mp3',
// isReady: false,
// isDownloading: false
// }
// ]
}
},
methods: {
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>

View file

@ -1,6 +1,6 @@
<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 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">
<span class="material-icons text-4xl">close</span>

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>

View file

@ -1,24 +1,28 @@
<template>
<div class="w-full min-h-screen h-full bg-bg text-white">
<app-appbar />
<div id="content" class="overflow-hidden" :class="streaming ? 'streaming' : ''">
<div id="content" class="overflow-hidden" :class="playerIsOpen ? 'playerOpen' : ''">
<Nuxt />
</div>
<app-stream-container ref="streamContainer" />
<modals-downloads-modal ref="downloadsModal" @selectDownload="selectDownload" @deleteDownload="deleteDownload" />
</div>
</template>
<script>
import Path from 'path'
import { Capacitor } from '@capacitor/core'
import { Network } from '@capacitor/network'
import { AppUpdate } from '@robingenz/capacitor-app-update'
import AudioDownloader from '@/plugins/audio-downloader'
export default {
middleware: 'authenticated',
data() {
return {}
},
computed: {
streaming() {
return this.$store.state.streamAudiobook
playerIsOpen() {
return this.$store.getters['playerIsOpen']
},
routeName() {
return this.$route.name
@ -30,10 +34,49 @@ export default {
if (isConnected) {
this.$router.push('/')
}
} else {
if (!isConnected) {
this.$router.push('/connect')
}
this.syncUserProgress()
},
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 {
// Server is more recent than local
newestLocal[audiobookId] = userAudiobooks[audiobookId]
localHasUpdates = true
}
} else {
// Not on local yet - store on local
newestLocal[audiobookId] = userAudiobooks[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) {
@ -49,16 +92,16 @@ export default {
await AppUpdate.openAppStore()
}
},
showUpdateToast(availableVersion, immediateUpdateAllowed) {
var toastText = immediateUpdateAllowed ? `Click here to update` : `Click here to open app store`
this.$toast.info(`Update is available for v${availableVersion}! ${toastText}`, {
draggable: false,
hideProgressBar: false,
timeout: 10000,
closeButton: false,
onClick: this.clickUpdateToast()
})
},
// showUpdateToast(availableVersion, immediateUpdateAllowed) {
// var toastText = immediateUpdateAllowed ? `Click here to update` : `Click here to open app store`
// this.$toast.info(`Update is available for v${availableVersion}! ${toastText}`, {
// draggable: false,
// hideProgressBar: false,
// timeout: 10000,
// closeButton: false,
// onClick: this.clickUpdateToast()
// })
// },
async checkForUpdate() {
const result = await AppUpdate.getAppUpdateInfo()
if (!result) {
@ -66,13 +109,165 @@ export default {
return
}
this.$store.commit('setAppUpdateInfo', result)
// if (result.updateAvailability === 2) {
// setTimeout(() => {
if (result.updateAvailability === 2) {
setTimeout(() => {
this.$toast.info(`Update is available!`, {
draggable: false,
hideProgressBar: false,
timeout: 4000,
closeButton: false,
onClick: this.clickUpdateToast()
})
// this.showUpdateToast(result.availableVersion, !!result.immediateUpdateAllowed)
// }, 5000)
// }
}, 5000)
}
},
onDownloadProgress(data) {
// var downloadId = data.downloadId
var progress = data.progress
var filename = data.filename
var audiobookId = filename ? Path.basename(filename, Path.extname(filename)) : ''
var downloadObj = this.$store.getters['downloads/getDownload'](audiobookId)
if (downloadObj) {
if (this.$refs.downloadsModal) {
this.$refs.downloadsModal.updateDownloadProgress({ audiobookId, progress })
}
this.$toast.update(downloadObj.toastId, { content: `${progress}% Downloading ${downloadObj.audiobook.book.title}` })
}
},
onDownloadComplete(data) {
var downloadId = data.downloadId
var contentUrl = data.contentUrl
var filename = data.filename
var audiobookId = filename ? Path.basename(filename, Path.extname(filename)) : ''
if (audiobookId) {
// Notify server to remove prepared download
if (this.$server.socket) {
this.$server.socket.emit('remove_download', downloadId)
}
console.log('Download complete', filename, downloadId, contentUrl, 'AudiobookId:', audiobookId)
var downloadObj = this.$store.getters['downloads/getDownload'](audiobookId)
if (!downloadObj) {
console.error('Could not find download...')
} else {
this.$toast.update(downloadObj.toastId, { content: `Success! ${downloadObj.audiobook.book.title} downloaded.`, options: { timeout: 5000, type: 'success' } }, true)
console.log('Found download, update with content url')
delete downloadObj.isDownloading
delete downloadObj.isPreparing
downloadObj.contentUrl = contentUrl
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)
}
},
onMediaLoaded(items) {
var jsitems = JSON.parse(items)
jsitems = jsitems.map((item) => {
return {
id: item.id,
size: item.size,
duration: item.duration,
filename: item.name,
audiobookId: item.name ? Path.basename(item.name, Path.extname(item.name)) : '',
contentUrl: item.uri.replace(/\\\//g, '/'),
coverUrl: item.coverUrl || null
}
})
jsitems.forEach((item) => {
var download = this.$store.getters['downloads/getDownload'](item.audiobookId)
if (!download) {
console.error(`Unknown media item found for filename ${item.filename}`)
var orphanDownload = {
id: `orphan-${item.id}`,
contentUrl: item.contentUrl,
coverUrl: item.coverUrl,
cover: item.coverUrl ? Capacitor.convertFileSrc(item.coverUrl) : null,
mediaId: item.id,
filename: item.filename,
size: item.size,
duration: item.duration,
isOrphan: true
}
this.$store.commit('downloads/addUpdateDownload', orphanDownload)
} else {
console.log(`Found media item for audiobook ${download.audiobook.book.title} (${item.audiobookId})`)
download.contentUrl = item.contentUrl
download.coverUrl = item.coverUrl
download.cover = item.coverUrl ? Capacitor.convertFileSrc(item.coverUrl) : null
download.size = item.size
download.duration = item.duration
this.$store.commit('downloads/addUpdateDownload', download)
download.audiobook.isDownloaded = true
this.$store.commit('audiobooks/addUpdate', download.audiobook)
}
})
var downloads = this.$store.state.downloads.downloads
downloads.forEach((download) => {
var matchingItem = jsitems.find((item) => item.audiobookId === download.id)
if (!matchingItem) {
console.error(`Saved download not in media store ${download.audiobook.book.title} (${download.id})`)
this.$store.commit('downloads/removeDownload', download)
}
})
this.checkLoadCurrent()
},
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({ filename: download.filename, url: download.contentUrl, coverUrl: download.coverUrl })
}
this.$store.commit('downloads/removeDownload', download)
},
async initMediaStore() {
// Load local database of downloads
await this.$store.dispatch('downloads/loadFromStorage')
await this.$localStore.loadUserAudiobooks()
// Request and setup listeners for media files on native
AudioDownloader.addListener('onDownloadComplete', (data) => {
this.onDownloadComplete(data)
})
AudioDownloader.addListener('onMediaLoaded', (data) => {
this.onMediaLoaded(data.items)
})
AudioDownloader.addListener('onDownloadProgress', (data) => {
this.onDownloadProgress(data)
})
AudioDownloader.load()
},
// parseSemver(ver) {
// if (!ver) return null
// 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-]+)*))?)$/)
@ -130,6 +325,16 @@ export default {
// this.$store.commit('setHasUpdate', true)
// }
// }
async setupNetworkListener() {
var status = await Network.getStatus()
console.log('Network status', status.connected, status.connectionType)
this.$store.commit('setNetworkStatus', status)
Network.addListener('networkStatusChange', (status) => {
console.log('Network status changed', status.connected, status.connectionType)
this.$store.commit('setNetworkStatus', status)
})
}
},
mounted() {
if (!this.$server) return console.error('No Server')
@ -137,16 +342,9 @@ export default {
this.$server.on('connected', this.connected)
this.$server.on('initialStream', this.initialStream)
if (!this.$server.connected) {
this.$router.push('/connect')
}
this.setupNetworkListener()
this.checkForUpdate()
// var checkForUpdateFlag = localStorage.getItem('checkForUpdate')
// if (!checkForUpdateFlag || checkForUpdateFlag !== '1') {
// this.checkForUpdate()
// }
this.initMediaStore()
}
}
</script>
@ -155,7 +353,8 @@ export default {
#content {
height: calc(100vh - 64px);
}
#content.streaming {
height: calc(100vh - 204px);
#content.playerOpen {
/* height: calc(100vh - 204px); */
height: calc(100vh - 240px);
}
</style>

View file

@ -26,8 +26,7 @@ export default {
],
link: [
{ 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/icon?family=Material+Icons' }
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Ubuntu+Mono&family=Open+Sans:wght@400;600' },
]
},
@ -37,9 +36,11 @@ export default {
plugins: [
'@/plugins/server.js',
'@/plugins/store.js',
'@/plugins/init.client.js',
'@/plugins/axios.js',
'@/plugins/my-native-audio.js',
'@/plugins/audio-downloader.js',
'@/plugins/toast.js'
],

479
package-lock.json generated
View file

@ -1,6 +1,6 @@
{
"name": "audiobookshelf-app",
"version": "v0.2.1-beta",
"version": "v0.4.0-beta",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@ -1000,6 +1000,11 @@
"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": {
"version": "3.2.2",
"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",
"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": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@csstools/convert-colors/-/convert-colors-1.4.0.tgz",
"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": {
"version": "2.2.2",
"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",
"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": {
"version": "1.2.10",
"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",
"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": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/boxen/-/boxen-5.0.1.tgz",
@ -3875,6 +3950,40 @@
"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": {
"version": "1.0.2",
"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",
"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": {
"version": "2.4.2",
"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": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@ -4296,6 +4422,16 @@
"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": {
"version": "3.7.0",
"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",
"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": {
"version": "1.0.1",
"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",
"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": {
"version": "1.1.3",
"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",
"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": {
"version": "5.2.0",
"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",
"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": {
"version": "3.7.1",
"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",
"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": {
"version": "1.3.796",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.796.tgz",
@ -5445,6 +5622,12 @@
"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": {
"version": "3.1.1",
"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": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -6070,11 +6279,58 @@
"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": {
"version": "11.12.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
"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": {
"version": "11.0.4",
"resolved": "https://registry.npmjs.org/globby/-/globby-11.0.4.tgz",
@ -6088,6 +6344,34 @@
"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": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.6.tgz",
@ -6511,6 +6795,11 @@
"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": {
"version": "1.7.3",
"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",
"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": {
"version": "2.1.0",
"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",
"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": {
"version": "1.0.2",
"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",
"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": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.0.tgz",
@ -7142,6 +7447,14 @@
"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": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
@ -7297,6 +7610,14 @@
"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": {
"version": "2.0.3",
"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": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
@ -7409,6 +7738,11 @@
"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": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@ -7446,6 +7780,23 @@
"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": {
"version": "1.3.5",
"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",
"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": {
"version": "1.0.1",
"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",
"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": {
"version": "4.0.1",
"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",
"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": {
"version": "1.0.0",
"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",
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz",
@ -11029,6 +11413,12 @@
"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": {
"version": "1.4.8",
"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",
"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": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz",
@ -11601,6 +11999,28 @@
"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": {
"version": "2.4.1",
"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",
"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": {
"version": "0.17.1",
"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": {
"version": "5.0.1",
"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": {
"version": "5.5.0",
"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": {
"version": "3.0.2",
"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",
"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": {
"version": "0.21.3",
"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": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",

View file

@ -1,6 +1,6 @@
{
"name": "audiobookshelf-app",
"version": "v0.4.0-beta",
"version": "v0.8.0-beta",
"author": "advplyr",
"scripts": {
"dev": "nuxt --hostname localhost --port 1337",
@ -10,14 +10,18 @@
"icons-android": "cordova-res android --skip-config --copy"
},
"dependencies": {
"@capacitor-community/sqlite": "^3.2.0",
"@capacitor/android": "^3.2.2",
"@capacitor/cli": "^3.1.2",
"@capacitor/core": "^3.2.2",
"@capacitor/dialog": "^1.0.3",
"@capacitor/ios": "^3.2.2",
"@capacitor/network": "^1.0.3",
"@capacitor/storage": "^1.1.0",
"@nuxtjs/axios": "^5.13.6",
"@robingenz/capacitor-app-update": "^1.0.0",
"axios": "^0.21.1",
"capacitor-data-storage-sqlite": "^3.2.0",
"core-js": "^3.15.1",
"hls.js": "^1.0.9",
"nuxt": "^2.15.7",

View file

@ -22,7 +22,9 @@
<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! Open 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>
</template>
@ -67,8 +69,15 @@ export default {
this.$server.logout()
this.$router.push('/connect')
},
openAppStore() {
AppUpdate.openAppStore()
},
async clickUpdate() {
await AppUpdate.openAppStore()
if (this.immediateUpdateAllowed) {
AppUpdate.performImmediateUpdate()
} else {
AppUpdate.openAppStore()
}
}
},
mounted() {}

View file

@ -3,17 +3,19 @@
<div class="flex">
<div class="w-32">
<div class="relative">
<cards-book-cover :audiobook="audiobook" :width="128" />
<cards-book-cover :audiobook="audiobook" :download-cover="downlaodedCover" :width="128" />
<div class="absolute bottom-0 left-0 h-1.5 bg-yellow-400 shadow-sm" :style="{ width: 128 * progressPercent + 'px' }"></div>
</div>
<!-- <cards-book-cover :audiobook="audiobook" :width="128" /> -->
<div class="flex my-4">
<p class="text-sm">{{ numTracks }} Tracks</p>
</div>
</div>
<div class="flex-grow px-3">
<h1 class="text-lg">{{ title }}</h1>
<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-gray-300 text-sm my-1">
{{ durationPretty }}<span class="px-4">{{ sizePretty }}</span>
{{ $elapsedPretty(duration) }}<span class="px-4">{{ $bytesPretty(size) }}</span>
</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' : ''">
@ -24,11 +26,15 @@
</div>
</div>
<ui-btn color="success" :disabled="streaming" class="flex items-center justify-center w-full mt-4" :padding-x="4" @click="playClick">
<span v-show="!streaming" class="material-icons">play_arrow</span>
<span class="px-1">{{ streaming ? 'Streaming' : 'Play' }}</span>
<div v-if="isConnected || isDownloadPlayable" class="flex mt-4">
<ui-btn color="success" :disabled="isPlaying" class="flex items-center justify-center w-full mr-2" :padding-x="4" @click="playClick">
<span v-show="!isPlaying" class="material-icons">play_arrow</span>
<span class="px-1">{{ isPlaying ? (isStreaming ? 'Streaming' : 'Playing') : isDownloadPlayable ? 'Play local' : 'Play stream' }}</span>
</ui-btn>
<div class="flex my-4"></div>
<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 class="w-full py-4">
@ -38,15 +44,25 @@
</template>
<script>
import Path from 'path'
import { Dialog } from '@capacitor/dialog'
import { Capacitor } from '@capacitor/core'
import AudioDownloader from '@/plugins/audio-downloader'
export default {
async asyncData({ params, redirect, app }) {
async asyncData({ store, params, redirect, app }) {
var audiobookId = params.id
var audiobook = await app.$axios.$get(`/api/audiobook/${audiobookId}`).catch((error) => {
var audiobook = null
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) {
console.error('No audiobook...', params.id)
return redirect('/')
@ -61,6 +77,9 @@ export default {
}
},
computed: {
isConnected() {
return this.$store.state.socketConnected
},
audiobookId() {
return this.audiobook.id
},
@ -87,14 +106,11 @@ export default {
if (!this.volumeNumber) return this.series
return `${this.series} #${this.volumeNumber}`
},
durationPretty() {
return this.audiobook.durationPretty
},
duration() {
return this.audiobook.duration
},
sizePretty() {
return this.audiobook.sizePretty
size() {
return this.audiobook.size
},
userAudiobooks() {
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
@ -102,27 +118,65 @@ export default {
userAudiobook() {
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() {
return this.userAudiobook ? this.userAudiobook.currentTime : 0
return this.mostRecentUserAudiobook ? this.mostRecentUserAudiobook.currentTime : 0
},
userTimeRemaining() {
return this.duration - this.userCurrentTime
},
progressPercent() {
return this.userAudiobook ? this.userAudiobook.progress : 0
},
streamAudiobook() {
return this.$store.state.streamAudiobook
return this.mostRecentUserAudiobook ? this.mostRecentUserAudiobook.progress : 0
},
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
},
downlaodedCover() {
return this.downloadObj ? this.downloadObj.cover : null
},
downloadObj() {
return this.$store.getters['downloads/getDownload'](this.audiobookId)
}
},
methods: {
playClick() {
this.$store.commit('setPlayOnLoad', true)
if (!this.isDownloadPlayable) {
// 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() {
const { value } = await Dialog.confirm({
@ -132,18 +186,27 @@ export default {
if (value) {
this.resettingProgress = true
this.$axios
if (this.$server.connected) {
await this.$axios
.$delete(`/api/user/audiobook/${this.audiobookId}`)
.then(() => {
console.log('Progress reset complete')
this.$toast.success(`Your progress was reset`)
this.resettingProgress = false
})
.catch((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() {
console.log('Audiobook Updated - Fetch full audiobook')
@ -155,12 +218,160 @@ export default {
.catch((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 = await this.$axios.$get(`/api/audiobook/${this.audiobookId}`).catch((error) => {
// console.error('Failed', error)
// return false
// })
var audiobook = this.audiobook
if (!audiobook) {
return
}
var downloadObject = {
id: this.audiobookId,
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 downloadCover(download) {
var coverUrl = this.getCoverUrlForDownload()
if (!coverUrl) {
return null
}
var extname = Path.extname(coverUrl) || '.jpg'
var downloadRequestPayload = {
downloadUrl: coverUrl,
filename: `${download.id}${extname}`,
title: download.audiobook.book.title
}
console.log('Starting cover download', coverUrl, downloadRequestPayload.filename)
var downloadRes = await AudioDownloader.downloadCover(downloadRequestPayload)
if (downloadRes && downloadRes.url) {
console.log('Cover art downloaded', downloadRes.url)
return downloadRes.url
} else {
console.error('Cover art failed to download')
return null
}
},
async startDownload(url, fileext, download) {
this.$toast.update(download.toastId, { content: `Downloading "${download.audiobook.book.title}"...` })
download.isDownloading = true
download.isPreparing = false
download.filename = `${download.id}${fileext}`
this.$store.commit('downloads/addUpdateDownload', download)
console.log('Starting Download URL', url)
var downloadRequestPayload = {
filename: download.filename,
downloadUrl: url,
title: download.audiobook.book.title
}
var downloadRes = await AudioDownloader.download(downloadRequestPayload)
var downloadId = downloadRes.value
if (!downloadId) {
console.error('Invalid download, removing')
this.$toast.update(download.toastId, { content: `Failed download "${download.audiobook.book.title}"`, options: { timeout: 5000, type: 'error' } })
this.$store.commit('downloads/removeDownload', download)
} else {
console.log('Download cover now')
var coverUrl = await this.downloadCover(download)
if (coverUrl) {
console.log('Got cover url', coverUrl)
download.coverUrl = coverUrl
download.cover = Capacitor.convertFileSrc(coverUrl)
this.$store.commit('downloads/addUpdateDownload', download)
}
}
},
downloadReady(prepareDownload) {
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() {
this.$server.socket.on('download_ready', this.downloadReady)
this.$server.socket.on('download_killed', this.downloadKilled)
this.$server.socket.on('download_failed', this.downloadFailed)
this.$store.commit('audiobooks/addListener', { id: 'audiobook', audiobookId: this.audiobookId, meth: this.audiobookUpdated })
},
beforeDestroy() {
this.$server.socket.off('download_ready', this.downloadReady)
this.$server.socket.off('download_killed', this.downloadKilled)
this.$server.socket.off('download_failed', this.downloadFailed)
this.$store.commit('audiobooks/removeListener', 'audiobook')
}
}

View file

@ -1,11 +1,14 @@
<template>
<div class="w-full h-full">
<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">
<img src="/Logo.png" class="h-20 w-20 mb-2" />
<h1 class="text-2xl font-book">AudioBookshelf</h1>
</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 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">
<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>
<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-btn :disabled="processing" type="submit" :padding-x="3" class="h-10">Submit</ui-btn>
<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 || !networkConnected" type="submit" :padding-x="3" class="h-10">{{ networkConnected ? 'Submit' : 'No Internet' }}</ui-btn>
</form>
<template v-if="showAuth">
<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="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>
</template>
@ -77,8 +80,16 @@ export default {
loggedIn: false
}
},
computed: {
networkConnected() {
return this.$store.state.networkConnected
}
},
methods: {
async submit() {
if (!this.networkConnected) {
return
}
if (!this.serverUrl.startsWith('http')) {
this.serverUrl = 'http://' + this.serverUrl
}
@ -94,6 +105,9 @@ export default {
}
},
async submitAuth() {
if (!this.networkConnected) {
return
}
if (!this.username) {
this.error = 'Invalid username'
return
@ -137,8 +151,11 @@ export default {
}
this.$server.on('connected', this.socketConnected)
var localServerUrl = localStorage.getItem('serverUrl')
var localUserToken = localStorage.getItem('userToken')
var localServerUrl = await this.$localStore.getServerUrl()
var localUserToken = await this.$localStore.getToken()
if (!this.networkConnected) return
if (localServerUrl) {
this.serverUrl = localServerUrl
if (localUserToken) {

View file

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

View file

@ -1,7 +1,5 @@
import Vue from 'vue'
import Server from '../Server'
Vue.prototype.$server = null
export default function ({ store }) {
Vue.prototype.$server = new Server(store)
export default function ({ store }, inject) {
inject('server', new Server(store))
}

558
plugins/store.js Normal file
View file

@ -0,0 +1,558 @@
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.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 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.

BIN
static/material-icons.woff2 Normal file

Binary file not shown.

View file

@ -12,6 +12,9 @@ export const state = () => ({
})
export const getters = {
getAudiobook: state => id => {
return state.audiobooks.find(ab => ab.id === id)
},
getFiltered: (state, getters, rootState) => () => {
var filtered = state.audiobooks
var settings = rootState.user.settings || {}
@ -51,7 +54,7 @@ export const getters = {
export const actions = {
load({ commit }) {
this.$axios
return this.$axios
.$get(`/api/audiobooks`)
.then((data) => {
console.log('Audiobooks request data', data)
@ -59,12 +62,17 @@ export const actions = {
})
.catch((error) => {
console.error('Failed', error)
commit('set', [])
})
},
}
}
export const mutations = {
reset(state) {
state.audiobooks = []
state.genres = [...STANDARD_GENRES]
state.tags = []
state.series = []
},
set(state, audiobooks) {
// GENRES
var genres = [...state.genres]
@ -92,7 +100,15 @@ export const mutations = {
state.series = series
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) => {
listener.meth()
})

65
store/downloads.js Normal file
View file

@ -0,0 +1,65 @@
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
}
}
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)
}
}
}

View file

@ -1,12 +1,29 @@
import Vue from 'vue'
export const state = () => ({
streamAudiobook: null,
playingDownload: null,
playOnLoad: false,
serverUrl: null,
user: null,
appUpdateInfo: null
appUpdateInfo: null,
socketConnected: false,
networkConnected: false,
networkConnectionType: 'unknown',
streamListener: null
})
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 mutations = {
@ -23,12 +40,37 @@ export const mutations = {
state.playOnLoad = val
},
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) {
state.serverUrl = url
},
setUser(state, user) {
state.user = user
setSocketConnected(state, val) {
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
}
}

View file

@ -1,6 +1,9 @@
import { Storage } from '@capacitor/storage'
import Vue from 'vue'
export const state = () => ({
user: null,
localUserAudiobooks: {},
settings: {
orderBy: 'book.title',
orderDesc: false,
@ -28,7 +31,9 @@ export const getters = {
}
export const actions = {
updateUserSettings({ commit }, payload) {
async updateUserSettings({ commit }, payload) {
if (Vue.prototype.$server.connected) {
var updatePayload = {
...payload
}
@ -44,17 +49,24 @@ export const actions = {
console.error('Failed to update settings', error)
return false
})
} else {
console.log('Update settings without server')
commit('setSettings', payload)
}
}
}
export const mutations = {
setLocalUserAudiobooks(state, userAudiobooks) {
state.localUserAudiobooks = userAudiobooks
},
setUser(state, user) {
state.user = user
if (user) {
if (user.token) localStorage.setItem('token', user.token)
if (user.token) this.$localStore.setToken(user.token)
console.log('setUser', user.username)
} else {
localStorage.removeItem('token')
this.$localStore.setToken(null)
console.warn('setUser cleared')
}
},
@ -69,6 +81,9 @@ export const mutations = {
}
}
if (hasChanges) {
console.log('Update settings in local storage')
this.$localStore.setUserSettings({ ...state.settings })
state.settingsListeners.forEach((listener) => {
listener.meth(state.settings)
})