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

View file

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

View file

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

View file

@ -1,10 +1,26 @@
[ [
{
"pkg": "@capacitor-community/sqlite",
"classpath": "com.getcapacitor.community.database.sqlite.CapacitorSQLitePlugin"
},
{ {
"pkg": "@capacitor/dialog", "pkg": "@capacitor/dialog",
"classpath": "com.capacitorjs.plugins.dialog.DialogPlugin" "classpath": "com.capacitorjs.plugins.dialog.DialogPlugin"
}, },
{
"pkg": "@capacitor/network",
"classpath": "com.capacitorjs.plugins.network.NetworkPlugin"
},
{
"pkg": "@capacitor/storage",
"classpath": "com.capacitorjs.plugins.storage.StoragePlugin"
},
{ {
"pkg": "@robingenz/capacitor-app-update", "pkg": "@robingenz/capacitor-app-update",
"classpath": "dev.robingenz.capacitor.appupdate.AppUpdatePlugin" "classpath": "dev.robingenz.capacitor.appupdate.AppUpdatePlugin"
},
{
"pkg": "capacitor-data-storage-sqlite",
"classpath": "com.jeep.plugin.capacitor.capacitordatastoragesqlite.CapacitorDataStorageSqlitePlugin"
} }
] ]

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 playbackSpeed:Float = 1f
var duration:Long = 0 var duration:Long = 0
var isLocal:Boolean = false
var contentUrl:String = ""
var hasPlayerLoaded:Boolean = false var hasPlayerLoaded:Boolean = false
val playlistUri:Uri var playlistUri:Uri = Uri.EMPTY
val coverUri:Uri var coverUri:Uri = Uri.EMPTY
var contentUri:Uri = Uri.EMPTY // For Local only
constructor(jsondata:JSObject) { constructor(jsondata:JSObject) {
id = jsondata.getString("id", "audiobook").toString() id = jsondata.getString("id", "audiobook").toString()
@ -34,7 +38,22 @@ class Audiobook {
playbackSpeed = jsondata.getDouble("playbackSpeed")!!.toFloat() playbackSpeed = jsondata.getDouble("playbackSpeed")!!.toFloat()
duration = jsondata.getString("duration", "0")!!.toLong() duration = jsondata.getString("duration", "0")!!.toLong()
// Local data
isLocal = jsondata.getBoolean("isLocal", false) == true
contentUrl = jsondata.getString("contentUrl", "").toString()
if (playlistUrl != "") {
playlistUri = Uri.parse(playlistUrl) playlistUri = Uri.parse(playlistUrl)
}
if (cover != "") {
coverUri = Uri.parse(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 package com.audiobookshelf.app
import android.content.ComponentName import android.app.DownloadManager
import android.content.Context import android.content.*
import android.content.Intent
import android.content.ServiceConnection
import android.os.Bundle import android.os.Bundle
import android.os.Handler
import android.os.IBinder import android.os.IBinder
import android.os.Looper
import android.util.Log import android.util.Log
import com.example.myapp.MyNativeAudio import android.widget.Toast
import com.getcapacitor.BridgeActivity import com.getcapacitor.BridgeActivity
class MainActivity : BridgeActivity() { class MainActivity : BridgeActivity() {
private val tag = "MainActivity" private val tag = "MainActivity"
@ -18,11 +19,45 @@ class MainActivity : BridgeActivity() {
private lateinit var mConnection : ServiceConnection private lateinit var mConnection : ServiceConnection
lateinit var pluginCallback : () -> Unit lateinit var pluginCallback : () -> Unit
lateinit var downloaderCallback : (String, Long) -> Unit
val 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?) { public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
Log.d(tag, "onCreate") Log.d(tag, "onCreate")
registerPlugin(MyNativeAudio::class.java) registerPlugin(MyNativeAudio::class.java)
registerPlugin(AudioDownloader::class.java)
var filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE).apply {
addAction(DownloadManager.ACTION_NOTIFICATION_CLICKED)
}
registerReceiver(broadcastReceiver, filter)
}
override fun onDestroy() {
super.onDestroy()
// unregisterReceiver(broadcastReceiver)
} }
override fun onPostCreate(savedInstanceState: Bundle?) { override fun onPostCreate(savedInstanceState: Bundle?) {
@ -62,4 +97,8 @@ class MainActivity : BridgeActivity() {
val stopIntent = Intent(this, PlayerNotificationService::class.java) val stopIntent = Intent(this, PlayerNotificationService::class.java)
stopService(stopIntent) stopService(stopIntent)
} }
fun registerBroadcastReceiver(cb: (String, Long) -> Unit) {
downloaderCallback = cb
}
} }

View file

@ -1,13 +1,10 @@
package com.example.myapp package com.audiobookshelf.app
import android.content.Intent import android.content.Intent
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.audiobookshelf.app.Audiobook
import com.audiobookshelf.app.MainActivity
import com.audiobookshelf.app.PlayerNotificationService
import com.getcapacitor.JSObject import com.getcapacitor.JSObject
import com.getcapacitor.Plugin import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall import com.getcapacitor.PluginCall
@ -56,12 +53,20 @@ class MyNativeAudio : Plugin() {
} else { } else {
Log.w(tag, "Service already started --") Log.w(tag, "Service already started --")
} }
var jsobj = JSObject()
var audiobook:Audiobook = Audiobook(call.data) var audiobook:Audiobook = Audiobook(call.data)
if (audiobook.playlistUrl == "" && audiobook.contentUrl == "") {
Log.e(tag, "Invalid URL for init audio player")
jsobj.put("success", false)
return call.resolve(jsobj)
}
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post() {
playerNotificationService.initPlayer(audiobook) playerNotificationService.initPlayer(audiobook)
call.resolve() jsobj.put("success", true)
call.resolve(jsobj)
} }
} }
@ -109,6 +114,7 @@ class MyNativeAudio : Plugin() {
call.resolve() call.resolve()
} }
} }
@PluginMethod @PluginMethod
fun seekBackward(call: PluginCall) { fun seekBackward(call: PluginCall) {
var amount:Long = call.getString("amount", "0")!!.toLong() var amount:Long = call.getString("amount", "0")!!.toLong()
@ -117,6 +123,7 @@ class MyNativeAudio : Plugin() {
call.resolve() call.resolve()
} }
} }
@PluginMethod @PluginMethod
fun setPlaybackSpeed(call: PluginCall) { fun setPlaybackSpeed(call: PluginCall) {
var playbackSpeed:Float = call.getFloat("speed", 1.0f)!! var playbackSpeed:Float = call.getFloat("speed", 1.0f)!!
@ -126,6 +133,7 @@ class MyNativeAudio : Plugin() {
call.resolve() call.resolve()
} }
} }
@PluginMethod @PluginMethod
fun terminateStream(call: PluginCall) { fun terminateStream(call: PluginCall) {
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post() {

View file

@ -9,6 +9,7 @@ import android.net.Uri
import android.os.Binder import android.os.Binder
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.provider.MediaStore
import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaControllerCompat import android.support.v4.media.session.MediaControllerCompat
@ -24,10 +25,15 @@ import com.google.android.exoplayer2.*
import com.google.android.exoplayer2.audio.AudioAttributes import com.google.android.exoplayer2.audio.AudioAttributes
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator
import com.google.android.exoplayer2.source.DefaultMediaSourceFactory
import com.google.android.exoplayer2.source.MediaSource
import com.google.android.exoplayer2.source.ProgressiveMediaExtractor
import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.source.hls.HlsMediaSource import com.google.android.exoplayer2.source.hls.HlsMediaSource
import com.google.android.exoplayer2.ui.PlayerNotificationManager import com.google.android.exoplayer2.ui.PlayerNotificationManager
import com.google.android.exoplayer2.upstream.* import com.google.android.exoplayer2.upstream.*
import kotlinx.coroutines.* import kotlinx.coroutines.*
import java.io.File
const val NOTIFICATION_LARGE_ICON_SIZE = 144 // px const val NOTIFICATION_LARGE_ICON_SIZE = 144 // px
@ -217,13 +223,13 @@ class PlayerNotificationService : Service() {
mediaSessionConnector = MediaSessionConnector(mediaSession) mediaSessionConnector = MediaSessionConnector(mediaSession)
val queueNavigator: TimelineQueueNavigator = object : TimelineQueueNavigator(mediaSession) { val queueNavigator: TimelineQueueNavigator = object : TimelineQueueNavigator(mediaSession) {
override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat { override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat {
return MediaDescriptionCompat.Builder() var builder = MediaDescriptionCompat.Builder()
.setMediaId(currentAudiobook!!.id) .setMediaId(currentAudiobook!!.id)
.setTitle(currentAudiobook!!.title) .setTitle(currentAudiobook!!.title)
.setSubtitle(currentAudiobook!!.author) .setSubtitle(currentAudiobook!!.author)
.setMediaUri(currentAudiobook!!.playlistUri) .setMediaUri(currentAudiobook!!.playlistUri)
.setIconUri(currentAudiobook!!.coverUri) .setIconUri(currentAudiobook!!.coverUri)
.build() return builder.build()
} }
} }
mediaSessionConnector.setQueueNavigator(queueNavigator) mediaSessionConnector.setQueueNavigator(queueNavigator)
@ -280,6 +286,9 @@ class PlayerNotificationService : Service() {
} }
} }
private fun setPlayerListeners() { private fun setPlayerListeners() {
mPlayer.addListener(object : Player.Listener { mPlayer.addListener(object : Player.Listener {
override fun onPlayerError(error: PlaybackException) { override fun onPlayerError(error: PlaybackException) {
@ -351,28 +360,42 @@ class PlayerNotificationService : Service() {
Log.d(tag, "Init Player audiobook already playing") Log.d(tag, "Init Player audiobook already playing")
} }
val metadata = MediaMetadataCompat.Builder() var metadataBuilder = MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, currentAudiobook!!.title) .putString(MediaMetadataCompat.METADATA_KEY_TITLE, currentAudiobook!!.title)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, currentAudiobook!!.title) .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, currentAudiobook!!.title)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, currentAudiobook!!.author) .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, currentAudiobook!!.author)
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, currentAudiobook!!.author) .putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, currentAudiobook!!.author)
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, currentAudiobook!!.author) .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, currentAudiobook!!.author)
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, currentAudiobook!!.series) .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, currentAudiobook!!.series)
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, currentAudiobook!!.cover)
.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, currentAudiobook!!.cover)
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, currentAudiobook!!.id) .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, currentAudiobook!!.id)
.build()
if (currentAudiobook!!.cover != "") {
metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, currentAudiobook!!.cover)
metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, currentAudiobook!!.cover)
}
var metadata = metadataBuilder.build()
mediaSession.setMetadata(metadata) mediaSession.setMetadata(metadata)
var mediaMetadata = MediaMetadata.Builder().build() var mediaMetadata = MediaMetadata.Builder().build()
var mediaItem = MediaItem.Builder().setUri(currentAudiobook!!.playlistUri).setMediaMetadata(mediaMetadata).build()
var 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() var dataSourceFactory = DefaultHttpDataSource.Factory()
dataSourceFactory.setUserAgent(channelId) dataSourceFactory.setUserAgent(channelId)
dataSourceFactory.setDefaultRequestProperties(hashMapOf("Authorization" to "Bearer ${currentAudiobook!!.token}")) 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.setMediaSource(mediaSource, true)
mPlayer.prepare() mPlayer.prepare()

View file

@ -2,8 +2,20 @@
include ':capacitor-android' include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
include ':capacitor-community-sqlite'
project(':capacitor-community-sqlite').projectDir = new File('../node_modules/@capacitor-community/sqlite/android')
include ':capacitor-dialog' include ':capacitor-dialog'
project(':capacitor-dialog').projectDir = new File('../node_modules/@capacitor/dialog/android') project(':capacitor-dialog').projectDir = new File('../node_modules/@capacitor/dialog/android')
include ':capacitor-network'
project(':capacitor-network').projectDir = new File('../node_modules/@capacitor/network/android')
include ':capacitor-storage'
project(':capacitor-storage').projectDir = new File('../node_modules/@capacitor/storage/android')
include ':robingenz-capacitor-app-update' include ':robingenz-capacitor-app-update'
project(':robingenz-capacitor-app-update').projectDir = new File('../node_modules/@robingenz/capacitor-app-update/android') project(':robingenz-capacitor-app-update').projectDir = new File('../node_modules/@robingenz/capacitor-app-update/android')
include ':capacitor-data-storage-sqlite'
project(':capacitor-data-storage-sqlite').projectDir = new File('../node_modules/capacitor-data-storage-sqlite/android')

View file

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

41
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.isResetting = false
this.initObject = { ...audiobookStreamData } this.initObject = { ...audiobookStreamData }
this.currentPlaybackRate = this.initObject.playbackSpeed this.currentPlaybackRate = this.initObject.playbackSpeed
MyNativeAudio.initPlayer(this.initObject) MyNativeAudio.initPlayer(this.initObject).then((res) => {
if (res && res.success) {
console.log('Success init audio player')
} else {
console.error('Failed to init audio player')
}
})
if (audiobookStreamData.isLocal) {
this.setStreamReady()
}
}, },
setFromObj() { setFromObj() {
if (!this.initObject) { if (!this.initObject) {
@ -210,7 +220,17 @@ export default {
return return
} }
this.isResetting = false this.isResetting = false
MyNativeAudio.initPlayer(this.initObject) MyNativeAudio.initPlayer(this.initObject).then((res) => {
if (res && res.success) {
console.log('Success init audio player')
} else {
console.error('Failed to init audio player')
}
})
if (audiobookStreamData.isLocal) {
this.setStreamReady()
}
}, },
play() { play() {
MyNativeAudio.playPlayer() MyNativeAudio.playPlayer()

View file

@ -13,14 +13,18 @@
<div class="flex-grow" /> <div class="flex-grow" />
<!-- <ui-menu :label="username" :items="menuItems" @action="menuAction" class="ml-5" /> --> <!-- <ui-menu :label="username" :items="menuItems" @action="menuAction" class="ml-5" /> -->
<nuxt-link to="/account" class="relative w-28 bg-fg border border-gray-500 rounded shadow-sm ml-5 pl-3 pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer hover:bg-bg hover:bg-opacity-40" aria-haspopup="listbox" aria-expanded="true"> <span class="material-icons cursor-pointer mx-4" @click="$store.commit('downloads/setShowModal', true)">source</span>
<widgets-connection-icon />
<!-- <nuxt-link to="/account" class="relative w-28 bg-fg border border-gray-500 rounded shadow-sm ml-5 pl-3 pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer hover:bg-bg hover:bg-opacity-40" aria-haspopup="listbox" aria-expanded="true">
<span class="flex items-center"> <span class="flex items-center">
<span class="block truncate">{{ username }}</span> <span class="block truncate">{{ username }}</span>
</span> </span>
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none"> <span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<span class="material-icons text-gray-100">person</span> <span class="material-icons text-gray-100">person</span>
</span> </span>
</nuxt-link> </nuxt-link> -->
</div> </div>
</div> </div>
</template> </template>
@ -89,4 +93,47 @@ export default {
#appbar { #appbar {
box-shadow: 0px 5px 5px #11111155; box-shadow: 0px 5px 5px #11111155;
} }
.loader-dots div {
animation-timing-function: cubic-bezier(0, 1, 1, 0);
}
.loader-dots div:nth-child(1) {
left: 0px;
animation: loader-dots1 0.6s infinite;
}
.loader-dots div:nth-child(2) {
left: 0px;
animation: loader-dots2 0.6s infinite;
}
.loader-dots div:nth-child(3) {
left: 10px;
animation: loader-dots2 0.6s infinite;
}
.loader-dots div:nth-child(4) {
left: 20px;
animation: loader-dots3 0.6s infinite;
}
@keyframes loader-dots1 {
0% {
transform: scale(0);
}
100% {
transform: scale(1);
}
}
@keyframes loader-dots3 {
0% {
transform: scale(1);
}
100% {
transform: scale(0);
}
}
@keyframes loader-dots2 {
0% {
transform: translate(0, 0);
}
100% {
transform: translate(10px, 0);
}
}
</style> </style>

View file

@ -4,7 +4,7 @@
<div :key="index" class="border-b border-opacity-10 w-full bookshelfRow py-4 flex justify-around relative"> <div :key="index" class="border-b border-opacity-10 w-full bookshelfRow py-4 flex justify-around relative">
<template v-for="audiobook in shelf"> <template v-for="audiobook in shelf">
<!-- <div :key="audiobook.id" class="relative px-4"> --> <!-- <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}`"> <!-- <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" /> <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> --> </nuxt-link> -->
@ -44,6 +44,9 @@ export default {
}, },
userAudiobooks() { userAudiobooks() {
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {} return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
},
localUserAudiobooks() {
return this.$store.state.user.localUserAudiobooks || {}
} }
}, },
methods: { methods: {
@ -52,11 +55,6 @@ export default {
filterBy: 'all' filterBy: 'all'
}) })
}, },
playAudiobook(audiobook) {
console.log('Play Audiobook', audiobook)
this.$store.commit('setStreamAudiobook', audiobook)
this.$server.socket.emit('open_stream', audiobook.id)
},
calcShelves() { calcShelves() {
var booksPerShelf = Math.floor(this.pageWidth / (this.cardWidth + 32)) var booksPerShelf = Math.floor(this.pageWidth / (this.cardWidth + 32))
var groupedBooks = [] var groupedBooks = []
@ -87,6 +85,16 @@ export default {
if (this.currFilterOrderKey !== this.filterOrderKey) { if (this.currFilterOrderKey !== this.filterOrderKey) {
this.calcShelves() this.calcShelves()
} }
},
socketConnected(isConnected) {
if (isConnected) {
console.log('Connected - Load from server')
this.$store.dispatch('audiobooks/load')
} else {
console.log('Disconnected - Reset to local storage')
this.$store.commit('audiobooks/reset')
this.$store.dispatch('downloads/loadFromStorage')
}
} }
}, },
mounted() { mounted() {
@ -94,7 +102,13 @@ export default {
this.$store.commit('user/addSettingsListener', { id: 'bookshelf', meth: this.settingsUpdated }) this.$store.commit('user/addSettingsListener', { id: 'bookshelf', meth: this.settingsUpdated })
window.addEventListener('resize', this.resize) window.addEventListener('resize', this.resize)
this.$server.on('connected', this.socketConnected)
if (this.$server.connected) {
this.$store.dispatch('audiobooks/load') this.$store.dispatch('audiobooks/load')
} else {
console.log('Bookshelf - Server not connected using downloaded')
}
this.init() this.init()
}, },
beforeDestroy() { beforeDestroy() {

View file

@ -1,21 +1,21 @@
<template> <template>
<div class="w-full p-4 pointer-events-none fixed bottom-0 left-0 right-0 z-20"> <div class="w-full p-4 pointer-events-none fixed bottom-0 left-0 right-0 z-20">
<div v-if="streamAudiobook" class="w-full bg-primary absolute bottom-0 left-0 right-0 z-50 p-2 pointer-events-auto" @click.stop @mousedown.stop @mouseup.stop> <div v-if="audiobook" class="w-full bg-primary absolute bottom-0 left-0 right-0 z-50 p-2 pointer-events-auto" @click.stop @mousedown.stop @mouseup.stop>
<div class="pl-16 pr-2 flex items-center pb-2"> <div class="pl-16 pr-2 flex items-center pb-2">
<div> <div>
<p class="px-2">{{ title }}</p> <p class="px-2">{{ title }}</p>
<p class="px-2 text-xs text-gray-400">by {{ author }}</p> <p class="px-2 text-xs text-gray-400">by {{ author }}</p>
</div> </div>
<div class="flex-grow" /> <div class="flex-grow" />
<div 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> <span class="material-icons text-2xl">format_list_bulleted</span>
</div> </div>
<span class="material-icons" @click="cancelStream">close</span> <span class="material-icons" @click="cancelStream">close</span>
</div> </div>
<div class="absolute left-2 -top-10"> <div class="absolute left-2 -top-10">
<cards-book-cover :audiobook="streamAudiobook" :width="64" /> <cards-book-cover :audiobook="audiobook" :width="64" />
</div> </div>
<audio-player-mini ref="audioPlayerMini" :loading="!stream || currStreamAudiobookId !== streamAudiobookId" @updateTime="updateTime" @selectPlaybackSpeed="showPlaybackSpeedModal = true" @hook:mounted="audioPlayerMounted" /> <audio-player-mini ref="audioPlayerMini" :loading="isLoading" @updateTime="updateTime" @selectPlaybackSpeed="showPlaybackSpeedModal = true" @hook:mounted="audioPlayerMounted" />
</div> </div>
<modals-playback-speed-modal v-model="showPlaybackSpeedModal" :playback-speed.sync="playbackSpeed" @change="changePlaybackSpeed" /> <modals-playback-speed-modal v-model="showPlaybackSpeedModal" :playback-speed.sync="playbackSpeed" @change="changePlaybackSpeed" />
<modals-chapters-modal v-model="showChapterModal" :chapters="chapters" @select="selectChapter" /> <modals-chapters-modal v-model="showChapterModal" :chapters="chapters" @select="selectChapter" />
@ -30,24 +30,62 @@ export default {
return { return {
audioPlayerReady: false, audioPlayerReady: false,
stream: null, stream: null,
lastServerUpdateSentSeconds: 0, download: null,
lastProgressTimeUpdate: 0,
showPlaybackSpeedModal: false, showPlaybackSpeedModal: false,
playbackSpeed: 1, playbackSpeed: 1,
showChapterModal: false 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: { computed: {
socketConnected() {
return this.$store.state.socketConnected
},
isLoading() {
if (this.playingDownload) return false
if (!this.streamAudiobook) return false
return !this.stream || this.streamAudiobook.id !== this.stream.audiobook.id
},
playingDownload() {
return this.$store.state.playingDownload
},
audiobook() {
if (this.playingDownload) return this.playingDownload.audiobook
return this.streamAudiobook
},
audiobookId() {
return this.audiobook ? this.audiobook.id : null
},
streamAudiobook() { streamAudiobook() {
return this.$store.state.streamAudiobook return this.$store.state.streamAudiobook
}, },
streamAudiobookId() {
return this.streamAudiobook ? this.streamAudiobook.id : null
},
currStreamAudiobookId() {
return this.stream ? this.stream.audiobook.id : null
},
book() { book() {
return this.streamAudiobook ? this.streamAudiobook.book || {} : {} return this.audiobook ? this.audiobook.book || {} : {}
}, },
title() { title() {
return this.book ? this.book.title : '' return this.book ? this.book.title : ''
@ -62,7 +100,7 @@ export default {
return this.book ? this.book.series : '' return this.book ? this.book.series : ''
}, },
chapters() { chapters() {
return this.streamAudiobook ? this.streamAudiobook.chapters || [] : [] return this.audiobook ? this.audiobook.chapters || [] : []
}, },
volumeNumber() { volumeNumber() {
return this.book ? this.book.volumeNumber : '' return this.book ? this.book.volumeNumber : ''
@ -73,7 +111,7 @@ export default {
return `${this.series} #${this.volumeNumber}` return `${this.series} #${this.volumeNumber}`
}, },
duration() { duration() {
return this.streamAudiobook ? this.streamAudiobook.duration || 0 : 0 return this.audiobook ? this.audiobook.duration || 0 : 0
}, },
coverForNative() { coverForNative() {
if (!this.cover) { if (!this.cover) {
@ -100,6 +138,14 @@ export default {
this.showChapterModal = false this.showChapterModal = false
}, },
async cancelStream() { 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({ const { value } = await Dialog.confirm({
title: 'Confirm', title: 'Confirm',
message: 'Cancel this stream?' message: 'Cancel this stream?'
@ -111,16 +157,42 @@ export default {
this.$refs.audioPlayerMini.terminateStream() this.$refs.audioPlayerMini.terminateStream()
} }
} }
}
}, },
updateTime(currentTime) { updateTime(currentTime) {
var diff = currentTime - this.lastServerUpdateSentSeconds var diff = currentTime - this.lastProgressTimeUpdate
if (diff > 4 || diff < 0) { if (diff > 4 || diff < 0) {
this.lastServerUpdateSentSeconds = currentTime this.lastProgressTimeUpdate = currentTime
if (this.stream) {
var updatePayload = { var updatePayload = {
currentTime, currentTime,
streamId: this.stream.id streamId: this.stream.id
} }
this.$server.socket.emit('stream_update', updatePayload) 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() {}, 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) { streamOpen(stream) {
console.log('[StreamContainer] Stream Open', stream) if (this.download) {
if (this.$refs.audioPlayerMini) {
this.$refs.audioPlayerMini.terminateStream()
}
}
this.lastProgressTimeUpdate = 0
console.log('[StreamContainer] Stream Open: ' + this.title)
if (!this.$refs.audioPlayerMini) { if (!this.$refs.audioPlayerMini) {
console.error('No Audio Player Mini') console.error('No Audio Player Mini')
return return
@ -160,6 +291,9 @@ export default {
this.stream = stream this.stream = stream
// Update local remove current
this.$localStore.setCurrent(null)
var playlistUrl = stream.clientPlaylistUri var playlistUrl = stream.clientPlaylistUri
var currentTime = stream.clientCurrentTime || 0 var currentTime = stream.clientCurrentTime || 0
var playOnLoad = this.$store.state.playOnLoad var playOnLoad = this.$store.state.playOnLoad
@ -177,13 +311,17 @@ export default {
playlistUrl: this.$server.url + playlistUrl, playlistUrl: this.$server.url + playlistUrl,
token: this.$store.getters['user/getToken'] token: this.$store.getters['user/getToken']
} }
this.$refs.audioPlayerMini.set(audiobookStreamData) this.$refs.audioPlayerMini.set(audiobookStreamData)
}, },
audioPlayerMounted() { audioPlayerMounted() {
console.log('Audio Player Mounted', this.$server.stream) console.log('Audio Player Mounted', this.$server.stream)
this.audioPlayerReady = true this.audioPlayerReady = true
if (this.$server.stream) {
if (this.playingDownload) {
console.log('[StreamContainer] Play download on audio mount')
this.playDownload()
} else if (this.$server.stream) {
console.log('[StreamContainer] Open stream on audio mount')
this.streamOpen(this.$server.stream) this.streamOpen(this.$server.stream)
} }
}, },
@ -206,6 +344,26 @@ export default {
this.playbackSpeed = settings.playbackRate this.playbackSpeed = settings.playbackRate
this.$refs.audioPlayerMini.updatePlaybackRate() 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() { mounted() {
@ -213,15 +371,19 @@ export default {
this.setListeners() this.setListeners()
this.$store.commit('user/addSettingsListener', { id: 'streamContainer', meth: this.settingsUpdated }) this.$store.commit('user/addSettingsListener', { id: 'streamContainer', meth: this.settingsUpdated })
this.$store.commit('setStreamListener', this.streamUpdated)
}, },
beforeDestroy() { beforeDestroy() {
if (this.$server.socket) {
this.$server.socket.off('stream_open', this.streamOpen) this.$server.socket.off('stream_open', this.streamOpen)
this.$server.socket.off('stream_closed', this.streamClosed) this.$server.socket.off('stream_closed', this.streamClosed)
this.$server.socket.off('stream_progress', this.streamProgress) this.$server.socket.off('stream_progress', this.streamProgress)
this.$server.socket.off('stream_ready', this.streamReady) this.$server.socket.off('stream_ready', this.streamReady)
this.$server.socket.off('stream_reset', this.streamReset) this.$server.socket.off('stream_reset', this.streamReset)
}
this.$store.commit('user/removeSettingsListener', 'streamContainer') this.$store.commit('user/removeSettingsListener', 'streamContainer')
this.$store.commit('removeStreamListener')
} }
} }
</script> </script>

View file

@ -11,7 +11,11 @@
<div class="rounded-sm h-full overflow-hidden relative box-shadow-book"> <div class="rounded-sm h-full overflow-hidden relative box-shadow-book">
<nuxt-link :to="`/audiobook/${audiobookId}`" class="cursor-pointer"> <nuxt-link :to="`/audiobook/${audiobookId}`" class="cursor-pointer">
<div class="w-full relative" :style="{ height: height + 'px' }"> <div class="w-full relative" :style="{ height: height + 'px' }">
<cards-book-cover :audiobook="audiobook" :author-override="authorFormat" :width="width" /> <cards-book-cover :audiobook="audiobook" :download-cover="downloadCover" :author-override="authorFormat" :width="width" />
<div v-if="download" class="absolute" :style="{ top: 0.5 * sizeMultiplier + 'rem', right: 0.5 * sizeMultiplier + 'rem' }">
<span class="material-icons text-success" :style="{ fontSize: 1.1 * sizeMultiplier + 'rem' }">download_done</span>
</div>
<div class="absolute bottom-0 left-0 h-1.5 bg-yellow-400 shadow-sm" :style="{ width: width * userProgressPercent + 'px' }"></div> <div class="absolute bottom-0 left-0 h-1.5 bg-yellow-400 shadow-sm" :style="{ width: width * userProgressPercent + 'px' }"></div>
@ -35,6 +39,10 @@ export default {
type: Object, type: Object,
default: () => null default: () => null
}, },
localUserProgress: {
type: Object,
default: () => null
},
width: { width: {
type: Number, type: Number,
default: 140 default: 140
@ -81,8 +89,13 @@ export default {
orderBy() { orderBy() {
return this.$store.getters['user/getUserSetting']('orderBy') return this.$store.getters['user/getUserSetting']('orderBy')
}, },
mostRecentUserProgress() {
if (!this.localUserProgress) return this.userProgress
if (!this.userProgress) return this.localUserProgress
return this.localUserProgress.lastUpdate > this.userProgress.lastUpdate ? this.localUserProgress : this.userProgress
},
userProgressPercent() { userProgressPercent() {
return this.userProgress ? this.userProgress.progress || 0 : 0 return this.mostRecentUserProgress ? this.mostRecentUserProgress.progress || 0 : 0
}, },
showError() { showError() {
return this.hasMissingParts || this.hasInvalidParts return this.hasMissingParts || this.hasInvalidParts
@ -93,6 +106,12 @@ export default {
hasInvalidParts() { hasInvalidParts() {
return this.audiobook.hasInvalidParts return this.audiobook.hasInvalidParts
}, },
downloadCover() {
return this.download ? this.download.cover : null
},
download() {
return this.$store.getters['downloads/getDownloadIfReady'](this.audiobookId)
},
errorText() { errorText() {
var txt = '' var txt = ''
if (this.hasMissingParts) { if (this.hasMissingParts) {
@ -105,12 +124,7 @@ export default {
return txt || 'Unknown Error' return txt || 'Unknown Error'
} }
}, },
methods: { methods: {},
play() {
this.$store.commit('setStreamAudiobook', this.audiobook)
this.$root.socket.emit('open_stream', this.audiobookId)
}
},
mounted() {} mounted() {}
} }
</script> </script>

View file

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

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> <template>
<div ref="wrapper" class="modal modal-bg w-full h-full max-h-screen fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-30 opacity-0"> <div ref="wrapper" class="modal modal-bg w-full h-full max-h-screen fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-30 opacity-0">
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" /> <div class="absolute top-0 left-0 w-full h-36 bg-gradient-to-b from-black to-transparent opacity-70 pointer-events-none" />
<div class="absolute z-40 top-4 right-4 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="show = false"> <div class="absolute z-40 top-4 right-4 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="show = false">
<span class="material-icons text-4xl">close</span> <span class="material-icons text-4xl">close</span>

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> <template>
<div class="w-full min-h-screen h-full bg-bg text-white"> <div class="w-full min-h-screen h-full bg-bg text-white">
<app-appbar /> <app-appbar />
<div id="content" class="overflow-hidden" :class="streaming ? 'streaming' : ''"> <div id="content" class="overflow-hidden" :class="playerIsOpen ? 'playerOpen' : ''">
<Nuxt /> <Nuxt />
</div> </div>
<app-stream-container ref="streamContainer" /> <app-stream-container ref="streamContainer" />
<modals-downloads-modal ref="downloadsModal" @selectDownload="selectDownload" @deleteDownload="deleteDownload" />
</div> </div>
</template> </template>
<script> <script>
import Path from 'path'
import { Capacitor } from '@capacitor/core'
import { Network } from '@capacitor/network'
import { AppUpdate } from '@robingenz/capacitor-app-update' import { AppUpdate } from '@robingenz/capacitor-app-update'
import AudioDownloader from '@/plugins/audio-downloader'
export default { export default {
middleware: 'authenticated',
data() { data() {
return {} return {}
}, },
computed: { computed: {
streaming() { playerIsOpen() {
return this.$store.state.streamAudiobook return this.$store.getters['playerIsOpen']
}, },
routeName() { routeName() {
return this.$route.name return this.$route.name
@ -30,10 +34,49 @@ export default {
if (isConnected) { if (isConnected) {
this.$router.push('/') 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) { initialStream(stream) {
@ -49,16 +92,16 @@ export default {
await AppUpdate.openAppStore() await AppUpdate.openAppStore()
} }
}, },
showUpdateToast(availableVersion, immediateUpdateAllowed) { // showUpdateToast(availableVersion, immediateUpdateAllowed) {
var toastText = immediateUpdateAllowed ? `Click here to update` : `Click here to open app store` // var toastText = immediateUpdateAllowed ? `Click here to update` : `Click here to open app store`
this.$toast.info(`Update is available for v${availableVersion}! ${toastText}`, { // this.$toast.info(`Update is available for v${availableVersion}! ${toastText}`, {
draggable: false, // draggable: false,
hideProgressBar: false, // hideProgressBar: false,
timeout: 10000, // timeout: 10000,
closeButton: false, // closeButton: false,
onClick: this.clickUpdateToast() // onClick: this.clickUpdateToast()
}) // })
}, // },
async checkForUpdate() { async checkForUpdate() {
const result = await AppUpdate.getAppUpdateInfo() const result = await AppUpdate.getAppUpdateInfo()
if (!result) { if (!result) {
@ -66,13 +109,165 @@ export default {
return return
} }
this.$store.commit('setAppUpdateInfo', result) this.$store.commit('setAppUpdateInfo', result)
if (result.updateAvailability === 2) {
// if (result.updateAvailability === 2) { setTimeout(() => {
// setTimeout(() => { this.$toast.info(`Update is available!`, {
draggable: false,
hideProgressBar: false,
timeout: 4000,
closeButton: false,
onClick: this.clickUpdateToast()
})
// this.showUpdateToast(result.availableVersion, !!result.immediateUpdateAllowed) // 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) { // parseSemver(ver) {
// if (!ver) return null // 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-]+)*))?)$/) // 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) // 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() { mounted() {
if (!this.$server) return console.error('No Server') if (!this.$server) return console.error('No Server')
@ -137,16 +342,9 @@ export default {
this.$server.on('connected', this.connected) this.$server.on('connected', this.connected)
this.$server.on('initialStream', this.initialStream) this.$server.on('initialStream', this.initialStream)
if (!this.$server.connected) { this.setupNetworkListener()
this.$router.push('/connect')
}
this.checkForUpdate() this.checkForUpdate()
this.initMediaStore()
// var checkForUpdateFlag = localStorage.getItem('checkForUpdate')
// if (!checkForUpdateFlag || checkForUpdateFlag !== '1') {
// this.checkForUpdate()
// }
} }
} }
</script> </script>
@ -155,7 +353,8 @@ export default {
#content { #content {
height: calc(100vh - 64px); height: calc(100vh - 64px);
} }
#content.streaming { #content.playerOpen {
height: calc(100vh - 204px); /* height: calc(100vh - 204px); */
height: calc(100vh - 240px);
} }
</style> </style>

View file

@ -26,8 +26,7 @@ export default {
], ],
link: [ link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }, { rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Fira+Mono&family=Ubuntu+Mono&family=Open+Sans:wght@400;600&family=Gentium+Book+Basic' }, { rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Ubuntu+Mono&family=Open+Sans:wght@400;600' },
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/icon?family=Material+Icons' }
] ]
}, },
@ -37,9 +36,11 @@ export default {
plugins: [ plugins: [
'@/plugins/server.js', '@/plugins/server.js',
'@/plugins/store.js',
'@/plugins/init.client.js', '@/plugins/init.client.js',
'@/plugins/axios.js', '@/plugins/axios.js',
'@/plugins/my-native-audio.js', '@/plugins/my-native-audio.js',
'@/plugins/audio-downloader.js',
'@/plugins/toast.js' '@/plugins/toast.js'
], ],

479
package-lock.json generated
View file

@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-app", "name": "audiobookshelf-app",
"version": "v0.2.1-beta", "version": "v0.4.0-beta",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {
@ -1000,6 +1000,11 @@
"to-fast-properties": "^2.0.0" "to-fast-properties": "^2.0.0"
} }
}, },
"@capacitor-community/sqlite": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@capacitor-community/sqlite/-/sqlite-3.2.0.tgz",
"integrity": "sha512-0tD+XKrtXS44DpeVLV0LR+UZafTBmLNHy3nQBr1lVMsefiunaU75q/BiYGhVdUIdp7x397y16s2RyTqroj5mbg=="
},
"@capacitor/android": { "@capacitor/android": {
"version": "3.2.2", "version": "3.2.2",
"resolved": "https://registry.npmjs.org/@capacitor/android/-/android-3.2.2.tgz", "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-3.2.2.tgz",
@ -1061,11 +1066,62 @@
"resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-3.2.2.tgz", "resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-3.2.2.tgz",
"integrity": "sha512-Eq17Y+UDHFmYGaZcObvxHAcHw0fF9TCBAg1f5f6qdV8ab3cKKEUB9xMvoCSZAueBfxFARrD18TsZJKoxh2YsLA==" "integrity": "sha512-Eq17Y+UDHFmYGaZcObvxHAcHw0fF9TCBAg1f5f6qdV8ab3cKKEUB9xMvoCSZAueBfxFARrD18TsZJKoxh2YsLA=="
}, },
"@capacitor/network": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@capacitor/network/-/network-1.0.3.tgz",
"integrity": "sha512-DgRusTC0UkTJE9IQIAMgqBnRnTaj8nFeGH7dwRldfVBZAtHBTkU8wCK/tU1oWtaY2Wam+iyVKXUAhYDO7yeD9Q=="
},
"@capacitor/storage": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@capacitor/storage/-/storage-1.1.0.tgz",
"integrity": "sha512-Fi5R542sHWfkzBcYeRl4zde+4zPqUpLk7S0LzrkCwn9eAy90v8yY6Pa6dXfADXvkPKDdPckmyCQ7CmM+YImsrQ=="
},
"@csstools/convert-colors": { "@csstools/convert-colors": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/@csstools/convert-colors/-/convert-colors-1.4.0.tgz", "resolved": "https://registry.npmjs.org/@csstools/convert-colors/-/convert-colors-1.4.0.tgz",
"integrity": "sha512-5a6wqoJV/xEdbRNKVo6I4hO3VjyDq//8q2f9I6PBAvMesJHFauXDorcNCsr9RzvsZnaWi5NYCcfyqP1QeFHFbw==" "integrity": "sha512-5a6wqoJV/xEdbRNKVo6I4hO3VjyDq//8q2f9I6PBAvMesJHFauXDorcNCsr9RzvsZnaWi5NYCcfyqP1QeFHFbw=="
}, },
"@electron/get": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/@electron/get/-/get-1.13.0.tgz",
"integrity": "sha512-+SjZhRuRo+STTO1Fdhzqnv9D2ZhjxXP6egsJ9kiO8dtP68cDx7dFCwWi64dlMQV7sWcfW1OYCW4wviEBzmRsfQ==",
"requires": {
"debug": "^4.1.1",
"env-paths": "^2.2.0",
"fs-extra": "^8.1.0",
"global-agent": "^2.0.2",
"global-tunnel-ng": "^2.7.1",
"got": "^9.6.0",
"progress": "^2.0.3",
"semver": "^6.2.0",
"sumchecker": "^3.0.1"
},
"dependencies": {
"fs-extra": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
"requires": {
"graceful-fs": "^4.2.0",
"jsonfile": "^4.0.0",
"universalify": "^0.1.0"
}
},
"jsonfile": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
"integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=",
"requires": {
"graceful-fs": "^4.1.6"
}
},
"universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="
}
}
},
"@ionic/cli-framework-output": { "@ionic/cli-framework-output": {
"version": "2.2.2", "version": "2.2.2",
"resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.2.tgz", "resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.2.tgz",
@ -2619,6 +2675,19 @@
"resolved": "https://registry.npmjs.org/@robingenz/capacitor-app-update/-/capacitor-app-update-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@robingenz/capacitor-app-update/-/capacitor-app-update-1.0.0.tgz",
"integrity": "sha512-pK8Yi7VgG/O/R4kJ3JtLpdeQgJzRIDPGM61bJhofTqu/+i26h8GhQdq4MB2OLJWk06Ht8MYDIIW/E0nxatrrnA==" "integrity": "sha512-pK8Yi7VgG/O/R4kJ3JtLpdeQgJzRIDPGM61bJhofTqu/+i26h8GhQdq4MB2OLJWk06Ht8MYDIIW/E0nxatrrnA=="
}, },
"@sindresorhus/is": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz",
"integrity": "sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ=="
},
"@szmarczak/http-timer": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz",
"integrity": "sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA==",
"requires": {
"defer-to-connect": "^1.0.1"
}
},
"@types/component-emitter": { "@types/component-emitter": {
"version": "1.2.10", "version": "1.2.10",
"resolved": "https://registry.npmjs.org/@types/component-emitter/-/component-emitter-1.2.10.tgz", "resolved": "https://registry.npmjs.org/@types/component-emitter/-/component-emitter-1.2.10.tgz",
@ -3523,6 +3592,12 @@
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24=" "integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24="
}, },
"boolean": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.1.4.tgz",
"integrity": "sha512-3hx0kwU3uzG6ReQ3pnaFQPSktpBw6RHN3/ivDKEuU8g1XSfafowyvDnadjv1xp8IZqhtSukxlwv9bF6FhX8m0w==",
"optional": true
},
"boxen": { "boxen": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/boxen/-/boxen-5.0.1.tgz", "resolved": "https://registry.npmjs.org/boxen/-/boxen-5.0.1.tgz",
@ -3875,6 +3950,40 @@
"schema-utils": "^2.0.0" "schema-utils": "^2.0.0"
} }
}, },
"cacheable-request": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz",
"integrity": "sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg==",
"requires": {
"clone-response": "^1.0.2",
"get-stream": "^5.1.0",
"http-cache-semantics": "^4.0.0",
"keyv": "^3.0.0",
"lowercase-keys": "^2.0.0",
"normalize-url": "^4.1.0",
"responselike": "^1.0.2"
},
"dependencies": {
"get-stream": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
"integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
"requires": {
"pump": "^3.0.0"
}
},
"lowercase-keys": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
"integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="
},
"normalize-url": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.1.tgz",
"integrity": "sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA=="
}
}
},
"call-bind": { "call-bind": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
@ -3941,6 +4050,15 @@
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001249.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001249.tgz",
"integrity": "sha512-vcX4U8lwVXPdqzPWi6cAJ3FnQaqXbBqy/GZseKNQzRj37J7qZdGcBtxq/QLFNLLlfsoXLUdHw8Iwenri86Tagw==" "integrity": "sha512-vcX4U8lwVXPdqzPWi6cAJ3FnQaqXbBqy/GZseKNQzRj37J7qZdGcBtxq/QLFNLLlfsoXLUdHw8Iwenri86Tagw=="
}, },
"capacitor-data-storage-sqlite": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/capacitor-data-storage-sqlite/-/capacitor-data-storage-sqlite-3.2.0.tgz",
"integrity": "sha512-+8uyd9O+k/UleSGAKI7rNkF5AAAMeLzvzIqBle7sYjLRuhRiuCLapJ5b5GLAbzdtyUp72YoZX29XCKk3ywDAyg==",
"requires": {
"electron": "~13.1.4",
"localforage": "^1.9.0"
}
},
"chalk": { "chalk": {
"version": "2.4.2", "version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
@ -4167,6 +4285,14 @@
} }
} }
}, },
"clone-response": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz",
"integrity": "sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws=",
"requires": {
"mimic-response": "^1.0.0"
}
},
"co": { "co": {
"version": "4.6.0", "version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@ -4296,6 +4422,16 @@
"typedarray": "^0.0.6" "typedarray": "^0.0.6"
} }
}, },
"config-chain": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
"integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==",
"optional": true,
"requires": {
"ini": "^1.3.4",
"proto-list": "~1.2.1"
}
},
"connect": { "connect": {
"version": "3.7.0", "version": "3.7.0",
"resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz",
@ -5016,6 +5152,14 @@
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
"integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=" "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU="
}, },
"decompress-response": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz",
"integrity": "sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M=",
"requires": {
"mimic-response": "^1.0.0"
}
},
"deep-equal": { "deep-equal": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz",
@ -5027,6 +5171,11 @@
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz",
"integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==" "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg=="
}, },
"defer-to-connect": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-1.1.3.tgz",
"integrity": "sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ=="
},
"define-properties": { "define-properties": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz",
@ -5118,6 +5267,12 @@
"resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-5.0.0.tgz", "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-5.0.0.tgz",
"integrity": "sha1-OHHMCmoALow+Wzz38zYmRnXwa50=" "integrity": "sha1-OHHMCmoALow+Wzz38zYmRnXwa50="
}, },
"detect-node": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
"optional": true
},
"detective": { "detective": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/detective/-/detective-5.2.0.tgz", "resolved": "https://registry.npmjs.org/detective/-/detective-5.2.0.tgz",
@ -5256,6 +5411,11 @@
"resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==" "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="
}, },
"duplexer3": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz",
"integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI="
},
"duplexify": { "duplexify": {
"version": "3.7.1", "version": "3.7.1",
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz",
@ -5272,6 +5432,23 @@
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0="
}, },
"electron": {
"version": "13.1.9",
"resolved": "https://registry.npmjs.org/electron/-/electron-13.1.9.tgz",
"integrity": "sha512-By4Zb72XNQLrPb70BXdIW3NtEHFwybP5DIQjohnCxOYONq5vojuHjNcTuWnBgMvwQ2qwykk6Tw5EwF2Pt0CWjA==",
"requires": {
"@electron/get": "^1.0.1",
"@types/node": "^14.6.2",
"extract-zip": "^1.0.3"
},
"dependencies": {
"@types/node": {
"version": "14.17.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.17.15.tgz",
"integrity": "sha512-D1sdW0EcSCmNdLKBGMYb38YsHUS6JcM7yQ6sLQ9KuZ35ck7LYCKE7kYFHOO59ayFOY3zobWVZxf4KXhYHcHYFA=="
}
}
},
"electron-to-chromium": { "electron-to-chromium": {
"version": "1.3.796", "version": "1.3.796",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.796.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.796.tgz",
@ -5445,6 +5622,12 @@
"is-symbol": "^1.0.2" "is-symbol": "^1.0.2"
} }
}, },
"es6-error": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
"integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
"optional": true
},
"escalade": { "escalade": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
@ -5726,6 +5909,32 @@
} }
} }
}, },
"extract-zip": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-1.7.0.tgz",
"integrity": "sha512-xoh5G1W/PB0/27lXgMQyIhP5DSY/LhoCsOyZgb+6iMmRtCwVBo55uKaMoEYrDCKQhWvqEip5ZPKAc6eFNyf/MA==",
"requires": {
"concat-stream": "^1.6.2",
"debug": "^2.6.9",
"mkdirp": "^0.5.4",
"yauzl": "^2.10.0"
},
"dependencies": {
"debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"requires": {
"ms": "2.0.0"
}
},
"ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
}
}
},
"fast-deep-equal": { "fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -6070,11 +6279,58 @@
"is-glob": "^4.0.1" "is-glob": "^4.0.1"
} }
}, },
"global-agent": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/global-agent/-/global-agent-2.2.0.tgz",
"integrity": "sha512-+20KpaW6DDLqhG7JDiJpD1JvNvb8ts+TNl7BPOYcURqCrXqnN1Vf+XVOrkKJAFPqfX+oEhsdzOj1hLWkBTdNJg==",
"optional": true,
"requires": {
"boolean": "^3.0.1",
"core-js": "^3.6.5",
"es6-error": "^4.1.1",
"matcher": "^3.0.0",
"roarr": "^2.15.3",
"semver": "^7.3.2",
"serialize-error": "^7.0.1"
},
"dependencies": {
"semver": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz",
"integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==",
"optional": true,
"requires": {
"lru-cache": "^6.0.0"
}
}
}
},
"global-tunnel-ng": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/global-tunnel-ng/-/global-tunnel-ng-2.7.1.tgz",
"integrity": "sha512-4s+DyciWBV0eK148wqXxcmVAbFVPqtc3sEtUE/GTQfuU80rySLcMhUmHKSHI7/LDj8q0gDYI1lIhRRB7ieRAqg==",
"optional": true,
"requires": {
"encodeurl": "^1.0.2",
"lodash": "^4.17.10",
"npm-conf": "^1.1.3",
"tunnel": "^0.0.6"
}
},
"globals": { "globals": {
"version": "11.12.0", "version": "11.12.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
"integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==" "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="
}, },
"globalthis": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.2.tgz",
"integrity": "sha512-ZQnSFO1la8P7auIOQECnm0sSuoMeaSq0EEdXMBFF2QJO4uNcwbyhSgG3MruWNbFTqCLmxVwGOl7LZ9kASvHdeQ==",
"optional": true,
"requires": {
"define-properties": "^1.1.3"
}
},
"globby": { "globby": {
"version": "11.0.4", "version": "11.0.4",
"resolved": "https://registry.npmjs.org/globby/-/globby-11.0.4.tgz", "resolved": "https://registry.npmjs.org/globby/-/globby-11.0.4.tgz",
@ -6088,6 +6344,34 @@
"slash": "^3.0.0" "slash": "^3.0.0"
} }
}, },
"got": {
"version": "9.6.0",
"resolved": "https://registry.npmjs.org/got/-/got-9.6.0.tgz",
"integrity": "sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q==",
"requires": {
"@sindresorhus/is": "^0.14.0",
"@szmarczak/http-timer": "^1.1.2",
"cacheable-request": "^6.0.0",
"decompress-response": "^3.3.0",
"duplexer3": "^0.1.4",
"get-stream": "^4.1.0",
"lowercase-keys": "^1.0.1",
"mimic-response": "^1.0.1",
"p-cancelable": "^1.0.0",
"to-readable-stream": "^1.0.0",
"url-parse-lax": "^3.0.0"
},
"dependencies": {
"get-stream": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz",
"integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==",
"requires": {
"pump": "^3.0.0"
}
}
}
},
"graceful-fs": { "graceful-fs": {
"version": "4.2.6", "version": "4.2.6",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.6.tgz", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.6.tgz",
@ -6511,6 +6795,11 @@
"http-errors": "~1.7.2" "http-errors": "~1.7.2"
} }
}, },
"http-cache-semantics": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz",
"integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ=="
},
"http-errors": { "http-errors": {
"version": "1.7.3", "version": "1.7.3",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz",
@ -6611,6 +6900,11 @@
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz",
"integrity": "sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==" "integrity": "sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw=="
}, },
"immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha1-nbHb0Pr43m++D13V5Wu2BigN5ps="
},
"import-cwd": { "import-cwd": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-2.1.0.tgz", "resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-2.1.0.tgz",
@ -7100,6 +7394,11 @@
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",
"integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==" "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA=="
}, },
"json-buffer": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz",
"integrity": "sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg="
},
"json-parse-better-errors": { "json-parse-better-errors": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz",
@ -7116,6 +7415,12 @@
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="
}, },
"json-stringify-safe": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
"integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=",
"optional": true
},
"json5": { "json5": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.0.tgz", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.0.tgz",
@ -7142,6 +7447,14 @@
"tsscmp": "1.0.6" "tsscmp": "1.0.6"
} }
}, },
"keyv": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz",
"integrity": "sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA==",
"requires": {
"json-buffer": "3.0.0"
}
},
"kind-of": { "kind-of": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
@ -7297,6 +7610,14 @@
"launch-editor": "^2.2.1" "launch-editor": "^2.2.1"
} }
}, },
"lie": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz",
"integrity": "sha1-mkNrLMd0bKWd56QfpGmz77dr2H4=",
"requires": {
"immediate": "~3.0.5"
}
},
"lilconfig": { "lilconfig": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.3.tgz", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.3.tgz",
@ -7334,6 +7655,14 @@
} }
} }
}, },
"localforage": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz",
"integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==",
"requires": {
"lie": "3.1.1"
}
},
"locate-path": { "locate-path": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
@ -7409,6 +7738,11 @@
"tslib": "^2.0.3" "tslib": "^2.0.3"
} }
}, },
"lowercase-keys": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz",
"integrity": "sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA=="
},
"lru-cache": { "lru-cache": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@ -7446,6 +7780,23 @@
"object-visit": "^1.0.0" "object-visit": "^1.0.0"
} }
}, },
"matcher": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
"integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
"optional": true,
"requires": {
"escape-string-regexp": "^4.0.0"
},
"dependencies": {
"escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"optional": true
}
}
},
"md5.js": { "md5.js": {
"version": "1.3.5", "version": "1.3.5",
"resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
@ -7572,6 +7923,11 @@
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-3.1.0.tgz", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-3.1.0.tgz",
"integrity": "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==" "integrity": "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ=="
}, },
"mimic-response": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
"integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="
},
"minimalistic-assert": { "minimalistic-assert": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
@ -7949,6 +8305,24 @@
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-3.3.0.tgz", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-3.3.0.tgz",
"integrity": "sha512-U+JJi7duF1o+u2pynbp2zXDW2/PADgC30f0GsHZtRh+HOcXHnw137TrNlyxxRvWW5fjKd3bcLHPxofWuCjaeZg==" "integrity": "sha512-U+JJi7duF1o+u2pynbp2zXDW2/PADgC30f0GsHZtRh+HOcXHnw137TrNlyxxRvWW5fjKd3bcLHPxofWuCjaeZg=="
}, },
"npm-conf": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/npm-conf/-/npm-conf-1.1.3.tgz",
"integrity": "sha512-Yic4bZHJOt9RCFbRP3GgpqhScOY4HH3V2P8yBj6CeYq118Qr+BLXqT2JvpJ00mryLESpgOxf5XlFv4ZjXxLScw==",
"optional": true,
"requires": {
"config-chain": "^1.1.11",
"pify": "^3.0.0"
},
"dependencies": {
"pify": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz",
"integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=",
"optional": true
}
}
},
"npm-run-path": { "npm-run-path": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
@ -8173,6 +8547,11 @@
"resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
"integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=" "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ="
}, },
"p-cancelable": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-1.1.0.tgz",
"integrity": "sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw=="
},
"p-defer": { "p-defer": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/p-defer/-/p-defer-1.0.0.tgz", "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-1.0.0.tgz",
@ -10998,6 +11377,11 @@
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
}, },
"progress": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="
},
"promise-inflight": { "promise-inflight": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz",
@ -11029,6 +11413,12 @@
"signal-exit": "^3.0.2" "signal-exit": "^3.0.2"
} }
}, },
"proto-list": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
"integrity": "sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk=",
"optional": true
},
"protocols": { "protocols": {
"version": "1.4.8", "version": "1.4.8",
"resolved": "https://registry.npmjs.org/protocols/-/protocols-1.4.8.tgz", "resolved": "https://registry.npmjs.org/protocols/-/protocols-1.4.8.tgz",
@ -11550,6 +11940,14 @@
"resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz",
"integrity": "sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=" "integrity": "sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo="
}, },
"responselike": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz",
"integrity": "sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec=",
"requires": {
"lowercase-keys": "^1.0.0"
}
},
"restore-cursor": { "restore-cursor": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz",
@ -11601,6 +11999,28 @@
"inherits": "^2.0.1" "inherits": "^2.0.1"
} }
}, },
"roarr": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
"integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==",
"optional": true,
"requires": {
"boolean": "^3.0.1",
"detect-node": "^2.0.4",
"globalthis": "^1.0.1",
"json-stringify-safe": "^5.0.1",
"semver-compare": "^1.0.0",
"sprintf-js": "^1.1.2"
},
"dependencies": {
"sprintf-js": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz",
"integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==",
"optional": true
}
}
},
"run-async": { "run-async": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz",
@ -11680,6 +12100,12 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
"integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw=="
}, },
"semver-compare": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
"integrity": "sha1-De4hahyUGrN+nvsXiPavxf9VN/w=",
"optional": true
},
"send": { "send": {
"version": "0.17.1", "version": "0.17.1",
"resolved": "https://registry.npmjs.org/send/-/send-0.17.1.tgz", "resolved": "https://registry.npmjs.org/send/-/send-0.17.1.tgz",
@ -11727,6 +12153,23 @@
} }
} }
}, },
"serialize-error": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
"integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
"optional": true,
"requires": {
"type-fest": "^0.13.1"
},
"dependencies": {
"type-fest": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
"integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
"optional": true
}
}
},
"serialize-javascript": { "serialize-javascript": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-5.0.1.tgz", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-5.0.1.tgz",
@ -12361,6 +12804,14 @@
} }
} }
}, },
"sumchecker": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
"integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==",
"requires": {
"debug": "^4.1.0"
}
},
"supports-color": { "supports-color": {
"version": "5.5.0", "version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
@ -12847,6 +13298,11 @@
} }
} }
}, },
"to-readable-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-1.0.0.tgz",
"integrity": "sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q=="
},
"to-regex": { "to-regex": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/to-regex/-/to-regex-3.0.2.tgz", "resolved": "https://registry.npmjs.org/to-regex/-/to-regex-3.0.2.tgz",
@ -12902,6 +13358,12 @@
"resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz", "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz",
"integrity": "sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY=" "integrity": "sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY="
}, },
"tunnel": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz",
"integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==",
"optional": true
},
"type-fest": { "type-fest": {
"version": "0.21.3", "version": "0.21.3",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz",
@ -13141,6 +13603,21 @@
} }
} }
}, },
"url-parse-lax": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz",
"integrity": "sha1-FrXK/Afb42dsGxmZF3gj1lA6yww=",
"requires": {
"prepend-http": "^2.0.0"
},
"dependencies": {
"prepend-http": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz",
"integrity": "sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc="
}
}
},
"use": { "use": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",

View file

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

View file

@ -22,7 +22,9 @@
<p class="font-mono pt-1 pb-4">{{ $config.version }}</p> <p class="font-mono pt-1 pb-4">{{ $config.version }}</p>
<ui-btn v-if="isUpdateAvailable" class="w-full my-4" color="success" @click="clickUpdate"> Version {{ availableVersion }} is available! 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> </div>
</template> </template>
@ -67,8 +69,15 @@ export default {
this.$server.logout() this.$server.logout()
this.$router.push('/connect') this.$router.push('/connect')
}, },
openAppStore() {
AppUpdate.openAppStore()
},
async clickUpdate() { async clickUpdate() {
await AppUpdate.openAppStore() if (this.immediateUpdateAllowed) {
AppUpdate.performImmediateUpdate()
} else {
AppUpdate.openAppStore()
}
} }
}, },
mounted() {} mounted() {}

View file

@ -3,17 +3,19 @@
<div class="flex"> <div class="flex">
<div class="w-32"> <div class="w-32">
<div class="relative"> <div class="relative">
<cards-book-cover :audiobook="audiobook" :width="128" /> <cards-book-cover :audiobook="audiobook" :download-cover="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 class="absolute bottom-0 left-0 h-1.5 bg-yellow-400 shadow-sm" :style="{ width: 128 * progressPercent + 'px' }"></div>
</div> </div>
<!-- <cards-book-cover :audiobook="audiobook" :width="128" /> --> <div class="flex my-4">
<p class="text-sm">{{ numTracks }} Tracks</p>
</div>
</div> </div>
<div class="flex-grow px-3"> <div class="flex-grow px-3">
<h1 class="text-lg">{{ title }}</h1> <h1 class="text-lg">{{ title }}</h1>
<h3 v-if="series" class="font-book text-gray-300 text-lg leading-7">{{ seriesText }}</h3> <h3 v-if="series" class="font-book text-gray-300 text-lg leading-7">{{ seriesText }}</h3>
<p class="text-sm text-gray-400">by {{ author }}</p> <p class="text-sm text-gray-400">by {{ author }}</p>
<p class="text-gray-300 text-sm my-1"> <p class="text-gray-300 text-sm my-1">
{{ durationPretty }}<span class="px-4">{{ sizePretty }}</span> {{ $elapsedPretty(duration) }}<span class="px-4">{{ $bytesPretty(size) }}</span>
</p> </p>
<div v-if="progressPercent > 0" class="px-4 py-2 bg-primary text-sm font-semibold rounded-md text-gray-200 mt-4 relative" :class="resettingProgress ? 'opacity-25' : ''"> <div v-if="progressPercent > 0" class="px-4 py-2 bg-primary text-sm font-semibold rounded-md text-gray-200 mt-4 relative" :class="resettingProgress ? 'opacity-25' : ''">
@ -24,11 +26,15 @@
</div> </div>
</div> </div>
<ui-btn color="success" :disabled="streaming" class="flex items-center justify-center w-full mt-4" :padding-x="4" @click="playClick"> <div v-if="isConnected || isDownloadPlayable" class="flex mt-4">
<span v-show="!streaming" class="material-icons">play_arrow</span> <ui-btn color="success" :disabled="isPlaying" class="flex items-center justify-center w-full mr-2" :padding-x="4" @click="playClick">
<span class="px-1">{{ streaming ? 'Streaming' : 'Play' }}</span> <span v-show="!isPlaying" class="material-icons">play_arrow</span>
<span class="px-1">{{ isPlaying ? (isStreaming ? 'Streaming' : 'Playing') : isDownloadPlayable ? 'Play local' : 'Play stream' }}</span>
</ui-btn> </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> </div>
<div class="w-full py-4"> <div class="w-full py-4">
@ -38,15 +44,25 @@
</template> </template>
<script> <script>
import Path from 'path'
import { Dialog } from '@capacitor/dialog' import { Dialog } from '@capacitor/dialog'
import { Capacitor } from '@capacitor/core'
import AudioDownloader from '@/plugins/audio-downloader'
export default { export default {
async asyncData({ params, redirect, app }) { async asyncData({ store, params, redirect, app }) {
var audiobookId = params.id var audiobookId = params.id
var audiobook = await app.$axios.$get(`/api/audiobook/${audiobookId}`).catch((error) => { var audiobook = null
if (app.$server.connected) {
audiobook = await app.$axios.$get(`/api/audiobook/${audiobookId}`).catch((error) => {
console.error('Failed', error) console.error('Failed', error)
return false return false
}) })
} else {
audiobook = store.getters['audiobooks/getAudiobook'](audiobookId)
}
if (!audiobook) { if (!audiobook) {
console.error('No audiobook...', params.id) console.error('No audiobook...', params.id)
return redirect('/') return redirect('/')
@ -61,6 +77,9 @@ export default {
} }
}, },
computed: { computed: {
isConnected() {
return this.$store.state.socketConnected
},
audiobookId() { audiobookId() {
return this.audiobook.id return this.audiobook.id
}, },
@ -87,14 +106,11 @@ export default {
if (!this.volumeNumber) return this.series if (!this.volumeNumber) return this.series
return `${this.series} #${this.volumeNumber}` return `${this.series} #${this.volumeNumber}`
}, },
durationPretty() {
return this.audiobook.durationPretty
},
duration() { duration() {
return this.audiobook.duration return this.audiobook.duration
}, },
sizePretty() { size() {
return this.audiobook.sizePretty return this.audiobook.size
}, },
userAudiobooks() { userAudiobooks() {
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {} return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
@ -102,27 +118,65 @@ export default {
userAudiobook() { userAudiobook() {
return this.userAudiobooks[this.audiobookId] || null return this.userAudiobooks[this.audiobookId] || null
}, },
localUserAudiobooks() {
return this.$store.state.user.localUserAudiobooks || {}
},
localUserAudiobook() {
return this.localUserAudiobooks[this.audiobookId] || null
},
mostRecentUserAudiobook() {
if (!this.localUserAudiobook) return this.userAudiobook
if (!this.userAudiobook) return this.localUserAudiobook
return this.localUserAudiobook.lastUpdate > this.userAudiobook.lastUpdate ? this.localUserAudiobook : this.userAudiobook
},
userCurrentTime() { userCurrentTime() {
return this.userAudiobook ? this.userAudiobook.currentTime : 0 return this.mostRecentUserAudiobook ? this.mostRecentUserAudiobook.currentTime : 0
}, },
userTimeRemaining() { userTimeRemaining() {
return this.duration - this.userCurrentTime return this.duration - this.userCurrentTime
}, },
progressPercent() { progressPercent() {
return this.userAudiobook ? this.userAudiobook.progress : 0 return this.mostRecentUserAudiobook ? this.mostRecentUserAudiobook.progress : 0
},
streamAudiobook() {
return this.$store.state.streamAudiobook
}, },
isStreaming() { isStreaming() {
return this.streamAudiobook && this.streamAudiobook.id === this.audiobookId return this.$store.getters['isAudiobookStreaming'](this.audiobookId)
},
isPlaying() {
return this.$store.getters['isAudiobookPlaying'](this.audiobookId)
},
numTracks() {
if (this.audiobook.tracks) return this.audiobook.tracks.length
return this.audiobook.numTracks || 0
},
isDownloading() {
return this.downloadObj ? this.downloadObj.isDownloading : false
},
isDownloadPreparing() {
return this.downloadObj ? this.downloadObj.isPreparing : false
},
isDownloadPlayable() {
return this.downloadObj && !this.isDownloading && !this.isDownloadPreparing
},
downlaodedCover() {
return this.downloadObj ? this.downloadObj.cover : null
},
downloadObj() {
return this.$store.getters['downloads/getDownload'](this.audiobookId)
} }
}, },
methods: { methods: {
playClick() { playClick() {
this.$store.commit('setPlayOnLoad', true) this.$store.commit('setPlayOnLoad', true)
if (!this.isDownloadPlayable) {
// Stream
console.log('[PLAYCLICK] Set Playing STREAM ' + this.title)
this.$store.commit('setStreamAudiobook', this.audiobook) this.$store.commit('setStreamAudiobook', this.audiobook)
this.$server.socket.emit('open_stream', this.audiobook.id) this.$server.socket.emit('open_stream', this.audiobook.id)
} else {
// Local
console.log('[PLAYCLICK] Set Playing Local Download ' + this.title)
this.$store.commit('setPlayingDownload', this.downloadObj)
}
}, },
async clearProgressClick() { async clearProgressClick() {
const { value } = await Dialog.confirm({ const { value } = await Dialog.confirm({
@ -132,18 +186,27 @@ export default {
if (value) { if (value) {
this.resettingProgress = true this.resettingProgress = true
this.$axios if (this.$server.connected) {
await this.$axios
.$delete(`/api/user/audiobook/${this.audiobookId}`) .$delete(`/api/user/audiobook/${this.audiobookId}`)
.then(() => { .then(() => {
console.log('Progress reset complete') console.log('Progress reset complete')
this.$toast.success(`Your progress was reset`) this.$toast.success(`Your progress was reset`)
this.resettingProgress = false
}) })
.catch((error) => { .catch((error) => {
console.error('Progress reset failed', error) console.error('Progress reset failed', error)
this.resettingProgress = false
}) })
} }
this.$localStore.updateUserAudiobookProgress({
audiobookId: this.audiobookId,
currentTime: 0,
totalDuration: this.duration,
progress: 0,
lastUpdate: Date.now(),
isRead: false
})
this.resettingProgress = false
}
}, },
audiobookUpdated() { audiobookUpdated() {
console.log('Audiobook Updated - Fetch full audiobook') console.log('Audiobook Updated - Fetch full audiobook')
@ -155,12 +218,160 @@ export default {
.catch((error) => { .catch((error) => {
console.error('Failed', error) console.error('Failed', error)
}) })
},
downloadClick() {
if (!this.$server.connected) return
if (this.downloadObj) {
console.log('Already downloaded', this.downloadObj)
} else {
this.prepareDownload()
}
},
async prepareDownload() {
// var audiobook = 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() { 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 }) this.$store.commit('audiobooks/addListener', { id: 'audiobook', audiobookId: this.audiobookId, meth: this.audiobookUpdated })
}, },
beforeDestroy() { 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') this.$store.commit('audiobooks/removeListener', 'audiobook')
} }
} }

View file

@ -1,11 +1,14 @@
<template> <template>
<div class="w-full h-full"> <div class="w-full h-full">
<div class="relative flex items-center justify-center min-h-screen sm:pt-0"> <div class="relative flex items-center justify-center min-h-screen sm:pt-0">
<nuxt-link to="/" class="absolute top-2 left-2 z-20">
<span class="material-icons text-4xl">arrow_back</span>
</nuxt-link>
<div class="absolute top-0 left-0 w-full p-6 flex items-center flex-col justify-center z-0 short:hidden"> <div class="absolute top-0 left-0 w-full p-6 flex items-center flex-col justify-center z-0 short:hidden">
<img src="/Logo.png" class="h-20 w-20 mb-2" /> <img src="/Logo.png" class="h-20 w-20 mb-2" />
<h1 class="text-2xl font-book">AudioBookshelf</h1> <h1 class="text-2xl font-book">AudioBookshelf</h1>
</div> </div>
<p class="hidden absolute short:block top-0 left-0 p-2 font-book text-xl">AudioBookshelf</p> <p class="hidden absolute short:block top-1.5 left-12 p-2 font-book text-xl">AudioBookshelf</p>
<div class="max-w-sm mx-auto sm:px-6 lg:px-8 z-10"> <div class="max-w-sm mx-auto sm:px-6 lg:px-8 z-10">
<div v-show="loggedIn" class="mt-8 bg-primary overflow-hidden shadow rounded-lg p-6 text-center"> <div v-show="loggedIn" class="mt-8 bg-primary overflow-hidden shadow rounded-lg p-6 text-center">
@ -15,8 +18,8 @@
<div v-show="!loggedIn" class="mt-8 bg-primary overflow-hidden shadow rounded-lg p-6"> <div v-show="!loggedIn" class="mt-8 bg-primary overflow-hidden shadow rounded-lg p-6">
<h2 class="text-xl leading-7 mb-4">Enter an <span class="font-book font-normal">AudioBookshelf</span><br />server address:</h2> <h2 class="text-xl leading-7 mb-4">Enter an <span class="font-book font-normal">AudioBookshelf</span><br />server address:</h2>
<form v-show="!showAuth" @submit.prevent="submit" novalidate> <form v-show="!showAuth" @submit.prevent="submit" novalidate>
<ui-text-input v-model="serverUrl" :disabled="processing" placeholder="http://55.55.55.55:13378" type="url" class="w-60 sm:w-72 h-10" /> <ui-text-input v-model="serverUrl" :disabled="processing || !networkConnected" placeholder="http://55.55.55.55:13378" type="url" class="w-60 sm:w-72 h-10" />
<ui-btn :disabled="processing" type="submit" :padding-x="3" class="h-10">Submit</ui-btn> <ui-btn :disabled="processing || !networkConnected" type="submit" :padding-x="3" class="h-10">{{ networkConnected ? 'Submit' : 'No Internet' }}</ui-btn>
</form> </form>
<template v-if="showAuth"> <template v-if="showAuth">
<div class="flex items-center"> <div class="flex items-center">
@ -29,7 +32,7 @@
<ui-text-input v-model="username" :disabled="processing" placeholder="username" class="w-full my-1 text-lg" /> <ui-text-input v-model="username" :disabled="processing" placeholder="username" class="w-full my-1 text-lg" />
<ui-text-input v-model="password" type="password" :disabled="processing" placeholder="password" class="w-full my-1 text-lg" /> <ui-text-input v-model="password" type="password" :disabled="processing" placeholder="password" class="w-full my-1 text-lg" />
<ui-btn :disabled="processing" type="submit" class="mt-1 h-10">Submit</ui-btn> <ui-btn :disabled="processing || !networkConnected" type="submit" class="mt-1 h-10">{{ networkConnected ? 'Submit' : 'No Internet' }}</ui-btn>
</form> </form>
</template> </template>
@ -77,8 +80,16 @@ export default {
loggedIn: false loggedIn: false
} }
}, },
computed: {
networkConnected() {
return this.$store.state.networkConnected
}
},
methods: { methods: {
async submit() { async submit() {
if (!this.networkConnected) {
return
}
if (!this.serverUrl.startsWith('http')) { if (!this.serverUrl.startsWith('http')) {
this.serverUrl = 'http://' + this.serverUrl this.serverUrl = 'http://' + this.serverUrl
} }
@ -94,6 +105,9 @@ export default {
} }
}, },
async submitAuth() { async submitAuth() {
if (!this.networkConnected) {
return
}
if (!this.username) { if (!this.username) {
this.error = 'Invalid username' this.error = 'Invalid username'
return return
@ -137,8 +151,11 @@ export default {
} }
this.$server.on('connected', this.socketConnected) this.$server.on('connected', this.socketConnected)
var localServerUrl = localStorage.getItem('serverUrl') var localServerUrl = await this.$localStore.getServerUrl()
var localUserToken = localStorage.getItem('userToken') var localUserToken = await this.$localStore.getToken()
if (!this.networkConnected) return
if (localServerUrl) { if (localServerUrl) {
this.serverUrl = localServerUrl this.serverUrl = localServerUrl
if (localUserToken) { if (localUserToken) {

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' import Server from '../Server'
Vue.prototype.$server = null export default function ({ store }, inject) {
export default function ({ store }) { inject('server', new Server(store))
Vue.prototype.$server = new Server(store)
} }

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

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 = () => ({ export const state = () => ({
streamAudiobook: null, streamAudiobook: null,
playingDownload: null,
playOnLoad: false, playOnLoad: false,
serverUrl: null, serverUrl: null,
user: null, appUpdateInfo: null,
appUpdateInfo: null socketConnected: false,
networkConnected: false,
networkConnectionType: '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 actions = {}
export const mutations = { export const mutations = {
@ -23,12 +40,37 @@ export const mutations = {
state.playOnLoad = val state.playOnLoad = val
}, },
setStreamAudiobook(state, audiobook) { setStreamAudiobook(state, audiobook) {
state.streamAudiobook = audiobook if (audiobook) {
state.playingDownload = null
}
Vue.set(state, 'streamAudiobook', audiobook)
if (state.streamListener) {
state.streamListener('stream', audiobook)
}
},
setPlayingDownload(state, download) {
if (download) {
state.streamAudiobook = null
}
Vue.set(state, 'playingDownload', download)
if (state.streamListener) {
state.streamListener('download', download)
}
}, },
setServerUrl(state, url) { setServerUrl(state, url) {
state.serverUrl = url state.serverUrl = url
}, },
setUser(state, user) { setSocketConnected(state, val) {
state.user = user state.socketConnected = val
},
setNetworkStatus(state, val) {
state.networkConnected = val.connected
state.networkConnectionType = val.connectionType
},
setStreamListener(state, val) {
state.streamListener = val
},
removeStreamListener(state) {
state.streamListener = null
} }
} }

View file

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