Merge branch 'advplyr:master' into master

This commit is contained in:
Rasmus Krämer 2022-04-11 11:29:46 +02:00 committed by GitHub
commit 2f4b3050fd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
123 changed files with 12478 additions and 31498 deletions

304
Server.js
View file

@ -1,304 +0,0 @@
import { io } from 'socket.io-client'
import { Storage } from '@capacitor/storage'
import axios from 'axios'
import EventEmitter from 'events'
class Server extends EventEmitter {
constructor(store) {
super()
this.store = store
this.url = null
this.socket = null
this.user = null
this.connected = false
this.initialized = false
this.stream = null
this.isConnectingSocket = false
}
get token() {
return this.user ? this.user.token : null
}
getAxiosConfig() {
return { headers: { Authorization: `Bearer ${this.token}` } }
}
getServerUrl(url) {
if (!url) return null
try {
var urlObject = new URL(url)
return `${urlObject.protocol}//${urlObject.hostname}:${urlObject.port}`
} catch (error) {
console.error('Invalid URL', error)
return null
}
}
setUser(user) {
this.user = user
this.store.commit('user/setUser', user)
if (user) {
// this.store.commit('user/setSettings', user.settings)
Storage.set({ key: 'token', value: user.token })
} else {
Storage.remove({ key: 'token' })
}
}
setServerUrl(url) {
this.url = url
this.store.commit('setServerUrl', url)
if (url) {
Storage.set({ key: 'serverUrl', value: url })
} else {
Storage.remove({ key: 'serverUrl' })
}
}
async connect(url, token) {
if (this.connected) {
console.warn('[SOCKET] Connection already established for ' + this.url)
return { success: true }
}
if (!url) {
console.error('Invalid url to connect')
return {
error: 'Invalid URL'
}
}
var serverUrl = this.getServerUrl(url)
var res = await this.ping(serverUrl)
if (!res || !res.success) {
return {
error: res ? res.error : 'Unknown Error'
}
}
var authRes = await this.authorize(serverUrl, token)
if (!authRes || authRes.error) {
return {
error: authRes ? authRes.error : 'Authorization Error'
}
}
this.setServerUrl(serverUrl)
this.setUser(authRes.user)
this.connectSocket()
return { success: true }
}
async check(url) {
var serverUrl = this.getServerUrl(url)
if (!serverUrl) {
return {
error: 'Invalid server url'
}
}
var res = await this.ping(serverUrl)
if (!res || res.error) {
return {
error: res ? res.error : 'Ping Failed'
}
}
return {
success: true,
serverUrl
}
}
async login(url, username, password) {
var serverUrl = this.getServerUrl(url)
var authUrl = serverUrl + '/login'
return axios.post(authUrl, { username, password }).then((res) => {
if (!res.data || !res.data.user) {
console.error(res.data.error)
return {
error: res.data.error || 'Unknown Error'
}
}
this.setServerUrl(serverUrl)
this.setUser(res.data.user)
this.connectSocket()
return {
user: res.data.user
}
}).catch(error => {
console.error('[Server] Server auth failed', error)
var errorMsg = null
if (error.response) {
errorMsg = error.response.data || 'Unknown Error'
} else if (error.request) {
errorMsg = 'Server did not respond'
} else {
errorMsg = 'Failed to send request'
}
return {
error: errorMsg
}
})
}
logout() {
this.setUser(null)
this.stream = null
if (this.socket) {
this.socket.disconnect()
}
this.emit('logout')
}
authorize(serverUrl, token) {
var authUrl = serverUrl + '/api/authorize'
return axios.post(authUrl, null, { headers: { Authorization: `Bearer ${token}` } }).then((res) => {
return res.data
}).catch(error => {
console.error('[Server] Server auth failed', error)
var errorMsg = null
if (error.response) {
errorMsg = error.response.data || 'Unknown Error'
} else if (error.request) {
errorMsg = 'Server did not respond'
} else {
errorMsg = 'Failed to send request'
}
return {
error: errorMsg
}
})
}
ping(url) {
var pingUrl = url + '/ping'
console.log('[Server] Check server', pingUrl)
return axios.get(pingUrl, { timeout: 1000 }).then((res) => {
return res.data
}).catch(error => {
console.error('Server check failed', error)
var errorMsg = null
if (error.response) {
errorMsg = error.response.data || 'Unknown Error'
} else if (error.request) {
errorMsg = 'Server did not respond'
} else {
errorMsg = 'Failed to send request'
}
return {
success: false,
error: errorMsg
}
})
}
connectSocket() {
if (this.socket && !this.connected) {
this.socket.connect()
console.log('[SOCKET] Submitting connect')
return
}
if (this.connected || this.socket) {
if (this.socket) console.error('[SOCKET] Socket already established', this.url)
else console.error('[SOCKET] Already connected to socket', this.url)
return
}
console.log('[SOCKET] Connect Socket', this.url)
const socketOptions = {
transports: ['websocket'],
upgrade: false,
// reconnectionAttempts: 3
}
this.socket = io(this.url, socketOptions)
this.socket.on('connect', () => {
console.log('[SOCKET] Socket Connected ' + this.socket.id)
// Authenticate socket with token
this.socket.emit('auth', this.token)
this.connected = true
this.emit('connected', true)
this.store.commit('setSocketConnected', true)
})
this.socket.on('disconnect', (reason) => {
console.log('[SOCKET] Socket Disconnected: ' + reason)
this.connected = false
this.emit('connected', false)
this.emit('initialized', false)
this.initialized = false
this.store.commit('setSocketConnected', false)
// this.socket.removeAllListeners()
// if (this.socket.io && this.socket.io.removeAllListeners) {
// console.log(`[SOCKET] Removing ALL IO listeners`)
// this.socket.io.removeAllListeners()
// }
})
this.socket.on('init', (data) => {
console.log('[SOCKET] Initial socket data received', data)
if (data.stream) {
this.stream = data.stream
this.store.commit('setStreamAudiobook', data.stream.audiobook)
this.emit('initialStream', data.stream)
}
if (data.serverSettings) {
this.store.commit('setServerSettings', data.serverSettings)
}
this.initialized = true
this.emit('initialized', true)
})
this.socket.on('user_updated', (user) => {
if (this.user && user.id === this.user.id) {
this.setUser(user)
}
})
this.socket.on('current_user_audiobook_update', (payload) => {
this.emit('currentUserAudiobookUpdate', payload)
})
this.socket.on('show_error_toast', (payload) => {
this.emit('show_error_toast', payload)
})
this.socket.on('show_success_toast', (payload) => {
this.emit('show_success_toast', payload)
})
this.socket.onAny((evt, args) => {
console.log(`[SOCKET] ${this.socket.id}: ${evt} ${JSON.stringify(args)}`)
})
this.socket.on('connect_error', (err) => {
console.error('[SOCKET] connection failed', err)
this.emit('socketConnectionFailed', err)
})
this.socket.io.on("reconnect_attempt", (attempt) => {
console.log(`[SOCKET] Reconnect Attempt ${this.socket.id}: ${attempt}`)
})
this.socket.io.on("reconnect_error", (err) => {
console.log(`[SOCKET] Reconnect Error ${this.socket.id}: ${err}`)
})
this.socket.io.on("reconnect_failed", () => {
console.log(`[SOCKET] Reconnect Failed ${this.socket.id}`)
})
this.socket.io.on("reconnect", () => {
console.log(`[SOCKET] Reconnect Success ${this.socket.id}`)
})
}
}
export default Server

View file

@ -85,6 +85,12 @@ dependencies {
// OK HTTP
implementation 'com.squareup.okhttp3:okhttp:4.9.2'
// Jackson for JSON
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.12.1'
// FFMPEG-Kit
implementation 'com.arthenica:ffmpeg-kit-full:4.5.1'
}
apply from: 'capacitor.build.gradle'

View file

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

View file

@ -7,7 +7,8 @@
<!-- Permissions -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<application
@ -70,7 +71,7 @@
<service
android:exported="true"
android:enabled="true"
android:name=".PlayerNotificationService">
android:name=".player.PlayerNotificationService">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService"/>
</intent-filter>

View file

@ -1,8 +1,4 @@
[
{
"pkg": "@capacitor-community/sqlite",
"classpath": "com.getcapacitor.community.database.sqlite.CapacitorSQLitePlugin"
},
{
"pkg": "@capacitor/app",
"classpath": "com.capacitorjs.plugins.app.AppPlugin"
@ -11,6 +7,10 @@
"pkg": "@capacitor/dialog",
"classpath": "com.capacitorjs.plugins.dialog.DialogPlugin"
},
{
"pkg": "@capacitor/haptics",
"classpath": "com.capacitorjs.plugins.haptics.HapticsPlugin"
},
{
"pkg": "@capacitor/network",
"classpath": "com.capacitorjs.plugins.network.NetworkPlugin"
@ -26,9 +26,5 @@
{
"pkg": "@robingenz/capacitor-app-update",
"classpath": "dev.robingenz.capacitor.appupdate.AppUpdatePlugin"
},
{
"pkg": "capacitor-data-storage-sqlite",
"classpath": "com.jeep.plugin.capacitor.capacitordatastoragesqlite.CapacitorDataStorageSqlitePlugin"
}
]

View file

@ -1,354 +0,0 @@
package com.audiobookshelf.app
import android.app.DownloadManager
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import com.anggrayudi.storage.callback.FileCallback
import com.anggrayudi.storage.file.*
import com.anggrayudi.storage.media.FileDescription
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import java.io.File
@CapacitorPlugin(name = "AudioDownloader")
class AudioDownloader : Plugin() {
private val tag = "AudioDownloader"
lateinit var mainActivity:MainActivity
lateinit var downloadManager:DownloadManager
// data class AudiobookItem(val uri: Uri, val name: String, val size: Long, val coverUrl: String) {
// fun toJSObject() : JSObject {
// var obj = JSObject()
// obj.put("uri", this.uri)
// obj.put("name", this.name)
// obj.put("size", this.size)
// obj.put("coverUrl", this.coverUrl)
// return obj
// }
// }
override fun load() {
mainActivity = (activity as MainActivity)
downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
var recieverEvent: (evt: String, id: Long) -> Unit = { evt: String, id: Long ->
if (evt == "complete") {
}
if (evt == "clicked") {
Log.d(tag, "Clicked $id back in the audiodownloader")
}
}
mainActivity.registerBroadcastReceiver(recieverEvent)
Log.d(tag, "Build SDK ${Build.VERSION.SDK_INT}")
}
// @PluginMethod
// fun load(call: PluginCall) {
// var audiobookUrls = call.data.getJSONArray("audiobookUrls")
// var len = audiobookUrls?.length()
// if (len == null) {
// len = 0
// }
// Log.d(tag, "CALLED LOAD $len")
// var audiobookItems:MutableList<AudiobookItem> = mutableListOf()
//
// (0 until len).forEach {
// var jsobj = audiobookUrls.get(it) as JSONObject
// var audiobookUrl = jsobj.get("contentUrl").toString()
// var coverUrl = jsobj.get("coverUrl").toString()
// var storageId = ""
// if(jsobj.has("storageId")) jsobj.get("storageId").toString()
//
// var basePath = ""
// if(jsobj.has("basePath")) jsobj.get("basePath").toString()
//
// var coverBasePath = ""
// if(jsobj.has("coverBasePath")) jsobj.get("coverBasePath").toString()
//
// Log.d(tag, "LOOKUP $storageId $basePath $audiobookUrl")
//
// var audiobookFile: DocumentFile? = null
// var coverFile: DocumentFile? = null
//
// // Android 9 OR Below use storage id and base path
// if (Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.P) {
// audiobookFile = DocumentFileCompat.fromSimplePath(context, storageId, basePath)
// if (coverUrl != null && coverUrl != "") {
// coverFile = DocumentFileCompat.fromSimplePath(context, storageId, coverBasePath)
// }
// } else {
// // Android 10 and up manually deleting will still load the file causing crash
// var exists = checkUriExists(Uri.parse(audiobookUrl))
// if (exists) {
// Log.d(tag, "Audiobook exists")
// audiobookFile = DocumentFileCompat.fromUri(context, Uri.parse(audiobookUrl))
// } else {
// Log.e(tag, "Audiobook does not exist")
// }
//
// var coverExists = checkUriExists(Uri.parse(coverUrl))
// if (coverExists) {
// Log.d(tag, "Cover Exists")
// coverFile = DocumentFileCompat.fromUri(context, Uri.parse(coverUrl))
// } else if (coverUrl != null && coverUrl != "") {
// Log.e(tag, "Cover does not exist")
// }
// }
//
// if (audiobookFile == null) {
// Log.e(tag, "Audiobook was not found $audiobookUrl")
// } else {
// Log.d(tag, "Audiobook File Found StorageId:${audiobookFile.getStorageId(context)} | AbsolutePath:${audiobookFile.getAbsolutePath(context)} | BasePath:${audiobookFile.getBasePath(context)}")
//
// var _name = audiobookFile.name
// if (_name == null) _name = ""
//
// var size = audiobookFile.length()
//
// if (audiobookFile.uri.toString() !== audiobookUrl) {
// Log.d(tag, "Audiobook URI ${audiobookFile.uri} is different from $audiobookUrl => using the latter")
// }
//
// // Use existing URI's - bug happening where new uri is different from initial
// var abItem = AudiobookItem(Uri.parse(audiobookUrl), _name, size, coverUrl)
//
// Log.d(tag, "Setting AB ITEM ${abItem.name} | ${abItem.size} | ${abItem.uri} | ${abItem.coverUrl}")
//
// audiobookItems.add(abItem)
// }
// }
//
// Log.d(tag, "Load Finished ${audiobookItems.size} found")
//
// var audiobookObjs:List<JSObject> = audiobookItems.map{ it.toJSObject() }
// var mediaItemNoticePayload = JSObject()
// mediaItemNoticePayload.put("items", audiobookObjs)
// notifyListeners("onMediaLoaded", mediaItemNoticePayload)
// }
@PluginMethod
fun download(call: PluginCall) {
var audiobookId = call.data.getString("audiobookId", "audiobook").toString()
var url = call.data.getString("downloadUrl", "unknown").toString()
var coverDownloadUrl = call.data.getString("coverDownloadUrl", "").toString()
var title = call.data.getString("title", "Audiobook").toString()
var filename = call.data.getString("filename", "audiobook.mp3").toString()
var coverFilename = call.data.getString("coverFilename", "cover.png").toString()
var downloadFolderUrl = call.data.getString("downloadFolderUrl", "").toString()
var folder = DocumentFileCompat.fromUri(context, Uri.parse(downloadFolderUrl))!!
Log.d(tag, "Called download: $url | Folder: ${folder.name} | $downloadFolderUrl")
var dlfilename = audiobookId + "." + File(filename).extension
var coverdlfilename = audiobookId + "." + File(coverFilename).extension
Log.d(tag, "DL Filename $dlfilename | Cover DL Filename $coverdlfilename")
var canWriteToFolder = folder.canWrite()
if (!canWriteToFolder) {
Log.e(tag, "Error Cannot Write to Folder ${folder.baseName}")
val ret = JSObject()
ret.put("error", "Cannot write to ${folder.baseName}")
call.resolve(ret)
return
}
var dlRequest = DownloadManager.Request(Uri.parse(url))
dlRequest.setTitle("Ab: $title")
dlRequest.setDescription("Downloading to ${folder.name}")
dlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
dlRequest.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, dlfilename)
var audiobookDownloadId = downloadManager.enqueue(dlRequest)
var coverDownloadId:Long? = null
if (coverDownloadUrl != "") {
var coverDlRequest = DownloadManager.Request(Uri.parse(coverDownloadUrl))
coverDlRequest.setTitle("Cover: $title")
coverDlRequest.setDescription("Downloading to ${folder.name}")
coverDlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_ONLY_COMPLETION)
coverDlRequest.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, coverdlfilename)
coverDownloadId = downloadManager.enqueue(coverDlRequest)
}
var progressReceiver : (id:Long, prog: Long) -> Unit = { id:Long, prog: Long ->
if (id == audiobookDownloadId) {
var jsobj = JSObject()
jsobj.put("audiobookId", audiobookId)
jsobj.put("progress", prog)
notifyListeners("onDownloadProgress", jsobj)
}
}
var coverDocFile:DocumentFile? = null
var doneReceiver : (id:Long, success: Boolean) -> Unit = { id:Long, success: Boolean ->
Log.d(tag, "RECEIVER DONE $id, SUCCES? $success")
var docfile:DocumentFile? = null
// Download was complete, now find downloaded file
if (id == coverDownloadId) {
docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, coverdlfilename)
Log.d(tag, "Move Cover File ${docfile?.name}")
// For unknown reason, Android 10 test was using the title set in "setTitle" for the dl manager as the filename
// check if this was the case
if (docfile?.name == null) {
docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, "Cover: $title")
Log.d(tag, "Cover File name attempt 2 ${docfile?.name}")
}
} else if (id == audiobookDownloadId) {
docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, dlfilename)
Log.d(tag, "Move Audiobook File ${docfile?.name}")
if (docfile?.name == null) {
docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, "Ab: $title")
Log.d(tag, "File name attempt 2 ${docfile?.name}")
}
}
// Callback for moving the downloaded file
var callback = object : FileCallback() {
override fun onPrepare() {
Log.d(tag, "PREPARING MOVE FILE")
}
override fun onFailed(errorCode:ErrorCode) {
Log.e(tag, "FAILED MOVE FILE $errorCode")
docfile?.delete()
coverDocFile?.delete()
if (id == audiobookDownloadId) {
var jsobj = JSObject()
jsobj.put("audiobookId", audiobookId)
jsobj.put("error", "Move failed")
notifyListeners("onDownloadFailed", jsobj)
}
}
override fun onCompleted(result:Any) {
var resultDocFile = result as DocumentFile
var simplePath = resultDocFile.getSimplePath(context)
var storageId = resultDocFile.getStorageId(context)
var size = resultDocFile.length()
Log.d(tag, "Finished Moving File, NAME: ${resultDocFile.name} | URI:${resultDocFile.uri} | AbsolutePath:${resultDocFile.getAbsolutePath(context)} | $storageId | SimplePath: $simplePath")
var abFolder = folder.findFolder(title)
var jsobj = JSObject()
jsobj.put("audiobookId", audiobookId)
jsobj.put("downloadId", id)
jsobj.put("storageId", storageId)
jsobj.put("storageType", resultDocFile.getStorageType(context))
jsobj.put("folderUrl", abFolder?.uri)
jsobj.put("folderName", abFolder?.name)
jsobj.put("downloadFolderUrl", downloadFolderUrl)
jsobj.put("contentUrl", resultDocFile.uri)
jsobj.put("basePath", resultDocFile.getBasePath(context))
jsobj.put("filename", filename)
jsobj.put("simplePath", simplePath)
jsobj.put("size", size)
if (resultDocFile.name == filename) {
Log.d(tag, "Audiobook Finishing Moving")
} else if (resultDocFile.name == coverFilename) {
coverDocFile = docfile
Log.d(tag, "Audiobook Cover Finished Moving")
jsobj.put("isCover", true)
}
notifyListeners("onDownloadComplete", jsobj)
}
}
// After file is downloaded, move the files into an audiobook directory inside the user selected folder
if (id == coverDownloadId) {
docfile?.moveFileTo(context, folder, FileDescription(coverFilename, title, MimeType.IMAGE), callback)
} else if (id == audiobookDownloadId) {
docfile?.moveFileTo(context, folder, FileDescription(filename, title, MimeType.AUDIO), callback)
}
}
var progressUpdater = DownloadProgressUpdater(downloadManager, audiobookDownloadId, progressReceiver, doneReceiver)
progressUpdater.run()
if (coverDownloadId != null) {
var coverProgressUpdater = DownloadProgressUpdater(downloadManager, coverDownloadId, progressReceiver, doneReceiver)
coverProgressUpdater.run()
}
val ret = JSObject()
ret.put("audiobookDownloadId", audiobookDownloadId)
ret.put("coverDownloadId", coverDownloadId)
call.resolve(ret)
}
internal class DownloadProgressUpdater(private val manager: DownloadManager, private val downloadId: Long, private var receiver: (Long, Long) -> Unit, private var doneReceiver: (Long, Boolean) -> Unit) : Thread() {
private val query: DownloadManager.Query = DownloadManager.Query()
private var totalBytes: Int = 0
private var TAG = "DownloadProgressUpdater"
init {
query.setFilterById(this.downloadId)
}
override fun run() {
Log.d(TAG, "RUN FOR ID $downloadId")
var keepRunning = true
var increment = 0
while (keepRunning) {
Thread.sleep(500)
increment++
if (increment % 4 == 0) {
Log.d(TAG, "Loop $increment : $downloadId")
}
manager.query(query).use {
if (it.moveToFirst()) {
//get total bytes of the file
if (totalBytes <= 0) {
totalBytes = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES))
if (totalBytes <= 0) {
Log.e(TAG, "Download Is 0 Bytes $downloadId")
doneReceiver(downloadId, false)
keepRunning = false
this.interrupt()
return
}
}
val downloadStatus = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_STATUS))
val bytesDownloadedSoFar = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR))
if (increment % 4 == 0) {
Log.d(TAG, "BYTES $increment : $downloadId : $bytesDownloadedSoFar : TOTAL: $totalBytes")
}
if (downloadStatus == DownloadManager.STATUS_SUCCESSFUL || downloadStatus == DownloadManager.STATUS_FAILED) {
if (downloadStatus == DownloadManager.STATUS_SUCCESSFUL) {
doneReceiver(downloadId, true)
} else {
doneReceiver(downloadId, false)
}
keepRunning = false
this.interrupt()
} else {
//update progress
val percentProgress = ((bytesDownloadedSoFar * 100L) / totalBytes)
receiver(downloadId, percentProgress)
}
} else {
Log.e(TAG, "NOT FOUND IN QUERY")
keepRunning = false
}
}
}
}
}
}

View file

@ -1,102 +0,0 @@
package com.audiobookshelf.app
import android.net.Uri
import android.os.Bundle
import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat
import com.getcapacitor.JSObject
class Audiobook {
var id:String
var ino:String
var libraryId:String
var folderId:String
var book:Book
var duration:Float
var size:Long
var numTracks:Int
var isMissing:Boolean
var isInvalid:Boolean
var path:String
var isDownloaded:Boolean = false
var downloadFolderUrl:String = ""
var folderUrl:String = ""
var contentUrl:String = ""
var filename:String = ""
var localCoverUrl:String = ""
var localCover:String = ""
var serverUrl:String = ""
var token:String = ""
constructor(jsobj: JSObject, serverUrl:String, token:String) {
this.serverUrl = serverUrl
this.token = token
id = jsobj.getString("id", "").toString()
ino = jsobj.getString("ino", "").toString()
libraryId = jsobj.getString("libraryId", "").toString()
folderId = jsobj.getString("folderId", "").toString()
var bookJsObj = jsobj.getJSObject("book")
book = bookJsObj?.let { Book(it) }!!
duration = jsobj.getDouble("duration").toFloat()
size = jsobj.getLong("size")
numTracks = jsobj.getInteger("numTracks")!!
isMissing = jsobj.getBoolean("isMissing")
isInvalid = jsobj.getBoolean("isInvalid")
path = jsobj.getString("path", "").toString()
isDownloaded = jsobj.getBoolean("isDownloaded")
if (isDownloaded) {
downloadFolderUrl = jsobj.getString("downloadFolderUrl", "").toString()
folderUrl = jsobj.getString("folderUrl", "").toString()
contentUrl = jsobj.getString("contentUrl", "").toString()
filename = jsobj.getString("filename", "").toString()
localCover = jsobj.getString("localCover", "").toString()
localCoverUrl = jsobj.getString("localCoverUrl", "").toString()
}
}
fun getCover():Uri {
if (isDownloaded) {
// return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
return Uri.parse(localCoverUrl)
}
if (book.cover == "" || serverUrl == "" || token == "") return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
return Uri.parse("$serverUrl${book.cover}?token=$token&ts=${book.lastUpdate}")
}
fun getDurationLong():Long {
return duration.toLong() * 1000L
}
fun toMediaMetadata():MediaMetadataCompat {
return MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, book.title)
putString(MediaMetadataCompat.METADATA_KEY_TITLE, book.title)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, book.authorFL)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, getCover().toString())
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getCover().toString())
putString(MediaMetadataCompat.METADATA_KEY_ART_URI, getCover().toString())
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, book.authorFL)
// val extras = Bundle()
// if (isDownloaded) {
// extras.putLong(
// MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
// MediaDescriptionCompat.STATUS_DOWNLOADED)
// }
// extras.putInt(
// MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
// MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED)
// putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, RESOURCE_ROOT_URI +
// context.resources.getResourceEntryName(R.drawable.notification_bg_low_normal))
}.build()
}
}

View file

@ -1,390 +0,0 @@
package com.audiobookshelf.app
import android.app.Activity
import android.content.Context
import android.content.SharedPreferences
import android.os.Handler
import android.os.Looper
import android.support.v4.media.MediaMetadataCompat
import android.util.Log
import com.getcapacitor.JSArray
import com.getcapacitor.JSObject
import com.jeep.plugin.capacitor.capacitordatastoragesqlite.CapacitorDataStorageSqlite
import okhttp3.*
import org.json.JSONArray
import java.io.IOException
class AudiobookManager {
var tag = "AudiobookManager"
interface OnStreamData {
fun onStreamReady(asd:AudiobookStreamData)
}
var hasLoaded = false
var isLoading = false
var ctx: Context
var serverUrl = ""
var token = ""
private var client:OkHttpClient
var localMediaManager:LocalMediaManager
var audiobooks:MutableList<Audiobook> = mutableListOf()
var audiobooksInProgress:MutableList<Audiobook> = mutableListOf()
var storageSharedPreferences: SharedPreferences? = null
constructor(_ctx:Context, _client:OkHttpClient) {
ctx = _ctx
client = _client
localMediaManager = LocalMediaManager(ctx)
}
fun init() {
storageSharedPreferences = ctx.getSharedPreferences("CapacitorStorage", Activity.MODE_PRIVATE)
serverUrl = storageSharedPreferences?.getString("serverUrl", "").toString()
Log.d(tag, "SHARED PREF SERVERURL $serverUrl")
token = storageSharedPreferences?.getString("token", "").toString()
Log.d(tag, "SHARED PREF TOKEN $token")
}
fun getPlaybackRate() : Float {
if (storageSharedPreferences != null) {
var userSettings = storageSharedPreferences?.getString("userSettings", "").toString()
if (userSettings != "") {
var json = JSObject(userSettings)
var playbackRate = json.getString("playbackRate", "1")
if (playbackRate != null) {
return playbackRate.toFloat()
}
}
}
return 1f
}
fun loadCategories(cb: (() -> Unit)) {
Log.d(tag, "LOAD Categories $serverUrl | $token")
var url = "$serverUrl/api/libraries/main/categories"
val request = Request.Builder()
.url(url).addHeader("Authorization", "Bearer $token")
.build()
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
Log.d(tag, "FAILURE TO CONNECT")
e.printStackTrace()
cb()
}
override fun onResponse(call: Call, response: Response) {
response.use {
if (!response.isSuccessful) throw IOException("Unexpected code $response")
var bodyString = response.body!!.string()
var results = JSONArray(bodyString)
// var results = resJson.getJSONArray("results")
var totalShelves = results.length() - 1
Log.d(tag, "Got categories $totalShelves")
for (i in 0..totalShelves) {
var shelfobj = results.get(i)
var jsobj = JSObject(shelfobj.toString())
var shelfId = jsobj.getString("id", "")
Log.d(tag, "Category shelf id $shelfId")
if (shelfId == "continue-reading") {
var entities = jsobj.getJSONArray("entities")
var totalEntities = entities.length() - 1
Log.d(tag, "Shelf total entities $totalEntities")
for (y in 0..totalEntities) {
var abobj = entities.get(y)
Log.d(tag, "Shelf category ab id $y = ${abobj.toString()}")
var abjsobj = JSObject(abobj.toString())
abjsobj.put("isDownloaded", false)
var audiobook = Audiobook(abjsobj, serverUrl, token)
if (audiobook.isMissing || audiobook.isInvalid || audiobook.numTracks <= 0) {
Log.d(tag, "Not an audiobook or invalid/missing")
} else {
var audiobookExists = audiobooksInProgress.find { it.id == audiobook.id }
if (audiobookExists == null) {
audiobooksInProgress.add(audiobook)
}
}
}
}
}
Log.d(tag, "${audiobooksInProgress.size} Audiobooks In Progress Loaded")
cb()
}
}
})
}
fun loadAudiobooks(cb: (() -> Unit)) {
Log.d(tag, "Load Audiobooks: $serverUrl | $token")
if (serverUrl == "" || token == "") {
Log.d(tag, "Load Audiobooks: No Server or Token set")
cb()
return
} else if (!serverUrl.startsWith("http")) {
Log.e(tag, "Load Audiobooks: Invalid server url $serverUrl")
cb()
return
}
// First load currently reading
loadCategories() {
// Then load all
var url = "$serverUrl/api/libraries/main/books/all?sort=book.title"
val request = Request.Builder()
.url(url).addHeader("Authorization", "Bearer $token")
.build()
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
Log.d(tag, "Load Audiobooks: FAILURE TO CONNECT")
e.printStackTrace()
cb()
}
override fun onResponse(call: Call, response: Response) {
response.use {
if (!response.isSuccessful) throw IOException("Unexpected code $response")
var bodyString = response.body!!.string()
var resJson = JSObject(bodyString)
var results = resJson.getJSONArray("results")
var totalBooks = results.length() - 1
for (i in 0..totalBooks) {
var abobj = results.get(i)
var jsobj = JSObject(abobj.toString())
jsobj.put("isDownloaded", false)
var audiobook = Audiobook(jsobj, serverUrl, token)
if (audiobook.isMissing || audiobook.isInvalid) {
Log.d(tag, "Audiobook ${audiobook.book.title} is missing or invalid")
} else if (audiobook.numTracks <= 0) {
Log.d(tag, "Audiobook ${audiobook.book.title} has audio tracks")
} else {
var audiobookExists = audiobooks.find { it.id == audiobook.id }
if (audiobookExists == null) {
audiobooks.add(audiobook)
} else {
Log.d(tag, "Audiobook already there from downloaded")
}
}
}
Log.d(tag, "${audiobooks.size} Audiobooks Loaded")
cb()
}
}
})
}
}
fun load() {
isLoading = true
hasLoaded = true
localMediaManager.loadLocalAudio()
// Load downloads from sql db
var db = CapacitorDataStorageSqlite(ctx)
db.openStore("storage", "downloads", false, "no-encryption", 1)
var keyvalues = db.keysvalues()
keyvalues.forEach {
Log.d(tag, "keyvalue ${it.getString("key")} | ${it.getString("value")}")
var dlobj = JSObject(it.getString("value"))
if (dlobj.has("audiobook")) {
var abobj = dlobj.getJSObject("audiobook")!!
abobj.put("isDownloaded", true)
abobj.put("contentUrl", dlobj.getString("contentUrl", "").toString())
abobj.put("filename", dlobj.getString("filename", "").toString())
abobj.put("folderUrl", dlobj.getString("folderUrl", "").toString())
abobj.put("downloadFolderUrl", dlobj.getString("downloadFolderUrl", "").toString())
abobj.put("localCoverUrl", dlobj.getString("coverUrl", "").toString())
abobj.put("localCover", dlobj.getString("cover", "").toString())
var audiobook = Audiobook(abobj, serverUrl, token)
audiobooks.add(audiobook)
}
}
}
fun openStream(audiobook:Audiobook, streamListener:OnStreamData) {
var url = "$serverUrl/api/books/${audiobook.id}/stream"
val request = Request.Builder()
.url(url).addHeader("Authorization", "Bearer $token")
.build()
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
e.printStackTrace()
}
override fun onResponse(call: Call, response: Response) {
response.use {
if (!response.isSuccessful) throw IOException("Unexpected code $response")
var playbackRate = getPlaybackRate()
var bodyString = response.body!!.string()
var stream = JSObject(bodyString)
var streamId = stream.getString("streamId", "").toString()
var startTime = stream.getDouble("startTime")
var streamUrl = stream.getString("streamUrl", "").toString()
var startTimeLong = (startTime * 1000).toLong()
var abStreamDataObj = JSObject()
abStreamDataObj.put("id", streamId)
abStreamDataObj.put("audiobookId", audiobook.id)
abStreamDataObj.put("playlistUrl", "$serverUrl$streamUrl")
abStreamDataObj.put("title", audiobook.book.title)
abStreamDataObj.put("author", audiobook.book.authorFL)
abStreamDataObj.put("token", token)
abStreamDataObj.put("cover", audiobook.getCover())
abStreamDataObj.put("duration", audiobook.getDurationLong())
abStreamDataObj.put("startTime", startTimeLong)
abStreamDataObj.put("playbackSpeed", playbackRate)
abStreamDataObj.put("playWhenReady", true)
abStreamDataObj.put("isLocal", false)
var audiobookStreamData = AudiobookStreamData(abStreamDataObj)
Handler(Looper.getMainLooper()).post() {
Log.d(tag, "Stream Ready on Main Looper")
streamListener.onStreamReady(audiobookStreamData)
}
Log.d(tag, "Init Player Stream")
}
}
})
}
fun initDownloadPlay(audiobook:Audiobook):AudiobookStreamData {
var playbackRate = getPlaybackRate()
var abStreamDataObj = JSObject()
abStreamDataObj.put("id", "download")
abStreamDataObj.put("audiobookId", audiobook.id)
abStreamDataObj.put("contentUrl", audiobook.contentUrl)
abStreamDataObj.put("title", audiobook.book.title)
abStreamDataObj.put("author", audiobook.book.authorFL)
abStreamDataObj.put("token", null)
abStreamDataObj.put("cover", audiobook.getCover())
abStreamDataObj.put("duration", audiobook.getDurationLong())
abStreamDataObj.put("startTime", 0)
abStreamDataObj.put("playbackSpeed", playbackRate)
abStreamDataObj.put("playWhenReady", true)
abStreamDataObj.put("isLocal", true)
var audiobookStreamData = AudiobookStreamData(abStreamDataObj)
return audiobookStreamData
}
fun initLocalPlay(local: LocalMediaManager.LocalAudio):AudiobookStreamData {
var abStreamDataObj = JSObject()
abStreamDataObj.put("id", "local")
abStreamDataObj.put("audiobookId", local.id)
abStreamDataObj.put("contentUrl", local.uri.toString())
abStreamDataObj.put("title", local.name)
abStreamDataObj.put("author", "")
abStreamDataObj.put("token", null)
abStreamDataObj.put("cover", local.coverUri)
abStreamDataObj.put("duration", local.duration)
abStreamDataObj.put("startTime", 0)
abStreamDataObj.put("playbackSpeed", 1)
abStreamDataObj.put("playWhenReady", true)
abStreamDataObj.put("isLocal", true)
var audiobookStreamData = AudiobookStreamData(abStreamDataObj)
return audiobookStreamData
}
private fun levenshtein(lhs : CharSequence, rhs : CharSequence) : Int {
val lhsLength = lhs.length + 1
val rhsLength = rhs.length + 1
var cost = Array(lhsLength) { it }
var newCost = Array(lhsLength) { 0 }
for (i in 1..rhsLength-1) {
newCost[0] = i
for (j in 1..lhsLength-1) {
val match = if(lhs[j - 1] == rhs[i - 1]) 0 else 1
val costReplace = cost[j - 1] + match
val costInsert = cost[j] + 1
val costDelete = newCost[j - 1] + 1
newCost[j] = Math.min(Math.min(costInsert, costDelete), costReplace)
}
val swap = cost
cost = newCost
newCost = swap
}
return cost[lhsLength - 1]
}
fun searchForAudiobook(query:String):Audiobook? {
var closestDistance = 99
var closestMatch:Audiobook? = null
audiobooks.forEach {
var dist = levenshtein(it.book.title, query)
Log.d(tag, "LEVENSHTEIN $dist")
if (dist < closestDistance) {
closestDistance = dist
closestMatch = it
}
}
if (closestMatch != null) {
Log.d(tag, "Closest Search is ${closestMatch?.book?.title} with distance $closestDistance")
if (closestDistance < 2) {
return closestMatch
}
return null
}
return null
}
fun getFirstAudiobook():Audiobook? {
if (audiobooks.isEmpty()) return null
return audiobooks[0]
}
fun getFirstLocal(): LocalMediaManager.LocalAudio? {
if (localMediaManager.localAudioFiles.isEmpty()) return null
return localMediaManager.localAudioFiles[0]
}
// Used for media browser loadChildren, fallback to using the samples if no audiobooks are there
fun getAudiobooksMediaMetadata() : List<MediaMetadataCompat> {
var mediaMetadata:MutableList<MediaMetadataCompat> = mutableListOf()
if (audiobooks.isEmpty()) {
localMediaManager.localAudioFiles.forEach { mediaMetadata.add(it.toMediaMetadata()) }
} else {
audiobooks.forEach { mediaMetadata.add(it.toMediaMetadata()) }
}
return mediaMetadata
}
// Used for media browser loadChildren, fallback to using the samples if no audiobooks are there
fun getDownloadedAudiobooksMediaMetadata() : List<MediaMetadataCompat> {
var mediaMetadata:MutableList<MediaMetadataCompat> = mutableListOf()
if (audiobooks.isEmpty()) {
localMediaManager.localAudioFiles.forEach { mediaMetadata.add(it.toMediaMetadata()) }
} else {
audiobooks.forEach { if (it.isDownloaded) { mediaMetadata.add(it.toMediaMetadata()) } }
}
return mediaMetadata
}
}

View file

@ -1,182 +0,0 @@
package com.audiobookshelf.app
import android.os.Handler
import android.os.Looper
import android.util.Log
import com.getcapacitor.JSObject
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.util.*
import kotlin.concurrent.schedule
/*
* Normal progress sync is handled in webview, but when using android auto webview may not be open.
* If webview is not open sync progress every 5s. Webview can be closed at any time so interval is always set.
*/
class AudiobookProgressSyncer constructor(playerNotificationService:PlayerNotificationService, client: OkHttpClient) {
private val tag = "AudiobookProgressSync"
private val playerNotificationService:PlayerNotificationService = playerNotificationService
private val client:OkHttpClient = client
private var listeningTimerTask: TimerTask? = null
var listeningTimerRunning:Boolean = false
private var webviewOpenOnStart:Boolean = false
private var webviewClosedMidSession:Boolean = false
private var listeningBookTitle:String? = ""
private var listeningBookIsLocal:Boolean = false
private var listeningBookId:String? = ""
private var listeningStreamId:String? = ""
private var lastPlaybackTime:Long = 0
private var lastUpdateTime:Long = 0
fun start() {
if (listeningTimerRunning) {
Log.d(tag, "start: Timer already running for $listeningBookTitle")
if (playerNotificationService.getCurrentBookTitle() != listeningBookTitle) {
Log.d(tag, "start: Changed audiobook stream - resetting timer")
listeningTimerTask?.cancel()
}
}
listeningTimerRunning = true
webviewOpenOnStart = playerNotificationService.getIsWebviewOpen()
listeningBookTitle = playerNotificationService.getCurrentBookTitle()
listeningBookIsLocal = playerNotificationService.getCurrentBookIsLocal()
listeningBookId = playerNotificationService.getCurrentBookId()
listeningStreamId = playerNotificationService.getCurrentStreamId()
lastPlaybackTime = playerNotificationService.getCurrentTime()
lastUpdateTime = System.currentTimeMillis() / 1000L
listeningTimerTask = Timer("ListeningTimer", false).schedule(0L, 5000L) {
Handler(Looper.getMainLooper()).post() {
// Webview was closed while android auto is open - switch to native sync
var isWebviewOpen = playerNotificationService.getIsWebviewOpen()
if (!isWebviewOpen && webviewOpenOnStart) {
Log.d(tag, "Listening Timer: webview closed Switching to native sync tracking")
webviewOpenOnStart = false
webviewClosedMidSession = true
lastUpdateTime = System.currentTimeMillis() / 1000L
} else if (isWebviewOpen && webviewClosedMidSession) {
Log.d(tag, "Listening Timer: webview re-opened Switching back to webview sync tracking")
webviewClosedMidSession = false
webviewOpenOnStart = true
lastUpdateTime = System.currentTimeMillis() / 1000L
}
if (!webviewOpenOnStart && playerNotificationService.currentPlayer.isPlaying) {
sync()
}
}
}
}
fun stop() {
if (!listeningTimerRunning) return
Log.d(tag, "stop: Stopping listening for $listeningBookTitle")
if (!webviewOpenOnStart) {
sync()
}
reset()
}
fun reset() {
listeningTimerTask?.cancel()
listeningTimerTask = null
listeningTimerRunning = false
listeningBookTitle = ""
listeningBookId = ""
listeningBookIsLocal = false
listeningStreamId = ""
}
fun sync() {
var currTime = System.currentTimeMillis() / 1000L
var elapsed = currTime - lastUpdateTime
lastUpdateTime = currTime
if (!listeningBookIsLocal) {
Log.d(tag, "ListeningTimer: Sending sync data to server: elapsed $elapsed | $listeningStreamId | $listeningBookId")
// Send sync data only for streaming books
var syncData: JSObject = JSObject()
syncData.put("timeListened", elapsed)
syncData.put("currentTime", playerNotificationService.getCurrentTime() / 1000)
syncData.put("streamId", listeningStreamId)
syncData.put("audiobookId", listeningBookId)
sendStreamSyncData(syncData) {
Log.d(tag, "Stream sync done")
}
} else if (listeningStreamId == "download") {
// TODO: Save downloaded audiobook progress & send to server if connected
Log.d(tag, "ListeningTimer: Is listening download")
// Send sync data only for local books
var syncData: JSObject = JSObject()
var duration = playerNotificationService.getAudiobookDuration() / 1000
var currentTime = playerNotificationService.getCurrentTime() / 1000
syncData.put("totalDuration", duration)
syncData.put("currentTime", currentTime)
syncData.put("progress", if (duration > 0) (currentTime / duration) else 0)
syncData.put("isRead", false)
syncData.put("lastUpdate", System.currentTimeMillis())
syncData.put("audiobookId", listeningBookId)
sendLocalSyncData(syncData) {
Log.d(tag, "Local sync done")
}
}
}
fun sendLocalSyncData(payload:JSObject, cb: (() -> Unit)) {
var serverUrl = playerNotificationService.getServerUrl()
var token = playerNotificationService.getUserToken()
if (serverUrl == "" || token == "") {
return
}
Log.d(tag, "Sync Local $serverUrl | $token")
var url = "$serverUrl/api/syncLocal"
sendServerRequest(url, token, payload, cb)
}
fun sendStreamSyncData(payload:JSObject, cb: (() -> Unit)) {
var serverUrl = playerNotificationService.getServerUrl()
var token = playerNotificationService.getUserToken()
if (serverUrl == "" || token == "") {
return
}
Log.d(tag, "Sync Stream $serverUrl | $token")
var url = "$serverUrl/api/syncStream"
sendServerRequest(url, token, payload, cb)
}
fun sendServerRequest(url:String, token:String, payload:JSObject, cb: () -> Unit) {
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = payload.toString().toRequestBody(mediaType)
val request = Request.Builder().post(requestBody)
.url(url).addHeader("Authorization", "Bearer $token")
.build()
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
Log.d(tag, "FAILURE TO CONNECT")
e.printStackTrace()
cb()
}
override fun onResponse(call: Call, response: Response) {
response.use {
if (!response.isSuccessful) throw IOException("Unexpected code $response")
cb()
}
}
})
}
}

View file

@ -1,176 +0,0 @@
package com.audiobookshelf.app
import android.net.Uri
import android.support.v4.media.MediaMetadataCompat
import android.util.Log
import com.getcapacitor.JSObject
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.MediaMetadata
import com.google.android.exoplayer2.util.MimeTypes
import java.lang.Exception
class AudiobookStreamData {
var id:String = "unset"
var audiobookId:String = ""
var token:String = ""
var playlistUrl:String = ""
var title:String = "No Title"
var author:String = "Unknown"
var series:String = ""
var cover:String = ""
var playWhenReady:Boolean = false
var startTime:Long = 0
var playbackSpeed:Float = 1f
var duration:Long = 0
var tracks:MutableList<String> = mutableListOf()
var isLocal:Boolean = false
var contentUrl:String = ""
var hasPlayerLoaded:Boolean = false
var playlistUri:Uri = Uri.EMPTY
var coverUri:Uri = Uri.EMPTY
var contentUri:Uri = Uri.EMPTY // For Local only
constructor(jsondata:JSObject) {
id = jsondata.getString("id", "unset").toString()
audiobookId = jsondata.getString("audiobookId", "").toString()
title = jsondata.getString("title", "No Title").toString()
token = jsondata.getString("token", "").toString()
author = jsondata.getString("author", "Unknown").toString()
series = jsondata.getString("series", "").toString()
cover = jsondata.getString("cover", "").toString()
playlistUrl = jsondata.getString("playlistUrl", "").toString()
playWhenReady = jsondata.getBoolean("playWhenReady", false) == true
if (jsondata.has("startTime")) {
startTime = jsondata.getString("startTime", "0")!!.toLong()
}
if (jsondata.has("duration")) {
duration = jsondata.getString("duration", "0")!!.toLong()
}
if (jsondata.has("playbackSpeed")) {
playbackSpeed = jsondata.getDouble("playbackSpeed")!!.toFloat()
}
// Local data
isLocal = jsondata.getBoolean("isLocal", false) == true
contentUrl = jsondata.getString("contentUrl", "").toString()
if (playlistUrl != "") {
playlistUri = Uri.parse(playlistUrl)
}
if (cover != "" && cover != null) {
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)
}
// Tracks for cast
try {
var tracksTest = jsondata.getJSONArray("tracks")
Log.d("AudiobookStreamData", "Load tracks from json array ${tracksTest.length()}")
for (i in 0 until tracksTest.length()) {
var track = tracksTest.get(i)
Log.d("AudiobookStreamData", "Extracting track $track")
tracks.add(track as String)
}
} catch(e:Exception) {
Log.d("AudiobookStreamData", "No tracks found $e")
}
}
fun clearCover() {
coverUri = Uri.EMPTY
cover = ""
}
fun getMediaMetadataCompat():MediaMetadataCompat {
var metadataBuilder = MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, author)
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, author)
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, author)
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, series)
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
// if (cover != "") {
// metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, cover)
// metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, cover)
// }
return metadataBuilder.build()
}
fun getMediaMetadata():MediaMetadata {
var metadataBuilder = MediaMetadata.Builder()
.setTitle(title)
.setDisplayTitle(title)
.setArtist(author)
.setAlbumArtist(author)
.setSubtitle(author)
// if (coverUri != Uri.EMPTY) {
// metadataBuilder.setArtworkUri(coverUri)
// }
if (playlistUri != Uri.EMPTY) {
metadataBuilder.setMediaUri(playlistUri)
}
if (contentUri != Uri.EMPTY) {
metadataBuilder.setMediaUri(contentUri)
}
return metadataBuilder.build()
}
fun getMimeType():String {
return if (isLocal) {
MimeTypes.BASE_TYPE_AUDIO
} else {
MimeTypes.APPLICATION_M3U8
}
}
fun getMediaUri():Uri {
return if (isLocal) {
contentUri
} else {
Uri.parse("$playlistUrl?token=$token")
}
}
fun getCastQueue():ArrayList<MediaItem> {
var mediaQueue: java.util.ArrayList<MediaItem> = java.util.ArrayList<MediaItem>()
for (i in 0 until tracks.size) {
var track = tracks[i]
var metadataBuilder = MediaMetadata.Builder()
.setTitle(title)
.setDisplayTitle(title)
.setArtist(author)
.setAlbumArtist(author)
.setSubtitle(author)
.setTrackNumber(i + 1)
if (coverUri != Uri.EMPTY) {
metadataBuilder.setArtworkUri(coverUri)
}
var mimeType = MimeTypes.BASE_TYPE_AUDIO
var mediaMetadata = metadataBuilder.build()
var mediaItem = MediaItem.Builder().setUri(Uri.parse(track)).setMediaMetadata(mediaMetadata).setMimeType(mimeType).build()
mediaQueue.add(mediaItem)
}
return mediaQueue
}
}

View file

@ -1,39 +0,0 @@
package com.audiobookshelf.app
import com.getcapacitor.JSObject
class Book {
var title:String
var subtitle:String
var author:String
var authorFL:String
var narrator:String
var series:String
var volumeNumber:String
var publisher:String
var description:String
var publishYear:String
var language:String
var cover:String
var coverFullPath:String
var genres:String
var lastUpdate:Long
constructor(jsobj: JSObject) {
title = jsobj.getString("title", "").toString()
subtitle = jsobj.getString("subtitle", "").toString()
author = jsobj.getString("author", "").toString()
authorFL = jsobj.getString("authorFL", "").toString()
narrator = jsobj.getString("narrator", "").toString()
series = jsobj.getString("series", "").toString()
volumeNumber = jsobj.getString("volumeNumber", "").toString()
publisher = jsobj.getString("publisher", "").toString()
description = jsobj.getString("description", "").toString()
publishYear = jsobj.getString("publishYear", "").toString()
language = jsobj.getString("language", "").toString()
cover = jsobj.getString("cover", "").toString()
coverFullPath = jsobj.getString("coverFullPath", "").toString()
genres = jsobj.getString("genres", "").toString()
lastUpdate = jsobj.getLong("lastUpdate")
}
}

View file

@ -11,6 +11,7 @@ import com.google.android.gms.cast.framework.media.CastMediaOptions
class CastOptionsProvider : OptionsProvider {
override fun getCastOptions(context: Context): CastOptions {
Log.d("CastOptionsProvider", "getCastOptions")
var appId = "FD1F76C5"
return CastOptions.Builder()
.setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID).setCastMediaOptions(
CastMediaOptions.Builder()

View file

@ -1,120 +0,0 @@
package com.audiobookshelf.app
import android.Manifest
import android.content.ContentResolver
import android.content.ContentUris
import android.content.Context
import android.content.pm.PackageManager
import android.content.res.AssetFileDescriptor
import android.database.Cursor
import android.media.MediaPlayer
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.support.v4.media.MediaMetadataCompat
import android.util.Log
import androidx.annotation.AnyRes
import androidx.core.content.ContextCompat
import androidx.core.net.toFile
import androidx.core.net.toUri
import com.bumptech.glide.Glide
import java.io.File
import java.io.IOException
class LocalMediaManager {
private var ctx: Context
val tag = "LocalAudioManager"
constructor(ctx: Context) {
this.ctx = ctx
}
data class LocalAudio(val uri: Uri,
val id: String,
val name: String,
val duration: Int,
val size: Int,
val coverUri: Uri?
) {
fun toMediaMetadata(): MediaMetadataCompat {
return MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, name)
putString(MediaMetadataCompat.METADATA_KEY_TITLE, name)
if (coverUri != null) {
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString())
}
}.build()
}
}
val localAudioFiles = mutableListOf<LocalAudio>()
/**
* get uri to drawable or any other resource type if u wish
* @param context - context
* @param drawableId - drawable res id
* @return - uri
*/
fun getUriToDrawable(context: Context,
@AnyRes drawableId: Int): Uri {
return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE
+ "://" + context.resources.getResourcePackageName(drawableId)
+ '/' + context.resources.getResourceTypeName(drawableId)
+ '/' + context.resources.getResourceEntryName(drawableId))
}
fun loadLocalAudio() {
localAudioFiles.clear()
localAudioFiles += LocalAudio(Uri.parse("asset:///public/samples/Anthem/AnthemSample.m4b"), "anthem_sample", "Anthem", 60000, 10000, getUriToDrawable(ctx, R.drawable.exo_icon_localaudio))
localAudioFiles += LocalAudio(Uri.parse("asset:///public/samples/Legend of Sleepy Hollow/LegendOfSleepyHollowSample.m4b"), "sleepy_hollow", "Legend of Sleepy Hollow", 60000, 10000, getUriToDrawable(ctx, R.drawable.exo_icon_localaudio))
// TODO: No longer reading in local audio files - just use samples
// if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
// Log.e(tag, "Permission not granted to read from external storage")
// return
// }
//
// val collection =
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// MediaStore.Audio.Media.getContentUri(
// MediaStore.VOLUME_EXTERNAL
// )
// } else {
// MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
// }
//
// val proj = arrayOf(MediaStore.Audio.Media._ID, MediaStore.Audio.Media.DISPLAY_NAME, MediaStore.Audio.Media.DURATION, MediaStore.Audio.Media.SIZE)
// val audioCursor: Cursor? = ctx.contentResolver.query(collection, proj, null, null, null)
//
// audioCursor?.use { cursor ->
// // Cache column indices.
// 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)
//
// while (cursor.moveToNext()) {
// // Get values of columns for a given video.
// val id = cursor.getLong(idColumn)
// val name = cursor.getString(nameColumn)
// val duration = cursor.getInt(durationColumn)
// val size = cursor.getInt(sizeColumn)
//
// val contentUri: Uri = ContentUris.withAppendedId(
// MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
// id
// )
// Log.d(tag, "Found local audio file $name")
// localAudioFiles += LocalAudio(contentUri, id.toString(), name, duration, size, null)
// }
// }
//
// Log.d(tag, "${localAudioFiles.size} Local Audio Files found")
}
}

View file

@ -1,16 +1,22 @@
package com.audiobookshelf.app
import android.Manifest
import android.app.DownloadManager
import android.content.*
import android.content.pm.PackageManager
import android.os.*
import android.util.Log
import androidx.core.app.ActivityCompat
import com.anggrayudi.storage.SimpleStorage
import com.anggrayudi.storage.SimpleStorageHelper
import com.audiobookshelf.app.data.DbManager
import com.audiobookshelf.app.data.AbsDatabase
import com.audiobookshelf.app.player.PlayerNotificationService
import com.audiobookshelf.app.plugins.AbsDownloader
import com.audiobookshelf.app.plugins.AbsAudioPlayer
import com.audiobookshelf.app.plugins.AbsFileSystem
import com.getcapacitor.BridgeActivity
import io.paperdb.Paper
class MainActivity : BridgeActivity() {
private val tag = "MainActivity"
@ -24,6 +30,11 @@ class MainActivity : BridgeActivity() {
val storageHelper = SimpleStorageHelper(this)
val storage = SimpleStorage(this)
val REQUEST_PERMISSIONS = 1
var PERMISSIONS_ALL = arrayOf(
Manifest.permission.READ_EXTERNAL_STORAGE
)
val broadcastReceiver = object: BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
@ -43,10 +54,21 @@ class MainActivity : BridgeActivity() {
super.onCreate(savedInstanceState)
Log.d(tag, "onCreate")
registerPlugin(MyNativeAudio::class.java)
registerPlugin(AudioDownloader::class.java)
registerPlugin(StorageManager::class.java)
registerPlugin(DbManager::class.java)
// var ss = SimpleStorage(this)
// ss.requestFullStorageAccess()
var permission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
if (permission != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this,
PERMISSIONS_ALL,
REQUEST_PERMISSIONS)
}
registerPlugin(AbsAudioPlayer::class.java)
registerPlugin(AbsDownloader::class.java)
registerPlugin(AbsFileSystem::class.java)
registerPlugin(AbsDatabase::class.java)
var filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE).apply {
addAction(DownloadManager.ACTION_NOTIFICATION_CLICKED)
@ -63,6 +85,7 @@ class MainActivity : BridgeActivity() {
override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
Log.d(tag, "onPostCreate MainActivity")
mConnection = object : ServiceConnection {
override fun onServiceDisconnected(name: ComponentName) {
@ -73,20 +96,21 @@ class MainActivity : BridgeActivity() {
override fun onServiceConnected(name: ComponentName, service: IBinder) {
Log.d(tag, "Service Connected $name")
mBounded = true
val mLocalBinder = service as PlayerNotificationService.LocalBinder
foregroundService = mLocalBinder.getService()
// Let MyNativeAudio know foreground service is ready and setup event listener
// Let NativeAudio know foreground service is ready and setup event listener
if (pluginCallback != null) {
pluginCallback()
}
}
}
val startIntent = Intent(this, PlayerNotificationService::class.java)
bindService(startIntent, mConnection as ServiceConnection, Context.BIND_AUTO_CREATE);
Intent(this, PlayerNotificationService::class.java).also { intent ->
Log.d(tag, "Binding PlayerNotificationService")
bindService(intent, mConnection, Context.BIND_AUTO_CREATE)
}
}

View file

@ -1,264 +0,0 @@
package com.audiobookshelf.app
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.documentfile.provider.DocumentFile
import com.anggrayudi.storage.SimpleStorage
import com.anggrayudi.storage.callback.FolderPickerCallback
import com.anggrayudi.storage.callback.StorageAccessCallback
import com.anggrayudi.storage.file.*
import com.getcapacitor.*
import com.getcapacitor.annotation.CapacitorPlugin
@CapacitorPlugin(name = "StorageManager")
class StorageManager : Plugin() {
private val TAG = "StorageManager"
lateinit var mainActivity:MainActivity
data class MediaFile(val uri: Uri, val name: String, val simplePath: String, val size: Long, val type: String, val isAudio: Boolean) {
fun toJSObject() : JSObject {
var obj = JSObject()
obj.put("uri", this.uri)
obj.put("name", this.name)
obj.put("simplePath", this.simplePath)
obj.put("size", this.size)
obj.put("type", this.type)
obj.put("isAudio", this.isAudio)
return obj
}
}
data class MediaFolder(val uri: Uri, val name: String, val simplePath: String, val mediaFiles:List<MediaFile>) {
fun toJSObject() : JSObject {
var obj = JSObject()
obj.put("uri", this.uri)
obj.put("name", this.name)
obj.put("simplePath", this.simplePath)
obj.put("files", this.mediaFiles.map { it.toJSObject() })
return obj
}
}
override fun load() {
mainActivity = (activity as MainActivity)
mainActivity.storage.storageAccessCallback = object : StorageAccessCallback {
override fun onRootPathNotSelected(
requestCode: Int,
rootPath: String,
uri: Uri,
selectedStorageType: StorageType,
expectedStorageType: StorageType
) {
Log.d(TAG, "STORAGE ACCESS CALLBACK")
}
override fun onCanceledByUser(requestCode: Int) {
Log.d(TAG, "STORAGE ACCESS CALLBACK")
}
override fun onExpectedStorageNotSelected(requestCode: Int, selectedFolder: DocumentFile, selectedStorageType: StorageType, expectedBasePath: String, expectedStorageType: StorageType) {
Log.d(TAG, "STORAGE ACCESS CALLBACK")
}
override fun onStoragePermissionDenied(requestCode: Int) {
Log.d(TAG, "STORAGE ACCESS CALLBACK")
}
override fun onRootPathPermissionGranted(requestCode: Int, root: DocumentFile) {
Log.d(TAG, "STORAGE ACCESS CALLBACK")
}
}
}
@PluginMethod
fun selectFolder(call: PluginCall) {
mainActivity.storage.folderPickerCallback = object : FolderPickerCallback {
override fun onFolderSelected(requestCode: Int, folder: DocumentFile) {
Log.d(TAG, "ON FOLDER SELECTED ${folder.uri} ${folder.name}")
var absolutePath = folder.getAbsolutePath(activity)
var storageId = folder.getStorageId(activity)
var storageType = folder.getStorageType(activity)
var simplePath = folder.getSimplePath(activity)
var basePath = folder.getBasePath(activity)
var jsobj = JSObject()
jsobj.put("uri", folder.uri)
jsobj.put("absolutePath", absolutePath)
jsobj.put("storageId", storageId)
jsobj.put("storageType", storageType)
jsobj.put("simplePath", simplePath)
jsobj.put("basePath", basePath)
call.resolve(jsobj)
}
override fun onStorageAccessDenied(requestCode: Int, folder: DocumentFile?, storageType: StorageType) {
Log.e(TAG, "STORAGE ACCESS DENIED")
var jsobj = JSObject()
jsobj.put("error", "Access Denied")
call.resolve(jsobj)
}
override fun onStoragePermissionDenied(requestCode: Int) {
Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode")
var jsobj = JSObject()
jsobj.put("error", "Permission Denied")
call.resolve(jsobj)
}
}
mainActivity.storage.openFolderPicker(6)
}
@RequiresApi(Build.VERSION_CODES.R)
@PluginMethod
fun requestStoragePermission(call: PluginCall) {
Log.d(TAG, "Request Storage Permissions")
mainActivity.storageHelper.requestStorageAccess()
call.resolve()
}
@PluginMethod
fun checkStoragePermission(call: PluginCall) {
var res = false
if (Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.P) {
res = SimpleStorage.hasStoragePermission(context)
Log.d(TAG, "Check Storage Access $res")
} else {
Log.d(TAG, "Has permission on Android 10 or up")
res = true
}
var jsobj = JSObject()
jsobj.put("value", res)
call.resolve(jsobj)
}
@PluginMethod
fun checkFolderPermissions(call: PluginCall) {
var folderUrl = call.data.getString("folderUrl", "").toString()
Log.d(TAG, "Check Folder Permissions for $folderUrl")
var hasAccess = SimpleStorage.hasStorageAccess(context,folderUrl,true)
var jsobj = JSObject()
jsobj.put("value", hasAccess)
call.resolve(jsobj)
}
@PluginMethod
fun searchFolder(call: PluginCall) {
var folderUrl = call.data.getString("folderUrl", "").toString()
Log.d(TAG, "Searching folder $folderUrl")
var df: DocumentFile? = DocumentFileCompat.fromUri(context, Uri.parse(folderUrl))
if (df == null) {
Log.e(TAG, "Folder Doc File Invalid $folderUrl")
var jsobj = JSObject()
jsobj.put("folders", JSArray())
jsobj.put("files", JSArray())
call.resolve(jsobj)
return
}
Log.d(TAG, "Folder as DF ${df.isDirectory} | ${df.getSimplePath(context)} | ${df.getBasePath(context)} | ${df.name}")
var mediaFolders = mutableListOf<MediaFolder>()
var foldersFound = df.search(false, DocumentFileType.FOLDER)
foldersFound.forEach {
Log.d(TAG, "Iterating over Folder Found ${it.name} | ${it.getSimplePath(context)} | URI: ${it.uri}")
var folderName = it.name ?: ""
var mediaFiles = mutableListOf<MediaFile>()
var filesInFolder = it.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*"))
filesInFolder.forEach { it2 ->
var mimeType = it2?.mimeType ?: ""
var filename = it2?.name ?: ""
var isAudio = mimeType.startsWith("audio")
Log.d(TAG, "Found $mimeType file $filename in folder $folderName")
var imageFile = MediaFile(it2.uri, filename, it2.getSimplePath(context), it2.length(), mimeType, isAudio)
mediaFiles.add(imageFile)
}
if (mediaFiles.size > 0) {
mediaFolders.add(MediaFolder(it.uri, folderName, it.getSimplePath(context), mediaFiles))
}
}
// Files in root dir
var rootMediaFiles = mutableListOf<MediaFile>()
var mediaFilesFound:List<DocumentFile> = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*"))
mediaFilesFound.forEach {
Log.d(TAG, "Folder Root File Found ${it.name} | ${it.getSimplePath(context)} | URI: ${it.uri} | ${it.mimeType}")
var mimeType = it?.mimeType ?: ""
var filename = it?.name ?: ""
var isAudio = mimeType.startsWith("audio")
Log.d(TAG, "Found $mimeType file $filename in root folder")
var imageFile = MediaFile(it.uri, filename, it.getSimplePath(context), it.length(), mimeType, isAudio)
rootMediaFiles.add(imageFile)
}
var jsobj = JSObject()
jsobj.put("folders", mediaFolders.map{ it.toJSObject() })
jsobj.put("files", rootMediaFiles.map{ it.toJSObject() })
call.resolve(jsobj)
}
@PluginMethod
fun delete(call: PluginCall) {
var url = call.data.getString("url", "").toString()
var coverUrl = call.data.getString("coverUrl", "").toString()
var folderUrl = call.data.getString("folderUrl", "").toString()
if (folderUrl != "") {
Log.d(TAG, "CALLED DELETE FOLDER: $folderUrl")
var folder = DocumentFileCompat.fromUri(context, Uri.parse(folderUrl))
var success = folder?.deleteRecursively(context)
var jsobj = JSObject()
jsobj.put("success", success)
call.resolve()
} else {
// Older audiobooks did not store a folder url, use cover and audiobook url
var abExists = checkUriExists(Uri.parse(url))
if (abExists) {
var abfile = DocumentFileCompat.fromUri(context, Uri.parse(url))
abfile?.delete()
}
var coverExists = checkUriExists(Uri.parse(coverUrl))
if (coverExists) {
var coverfile = DocumentFileCompat.fromUri(context, Uri.parse(coverUrl))
coverfile?.delete()
}
}
}
fun checkUriExists(uri: Uri?): Boolean {
if (uri == null) return false
val resolver = context.contentResolver
var cursor: Cursor? = null
return try {
cursor = resolver.query(uri, null, null, null, null)
//cursor null: content Uri was invalid or some other error occurred
//cursor.moveToFirst() false: Uri was ok but no entry found.
(cursor != null && cursor.moveToFirst())
} catch (t: Throwable) {
false
} finally {
try {
cursor?.close()
} catch (t: Throwable) {
}
false
}
}
}

View file

@ -0,0 +1,74 @@
package com.audiobookshelf.app.data
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true)
data class AudioProbeStream(
val index:Int,
val codec_name:String,
val codec_long_name:String,
val channels:Int,
val channel_layout:String,
val duration:Double,
val bit_rate:Double
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class AudioProbeChapterTags(
val title:String
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class AudioProbeChapter(
val id:Int,
val start:Int,
val end:Int,
val tags:AudioProbeChapterTags?
) {
@JsonIgnore
fun getBookChapter():BookChapter {
var startS = start / 1000.0
var endS = end / 1000.0
var title = tags?.title ?: "Chapter $id"
return BookChapter(id, startS, endS, title)
}
}
@JsonIgnoreProperties(ignoreUnknown = true)
data class AudioProbeFormatTags(
val artist:String?,
val album:String?,
val comment:String?,
val date:String?,
val genre:String?,
val title:String?
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class AudioProbeFormat(
val filename:String,
val format_name:String,
val duration:Double,
val size:Long,
val bit_rate:Double,
val tags:AudioProbeFormatTags
)
@JsonIgnoreProperties(ignoreUnknown = true)
class AudioProbeResult (
val streams:MutableList<AudioProbeStream>,
val chapters:MutableList<AudioProbeChapter>,
val format:AudioProbeFormat) {
val duration get() = format.duration
val size get() = format.size
val title get() = format.tags.title ?: format.filename.split("/").last()
val artist get() = format.tags.artist ?: ""
@JsonIgnore
fun getBookChapters(): List<BookChapter> {
if (chapters.isEmpty()) return mutableListOf()
return chapters.map { it.getBookChapter() }
}
}

View file

@ -0,0 +1,339 @@
package com.audiobookshelf.app.data
import android.net.Uri
import android.support.v4.media.MediaMetadataCompat
import com.audiobookshelf.app.R
import com.audiobookshelf.app.device.DeviceManager
import com.fasterxml.jackson.annotation.*
@JsonIgnoreProperties(ignoreUnknown = true)
data class LibraryItem(
var id:String,
var ino:String,
var libraryId:String,
var folderId:String,
var path:String,
var relPath:String,
var mtimeMs:Long,
var ctimeMs:Long,
var birthtimeMs:Long,
var addedAt:Long,
var updatedAt:Long,
var lastScan:Long?,
var scanVersion:String?,
var isMissing:Boolean,
var isInvalid:Boolean,
var mediaType:String,
var media:MediaType,
var libraryFiles:MutableList<LibraryFile>?
) {
@get:JsonIgnore
val title get() = media.metadata.title
@get:JsonIgnore
val authorName get() = media.metadata.getAuthorDisplayName()
@JsonIgnore
fun getCoverUri():Uri {
if (media.coverPath == null) {
return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
}
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}")
}
@JsonIgnore
fun getMediaMetadata(): MediaMetadataCompat {
return MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, authorName)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, getCoverUri().toString())
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getCoverUri().toString())
putString(MediaMetadataCompat.METADATA_KEY_ART_URI, getCoverUri().toString())
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, authorName)
}.build()
}
}
// This auto-detects whether it is a Book or Podcast
@JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION)
@JsonSubTypes(
JsonSubTypes.Type(Book::class),
JsonSubTypes.Type(Podcast::class)
)
open class MediaType(var metadata:MediaTypeMetadata, var coverPath:String?) {
@JsonIgnore
open fun getAudioTracks():List<AudioTrack> { return mutableListOf() }
@JsonIgnore
open fun setAudioTracks(audioTracks:MutableList<AudioTrack>) { }
@JsonIgnore
open fun addAudioTrack(audioTrack:AudioTrack) { }
@JsonIgnore
open fun removeAudioTrack(localFileId:String) { }
}
@JsonIgnoreProperties(ignoreUnknown = true)
class Podcast(
metadata:PodcastMetadata,
coverPath:String?,
var tags:MutableList<String>,
var episodes:MutableList<PodcastEpisode>?,
var autoDownloadEpisodes:Boolean
) : MediaType(metadata, coverPath) {
@JsonIgnore
override fun getAudioTracks():List<AudioTrack> {
var tracks = episodes?.map { it.audioTrack }
return tracks?.filterNotNull() ?: mutableListOf()
}
@JsonIgnore
override fun setAudioTracks(audioTracks:MutableList<AudioTrack>) {
// Remove episodes no longer there in tracks
episodes = episodes?.filter { ep ->
audioTracks.find { it.localFileId == ep.audioTrack?.localFileId } != null
} as MutableList<PodcastEpisode>
// Add new episodes
audioTracks.forEach { at ->
if (episodes?.find{ it.audioTrack?.localFileId == at.localFileId } == null) {
var newEpisode = PodcastEpisode("local_" + at.localFileId,episodes?.size ?: 0 + 1,null,null,at.title,null,null,null,at)
episodes?.add(newEpisode)
}
}
var index = 1
episodes?.forEach {
it.index = index
index++
}
}
@JsonIgnore
override fun addAudioTrack(audioTrack:AudioTrack) {
var newEpisode = PodcastEpisode("local_" + audioTrack.localFileId,episodes?.size ?: 0 + 1,null,null,audioTrack.title,null,null,null,audioTrack)
episodes?.add(newEpisode)
var index = 1
episodes?.forEach {
it.index = index
index++
}
}
@JsonIgnore
override fun removeAudioTrack(localFileId:String) {
episodes?.removeIf { it.audioTrack?.localFileId == localFileId }
var index = 1
episodes?.forEach {
it.index = index
index++
}
}
@JsonIgnore
fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode) {
var newEpisode = PodcastEpisode("local_" + episode.id,episodes?.size ?: 0 + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,audioTrack)
episodes?.add(newEpisode)
var index = 1
episodes?.forEach {
it.index = index
index++
}
}
}
@JsonIgnoreProperties(ignoreUnknown = true)
class Book(
metadata:BookMetadata,
coverPath:String?,
var tags:List<String>,
var audioFiles:List<AudioFile>?,
var chapters:List<BookChapter>?,
var tracks:MutableList<AudioTrack>?,
var size:Long?,
var duration:Double?
) : MediaType(metadata, coverPath) {
@JsonIgnore
override fun getAudioTracks():List<AudioTrack> {
return tracks ?: mutableListOf()
}
@JsonIgnore
override fun setAudioTracks(audioTracks:MutableList<AudioTrack>) {
tracks = audioTracks
// TODO: Is it necessary to calculate this each time? check if can remove safely
var totalDuration = 0.0
tracks?.forEach {
totalDuration += it.duration
}
duration = totalDuration
}
@JsonIgnore
override fun addAudioTrack(audioTrack:AudioTrack) {
tracks?.add(audioTrack)
var totalDuration = 0.0
tracks?.forEach {
totalDuration += it.duration
}
duration = totalDuration
}
@JsonIgnore
override fun removeAudioTrack(localFileId:String) {
tracks?.removeIf { it.localFileId == localFileId }
tracks?.sortBy { it.index }
var index = 1
var startOffset = 0.0
var totalDuration = 0.0
tracks?.forEach {
it.index = index
it.startOffset = startOffset
totalDuration += it.duration
index++
startOffset += it.duration
}
duration = totalDuration
}
}
// This auto-detects whether it is a BookMetadata or PodcastMetadata
@JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION)
@JsonSubTypes(
JsonSubTypes.Type(BookMetadata::class),
JsonSubTypes.Type(PodcastMetadata::class)
)
open class MediaTypeMetadata(var title:String) {
@JsonIgnore
open fun getAuthorDisplayName():String { return "Unknown" }
}
@JsonIgnoreProperties(ignoreUnknown = true)
class BookMetadata(
title:String,
var subtitle:String?,
var authors:MutableList<Author>?,
var narrators:MutableList<String>?,
var genres:MutableList<String>,
var publishedYear:String?,
var publishedDate:String?,
var publisher:String?,
var description:String?,
var isbn:String?,
var asin:String?,
var language:String?,
var explicit:Boolean,
// In toJSONExpanded
var authorName:String?,
var authorNameLF:String?,
var narratorName:String?,
var seriesName:String?
) : MediaTypeMetadata(title) {
@JsonIgnore
override fun getAuthorDisplayName():String { return authorName ?: "Unknown" }
}
@JsonIgnoreProperties(ignoreUnknown = true)
class PodcastMetadata(
title:String,
var author:String?,
var feedUrl:String?,
var genres:MutableList<String>
) : MediaTypeMetadata(title) {
@JsonIgnore
override fun getAuthorDisplayName():String { return author ?: "Unknown" }
}
@JsonIgnoreProperties(ignoreUnknown = true)
data class Author(
var id:String,
var name:String,
var coverPath:String?
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class PodcastEpisode(
var id:String,
var index:Int,
var episode:String?,
var episodeType:String?,
var title:String?,
var subtitle:String?,
var description:String?,
var audioFile:AudioFile?,
var audioTrack:AudioTrack?
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class LibraryFile(
var ino:String,
var metadata:FileMetadata
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class FileMetadata(
var filename:String,
var ext:String,
var path:String,
var relPath:String
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class AudioFile(
var index:Int,
var ino:String,
var metadata:FileMetadata
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class Library(
var id:String,
var name:String,
var folders:MutableList<Folder>,
var icon:String,
var mediaType:String
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class Folder(
var id:String,
var fullPath:String
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class AudioTrack(
var index:Int,
var startOffset:Double,
var duration:Double,
var title:String,
var contentUrl:String,
var mimeType:String,
var metadata:FileMetadata?,
var isLocal:Boolean,
var localFileId:String?,
var audioProbeResult:AudioProbeResult?,
var serverIndex:Int? // Need to know if server track index is different
) {
@get:JsonIgnore
val startOffsetMs get() = (startOffset * 1000L).toLong()
@get:JsonIgnore
val durationMs get() = (duration * 1000L).toLong()
@get:JsonIgnore
val endOffsetMs get() = startOffsetMs + durationMs
@get:JsonIgnore
val relPath get() = metadata?.relPath ?: ""
@JsonIgnore
fun getBookChapter():BookChapter {
return BookChapter(index + 1,startOffset, startOffset + duration, title)
}
}
@JsonIgnoreProperties(ignoreUnknown = true)
data class BookChapter(
var id:Int,
var start:Double,
var end:Double,
var title:String?
)

View file

@ -1,18 +1,142 @@
package com.audiobookshelf.app.data
import android.util.Log
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import com.audiobookshelf.app.plugins.AbsDownloader
import io.paperdb.Paper
import org.json.JSONObject
@CapacitorPlugin(name = "DbManager")
class DbManager : Plugin() {
class DbManager {
val tag = "DbManager"
fun getDeviceData(): DeviceData {
return Paper.book("device").read("data") ?: DeviceData(mutableListOf(), null, null)
}
fun saveDeviceData(deviceData:DeviceData) {
Paper.book("device").write("data", deviceData)
}
fun getLocalLibraryItems(mediaType:String? = null):MutableList<LocalLibraryItem> {
var localLibraryItems:MutableList<LocalLibraryItem> = mutableListOf()
Paper.book("localLibraryItems").allKeys.forEach {
var localLibraryItem:LocalLibraryItem? = Paper.book("localLibraryItems").read(it)
if (localLibraryItem != null && (mediaType.isNullOrEmpty() || mediaType == localLibraryItem?.mediaType)) {
// TODO: Check to make sure all file paths exist
// if (localMediaItem.coverContentUrl != null) {
// var file = DocumentFile.fromSingleUri(ctx)
// if (!file.exists()) {
// Log.e(tag, "Local media item cover url does not exist ${localMediaItem.coverContentUrl}")
// removeLocalMediaItem(localMediaItem.id)
// } else {
// localMediaItems.add(localMediaItem)
// }
// } else {
localLibraryItems.add(localLibraryItem)
// }
}
}
return localLibraryItems
}
fun getLocalLibraryItemsInFolder(folderId:String):List<LocalLibraryItem> {
var localLibraryItems = getLocalLibraryItems()
return localLibraryItems.filter {
it.folderId == folderId
}
}
fun getLocalLibraryItemByLLId(libraryItemId:String):LocalLibraryItem? {
return getLocalLibraryItems().find { it.libraryItemId == libraryItemId }
}
fun getLocalLibraryItem(localLibraryItemId:String):LocalLibraryItem? {
return Paper.book("localLibraryItems").read(localLibraryItemId)
}
fun removeLocalLibraryItem(localLibraryItemId:String) {
Paper.book("localLibraryItems").delete(localLibraryItemId)
}
fun saveLocalLibraryItems(localLibraryItems:List<LocalLibraryItem>) {
localLibraryItems.map {
Paper.book("localLibraryItems").write(it.id, it)
}
}
fun saveLocalLibraryItem(localLibraryItem:LocalLibraryItem) {
Paper.book("localLibraryItems").write(localLibraryItem.id, localLibraryItem)
}
fun saveLocalFolder(localFolder:LocalFolder) {
Paper.book("localFolders").write(localFolder.id,localFolder)
}
fun getLocalFolder(folderId:String):LocalFolder? {
return Paper.book("localFolders").read(folderId)
}
fun getAllLocalFolders():List<LocalFolder> {
var localFolders:MutableList<LocalFolder> = mutableListOf()
Paper.book("localFolders").allKeys.forEach { localFolderId ->
Paper.book("localFolders").read<LocalFolder>(localFolderId)?.let {
localFolders.add(it)
}
}
return localFolders
}
fun removeLocalFolder(folderId:String) {
var localLibraryItems = getLocalLibraryItemsInFolder(folderId)
localLibraryItems.forEach {
Paper.book("localLibraryItems").delete(it.id)
}
Paper.book("localFolders").delete(folderId)
}
fun saveDownloadItem(downloadItem: AbsDownloader.DownloadItem) {
Paper.book("downloadItems").write(downloadItem.id, downloadItem)
}
fun removeDownloadItem(downloadItemId:String) {
Paper.book("downloadItems").delete(downloadItemId)
}
fun getDownloadItems():List<AbsDownloader.DownloadItem> {
var downloadItems:MutableList<AbsDownloader.DownloadItem> = mutableListOf()
Paper.book("downloadItems").allKeys.forEach { downloadItemId ->
Paper.book("downloadItems").read<AbsDownloader.DownloadItem>(downloadItemId)?.let {
downloadItems.add(it)
}
}
return downloadItems
}
fun saveLocalMediaProgress(mediaProgress:LocalMediaProgress) {
Paper.book("localMediaProgress").write(mediaProgress.id,mediaProgress)
}
// For books this will just be the localLibraryItemId for podcast episodes this will be "{localLibraryItemId}-{episodeId}"
fun getLocalMediaProgress(localMediaProgressId:String):LocalMediaProgress? {
return Paper.book("localMediaProgress").read(localMediaProgressId)
}
fun getAllLocalMediaProgress():List<LocalMediaProgress> {
var mediaProgress:MutableList<LocalMediaProgress> = mutableListOf()
Paper.book("localMediaProgress").allKeys.forEach { localMediaProgressId ->
Paper.book("localMediaProgress").read<LocalMediaProgress>(localMediaProgressId)?.let {
mediaProgress.add(it)
}
}
return mediaProgress
}
fun removeLocalMediaProgress(localMediaProgressId:String) {
Paper.book("localMediaProgress").delete(localMediaProgressId)
}
fun saveLocalPlaybackSession(playbackSession:PlaybackSession) {
Paper.book("localPlaybackSession").write(playbackSession.id,playbackSession)
}
fun getLocalPlaybackSession(playbackSessionId:String):PlaybackSession? {
return Paper.book("localPlaybackSession").read(playbackSessionId)
}
fun saveObject(db:String, key:String, value:JSONObject) {
Log.d(tag, "Saving Object $key ${value.toString()}")
Paper.book(db).write(key, value)
@ -23,32 +147,4 @@ class DbManager : Plugin() {
Log.d(tag, "Loaded Object $key $json")
return json
}
@PluginMethod
fun saveFromWebview(call: PluginCall) {
var db = call.getString("db", "").toString()
var key = call.getString("key", "").toString()
var value = call.getObject("value")
if (db == "" || key == "" || value == null) {
Log.d(tag, "saveFromWebview Invalid key/value")
} else {
var json = value as JSONObject
saveObject(db, key, json)
}
call.resolve()
}
@PluginMethod
fun loadFromWebview(call:PluginCall) {
var db = call.getString("db", "").toString()
var key = call.getString("key", "").toString()
if (db == "" || key == "") {
Log.d(tag, "loadFromWebview Invalid Key")
call.resolve()
return
}
var json = loadObject(db, key)
var jsobj = JSObject.fromJSONObject(json)
call.resolve(jsobj)
}
}

View file

@ -0,0 +1,50 @@
package com.audiobookshelf.app.data
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import java.util.*
data class ServerConnectionConfig(
var id:String,
var index:Int,
var name:String,
var address:String,
var userId:String,
var username:String,
var token:String
)
data class DeviceData(
var serverConnectionConfigs:MutableList<ServerConnectionConfig>,
var lastServerConnectionConfigId:String?,
var currentLocalPlaybackSession:PlaybackSession? // Stored to open up where left off for local media
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class LocalFile(
var id:String,
var filename:String?,
var contentUrl:String,
var basePath:String,
var absolutePath:String,
var simplePath:String,
var mimeType:String?,
var size:Long
) {
@JsonIgnore
fun isAudioFile():Boolean {
return mimeType?.startsWith("audio") == true
}
}
@JsonIgnoreProperties(ignoreUnknown = true)
data class LocalFolder(
var id:String,
var name:String,
var contentUrl:String,
var basePath:String,
var absolutePath:String,
var simplePath:String,
var storageType:String,
var mediaType:String
)

View file

@ -0,0 +1,15 @@
package com.audiobookshelf.app.data
data class FolderScanResult(
var itemsAdded:Int,
var itemsUpdated:Int,
var itemsRemoved:Int,
var itemsUpToDate:Int,
val localFolder:LocalFolder,
val localLibraryItems:List<LocalLibraryItem>,
)
data class LocalLibraryItemScanResult(
val updated:Boolean,
val localLibraryItem:LocalLibraryItem,
)

View file

@ -0,0 +1,78 @@
package com.audiobookshelf.app.data
import com.audiobookshelf.app.device.DeviceManager
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import java.util.*
@JsonIgnoreProperties(ignoreUnknown = true)
data class LocalLibraryItem(
var id:String,
var folderId:String,
var basePath:String,
var absolutePath:String,
var contentUrl:String,
var isInvalid:Boolean,
var mediaType:String,
var media:MediaType,
var localFiles:MutableList<LocalFile>,
var coverContentUrl:String?,
var coverAbsolutePath:String?,
var isLocal:Boolean,
// If local library item is linked to a server item
var serverConnectionConfigId:String?,
var serverAddress:String?,
var serverUserId:String?,
var libraryItemId:String?
) {
@JsonIgnore
fun getDuration():Double {
var total = 0.0
var audioTracks = media.getAudioTracks()
audioTracks.forEach{ total += it.duration }
return total
}
@JsonIgnore
fun updateFromScan(audioTracks:MutableList<AudioTrack>, _localFiles:MutableList<LocalFile>) {
media.setAudioTracks(audioTracks)
localFiles = _localFiles
if (coverContentUrl != null) {
if (localFiles.find { it.contentUrl == coverContentUrl } == null) {
// Cover was removed
coverContentUrl = null
coverAbsolutePath = null
media.coverPath = null
}
}
}
@JsonIgnore
fun getPlaybackSession(episodeId:String):PlaybackSession {
var sessionId = "play-${UUID.randomUUID()}"
val mediaProgressId = if (episodeId.isNullOrEmpty()) id else "$id-$episodeId"
var mediaProgress = DeviceManager.dbManager.getLocalMediaProgress(mediaProgressId)
var currentTime = mediaProgress?.currentTime ?: 0.0
// TODO: Clean up add mediaType methods for displayTitle and displayAuthor
var mediaMetadata = media.metadata
var chapters = if (mediaType == "book") (media as Book).chapters else mutableListOf()
var authorName = "Unknown"
if (mediaType == "book") {
var bookMetadata = mediaMetadata as BookMetadata
authorName = bookMetadata?.authorName ?: "Unknown"
}
var episodeIdNullable = if (episodeId.isNullOrEmpty()) null else episodeId
var dateNow = System.currentTimeMillis()
return PlaybackSession(sessionId,serverUserId,libraryItemId,episodeIdNullable, mediaType, mediaMetadata, chapters ?: mutableListOf(), mediaMetadata.title, authorName,null,getDuration(),PLAYMETHOD_LOCAL,dateNow,0L,0L, media.getAudioTracks() as MutableList<AudioTrack>,currentTime,null,this,serverConnectionConfigId, serverAddress)
}
@JsonIgnore
fun removeLocalFile(localFileId:String) {
localFiles.removeIf { it.id == localFileId }
}
}

View file

@ -0,0 +1,71 @@
package com.audiobookshelf.app.data
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
/*
Used as a helper class to generate LocalLibraryItem from scan results
*/
@JsonIgnoreProperties(ignoreUnknown = true)
data class LocalMediaItem(
var id:String,
var name: String,
var mediaType:String,
var folderId:String,
var contentUrl:String,
var simplePath: String,
var basePath:String,
var absolutePath:String,
var audioTracks:MutableList<AudioTrack>,
var localFiles:MutableList<LocalFile>,
var coverContentUrl:String?,
var coverAbsolutePath:String?
) {
@JsonIgnore
fun getDuration():Double {
var total = 0.0
audioTracks.forEach{ total += it.duration }
return total
}
@JsonIgnore
fun getTotalSize():Long {
var total = 0L
localFiles.forEach { total += it.size }
return total
}
@JsonIgnore
fun getMediaMetadata():MediaTypeMetadata {
return if (mediaType == "book") {
BookMetadata(name,null, mutableListOf(), mutableListOf(), mutableListOf(),null,null,null,null,null,null,null,false,null,null,null,null)
} else {
PodcastMetadata(name,null,null, mutableListOf())
}
}
@JsonIgnore
fun getAudiobookChapters():List<BookChapter> {
if (mediaType != "book" || audioTracks.isEmpty()) return mutableListOf()
if (audioTracks.size == 1) { // Single track audiobook look for chapters from ffprobe
return audioTracks[0].audioProbeResult?.getBookChapters() ?: mutableListOf()
}
// Multi-track make chapters from tracks
return audioTracks.map { it.getBookChapter() }
}
@JsonIgnore
fun getLocalLibraryItem():LocalLibraryItem {
var mediaMetadata = getMediaMetadata()
if (mediaType == "book") {
var chapters = getAudiobookChapters()
var book = Book(mediaMetadata as BookMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), chapters,audioTracks,getTotalSize(),getDuration())
return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false,mediaType, book, localFiles, coverContentUrl, coverAbsolutePath,true,null,null,null,null)
} else {
var podcast = Podcast(mediaMetadata as PodcastMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), false)
return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false, mediaType, podcast,localFiles,coverContentUrl, coverAbsolutePath, true, null,null,null,null)
}
}
}

View file

@ -0,0 +1,22 @@
package com.audiobookshelf.app.data
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true)
data class LocalMediaProgress(
var id:String,
var localLibraryItemId:String,
var episodeId:String?,
var duration:Double,
var progress:Double, // 0 to 1
var currentTime:Double,
var isFinished:Boolean,
var lastUpdate:Long,
var startedAt:Long,
var finishedAt:Long?,
// For local lib items from server to support server sync
var serverConnectionConfigId:String?,
var serverAddress:String?,
var serverUserId:String?,
var libraryItemId:String?
)

View file

@ -0,0 +1,192 @@
package com.audiobookshelf.app.data
import android.net.Uri
import android.support.v4.media.MediaMetadataCompat
import com.audiobookshelf.app.R
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.player.MediaProgressSyncData
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.MediaMetadata
import com.google.android.gms.cast.MediaInfo
import com.google.android.gms.cast.MediaQueueItem
import com.google.android.gms.common.images.WebImage
// TODO: enum or something in kotlin?
val PLAYMETHOD_DIRECTPLAY = 0
val PLAYMETHOD_DIRECTSTREAM = 1
val PLAYMETHOD_TRANSCODE = 2
val PLAYMETHOD_LOCAL = 3
@JsonIgnoreProperties(ignoreUnknown = true)
class PlaybackSession(
var id:String,
var userId:String?,
var libraryItemId:String?,
var episodeId:String?,
var mediaType:String,
var mediaMetadata:MediaTypeMetadata,
var chapters:List<BookChapter>,
var displayTitle: String?,
var displayAuthor: String?,
var coverPath:String?,
var duration:Double,
var playMethod:Int,
var startedAt:Long,
var updatedAt:Long,
var timeListening:Long,
var audioTracks:MutableList<AudioTrack>,
var currentTime:Double,
var libraryItem:LibraryItem?,
var localLibraryItem:LocalLibraryItem?,
var serverConnectionConfigId:String?,
var serverAddress:String?
) {
@get:JsonIgnore
val isHLS get() = playMethod == PLAYMETHOD_TRANSCODE
@get:JsonIgnore
val isLocal get() = playMethod == PLAYMETHOD_LOCAL
@get:JsonIgnore
val currentTimeMs get() = (currentTime * 1000L).toLong()
@get:JsonIgnore
val localLibraryItemId get() = localLibraryItem?.id ?: ""
@get:JsonIgnore
val localMediaProgressId get() = if (episodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$episodeId"
@get:JsonIgnore
val progress get() = currentTime / getTotalDuration()
@JsonIgnore
fun getCurrentTrackIndex():Int {
for (i in 0..(audioTracks.size - 1)) {
var track = audioTracks[i]
if (currentTimeMs >= track.startOffsetMs && (track.endOffsetMs) > currentTimeMs) {
return i
}
}
return audioTracks.size - 1
}
@JsonIgnore
fun getCurrentTrackTimeMs():Long {
var currentTrack = audioTracks[this.getCurrentTrackIndex()]
var time = currentTime - currentTrack.startOffset
return (time * 1000L).toLong()
}
@JsonIgnore
fun getTrackStartOffsetMs(index:Int):Long {
var currentTrack = audioTracks[index]
return (currentTrack.startOffset * 1000L).toLong()
}
@JsonIgnore
fun getTotalDuration():Double {
var total = 0.0
audioTracks.forEach { total += it.duration }
return total
}
@JsonIgnore
fun getCoverUri(): Uri {
if (localLibraryItem?.coverContentUrl != null) return Uri.parse(localLibraryItem?.coverContentUrl) ?: Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
if (coverPath == null) return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
return Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")
}
@JsonIgnore
fun getContentUri(audioTrack:AudioTrack): Uri {
if (isLocal) return Uri.parse(audioTrack.contentUrl) // Local content url
return Uri.parse("$serverAddress${audioTrack.contentUrl}?token=${DeviceManager.token}")
}
@JsonIgnore
fun getMediaMetadataCompat(): MediaMetadataCompat {
var metadataBuilder = MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, displayTitle)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, displayAuthor)
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, displayAuthor)
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, displayAuthor)
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, "series")
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
return metadataBuilder.build()
}
@JsonIgnore
fun getExoMediaMetadata(audioTrack:AudioTrack): MediaMetadata {
var metadataBuilder = MediaMetadata.Builder()
.setTitle(displayTitle)
.setDisplayTitle(displayTitle)
.setArtist(displayAuthor)
.setAlbumArtist(displayAuthor)
.setSubtitle(displayAuthor)
var contentUri = this.getContentUri(audioTrack)
metadataBuilder.setMediaUri(contentUri)
return metadataBuilder.build()
}
@JsonIgnore
fun getMediaItems():List<MediaItem> {
var mediaItems:MutableList<MediaItem> = mutableListOf()
for (audioTrack in audioTracks) {
var mediaMetadata = this.getExoMediaMetadata(audioTrack)
var mediaUri = this.getContentUri(audioTrack)
var mimeType = audioTrack.mimeType
var queueItem = getQueueItem(audioTrack) // Queue item used in exo player CastManager
var mediaItem = MediaItem.Builder().setUri(mediaUri).setTag(queueItem).setMediaMetadata(mediaMetadata).setMimeType(mimeType).build()
mediaItems.add(mediaItem)
}
return mediaItems
}
@JsonIgnore
fun getCastMediaMetadata(audioTrack:AudioTrack):com.google.android.gms.cast.MediaMetadata {
var castMetadata = com.google.android.gms.cast.MediaMetadata(com.google.android.gms.cast.MediaMetadata.MEDIA_TYPE_AUDIOBOOK_CHAPTER)
castMetadata.addImage(WebImage(getCoverUri()))
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_TITLE, displayTitle)
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_ARTIST, displayAuthor)
castMetadata.putInt(com.google.android.gms.cast.MediaMetadata.KEY_TRACK_NUMBER, audioTrack.index)
return castMetadata
}
@JsonIgnore
fun getQueueItem(audioTrack:AudioTrack):MediaQueueItem {
var castMetadata = getCastMediaMetadata(audioTrack)
var mediaUri = getContentUri(audioTrack)
var mediaInfoBuilder = MediaInfo.Builder(mediaUri.toString())
mediaInfoBuilder.setContentUrl(mediaUri.toString())
mediaInfoBuilder.setMetadata(castMetadata)
mediaInfoBuilder.setContentType(audioTrack.mimeType)
var mediaInfo = mediaInfoBuilder.build()
var queueItem = MediaQueueItem.Builder(mediaInfo)
queueItem.setItemId(audioTrack.index)
queueItem.setPlaybackDuration(audioTrack.duration)
return queueItem.build()
}
@JsonIgnore
fun clone():PlaybackSession {
return PlaybackSession(id,userId,libraryItemId,episodeId,mediaType,mediaMetadata,chapters,displayTitle,displayAuthor,coverPath,duration,playMethod,startedAt,updatedAt,timeListening,audioTracks,currentTime,libraryItem,localLibraryItem,serverConnectionConfigId,serverAddress)
}
@JsonIgnore
fun syncData(syncData:MediaProgressSyncData) {
timeListening += syncData.timeListened
updatedAt = System.currentTimeMillis()
currentTime = syncData.currentTime
}
@JsonIgnore
fun getNewLocalMediaProgress():LocalMediaProgress {
return LocalMediaProgress(localMediaProgressId,localLibraryItemId,episodeId,getTotalDuration(),progress,currentTime,false,updatedAt,startedAt,null,serverConnectionConfigId,serverAddress,userId,libraryItemId)
}
}

View file

@ -0,0 +1,25 @@
package com.audiobookshelf.app.device
import android.util.Log
import com.audiobookshelf.app.data.DbManager
import com.audiobookshelf.app.data.DeviceData
import com.audiobookshelf.app.data.ServerConnectionConfig
object DeviceManager {
val tag = "DeviceManager"
val dbManager:DbManager = DbManager()
var deviceData:DeviceData = dbManager.getDeviceData()
var serverConnectionConfig: ServerConnectionConfig? = null
val serverAddress get() = serverConnectionConfig?.address ?: ""
val serverUserId get() = serverConnectionConfig?.userId ?: ""
val token get() = serverConnectionConfig?.token ?: ""
init {
Log.d(tag, "Device Manager Singleton invoked")
}
fun getBase64Id(id:String):String {
return android.util.Base64.encodeToString(id.toByteArray(), android.util.Base64.DEFAULT)
}
}

View file

@ -0,0 +1,424 @@
package com.audiobookshelf.app.device
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import com.anggrayudi.storage.file.*
import com.arthenica.ffmpegkit.FFmpegKitConfig
import com.arthenica.ffmpegkit.FFprobeKit
import com.arthenica.ffmpegkit.Level
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.plugins.AbsDownloader
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
class FolderScanner(var ctx: Context) {
private val tag = "FolderScanner"
private fun getLocalLibraryItemId(mediaItemId:String):String {
return "local_" + DeviceManager.getBase64Id(mediaItemId)
}
enum class ItemScanResult {
ADDED, REMOVED, UPDATED, UPTODATE
}
// TODO: CLEAN this monster! Divide into bite-size methods
fun scanForMediaItems(localFolder:LocalFolder, forceAudioProbe:Boolean):FolderScanResult? {
FFmpegKitConfig.enableLogCallback { log ->
if (log.level != Level.AV_LOG_STDERR) { // STDERR is filled with junk
Log.d(tag, "FFmpeg-Kit Log: (${log.level}) ${log.message}")
}
}
var df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(localFolder.contentUrl))
if (df == null) {
Log.e(tag, "Folder Doc File Invalid $localFolder.contentUrl")
return null
}
var mediaItemsUpdated = 0
var mediaItemsAdded = 0
var mediaItemsRemoved = 0
var mediaItemsUpToDate = 0
// Search for files in media item folder
var foldersFound = df.search(false, DocumentFileType.FOLDER)
// Match folders found with local library items already saved in db
var existingLocalLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(localFolder.id)
// Remove existing items no longer there
existingLocalLibraryItems = existingLocalLibraryItems.filter { lli ->
var fileFound = foldersFound.find { f -> lli.id == getLocalLibraryItemId(f.id) }
if (fileFound == null) {
Log.d(tag, "Existing local library item is no longer in file system ${lli.media.metadata.title}")
DeviceManager.dbManager.removeLocalLibraryItem(lli.id)
mediaItemsRemoved++
}
fileFound != null
}
foldersFound.forEach { itemFolder ->
Log.d(tag, "Iterating over Folder Found ${itemFolder.name} | ${itemFolder.getSimplePath(ctx)} | URI: ${itemFolder.uri}")
var existingItem = existingLocalLibraryItems.find { emi -> emi.id == getLocalLibraryItemId(itemFolder.id) }
var result = scanLibraryItemFolder(itemFolder, localFolder, existingItem, forceAudioProbe)
if (result == ItemScanResult.REMOVED) mediaItemsRemoved++
else if (result == ItemScanResult.UPDATED) mediaItemsUpdated++
else if (result == ItemScanResult.ADDED) mediaItemsAdded++
else mediaItemsUpToDate++
}
Log.d(tag, "Folder $${localFolder.name} scan Results: $mediaItemsAdded Added | $mediaItemsUpdated Updated | $mediaItemsRemoved Removed | $mediaItemsUpToDate Up-to-date")
return if (mediaItemsAdded > 0 || mediaItemsUpdated > 0 || mediaItemsRemoved > 0) {
var folderLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(localFolder.id) // Get all local media items
FolderScanResult(mediaItemsAdded, mediaItemsUpdated, mediaItemsRemoved, mediaItemsUpToDate, localFolder, folderLibraryItems)
} else {
Log.d(tag, "No Media Items to save")
FolderScanResult(mediaItemsAdded, mediaItemsUpdated, mediaItemsRemoved, mediaItemsUpToDate, localFolder, mutableListOf())
}
}
fun scanLibraryItemFolder(itemFolder:DocumentFile, localFolder:LocalFolder, existingItem:LocalLibraryItem?, forceAudioProbe:Boolean):ItemScanResult {
var itemFolderName = itemFolder.name ?: ""
var itemId = getLocalLibraryItemId(itemFolder.id)
var existingLocalFiles = existingItem?.localFiles ?: mutableListOf()
var existingAudioTracks = existingItem?.media?.getAudioTracks() ?: mutableListOf()
var isNewOrUpdated = existingItem == null
var audioTracks = mutableListOf<AudioTrack>()
var localFiles = mutableListOf<LocalFile>()
var index = 1
var startOffset = 0.0
var coverContentUrl:String? = null
var coverAbsolutePath:String? = null
var filesInFolder = itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*"))
var isPodcast = localFolder.mediaType == "podcast"
var existingLocalFilesRemoved = existingLocalFiles.filter { elf ->
filesInFolder.find { fif -> DeviceManager.getBase64Id(fif.id) == elf.id } == null // File was not found in media item folder
}
if (existingLocalFilesRemoved.isNotEmpty()) {
Log.d(tag, "${existingLocalFilesRemoved.size} Local files were removed from local media item ${existingItem?.media?.metadata?.title}")
isNewOrUpdated = true
}
filesInFolder.forEach { file ->
var mimeType = file?.mimeType ?: ""
var filename = file?.name ?: ""
var isAudio = mimeType.startsWith("audio")
Log.d(tag, "Found $mimeType file $filename in folder $itemFolderName")
var localFileId = DeviceManager.getBase64Id(file.id)
var localFile = LocalFile(localFileId,filename,file.uri.toString(),file.getBasePath(ctx), file.getAbsolutePath(ctx),file.getSimplePath(ctx),mimeType,file.length())
localFiles.add(localFile)
Log.d(tag, "File attributes Id:${localFileId}|ContentUrl:${localFile.contentUrl}|isDownloadsDocument:${file.isDownloadsDocument}")
if (isAudio) {
var audioTrackToAdd:AudioTrack? = null
var existingAudioTrack = existingAudioTracks.find { eat -> eat.localFileId == localFileId }
if (existingAudioTrack != null) { // Update existing audio track
if (existingAudioTrack.index != index) {
Log.d(tag, "Updating Audio track index from ${existingAudioTrack.index} to $index")
existingAudioTrack.index = index
isNewOrUpdated = true
}
if (existingAudioTrack.startOffset != startOffset) {
Log.d(tag, "Updating Audio track startOffset ${existingAudioTrack.startOffset} to $startOffset")
existingAudioTrack.startOffset = startOffset
isNewOrUpdated = true
}
}
if (existingAudioTrack == null || forceAudioProbe) {
Log.d(tag, "Scanning Audio File Path ${localFile.absolutePath}")
// TODO: Make asynchronous
var session = FFprobeKit.execute("-i \"${localFile.absolutePath}\" -print_format json -show_format -show_streams -select_streams a -show_chapters -loglevel quiet")
val audioProbeResult = jacksonObjectMapper().readValue<AudioProbeResult>(session.output)
Log.d(tag, "Probe Result DATA ${audioProbeResult.duration} | ${audioProbeResult.size} | ${audioProbeResult.title} | ${audioProbeResult.artist}")
if (existingAudioTrack != null) {
// Update audio probe data on existing audio track
existingAudioTrack.audioProbeResult = audioProbeResult
audioTrackToAdd = existingAudioTrack
} else {
// Create new audio track
var track = AudioTrack(index, startOffset, audioProbeResult.duration, filename, localFile.contentUrl, mimeType, null, true, localFileId, audioProbeResult, null)
audioTrackToAdd = track
}
startOffset += audioProbeResult.duration
isNewOrUpdated = true
} else {
audioTrackToAdd = existingAudioTrack
}
startOffset += audioTrackToAdd.duration
index++
audioTracks.add(audioTrackToAdd)
} else {
var existingLocalFile = existingLocalFiles.find { elf -> elf.id == localFileId }
if (existingLocalFile == null) {
isNewOrUpdated = true
}
if (existingItem != null && existingItem.coverContentUrl == null) {
// Existing media item did not have a cover - cover found on scan
isNewOrUpdated = true
existingItem.coverAbsolutePath = localFile.absolutePath
existingItem.coverContentUrl = localFile.contentUrl
existingItem.media.coverPath = localFile.absolutePath
}
// First image file use as cover path
if (coverContentUrl == null) {
coverContentUrl = localFile.contentUrl
coverAbsolutePath = localFile.absolutePath
}
}
}
if (existingItem != null && audioTracks.isEmpty()) {
Log.d(tag, "Local library item ${existingItem.media.metadata.title} no longer has audio tracks - removing item")
DeviceManager.dbManager.removeLocalLibraryItem(existingItem.id)
return ItemScanResult.REMOVED
} else if (existingItem != null && !isNewOrUpdated) {
Log.d(tag, "Local library item ${existingItem.media.metadata.title} has no updates")
return ItemScanResult.UPTODATE
} else if (existingItem != null) {
Log.d(tag, "Updating local library item ${existingItem.media.metadata.title}")
existingItem.updateFromScan(audioTracks,localFiles)
DeviceManager.dbManager.saveLocalLibraryItem(existingItem)
return ItemScanResult.UPDATED
} else if (audioTracks.isNotEmpty()) {
Log.d(tag, "Found local media item named $itemFolderName with ${audioTracks.size} tracks and ${localFiles.size} local files")
var localMediaItem = LocalMediaItem(itemId, itemFolderName, localFolder.mediaType, localFolder.id, itemFolder.uri.toString(), itemFolder.getSimplePath(ctx), itemFolder.getBasePath(ctx), itemFolder.getAbsolutePath(ctx),audioTracks,localFiles,coverContentUrl,coverAbsolutePath)
var localLibraryItem = localMediaItem.getLocalLibraryItem()
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
return ItemScanResult.ADDED
} else {
return ItemScanResult.UPTODATE
}
}
// Scan item after download and create local library item
fun scanDownloadItem(downloadItem: AbsDownloader.DownloadItem):LocalLibraryItem? {
var folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl))
var foldersFound = folderDf?.search(false, DocumentFileType.FOLDER) ?: mutableListOf()
var itemFolderUrl = ""
var itemFolderBasePath = ""
var itemFolderAbsolutePath = ""
foldersFound.forEach {
if (it.name == downloadItem.itemTitle) {
itemFolderUrl = it.uri.toString()
itemFolderBasePath = it.getBasePath(ctx)
itemFolderAbsolutePath = it.getAbsolutePath(ctx)
}
}
if (itemFolderUrl == "") {
Log.d(tag, "scanDownloadItem failed to find media folder")
return null
}
var df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(itemFolderUrl))
if (df == null) {
Log.e(tag, "Folder Doc File Invalid ${downloadItem.itemFolderPath}")
return null
}
Log.d(tag, "scanDownloadItem starting for ${downloadItem.itemFolderPath} | ${df.uri}")
// Search for files in media item folder
var filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*"))
Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}")
var localLibraryItem:LocalLibraryItem? = null
if (downloadItem.mediaType == "book") {
localLibraryItem = LocalLibraryItem("local_${downloadItem.libraryItemId}", downloadItem.localFolder.id, itemFolderBasePath, itemFolderAbsolutePath, itemFolderUrl, false, downloadItem.mediaType, downloadItem.media, mutableListOf(), null, null, true, downloadItem.serverConnectionConfigId, downloadItem.serverAddress, downloadItem.serverUserId, downloadItem.libraryItemId)
} else {
// Lookup or create podcast local library item
localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem("local_${downloadItem.libraryItemId}")
if (localLibraryItem == null) {
Log.d(tag, "Podcast local library item not created yet for ${downloadItem.media.metadata.title}")
localLibraryItem = LocalLibraryItem("local_${downloadItem.libraryItemId}", downloadItem.localFolder.id, itemFolderBasePath, itemFolderAbsolutePath, itemFolderUrl, false, downloadItem.mediaType, downloadItem.media, mutableListOf(), null, null, true,downloadItem.serverConnectionConfigId,downloadItem.serverAddress,downloadItem.serverUserId,downloadItem.libraryItemId)
}
}
var audioTracks:MutableList<AudioTrack> = mutableListOf()
filesFound.forEach { docFile ->
var itemPart = downloadItem.downloadItemParts.find { itemPart ->
itemPart.filename == docFile.name
}
if (itemPart == null) {
if (downloadItem.mediaType == "book") { // for books every download item should be a file found
Log.e(tag, "scanDownloadItem: Item part not found for doc file ${docFile.name} | ${docFile.getAbsolutePath(ctx)} | ${docFile.uri}")
}
} else if (itemPart.audioTrack != null) { // Is audio track
var audioTrackFromServer = itemPart.audioTrack
var localFileId = DeviceManager.getBase64Id(docFile.id)
var localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
localLibraryItem.localFiles.add(localFile)
// TODO: Make asynchronous
var session = FFprobeKit.execute("-i \"${localFile.absolutePath}\" -print_format json -show_format -show_streams -select_streams a -show_chapters -loglevel quiet")
val audioProbeResult = jacksonObjectMapper().readValue<AudioProbeResult>(session.output)
Log.d(tag, "Probe Result DATA ${audioProbeResult.duration} | ${audioProbeResult.size} | ${audioProbeResult.title} | ${audioProbeResult.artist}")
// Create new audio track
var track = AudioTrack(audioTrackFromServer?.index ?: -1, audioTrackFromServer?.startOffset ?: 0.0, audioProbeResult.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, audioTrackFromServer?.index ?: -1)
audioTracks.add(track)
// Add podcast episodes to library
itemPart.episode?.let { podcastEpisode ->
var podcast = localLibraryItem.media as Podcast
podcast.addEpisode(track, podcastEpisode)
}
} else { // Cover image
var localFileId = DeviceManager.getBase64Id(docFile.id)
var localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
localLibraryItem.coverAbsolutePath = localFile.absolutePath
localLibraryItem.coverContentUrl = localFile.contentUrl
localLibraryItem.localFiles.add(localFile)
}
}
if (audioTracks.isEmpty()) {
Log.d(tag, "scanDownloadItem did not find any audio tracks in folder for ${downloadItem.itemFolderPath}")
return null
}
// For books sort audio tracks then set
if (downloadItem.mediaType == "book") {
audioTracks.sortBy { it.index }
var indexCheck = 1
var startOffset = 0.0
audioTracks.forEach { audioTrack ->
if (audioTrack.index != indexCheck || audioTrack.startOffset != startOffset) {
audioTrack.index = indexCheck
audioTrack.startOffset = startOffset
}
indexCheck++
startOffset += audioTrack.duration
}
localLibraryItem.media.setAudioTracks(audioTracks)
}
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
return localLibraryItem
}
fun scanLocalLibraryItem(localLibraryItem:LocalLibraryItem, forceAudioProbe:Boolean):LocalLibraryItemScanResult? {
var df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(localLibraryItem.contentUrl))
if (df == null) {
Log.e(tag, "Item Folder Doc File Invalid ${localLibraryItem.absolutePath}")
return null
}
Log.d(tag, "scanLocalLibraryItem starting for ${localLibraryItem.absolutePath} | ${df.uri}")
var wasUpdated = false
// Search for files in media item folder
var filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*"))
Log.d(tag, "scanLocalLibraryItem ${filesFound.size} files found in ${localLibraryItem.absolutePath}")
filesFound.forEach {
try {
Log.d(tag, "Checking file found ${it.name} | ${it.id}")
}catch(e:Exception) {
Log.d(tag, "Check file found exception", e)
}
}
var existingAudioTracks = localLibraryItem.media.getAudioTracks()
// Remove any files no longer found in library item folder
var existingLocalFileIds = localLibraryItem.localFiles.map { it.id }
existingLocalFileIds.forEach { localFileId ->
Log.d(tag, "Checking local file id is there $localFileId")
if (filesFound.find { DeviceManager.getBase64Id(it.id) == localFileId } == null) {
Log.d(tag, "scanLocalLibraryItem file $localFileId was removed from ${localLibraryItem.absolutePath}")
localLibraryItem.localFiles.removeIf { it.id == localFileId }
if (existingAudioTracks.find { it.localFileId == localFileId } != null) {
Log.d(tag, "scanLocalLibraryItem audio track file ${localFileId} was removed from ${localLibraryItem.absolutePath}")
localLibraryItem.media.removeAudioTrack(localFileId)
}
wasUpdated = true
}
}
filesFound.forEach { docFile ->
var localFileId = DeviceManager.getBase64Id(docFile.id)
var existingLocalFile = localLibraryItem.localFiles.find { it.id == localFileId }
if (existingLocalFile == null || (existingLocalFile.isAudioFile() && forceAudioProbe)) {
var localFile = existingLocalFile ?: LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx), docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
if (existingLocalFile == null) {
localLibraryItem.localFiles.add(localFile)
Log.d(tag, "scanLocalLibraryItem new file found ${localFile.filename}")
}
if (localFile.isAudioFile()) {
// TODO: Make asynchronous
var session = FFprobeKit.execute("-i \"${localFile.absolutePath}\" -print_format json -show_format -show_streams -select_streams a -show_chapters -loglevel quiet")
val audioProbeResult = jacksonObjectMapper().readValue<AudioProbeResult>(session.output)
Log.d(tag, "Probe Result DATA ${audioProbeResult.duration} | ${audioProbeResult.size} | ${audioProbeResult.title} | ${audioProbeResult.artist}")
var existingTrack = existingAudioTracks.find { audioTrack ->
audioTrack.localFileId == localFile.id
}
if (existingTrack == null) {
// Create new audio track
var lastTrack = existingAudioTracks.lastOrNull()
var startOffset = (lastTrack?.startOffset ?: 0.0) + (lastTrack?.duration ?: 0.0)
var track = AudioTrack(existingAudioTracks.size, startOffset, audioProbeResult.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, null)
localLibraryItem.media.addAudioTrack(track)
wasUpdated = true
} else {
existingTrack.audioProbeResult = audioProbeResult
// TODO: Update data found from probe
wasUpdated = true
}
} else { // Check if cover is empty
if (localLibraryItem.coverContentUrl == null) {
Log.d(tag, "scanLocalLibraryItem setting cover for ${localLibraryItem.media.metadata.title}")
localLibraryItem.coverContentUrl = localFile.contentUrl
localLibraryItem.coverAbsolutePath = localFile.absolutePath
wasUpdated = true
}
}
}
}
if (wasUpdated) {
Log.d(tag, "Local library item was updated - saving it")
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
} else {
Log.d(tag, "Local library item was up-to-date")
}
return LocalLibraryItemScanResult(wasUpdated, localLibraryItem)
}
}

View file

@ -0,0 +1,70 @@
package com.audiobookshelf.app.media
import com.audiobookshelf.app.data.LibraryItem
import com.audiobookshelf.app.data.PlaybackSession
import com.audiobookshelf.app.server.ApiHandler
import java.util.*
class MediaManager(var apiHandler: ApiHandler) {
var serverLibraryItems = listOf<LibraryItem>()
fun loadLibraryItems(cb: (List<LibraryItem>) -> Unit) {
if (serverLibraryItems.isNotEmpty()) {
cb(serverLibraryItems)
} else {
apiHandler.getLibraryItems("main") { libraryItems ->
serverLibraryItems = libraryItems
cb(libraryItems)
}
}
}
fun getFirstItem() : LibraryItem? {
return if (serverLibraryItems.isNotEmpty()) serverLibraryItems[0] else null
}
fun getById(id:String) : LibraryItem? {
return serverLibraryItems.find { it.id == id }
}
fun getFromSearch(query:String?) : LibraryItem? {
if (query.isNullOrEmpty()) return getFirstItem()
return serverLibraryItems.find {
it.title.lowercase(Locale.getDefault()).contains(query.lowercase(Locale.getDefault()))
}
}
fun play(libraryItem:LibraryItem, cb: (PlaybackSession) -> Unit) {
apiHandler.playLibraryItem(libraryItem.id,"",false) {
cb(it)
}
}
private fun levenshtein(lhs : CharSequence, rhs : CharSequence) : Int {
val lhsLength = lhs.length + 1
val rhsLength = rhs.length + 1
var cost = Array(lhsLength) { it }
var newCost = Array(lhsLength) { 0 }
for (i in 1..rhsLength-1) {
newCost[0] = i
for (j in 1..lhsLength-1) {
val match = if(lhs[j - 1] == rhs[i - 1]) 0 else 1
val costReplace = cost[j - 1] + match
val costInsert = cost[j] + 1
val costDelete = newCost[j - 1] + 1
newCost[j] = Math.min(Math.min(costInsert, costDelete), costReplace)
}
val swap = cost
cost = newCost
newCost = swap
}
return cost[lhsLength - 1]
}
}

View file

@ -1,10 +1,11 @@
package com.audiobookshelf.app
package com.audiobookshelf.app.player
import android.app.PendingIntent
import android.graphics.Bitmap
import android.net.Uri
import android.support.v4.media.session.MediaControllerCompat
import android.util.Log
import com.audiobookshelf.app.R
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions

View file

@ -1,4 +1,4 @@
package com.audiobookshelf.app
package com.audiobookshelf.app.player
import android.content.ContentResolver
import android.content.Context
@ -6,12 +6,14 @@ import android.net.Uri
import android.support.v4.media.MediaMetadataCompat
import android.util.Log
import androidx.annotation.AnyRes
import com.audiobookshelf.app.R
import com.audiobookshelf.app.data.LibraryItem
class BrowseTree(
val context: Context,
audiobooksInProgress: List<Audiobook>,
audiobookMetadata: List<MediaMetadataCompat>,
itemsInProgress: List<LibraryItem>,
itemsMetadata: List<MediaMetadataCompat>,
downloadedMetadata: List<MediaMetadataCompat>
) {
private val mediaIdToChildren = mutableMapOf<String, MutableList<MediaMetadataCompat>>()
@ -33,7 +35,6 @@ class BrowseTree(
init {
val rootList = mediaIdToChildren[AUTO_BROWSE_ROOT] ?: mutableListOf()
val continueReadingMetadata = MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, CONTINUE_ROOT)
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Reading")
@ -42,27 +43,20 @@ class BrowseTree(
val allMetadata = MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, ALL_ROOT)
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Audiobooks")
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Library Items")
var resource = getUriToDrawable(context, R.drawable.exo_icon_books).toString()
Log.d("BrowseTree", "RESOURCE $resource")
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, resource)
}.build()
val downloadsMetadata = MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, DOWNLOADS_ROOT)
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Downloads")
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_downloaddone).toString())
}.build()
// val localsMetadata = MediaMetadataCompat.Builder().apply {
// putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, LOCAL_ROOT)
// putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Samples")
// putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_localaudio).toString())
// }.build()
if (audiobooksInProgress.isNotEmpty()) {
if (itemsInProgress.isNotEmpty()) {
rootList += continueReadingMetadata
}
rootList += allMetadata
@ -70,13 +64,13 @@ class BrowseTree(
// rootList += localsMetadata
mediaIdToChildren[AUTO_BROWSE_ROOT] = rootList
audiobooksInProgress.forEach { audiobook ->
itemsInProgress.forEach { libraryItem ->
val children = mediaIdToChildren[CONTINUE_ROOT] ?: mutableListOf()
children += audiobook.toMediaMetadata()
children += libraryItem.getMediaMetadata()
mediaIdToChildren[CONTINUE_ROOT] = children
}
audiobookMetadata.forEach {
itemsMetadata.forEach {
val allChildren = mediaIdToChildren[ALL_ROOT] ?: mutableListOf()
allChildren += it
mediaIdToChildren[ALL_ROOT] = allChildren
@ -87,13 +81,6 @@ class BrowseTree(
allChildren += it
mediaIdToChildren[DOWNLOADS_ROOT] = allChildren
}
// localAudio.forEach { local ->
// val localChildren = mediaIdToChildren[LOCAL_ROOT] ?: mutableListOf()
// localChildren += local.toMediaMetadata()
// mediaIdToChildren[LOCAL_ROOT] = localChildren
// }
// Log.d("BrowseTree", "Set LOCAL AUDIO ${localAudio.size}")
}
operator fun get(mediaId: String) = mediaIdToChildren[mediaId]

View file

@ -1,4 +1,4 @@
package com.audiobookshelf.app
package com.audiobookshelf.app.player
import android.app.Activity
import android.app.AlertDialog
@ -9,20 +9,22 @@ import androidx.mediarouter.app.MediaRouteChooserDialog
import androidx.mediarouter.media.MediaRouteSelector
import androidx.mediarouter.media.MediaRouter
import com.getcapacitor.PluginCall
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.ext.cast.CastPlayer
import com.google.android.exoplayer2.ext.cast.MediaItemConverter
import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener
import com.google.android.gms.cast.Cast
import com.google.android.gms.cast.CastDevice
import com.google.android.gms.cast.CastMediaControlIntent
import com.google.android.gms.cast.MediaQueueItem
import com.google.android.gms.cast.framework.*
import org.json.JSONObject
import java.util.ArrayList
class CastManager constructor(playerNotificationService:PlayerNotificationService) {
private val tag = "SleepTimerManager"
private val tag = "CastManager"
private val playerNotificationService:PlayerNotificationService = playerNotificationService
private var newConnectionListener:SessionListener? = null
private var newConnectionListener: SessionListener? = null
private var mainActivity:Activity? = null
private fun switchToPlayer(useCastPlayer:Boolean) {
@ -291,6 +293,22 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic
}
}
inner class CustomConverter : MediaItemConverter {
override fun toMediaQueueItem(mediaItem: MediaItem): MediaQueueItem {
// The MediaQueueItem you build is expected to be in the tag.
var queueItem = (mediaItem.playbackProperties!!.tag as MediaQueueItem?)!!
Log.d(tag, "Test toMediaQueueItem ${queueItem.media!!.contentUrl} | ${queueItem.playbackDuration} | ${queueItem.itemId}")
return queueItem
}
override fun toMediaItem(mediaQueueItem: MediaQueueItem): MediaItem {
return MediaItem.Builder()
.setUri(mediaQueueItem.media!!.contentUrl)
.setTag(mediaQueueItem)
.build()
}
}
private fun listenForConnection(callback: ConnectionCallback) {
// We should only ever have one of these listeners active at a time, so remove previous
getSessionManager()?.removeSessionManagerListener(newConnectionListener, CastSession::class.java)
@ -302,9 +320,10 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic
try {
val castContext = CastContext.getSharedInstance(mainActivity)
playerNotificationService.castPlayer = CastPlayer(castContext).apply {
playerNotificationService.castPlayer = CastPlayer(castContext, CustomConverter()).apply {
setSessionAvailabilityListener(CastSessionAvailabilityListener())
addListener(playerNotificationService.getPlayerListener())
addListener(PlayerListener(playerNotificationService))
}
Log.d(tag, "CAST Cast Player Applied")
switchToPlayer(true)
@ -313,8 +332,6 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic
"Exception thrown when attempting to obtain CastContext. " + e.message)
return
}
// media.setSession(castSession)
// callback.onJoin(ChromecastUtilities.createSessionObject(castSession))
}

View file

@ -0,0 +1,132 @@
package com.audiobookshelf.app.player
import android.os.Handler
import android.os.Looper
import android.util.Log
import com.audiobookshelf.app.data.LocalMediaProgress
import com.audiobookshelf.app.data.PlaybackSession
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.server.ApiHandler
import java.util.*
import kotlin.concurrent.schedule
import kotlin.math.roundToInt
data class MediaProgressSyncData(
var timeListened:Long, // seconds
var duration:Double, // seconds
var currentTime:Double // seconds
)
class MediaProgressSyncer(playerNotificationService:PlayerNotificationService, apiHandler: ApiHandler) {
private val tag = "MediaProgressSync"
private val playerNotificationService:PlayerNotificationService = playerNotificationService
private val apiHandler = apiHandler
private var listeningTimerTask: TimerTask? = null
var listeningTimerRunning:Boolean = false
private var lastSyncTime:Long = 0
var currentPlaybackSession: PlaybackSession? = null // copy of pb session currently syncing
var currentLocalMediaProgress: LocalMediaProgress? = null
val currentDisplayTitle get() = currentPlaybackSession?.displayTitle ?: "Unset"
val currentIsLocal get() = currentPlaybackSession?.isLocal == true
val currentSessionId get() = currentPlaybackSession?.id ?: ""
val currentPlaybackDuration get() = currentPlaybackSession?.duration ?: 0.0
fun start() {
if (listeningTimerRunning) {
Log.d(tag, "start: Timer already running for $currentDisplayTitle")
if (playerNotificationService.getCurrentPlaybackSessionId() != currentSessionId) {
Log.d(tag, "Playback session changed, reset timer")
currentLocalMediaProgress = null
listeningTimerTask?.cancel()
lastSyncTime = 0L
} else {
return
}
}
listeningTimerRunning = true
lastSyncTime = System.currentTimeMillis()
currentPlaybackSession = playerNotificationService.getCurrentPlaybackSessionCopy()
listeningTimerTask = Timer("ListeningTimer", false).schedule(0L, 5000L) {
Handler(Looper.getMainLooper()).post() {
if (playerNotificationService.currentPlayer.isPlaying) {
var currentTime = playerNotificationService.getCurrentTimeSeconds()
sync(currentTime)
}
}
}
}
fun stop() {
if (!listeningTimerRunning) return
Log.d(tag, "stop: Stopping listening for $currentDisplayTitle")
var currentTime = playerNotificationService.getCurrentTimeSeconds()
sync(currentTime)
reset()
}
fun sync(currentTime:Double) {
var diffSinceLastSync = System.currentTimeMillis() - lastSyncTime
if (diffSinceLastSync < 1000L) {
return
}
var listeningTimeToAdd = diffSinceLastSync / 1000L
lastSyncTime = System.currentTimeMillis()
var syncData = MediaProgressSyncData(listeningTimeToAdd,currentPlaybackDuration,currentTime)
currentPlaybackSession?.syncData(syncData)
if (currentIsLocal) {
// Save local progress sync
currentPlaybackSession?.let {
DeviceManager.dbManager.saveLocalPlaybackSession(it)
saveLocalProgress(it)
// Send sync to server also if connected to this server and local item belongs to this server
if (it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
apiHandler.sendLocalProgressSync(it) {
Log.d(tag, "Local progress sync data sent to server $currentDisplayTitle for time $currentTime")
}
}
}
} else {
apiHandler.sendProgressSync(currentSessionId, syncData) {
Log.d(tag, "Progress sync data sent to server $currentDisplayTitle for time $currentTime")
}
}
}
private fun saveLocalProgress(playbackSession:PlaybackSession) {
if (currentLocalMediaProgress == null) {
var mediaProgress = DeviceManager.dbManager.getLocalMediaProgress(playbackSession.localMediaProgressId)
if (mediaProgress == null) {
currentLocalMediaProgress = playbackSession.getNewLocalMediaProgress()
} else {
currentLocalMediaProgress = mediaProgress
}
} else {
currentLocalMediaProgress?.currentTime = playbackSession.currentTime
currentLocalMediaProgress?.lastUpdate = playbackSession.updatedAt
currentLocalMediaProgress?.progress = playbackSession.progress
}
currentLocalMediaProgress?.let {
DeviceManager.dbManager.saveLocalMediaProgress(it)
playerNotificationService.clientEventEmitter?.onLocalMediaProgressUpdate(it)
Log.d(tag, "Saved Local Progress Current Time: ${it.currentTime} | Duration ${it.duration} | Progress ${(it.progress * 100).roundToInt()}%")
}
}
fun reset() {
listeningTimerTask?.cancel()
listeningTimerTask = null
listeningTimerRunning = false
currentPlaybackSession = null
currentLocalMediaProgress = null
lastSyncTime = 0L
}
}

View file

@ -0,0 +1,194 @@
package com.audiobookshelf.app.player
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.Message
import android.support.v4.media.session.MediaSessionCompat
import android.util.Log
import android.view.KeyEvent
import com.audiobookshelf.app.data.LibraryItem
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.util.*
import kotlin.concurrent.schedule
class MediaSessionCallback(var playerNotificationService:PlayerNotificationService) : MediaSessionCompat.Callback() {
var tag = "MediaSessionCallback"
private var mediaButtonClickCount: Int = 0
var mediaButtonClickTimeout: Long = 1000 //ms
var seekAmount: Long = 20000 //ms
override fun onPrepare() {
Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT")
playerNotificationService.mediaManager.getFirstItem()?.let { li ->
playerNotificationService.mediaManager.play(li) {
Log.d(tag, "About to prepare player with li ${li.title}")
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it,true)
}
}
}
}
override fun onPlay() {
Log.d(tag, "ON PLAY MEDIA SESSION COMPAT")
playerNotificationService.play()
}
override fun onPrepareFromSearch(query: String?, extras: Bundle?) {
Log.d(tag, "ON PREPARE FROM SEARCH $query")
super.onPrepareFromSearch(query, extras)
}
override fun onPlayFromSearch(query: String?, extras: Bundle?) {
Log.d(tag, "ON PLAY FROM SEARCH $query")
playerNotificationService.mediaManager.getFromSearch(query)?.let { li ->
playerNotificationService.mediaManager.play(li) {
Log.d(tag, "About to prepare player with li ${li.title}")
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it,true)
}
}
}
}
override fun onPause() {
Log.d(tag, "ON PAUSE MEDIA SESSION COMPAT")
playerNotificationService.pause()
}
override fun onStop() {
playerNotificationService.pause()
}
override fun onSkipToPrevious() {
playerNotificationService.seekBackward(seekAmount)
}
override fun onSkipToNext() {
playerNotificationService.seekForward(seekAmount)
}
override fun onFastForward() {
playerNotificationService.seekForward(seekAmount)
}
override fun onRewind() {
playerNotificationService.seekForward(seekAmount)
}
override fun onSeekTo(pos: Long) {
playerNotificationService.seekPlayer(pos)
}
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
Log.d(tag, "ON PLAY FROM MEDIA ID $mediaId")
var libraryItem: LibraryItem? = null
if (mediaId.isNullOrEmpty()) {
libraryItem = playerNotificationService.mediaManager.getFirstItem()
} else {
libraryItem = playerNotificationService.mediaManager.getById(mediaId)
}
libraryItem?.let { li ->
playerNotificationService.mediaManager.play(li) {
Log.d(tag, "About to prepare player with li ${li.title}")
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it,true)
}
}
}
}
override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean {
return handleCallMediaButton(mediaButtonEvent)
}
fun handleCallMediaButton(intent: Intent): Boolean {
if(Intent.ACTION_MEDIA_BUTTON == intent.getAction()) {
var keyEvent = intent.getParcelableExtra<KeyEvent>(Intent.EXTRA_KEY_EVENT)
if (keyEvent?.getAction() == KeyEvent.ACTION_UP) {
when (keyEvent?.getKeyCode()) {
KeyEvent.KEYCODE_HEADSETHOOK -> {
if (0 == mediaButtonClickCount) {
if (playerNotificationService.mPlayer.isPlaying)
playerNotificationService.pause()
else
playerNotificationService.play()
}
handleMediaButtonClickCount()
}
KeyEvent.KEYCODE_MEDIA_PLAY -> {
if (0 == mediaButtonClickCount) {
playerNotificationService.play()
playerNotificationService.sleepTimerManager.checkShouldExtendSleepTimer()
}
handleMediaButtonClickCount()
}
KeyEvent.KEYCODE_MEDIA_PAUSE -> {
if (0 == mediaButtonClickCount) playerNotificationService.pause()
handleMediaButtonClickCount()
}
KeyEvent.KEYCODE_MEDIA_NEXT -> {
playerNotificationService.seekForward(seekAmount)
}
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
playerNotificationService.seekBackward(seekAmount)
}
KeyEvent.KEYCODE_MEDIA_STOP -> {
playerNotificationService.terminateStream()
}
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
if (playerNotificationService.mPlayer.isPlaying) {
if (0 == mediaButtonClickCount) playerNotificationService.pause()
handleMediaButtonClickCount()
} else {
if (0 == mediaButtonClickCount) {
playerNotificationService.play()
playerNotificationService.sleepTimerManager.checkShouldExtendSleepTimer()
}
handleMediaButtonClickCount()
}
}
else -> {
Log.d(tag, "KeyCode:${keyEvent?.getKeyCode()}")
return false
}
}
}
}
return true
}
fun handleMediaButtonClickCount() {
mediaButtonClickCount++
if (1 == mediaButtonClickCount) {
Timer().schedule(mediaButtonClickTimeout) {
mediaBtnHandler.sendEmptyMessage(mediaButtonClickCount)
mediaButtonClickCount = 0
}
}
}
private val mediaBtnHandler : Handler = @SuppressLint("HandlerLeak")
object : Handler(){
override fun handleMessage(msg: Message) {
super.handleMessage(msg)
if (2 == msg.what) {
playerNotificationService.seekBackward(seekAmount)
playerNotificationService.play()
}
else if (msg.what >= 3) {
playerNotificationService.seekForward(seekAmount)
playerNotificationService.play()
}
}
}
}

View file

@ -0,0 +1,70 @@
package com.audiobookshelf.app.player
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.ResultReceiver
import android.support.v4.media.session.PlaybackStateCompat
import android.util.Log
import com.audiobookshelf.app.data.LibraryItem
import com.google.android.exoplayer2.ControlDispatcher
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificationService) : MediaSessionConnector.PlaybackPreparer {
var tag = "MediaSessionPlaybackPreparer"
override fun onCommand(player: Player, controlDispatcher: ControlDispatcher, command: String, extras: Bundle?, cb: ResultReceiver?): Boolean {
Log.d(tag, "ON COMMAND $command")
return false
}
override fun getSupportedPrepareActions(): Long {
return PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID or
PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or
PlaybackStateCompat.ACTION_PREPARE_FROM_SEARCH or
PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH
}
override fun onPrepare(playWhenReady: Boolean) {
Log.d(tag, "ON PREPARE $playWhenReady")
playerNotificationService.mediaManager.getFirstItem()?.let { li ->
playerNotificationService.mediaManager.play(li) {
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it,playWhenReady)
}
}
}
}
override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) {
Log.d(tag, "ON PREPARE FROM MEDIA ID $mediaId $playWhenReady")
var libraryItem: LibraryItem? = playerNotificationService.mediaManager.getById(mediaId)
libraryItem?.let { li ->
playerNotificationService.mediaManager.play(li) {
Log.d(tag, "About to prepare player with li ${li.title}")
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it,playWhenReady)
}
}
}
}
override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) {
Log.d(tag, "ON PREPARE FROM SEARCH $query")
playerNotificationService.mediaManager.getFromSearch(query)?.let { li ->
playerNotificationService.mediaManager.play(li) {
Log.d(tag, "About to prepare player with li ${li.title}")
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it,playWhenReady)
}
}
}
}
override fun onPrepareFromUri(uri: Uri, playWhenReady: Boolean, extras: Bundle?) {
Log.d(tag, "ON PREPARE FROM URI $uri")
}
}

View file

@ -0,0 +1,102 @@
package com.audiobookshelf.app.player
import android.util.Log
import com.google.android.exoplayer2.PlaybackException
import com.google.android.exoplayer2.Player
class PlayerListener(var playerNotificationService:PlayerNotificationService) : Player.Listener {
var tag = "PlayerListener"
companion object {
var lastPauseTime: Long = 0 //ms
}
private var onSeekBack: Boolean = false
override fun onPlayerError(error: PlaybackException) {
error.message?.let { Log.e(tag, it) }
error.localizedMessage?.let { Log.e(tag, it) }
}
override fun onEvents(player: Player, events: Player.Events) {
if (events.contains(Player.EVENT_POSITION_DISCONTINUITY)) {
Log.d(tag, "EVENT_POSITION_DISCONTINUITY")
}
if (events.contains(Player.EVENT_IS_LOADING_CHANGED)) {
Log.d(tag, "EVENT_IS_LOADING_CHANGED : " + playerNotificationService.mPlayer.isLoading.toString())
}
if (events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED)) {
if (playerNotificationService.currentPlayer.playbackState == Player.STATE_READY) {
Log.d(tag, "STATE_READY : " + playerNotificationService.mPlayer.duration.toString())
if (lastPauseTime == 0L) {
playerNotificationService.sendClientMetadata("ready_no_sync")
lastPauseTime = -1;
} else playerNotificationService.sendClientMetadata("ready")
}
if (playerNotificationService.currentPlayer.playbackState == Player.STATE_BUFFERING) {
Log.d(tag, "STATE_BUFFERING : " + playerNotificationService.mPlayer.currentPosition.toString())
if (lastPauseTime == 0L) playerNotificationService.sendClientMetadata("buffering_no_sync")
else playerNotificationService.sendClientMetadata("buffering")
}
if (playerNotificationService.currentPlayer.playbackState == Player.STATE_ENDED) {
Log.d(tag, "STATE_ENDED")
playerNotificationService.sendClientMetadata("ended")
}
if (playerNotificationService.currentPlayer.playbackState == Player.STATE_IDLE) {
Log.d(tag, "STATE_IDLE")
playerNotificationService.sendClientMetadata("idle")
}
}
if (events.contains(Player.EVENT_MEDIA_METADATA_CHANGED)) {
Log.d(tag, "EVENT_MEDIA_METADATA_CHANGED")
}
if (events.contains(Player.EVENT_PLAYLIST_METADATA_CHANGED)) {
Log.d(tag, "EVENT_PLAYLIST_METADATA_CHANGED")
}
if (events.contains(Player.EVENT_IS_PLAYING_CHANGED)) {
Log.d(tag, "EVENT IS PLAYING CHANGED")
if (player.isPlaying) {
if (lastPauseTime > 0) {
if (onSeekBack) onSeekBack = false
else {
var backTime = calcPauseSeekBackTime()
if (backTime > 0) {
if (backTime >= playerNotificationService.mPlayer.currentPosition) backTime = playerNotificationService.mPlayer.currentPosition - 500
Log.d(tag, "SeekBackTime $backTime")
onSeekBack = true
playerNotificationService.seekBackward(backTime)
}
}
}
} else lastPauseTime = System.currentTimeMillis()
// Start/stop progress sync interval
Log.d(tag, "Playing ${playerNotificationService.getCurrentBookTitle()}")
if (player.isPlaying) {
playerNotificationService.mediaProgressSyncer.start()
} else {
playerNotificationService.mediaProgressSyncer.stop()
}
playerNotificationService.clientEventEmitter?.onPlayingUpdate(player.isPlaying)
}
}
fun calcPauseSeekBackTime() : Long {
if (lastPauseTime <= 0) return 0
var time: Long = System.currentTimeMillis() - lastPauseTime
var seekback: Long = 0
if (time < 60000) seekback = 0
else if (time < 120000) seekback = 10000
else if (time < 300000) seekback = 15000
else if (time < 1800000) seekback = 20000
else if (time < 3600000) seekback = 25000
else seekback = 29500
return seekback
}
}

View file

@ -0,0 +1,31 @@
package com.audiobookshelf.app.player
import android.app.Notification
import android.util.Log
import com.google.android.exoplayer2.ui.PlayerNotificationManager
class PlayerNotificationListener(var playerNotificationService:PlayerNotificationService) : PlayerNotificationManager.NotificationListener {
var tag = "PlayerNotificationListener"
override fun onNotificationPosted(
notificationId: Int,
notification: Notification,
onGoing: Boolean) {
// Start foreground service
Log.d(tag, "Notification Posted $notificationId - Start Foreground | $notification")
playerNotificationService.startForeground(notificationId, notification)
}
override fun onNotificationCancelled(
notificationId: Int,
dismissedByUser: Boolean
) {
if (dismissedByUser) {
Log.d(tag, "onNotificationCancelled dismissed by user")
playerNotificationService.stopSelf()
} else {
Log.d(tag, "onNotificationCancelled not dismissed by user")
}
}
}

View file

@ -0,0 +1,594 @@
package com.audiobookshelf.app.player
import android.app.*
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.hardware.Sensor
import android.hardware.SensorManager
import android.os.*
import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaControllerCompat
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.media.MediaBrowserServiceCompat
import androidx.media.utils.MediaConstants
import com.audiobookshelf.app.data.LibraryItem
import com.audiobookshelf.app.data.LocalMediaProgress
import com.audiobookshelf.app.data.PlaybackSession
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.media.MediaManager
import com.audiobookshelf.app.server.ApiHandler
import com.getcapacitor.JSObject
import com.google.android.exoplayer2.*
import com.google.android.exoplayer2.audio.AudioAttributes
import com.google.android.exoplayer2.ext.cast.CastPlayer
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator
import com.google.android.exoplayer2.source.MediaSource
import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.source.hls.HlsMediaSource
import com.google.android.exoplayer2.ui.PlayerControlView
import com.google.android.exoplayer2.ui.PlayerNotificationManager
import com.google.android.exoplayer2.upstream.*
import okhttp3.OkHttpClient
import java.util.*
import kotlin.concurrent.schedule
const val SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m
class PlayerNotificationService : MediaBrowserServiceCompat() {
companion object {
var isStarted = false
}
interface ClientEventEmitter {
fun onPlaybackSession(playbackSession:PlaybackSession)
fun onPlaybackClosed()
fun onPlayingUpdate(isPlaying: Boolean)
fun onMetadata(metadata: JSObject)
fun onPrepare(audiobookId: String, playWhenReady: Boolean)
fun onSleepTimerEnded(currentPosition: Long)
fun onSleepTimerSet(sleepTimeRemaining: Int)
fun onLocalMediaProgressUpdate(localMediaProgress: LocalMediaProgress)
}
private val tag = "PlayerService"
private val binder = LocalBinder()
var clientEventEmitter:ClientEventEmitter? = null
private lateinit var ctx:Context
private lateinit var mediaSessionConnector: MediaSessionConnector
private lateinit var playerNotificationManager: PlayerNotificationManager
private lateinit var mediaSession: MediaSessionCompat
private lateinit var transportControls:MediaControllerCompat.TransportControls
lateinit var mediaManager: MediaManager
lateinit var apiHandler: ApiHandler
lateinit var mPlayer: SimpleExoPlayer
lateinit var currentPlayer:Player
var castPlayer:CastPlayer? = null
lateinit var sleepTimerManager:SleepTimerManager
lateinit var castManager:CastManager
lateinit var mediaProgressSyncer:MediaProgressSyncer
private var notificationId = 10;
private var channelId = "audiobookshelf_channel"
private var channelName = "Audiobookshelf Channel"
private var currentPlaybackSession:PlaybackSession? = null
var isAndroidAuto = false
// The following are used for the shake detection
private var isShakeSensorRegistered:Boolean = false
private var mSensorManager: SensorManager? = null
private var mAccelerometer: Sensor? = null
private var mShakeDetector: ShakeDetector? = null
private var shakeSensorUnregisterTask:TimerTask? = null
/*
Service related stuff
*/
override fun onBind(intent: Intent): IBinder? {
Log.d(tag, "onBind")
// Android Auto Media Browser Service
if (SERVICE_INTERFACE == intent.action) {
Log.d(tag, "Is Media Browser Service")
return super.onBind(intent);
}
return binder
}
inner class LocalBinder : Binder() {
// Return this instance of LocalService so clients can call public methods
fun getService(): PlayerNotificationService = this@PlayerNotificationService
}
fun stopService(context: Context) {
val stopIntent = Intent(context, PlayerNotificationService::class.java)
context.stopService(stopIntent)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
isStarted = true
Log.d(tag, "onStartCommand $startId")
return START_STICKY
}
override fun onStart(intent: Intent?, startId: Int) {
Log.d(tag, "onStart $startId")
}
@RequiresApi(Build.VERSION_CODES.O)
private fun createNotificationChannel(channelId: String, channelName: String): String {
val chan = NotificationChannel(channelId,
channelName, NotificationManager.IMPORTANCE_LOW)
chan.lightColor = Color.DKGRAY
chan.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
val service = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
service.createNotificationChannel(chan)
return channelId
}
// detach player
override fun onDestroy() {
playerNotificationManager.setPlayer(null)
mPlayer.release()
mediaSession.release()
mediaProgressSyncer.reset()
Log.d(tag, "onDestroy")
isStarted = false
super.onDestroy()
}
//removing service when user swipe out our app
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
Log.d(tag, "onTaskRemoved")
stopSelf()
}
override fun onCreate() {
super.onCreate()
ctx = this
// Initialize player
var customLoadControl:LoadControl = DefaultLoadControl.Builder().setBufferDurationsMs(
1000 * 20, // 20s min buffer
1000 * 45, // 45s max buffer
1000 * 5, // 5s playback start
1000 * 20 // 20s playback rebuffer
).build()
var simpleExoPlayerBuilder = SimpleExoPlayer.Builder(this)
simpleExoPlayerBuilder.setLoadControl(customLoadControl)
simpleExoPlayerBuilder.setSeekBackIncrementMs(10000)
simpleExoPlayerBuilder.setSeekForwardIncrementMs(10000)
mPlayer = simpleExoPlayerBuilder.build()
mPlayer.setHandleAudioBecomingNoisy(true)
mPlayer.addListener(PlayerListener(this))
var audioAttributes:AudioAttributes = AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.CONTENT_TYPE_SPEECH).build()
mPlayer.setAudioAttributes(audioAttributes, true)
currentPlayer = mPlayer
// Initialize API
apiHandler = ApiHandler(ctx)
// Initialize sleep timer
sleepTimerManager = SleepTimerManager(this)
// Initialize Cast Manager
castManager = CastManager(this)
// Initialize Media Progress Syncer
mediaProgressSyncer = MediaProgressSyncer(this, apiHandler)
// Initialize shake sensor
Log.d(tag, "onCreate Register sensor listener ${mAccelerometer?.isWakeUpSensor}")
initSensor()
// Initialize media manager
mediaManager = MediaManager(apiHandler)
channelId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
createNotificationChannel(channelId, channelName)
} else ""
val sessionActivityPendingIntent =
packageManager?.getLaunchIntentForPackage(packageName)?.let { sessionIntent ->
PendingIntent.getActivity(this, 0, sessionIntent, 0)
}
mediaSession = MediaSessionCompat(this, tag)
.apply {
setSessionActivity(sessionActivityPendingIntent)
isActive = true
}
val mediaController = MediaControllerCompat(ctx, mediaSession.sessionToken)
// This is for Media Browser
sessionToken = mediaSession.sessionToken
val builder = PlayerNotificationManager.Builder(
ctx,
notificationId,
channelId)
builder.setMediaDescriptionAdapter(AbMediaDescriptionAdapter(mediaController, this))
builder.setNotificationListener(PlayerNotificationListener(this))
playerNotificationManager = builder.build()
playerNotificationManager.setMediaSessionToken(mediaSession.sessionToken)
playerNotificationManager.setUsePlayPauseActions(true)
playerNotificationManager.setUseNextAction(false)
playerNotificationManager.setUsePreviousAction(false)
playerNotificationManager.setUseChronometer(false)
playerNotificationManager.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX)
playerNotificationManager.setUseFastForwardActionInCompactView(true)
playerNotificationManager.setUseRewindActionInCompactView(true)
// Unknown action
playerNotificationManager.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE)
transportControls = mediaController.transportControls
mediaSessionConnector = MediaSessionConnector(mediaSession)
val queueNavigator: TimelineQueueNavigator = object : TimelineQueueNavigator(mediaSession) {
override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat {
var builder = MediaDescriptionCompat.Builder()
.setMediaId(currentPlaybackSession!!.id)
.setTitle(currentPlaybackSession!!.displayTitle)
.setSubtitle(currentPlaybackSession!!.displayAuthor)
.setIconUri(currentPlaybackSession!!.getCoverUri())
return builder.build()
}
// .setMediaUri(currentPlaybackSession!!.getContentUri())
}
mediaSessionConnector.setEnabledPlaybackActions(
PlaybackStateCompat.ACTION_PLAY_PAUSE
or PlaybackStateCompat.ACTION_PLAY
or PlaybackStateCompat.ACTION_PAUSE
or PlaybackStateCompat.ACTION_SEEK_TO
or PlaybackStateCompat.ACTION_FAST_FORWARD
or PlaybackStateCompat.ACTION_REWIND
or PlaybackStateCompat.ACTION_STOP
)
mediaSessionConnector.setQueueNavigator(queueNavigator)
mediaSessionConnector.setPlaybackPreparer(MediaSessionPlaybackPreparer(this))
mediaSessionConnector.setPlayer(mPlayer)
//attach player to playerNotificationManager
playerNotificationManager.setPlayer(mPlayer)
mediaSession.setCallback(MediaSessionCallback(this))
}
/*
User callable methods
*/
fun preparePlayer(playbackSession: PlaybackSession, playWhenReady:Boolean) {
currentPlaybackSession = playbackSession
clientEventEmitter?.onPlaybackSession(playbackSession)
var metadata = playbackSession.getMediaMetadataCompat()
mediaSession.setMetadata(metadata)
var mediaItems = playbackSession.getMediaItems()
if (mPlayer == currentPlayer) {
var mediaSource:MediaSource
if (playbackSession.isLocal) {
Log.d(tag, "Playing Local Item")
var dataSourceFactory = DefaultDataSourceFactory(ctx, channelId)
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0])
} else if (!playbackSession.isHLS) {
Log.d(tag, "Direct Playing Item")
var dataSourceFactory = DefaultDataSourceFactory(ctx, channelId)
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0])
} else {
Log.d(tag, "Playing HLS Item")
var dataSourceFactory = DefaultHttpDataSource.Factory()
dataSourceFactory.setUserAgent(channelId)
dataSourceFactory.setDefaultRequestProperties(hashMapOf("Authorization" to "Bearer ${DeviceManager.token}"))
mediaSource = HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0])
}
mPlayer.setMediaSource(mediaSource)
} else if (castPlayer != null) {
castPlayer?.addMediaItem(mediaItems[0]) // TODO: Media items never actually get added, not sure what is going on....
Log.d(tag, "Cast Player ADDED MEDIA ITEM ${castPlayer?.currentMediaItem} | ${castPlayer?.duration} | ${castPlayer?.mediaItemCount}")
}
// Add remaining media items if multi-track
if (mediaItems.size > 1) {
currentPlayer.addMediaItems(mediaItems.subList(1, mediaItems.size))
Log.d(tag, "currentPlayer total media items ${currentPlayer.mediaItemCount}")
var currentTrackIndex = playbackSession.getCurrentTrackIndex()
var currentTrackTime = playbackSession.getCurrentTrackTimeMs()
Log.d(tag, "currentPlayer current track index $currentTrackIndex & current track time $currentTrackTime")
currentPlayer.seekTo(currentTrackIndex, currentTrackTime)
} else {
currentPlayer.seekTo(playbackSession.currentTimeMs)
}
Log.d(tag, "Prepare complete for session ${currentPlaybackSession?.displayTitle} | ${currentPlayer.mediaItemCount}")
currentPlayer.playWhenReady = playWhenReady
currentPlayer.setPlaybackSpeed(1f) // TODO: Playback speed should come from settings
currentPlayer.prepare()
}
fun switchToPlayer(useCastPlayer: Boolean) {
currentPlayer = if (useCastPlayer) {
Log.d(tag, "switchToPlayer: Using Cast Player " + castPlayer?.deviceInfo)
mediaSessionConnector.setPlayer(castPlayer)
castPlayer as CastPlayer
} else {
Log.d(tag, "switchToPlayer: Using ExoPlayer")
mediaSessionConnector.setPlayer(mPlayer)
mPlayer
}
currentPlaybackSession?.let {
Log.d(tag, "switchToPlayer: Preparing current playback session ${it.displayTitle}")
preparePlayer(it, false)
}
}
fun getCurrentTime() : Long {
if (currentPlayer.mediaItemCount > 1) {
var windowIndex = currentPlayer.currentWindowIndex
var currentTrackStartOffset = currentPlaybackSession?.getTrackStartOffsetMs(windowIndex) ?: 0L
return currentPlayer.currentPosition + currentTrackStartOffset
} else {
return currentPlayer.currentPosition
}
}
fun getCurrentTimeSeconds() : Double {
return getCurrentTime() / 1000.0
}
fun getBufferedTime() : Long {
if (currentPlayer.mediaItemCount > 1) {
var windowIndex = currentPlayer.currentWindowIndex
var currentTrackStartOffset = currentPlaybackSession?.getTrackStartOffsetMs(windowIndex) ?: 0L
return currentPlayer.bufferedPosition + currentTrackStartOffset
} else {
return currentPlayer.bufferedPosition
}
}
fun getDuration() : Long {
return currentPlayer.duration
}
fun getCurrentBookTitle() : String? {
return currentPlaybackSession?.displayTitle
}
fun getCurrentPlaybackSessionCopy() :PlaybackSession? {
return currentPlaybackSession?.clone()
}
fun getCurrentPlaybackSessionId() :String? {
return currentPlaybackSession?.id
}
fun play() {
if (currentPlayer.isPlaying) {
Log.d(tag, "Already playing")
return
}
currentPlayer.volume = 1F
if (currentPlayer == castPlayer) {
Log.d(tag, "CAST Player set on play ${currentPlayer.isLoading} || ${currentPlayer.duration} | ${currentPlayer.currentPosition}")
}
currentPlayer.play()
}
fun pause() {
currentPlayer.pause()
}
fun playPause():Boolean {
return if (currentPlayer.isPlaying) {
pause()
false
} else {
play()
true
}
}
fun seekPlayer(time: Long) {
if (currentPlayer.mediaItemCount > 1) {
currentPlaybackSession?.currentTime = time / 1000.0
var newWindowIndex = currentPlaybackSession?.getCurrentTrackIndex() ?: 0
var newTimeOffset = currentPlaybackSession?.getCurrentTrackTimeMs() ?: 0
currentPlayer.seekTo(newWindowIndex, newTimeOffset)
} else {
currentPlayer.seekTo(time)
}
}
fun seekForward(amount: Long) {
currentPlayer.seekTo(mPlayer.currentPosition + amount)
}
fun seekBackward(amount: Long) {
currentPlayer.seekTo(mPlayer.currentPosition - amount)
}
fun setPlaybackSpeed(speed: Float) {
currentPlayer.setPlaybackSpeed(speed)
}
fun terminateStream() {
currentPlayer.clearMediaItems()
currentPlaybackSession = null
clientEventEmitter?.onPlaybackClosed()
PlayerListener.lastPauseTime = 0
}
fun sendClientMetadata(stateName: String) {
var metadata = JSObject()
var duration = currentPlaybackSession?.getTotalDuration() ?: 0
metadata.put("duration", duration)
metadata.put("currentTime", getCurrentTime())
metadata.put("stateName", stateName)
clientEventEmitter?.onMetadata(metadata)
}
//
// MEDIA BROWSER STUFF (ANDROID AUTO)
//
private val ANDROID_AUTO_PKG_NAME = "com.google.android.projection.gearhead"
private val ANDROID_AUTO_SIMULATOR_PKG_NAME = "com.google.android.autosimulator"
private val ANDROID_WEARABLE_PKG_NAME = "com.google.android.wearable.app"
private val ANDROID_GSEARCH_PKG_NAME = "com.google.android.googlequicksearchbox"
private val ANDROID_AUTOMOTIVE_PKG_NAME = "com.google.android.carassistant"
private val VALID_MEDIA_BROWSERS = mutableListOf<String>(ANDROID_AUTO_PKG_NAME, ANDROID_AUTO_SIMULATOR_PKG_NAME, ANDROID_WEARABLE_PKG_NAME, ANDROID_GSEARCH_PKG_NAME, ANDROID_AUTOMOTIVE_PKG_NAME)
private val AUTO_MEDIA_ROOT = "/"
private val ALL_ROOT = "__ALL__"
private lateinit var browseTree:BrowseTree
// Only allowing android auto or similar to access media browser service
// normal loading of audiobooks is handled in webview (not natively)
private fun isValid(packageName: String, uid: Int) : Boolean {
Log.d(tag, "onGetRoot: Checking package $packageName with uid $uid")
if (!VALID_MEDIA_BROWSERS.contains(packageName)) {
Log.d(tag, "onGetRoot: package $packageName not valid for the media browser service")
return false
}
return true
}
override fun onGetRoot(clientPackageName: String, clientUid: Int, rootHints: Bundle?): BrowserRoot? {
// Verify that the specified package is allowed to access your content
return if (!isValid(clientPackageName, clientUid)) {
// No further calls will be made to other media browsing methods.
null
} else {
// Flag is used to enable syncing progress natively (normally syncing is handled in webview)
isAndroidAuto = true
val extras = Bundle()
extras.putBoolean(
MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true)
extras.putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM)
extras.putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM)
BrowserRoot(AUTO_MEDIA_ROOT, extras)
}
}
override fun onLoadChildren(parentMediaId: String, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
Log.d(tag, "ON LOAD CHILDREN $parentMediaId")
var flag = if (parentMediaId == AUTO_MEDIA_ROOT) MediaBrowserCompat.MediaItem.FLAG_BROWSABLE else MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
result.detach()
mediaManager.loadLibraryItems { libraryItems ->
var itemMediaMetadata:List<MediaMetadataCompat> = libraryItems.map { it.getMediaMetadata() }
browseTree = BrowseTree(this, mutableListOf(), itemMediaMetadata, mutableListOf())
val children = browseTree[parentMediaId]?.map { item ->
MediaBrowserCompat.MediaItem(item.description, flag)
}
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
}
// TODO: For using sub menus. Check if this is the root menu:
// if (AUTO_MEDIA_ROOT == parentMediaId) {
// build the MediaItem objects for the top level,
// and put them in the mediaItems list
// } else {
// examine the passed parentMediaId to see which submenu we're at,
// and put the children of that menu in the mediaItems list
// }
}
override fun onSearch(query: String, extras: Bundle?, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
result.detach()
mediaManager.loadLibraryItems { libraryItems ->
var itemMediaMetadata:List<MediaMetadataCompat> = libraryItems.map { it.getMediaMetadata() }
browseTree = BrowseTree(this, mutableListOf(), itemMediaMetadata, mutableListOf())
val children = browseTree[ALL_ROOT]?.map { item ->
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
}
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
}
}
//
// SHAKE SENSOR
//
private fun initSensor() {
// ShakeDetector initialization
mSensorManager = getSystemService(SENSOR_SERVICE) as SensorManager
mAccelerometer = mSensorManager!!.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
mShakeDetector = ShakeDetector()
mShakeDetector!!.setOnShakeListener(object : ShakeDetector.OnShakeListener {
override fun onShake(count: Int) {
Log.d(tag, "PHONE SHAKE! $count")
sleepTimerManager.handleShake()
}
})
}
// Shake sensor used for sleep timer
fun registerSensor() {
if (isShakeSensorRegistered) {
Log.w(tag, "Shake sensor already registered")
return
}
shakeSensorUnregisterTask?.cancel()
Log.d(tag, "Registering shake SENSOR ${mAccelerometer?.isWakeUpSensor}")
var success = mSensorManager!!.registerListener(
mShakeDetector,
mAccelerometer,
SensorManager.SENSOR_DELAY_UI
)
if (success) isShakeSensorRegistered = true
}
fun unregisterSensor() {
if (!isShakeSensorRegistered) return
// Unregister shake sensor after wake up expiration
shakeSensorUnregisterTask?.cancel()
shakeSensorUnregisterTask = Timer("ShakeUnregisterTimer", false).schedule(SLEEP_TIMER_WAKE_UP_EXPIRATION) {
Handler(Looper.getMainLooper()).post() {
Log.d(tag, "wake time expired: Unregistering shake sensor")
mSensorManager!!.unregisterListener(mShakeDetector)
isShakeSensorRegistered = false
}
}
}
}

View file

@ -1,10 +1,9 @@
package com.audiobookshelf.app
package com.audiobookshelf.app.player
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import java.lang.Math.sqrt
import kotlin.math.sqrt
class ShakeDetector : SensorEventListener {

View file

@ -1,4 +1,4 @@
package com.audiobookshelf.app
package com.audiobookshelf.app.player
import android.os.Handler
import android.os.Looper
@ -87,7 +87,7 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
}
}
playerNotificationService.listener?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
sleepTimerRunning = true
sleepTimerTask = Timer("SleepTimer", false).schedule(0L, 1000L) {
@ -99,14 +99,14 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
Log.d(tag, "Timer Elapsed $sleepTimerElapsed | Sleep TIMER time remaining $sleepTimeSecondsRemaining s")
if (sleepTimeSecondsRemaining > 0) {
playerNotificationService.listener?.onSleepTimerSet(sleepTimeSecondsRemaining)
playerNotificationService.clientEventEmitter?.onSleepTimerSet(sleepTimeSecondsRemaining)
}
if (sleepTimeSecondsRemaining <= 0) {
Log.d(tag, "Sleep Timer Pausing Player on Chapter")
pause()
playerNotificationService.listener?.onSleepTimerEnded(getCurrentTime())
playerNotificationService.clientEventEmitter?.onSleepTimerEnded(getCurrentTime())
clearSleepTimer()
sleepTimerFinishedAt = System.currentTimeMillis()
} else if (sleepTimeSecondsRemaining <= 30) {
@ -136,7 +136,7 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
fun cancelSleepTimer() {
Log.d(tag, "Canceling Sleep Timer")
clearSleepTimer()
playerNotificationService.listener?.onSleepTimerSet(0)
playerNotificationService.clientEventEmitter?.onSleepTimerSet(0)
}
private fun extendSleepTime() {
@ -150,7 +150,7 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
if (sleepTimerEndTime > getDuration()) sleepTimerEndTime = getDuration()
}
playerNotificationService.listener?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
}
fun checkShouldExtendSleepTimer() {
@ -197,7 +197,7 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
}
setVolume(1F)
playerNotificationService.listener?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
}
fun decreaseSleepTime(time: Long) {
@ -219,6 +219,6 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
}
setVolume(1F)
playerNotificationService.listener?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
}
}

View file

@ -1,31 +1,47 @@
package com.audiobookshelf.app
package com.audiobookshelf.app.plugins
import android.content.Intent
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.core.content.ContextCompat
import com.capacitorjs.plugins.app.AppPlugin
import com.audiobookshelf.app.MainActivity
import com.audiobookshelf.app.data.LocalMediaProgress
import com.audiobookshelf.app.data.PlaybackSession
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.player.CastManager
import com.audiobookshelf.app.player.PlayerNotificationService
import com.audiobookshelf.app.server.ApiHandler
import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.getcapacitor.*
import com.getcapacitor.annotation.CapacitorPlugin
import org.json.JSONObject
@CapacitorPlugin(name = "MyNativeAudio")
class MyNativeAudio : Plugin() {
private val tag = "MyNativeAudio"
@CapacitorPlugin(name = "AbsAudioPlayer")
class AbsAudioPlayer : Plugin() {
private val tag = "AbsAudioPlayer"
lateinit var mainActivity:MainActivity
lateinit var mainActivity: MainActivity
lateinit var apiHandler:ApiHandler
lateinit var playerNotificationService: PlayerNotificationService
override fun load() {
mainActivity = (activity as MainActivity)
apiHandler = ApiHandler(mainActivity)
var foregroundServiceReady : () -> Unit = {
playerNotificationService = mainActivity.foregroundService
playerNotificationService.setBridge(bridge)
playerNotificationService.clientEventEmitter = (object : PlayerNotificationService.ClientEventEmitter {
override fun onPlaybackSession(playbackSession: PlaybackSession) {
notifyListeners("onPlaybackSession", JSObject(jacksonObjectMapper().writeValueAsString(playbackSession)))
}
override fun onPlaybackClosed() {
emit("onPlaybackClosed", true)
}
playerNotificationService.setCustomObjectListener(object : PlayerNotificationService.MyCustomObjectListener {
override fun onPlayingUpdate(isPlaying: Boolean) {
emit("onPlayingUpdate", isPlaying)
}
@ -48,6 +64,10 @@ class MyNativeAudio : Plugin() {
override fun onSleepTimerSet(sleepTimeRemaining: Int) {
emit("onSleepTimerSet", sleepTimeRemaining)
}
override fun onLocalMediaProgressUpdate(localMediaProgress: LocalMediaProgress) {
notifyListeners("onLocalMediaProgressUpdate", JSObject(jacksonObjectMapper().writeValueAsString(localMediaProgress)))
}
})
}
mainActivity.pluginCallback = foregroundServiceReady
@ -60,27 +80,43 @@ class MyNativeAudio : Plugin() {
}
@PluginMethod
fun initPlayer(call: PluginCall) {
fun prepareLibraryItem(call: PluginCall) {
// Need to make sure the player service has been started
if (!PlayerNotificationService.isStarted) {
Log.w(tag, "Starting foreground service --")
Log.w(tag, "prepareLibraryItem: PlayerService not started - Starting foreground service --")
Intent(mainActivity, PlayerNotificationService::class.java).also { intent ->
ContextCompat.startForegroundService(mainActivity, intent)
}
}
var jsobj = JSObject()
var audiobookStreamData:AudiobookStreamData = AudiobookStreamData(call.data)
if (audiobookStreamData.playlistUrl == "" && audiobookStreamData.contentUrl == "") {
Log.e(tag, "Invalid URL for init audio player")
var libraryItemId = call.getString("libraryItemId", "").toString()
var episodeId = call.getString("episodeId", "").toString()
var playWhenReady = call.getBoolean("playWhenReady") == true
jsobj.put("success", false)
return call.resolve(jsobj)
if (libraryItemId.isEmpty()) {
Log.e(tag, "Invalid call to play library item no library item id")
return call.resolve()
}
Handler(Looper.getMainLooper()).post() {
playerNotificationService.initPlayer(audiobookStreamData)
jsobj.put("success", true)
call.resolve(jsobj)
if (libraryItemId.startsWith("local")) { // Play local media item
DeviceManager.dbManager.getLocalLibraryItem(libraryItemId)?.let {
Handler(Looper.getMainLooper()).post() {
Log.d(tag, "Preparing Local Media item ${jacksonObjectMapper().writeValueAsString(it)}")
var playbackSession = it.getPlaybackSession(episodeId)
playerNotificationService.preparePlayer(playbackSession, playWhenReady)
}
return call.resolve(JSObject())
}
} else { // Play library item from server
apiHandler.playLibraryItem(libraryItemId, episodeId, false) {
Handler(Looper.getMainLooper()).post() {
Log.d(tag, "Preparing Player TEST ${jacksonObjectMapper().writeValueAsString(it)}")
playerNotificationService.preparePlayer(it, playWhenReady)
}
call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(it)))
}
}
}
@ -96,28 +132,6 @@ class MyNativeAudio : Plugin() {
}
}
@PluginMethod
fun getStreamSyncData(call: PluginCall) {
Handler(Looper.getMainLooper()).post() {
var isPlaying = playerNotificationService.getPlayStatus()
var lastPauseTime = playerNotificationService.getTheLastPauseTime()
Log.d(tag, "Get Last Pause Time $lastPauseTime")
var currentTime = playerNotificationService.getCurrentTime()
//if (!isPlaying) currentTime -= playerNotificationService.calcPauseSeekBackTime()
var id = playerNotificationService.getCurrentAudiobookId()
Log.d(tag, "Get Current id $id")
var duration = playerNotificationService.getDuration()
Log.d(tag, "Get duration $duration")
val ret = JSObject()
ret.put("lastPauseTime", lastPauseTime)
ret.put("currentTime", currentTime)
ret.put("isPlaying", isPlaying)
ret.put("id", id)
ret.put("duration", duration)
call.resolve(ret)
}
}
@PluginMethod
fun pausePlayer(call: PluginCall) {
Handler(Looper.getMainLooper()).post() {
@ -134,6 +148,14 @@ class MyNativeAudio : Plugin() {
}
}
@PluginMethod
fun playPause(call: PluginCall) {
Handler(Looper.getMainLooper()).post() {
var playing = playerNotificationService.playPause()
call.resolve(JSObject("{\"playing\":$playing}"))
}
}
@PluginMethod
fun seekPlayer(call: PluginCall) {
var time:Long = call.getString("timeMs", "0")!!.toLong()
@ -233,19 +255,19 @@ class MyNativeAudio : Plugin() {
@PluginMethod
fun requestSession(call: PluginCall) {
Log.d(tag, "CAST REQUEST SESSION PLUGIN")
call.resolve()
playerNotificationService.castManager.requestSession(mainActivity, object : CastManager.RequestSessionCallback() {
override fun onError(errorCode: Int) {
Log.e(tag, "CAST REQUEST SESSION CALLBACK ERROR $errorCode")
}
playerNotificationService.castManager.requestSession(mainActivity, object : CastManager.RequestSessionCallback() {
override fun onError(errorCode: Int) {
Log.e(tag, "CAST REQUEST SESSION CALLBACK ERROR $errorCode")
}
override fun onCancel() {
Log.d(tag, "CAST REQUEST SESSION ON CANCEL")
}
override fun onCancel() {
Log.d(tag, "CAST REQUEST SESSION ON CANCEL")
}
override fun onJoin(jsonSession: JSONObject?) {
Log.d(tag, "CAST REQUEST SESSION ON JOIN")
}
})
override fun onJoin(jsonSession: JSONObject?) {
Log.d(tag, "CAST REQUEST SESSION ON JOIN")
}
})
}
}

View file

@ -0,0 +1,238 @@
package com.audiobookshelf.app.data
import android.util.Log
import com.audiobookshelf.app.MainActivity
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.server.ApiHandler
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.json.JSONObject
@CapacitorPlugin(name = "AbsDatabase")
class AbsDatabase : Plugin() {
val tag = "AbsDatabase"
lateinit var mainActivity: MainActivity
lateinit var apiHandler: ApiHandler
data class LocalMediaProgressPayload(val value:List<LocalMediaProgress>)
data class LocalLibraryItemsPayload(val value:List<LocalLibraryItem>)
data class LocalFoldersPayload(val value:List<LocalFolder>)
override fun load() {
mainActivity = (activity as MainActivity)
apiHandler = ApiHandler(mainActivity)
}
@PluginMethod
fun getDeviceData(call:PluginCall) {
GlobalScope.launch(Dispatchers.IO) {
var deviceData = DeviceManager.dbManager.getDeviceData()
call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(deviceData)))
}
}
@PluginMethod
fun getLocalFolders(call:PluginCall) {
GlobalScope.launch(Dispatchers.IO) {
var folders = DeviceManager.dbManager.getAllLocalFolders()
call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(LocalFoldersPayload(folders))))
}
}
@PluginMethod
fun getLocalFolder(call:PluginCall) {
var folderId = call.getString("folderId", "").toString()
GlobalScope.launch(Dispatchers.IO) {
DeviceManager.dbManager.getLocalFolder(folderId)?.let {
var folderObj = jacksonObjectMapper().writeValueAsString(it)
call.resolve(JSObject(folderObj))
} ?: call.resolve()
}
}
@PluginMethod
fun getLocalLibraryItem(call:PluginCall) {
var id = call.getString("id", "").toString()
GlobalScope.launch(Dispatchers.IO) {
var localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(id)
if (localLibraryItem == null) {
call.resolve()
} else {
call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(localLibraryItem)))
}
}
}
@PluginMethod
fun getLocalLibraryItemByLLId(call:PluginCall) {
var libraryItemId = call.getString("libraryItemId", "").toString()
GlobalScope.launch(Dispatchers.IO) {
var localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLLId(libraryItemId)
if (localLibraryItem == null) {
call.resolve()
} else {
call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(localLibraryItem)))
}
}
}
@PluginMethod
fun getLocalLibraryItems(call:PluginCall) {
var mediaType = call.getString("mediaType", "").toString()
GlobalScope.launch(Dispatchers.IO) {
var localLibraryItems = DeviceManager.dbManager.getLocalLibraryItems(mediaType)
call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(LocalLibraryItemsPayload(localLibraryItems))))
}
}
@PluginMethod
fun getLocalLibraryItemsInFolder(call:PluginCall) {
var folderId = call.getString("folderId", "").toString()
GlobalScope.launch(Dispatchers.IO) {
var localLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(folderId)
call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(LocalLibraryItemsPayload(localLibraryItems))))
}
}
@PluginMethod
fun setCurrentServerConnectionConfig(call:PluginCall) {
var serverConnectionConfigId = call.getString("id", "").toString()
var serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConnectionConfigId }
var userId = call.getString("userId", "").toString()
var username = call.getString("username", "").toString()
var token = call.getString("token", "").toString()
GlobalScope.launch(Dispatchers.IO) {
if (serverConnectionConfig == null) { // New Server Connection
var serverAddress = call.getString("address", "").toString()
// Create new server connection config
var sscId = DeviceManager.getBase64Id("$serverAddress@$username")
var sscIndex = DeviceManager.deviceData.serverConnectionConfigs.size
serverConnectionConfig = ServerConnectionConfig(sscId, sscIndex, "$serverAddress ($username)", serverAddress, userId, username, token)
// Add and save
DeviceManager.deviceData.serverConnectionConfigs.add(serverConnectionConfig!!)
DeviceManager.deviceData.lastServerConnectionConfigId = serverConnectionConfig?.id
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
} else {
var shouldSave = false
if (serverConnectionConfig?.username != username || serverConnectionConfig?.token != token) {
serverConnectionConfig?.userId = userId
serverConnectionConfig?.username = username
serverConnectionConfig?.name = "${serverConnectionConfig?.address} (${serverConnectionConfig?.username})"
serverConnectionConfig?.token = token
shouldSave = true
}
// Set last connection config
if (DeviceManager.deviceData.lastServerConnectionConfigId != serverConnectionConfigId) {
DeviceManager.deviceData.lastServerConnectionConfigId = serverConnectionConfigId
shouldSave = true
}
if (shouldSave) DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
}
DeviceManager.serverConnectionConfig = serverConnectionConfig
call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(DeviceManager.serverConnectionConfig)))
}
}
@PluginMethod
fun removeServerConnectionConfig(call:PluginCall) {
GlobalScope.launch(Dispatchers.IO) {
var serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
DeviceManager.deviceData.serverConnectionConfigs = DeviceManager.deviceData.serverConnectionConfigs.filter { it.id != serverConnectionConfigId } as MutableList<ServerConnectionConfig>
if (DeviceManager.deviceData.lastServerConnectionConfigId == serverConnectionConfigId) {
DeviceManager.deviceData.lastServerConnectionConfigId = null
}
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
if (DeviceManager.serverConnectionConfig?.id == serverConnectionConfigId) {
DeviceManager.serverConnectionConfig = null
}
call.resolve()
}
}
@PluginMethod
fun logout(call:PluginCall) {
GlobalScope.launch(Dispatchers.IO) {
DeviceManager.serverConnectionConfig = null
DeviceManager.deviceData.lastServerConnectionConfigId = null
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
call.resolve()
}
}
@PluginMethod
fun getAllLocalMediaProgress(call:PluginCall) {
GlobalScope.launch(Dispatchers.IO) {
var localMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress()
call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(LocalMediaProgressPayload(localMediaProgress))))
}
}
@PluginMethod
fun removeLocalMediaProgress(call:PluginCall) {
var localMediaProgressId = call.getString("localMediaProgressId", "").toString()
DeviceManager.dbManager.removeLocalMediaProgress(localMediaProgressId)
call.resolve()
}
@PluginMethod
fun syncLocalMediaProgressWithServer(call:PluginCall) {
if (DeviceManager.serverConnectionConfig == null) {
Log.e(tag, "syncLocalMediaProgressWithServer not connected to server")
return call.resolve()
}
apiHandler.syncMediaProgress {
call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(it)))
}
}
//
// Generic Webview calls to db
//
@PluginMethod
fun saveFromWebview(call: PluginCall) {
var db = call.getString("db", "").toString()
var key = call.getString("key", "").toString()
var value = call.getObject("value")
GlobalScope.launch(Dispatchers.IO) {
if (db == "" || key == "" || value == null) {
Log.d(tag, "saveFromWebview Invalid key/value")
} else {
var json = value as JSONObject
DeviceManager.dbManager.saveObject(db, key, json)
}
call.resolve()
}
}
@PluginMethod
fun loadFromWebview(call:PluginCall) {
var db = call.getString("db", "").toString()
var key = call.getString("key", "").toString()
if (db == "" || key == "") {
Log.d(tag, "loadFromWebview Invalid Key")
call.resolve()
return
}
var json = DeviceManager.dbManager.loadObject(db, key)
var jsobj = JSObject.fromJSONObject(json)
call.resolve(jsobj)
}
}

View file

@ -0,0 +1,351 @@
package com.audiobookshelf.app.plugins
import android.app.DownloadManager
import android.content.Context
import android.net.Uri
import android.os.Build
import android.util.Log
import com.audiobookshelf.app.MainActivity
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.device.FolderScanner
import com.audiobookshelf.app.server.ApiHandler
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.io.File
import java.util.*
@CapacitorPlugin(name = "AbsDownloader")
class AbsDownloader : Plugin() {
private val tag = "AbsDownloader"
lateinit var mainActivity: MainActivity
lateinit var downloadManager: DownloadManager
lateinit var apiHandler: ApiHandler
lateinit var folderScanner: FolderScanner
data class DownloadItemPart(
val id: String,
val filename: String,
val destinationPath:String,
val itemTitle: String,
val serverPath: String,
val localFolderName: String,
val localFolderId: String,
val audioTrack: AudioTrack?,
val episode:PodcastEpisode?,
var completed:Boolean,
@JsonIgnore val uri: Uri,
@JsonIgnore val destinationUri: Uri,
var downloadId: Long?,
var progress: Long
) {
@JsonIgnore
fun getDownloadRequest(): DownloadManager.Request {
var dlRequest = DownloadManager.Request(uri)
dlRequest.setTitle(filename)
dlRequest.setDescription("Downloading to $localFolderName for book $itemTitle")
dlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
dlRequest.setDestinationUri(destinationUri)
return dlRequest
}
}
data class DownloadItem(
val id: String,
val libraryItemId:String,
val episodeId:String?,
val serverConnectionConfigId:String,
val serverAddress:String,
val serverUserId:String,
val mediaType: String,
val itemFolderPath:String,
val localFolder: LocalFolder,
val itemTitle: String,
val media:MediaType,
val downloadItemParts: MutableList<DownloadItemPart>
)
var downloadQueue: MutableList<DownloadItem> = mutableListOf()
override fun load() {
mainActivity = (activity as MainActivity)
downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
folderScanner = FolderScanner(mainActivity)
apiHandler = ApiHandler(mainActivity)
var recieverEvent: (evt: String, id: Long) -> Unit = { evt: String, id: Long ->
if (evt == "complete") {
}
if (evt == "clicked") {
Log.d(tag, "Clicked $id back in the downloader")
}
}
mainActivity.registerBroadcastReceiver(recieverEvent)
Log.d(tag, "Build SDK ${Build.VERSION.SDK_INT}")
}
@PluginMethod
fun downloadLibraryItem(call: PluginCall) {
var libraryItemId = call.data.getString("libraryItemId").toString()
var episodeId = call.data.getString("episodeId").toString()
var localFolderId = call.data.getString("localFolderId").toString()
Log.d(tag, "Download library item $libraryItemId to folder $localFolderId")
var downloadId = if (episodeId.isNullOrEmpty()) libraryItemId else "$libraryItemId-$episodeId"
if (downloadQueue.find { it.id == downloadId } != null) {
Log.d(tag, "Download already started for this media entity $downloadId")
return call.resolve(JSObject("{\"error\":\"Download already started for this media entity\"}"))
}
apiHandler.getLibraryItem(libraryItemId) { libraryItem ->
Log.d(tag, "Got library item from server ${libraryItem.id}")
var localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
if (localFolder != null) {
if (!episodeId.isNullOrEmpty() && libraryItem.mediaType != "podcast") {
Log.e(tag, "Library item is not a podcast but episode was requested")
call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
} else if (!episodeId.isNullOrEmpty()) {
var podcast = libraryItem.media as Podcast
var episode = podcast.episodes?.find { podcastEpisode ->
podcastEpisode.id == episodeId
}
if (episode == null) {
call.resolve(JSObject("{\"error\":\"Invalid podcast episode not found\"}"))
} else {
startLibraryItemDownload(libraryItem, localFolder, episode)
call.resolve()
}
} else {
startLibraryItemDownload(libraryItem, localFolder, null)
call.resolve()
}
} else {
call.resolve(JSObject("{\"error\":\"Local Folder Not Found\"}"))
}
}
}
// Clean folder path so it can be used in URL
fun cleanRelPath(relPath: String): String {
var cleanedRelPath = relPath.replace("\\", "/").replace("%", "%25").replace("#", "%23")
return if (cleanedRelPath.startsWith("/")) cleanedRelPath.substring(1) else cleanedRelPath
}
// Item filenames could be the same if they are in subfolders, this will make them unique
fun getFilenameFromRelPath(relPath: String): String {
var cleanedRelPath = relPath.replace("\\", "_").replace("/", "_")
return if (cleanedRelPath.startsWith("_")) cleanedRelPath.substring(1) else cleanedRelPath
}
fun getAbMetadataText(libraryItem:LibraryItem):String {
var bookMedia = libraryItem.media as com.audiobookshelf.app.data.Book
var fileString = ";ABMETADATA1\n"
// fileString += "#libraryItemId=${libraryItem.id}\n"
// fileString += "title=${bookMedia.metadata.title}\n"
// fileString += "author=${bookMedia.metadata.authorName}\n"
// fileString += "narrator=${bookMedia.metadata.narratorName}\n"
// fileString += "series=${bookMedia.metadata.seriesName}\n"
return fileString
}
fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) {
if (libraryItem.mediaType == "book") {
var bookTitle = libraryItem.media.metadata.title
var tracks = libraryItem.media.getAudioTracks()
Log.d(tag, "Starting library item download with ${tracks.size} tracks")
var itemFolderPath = localFolder.absolutePath + "/" + bookTitle
var downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, libraryItem.media, mutableListOf())
// Create download item part for each audio track
tracks.forEach { audioTrack ->
var serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioTrack.relPath)}"
var destinationFilename = getFilenameFromRelPath(audioTrack.relPath)
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName ${destinationFilename}")
var destinationFile = File("$itemFolderPath/$destinationFilename")
if (destinationFile.exists()) {
Log.d(tag, "Audio file already exists, removing it from ${destinationFile.absolutePath}")
destinationFile.delete()
}
var destinationUri = Uri.fromFile(destinationFile)
var downloadUri = Uri.parse("${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}")
Log.d(tag, "Audio File Destination Uri $destinationUri | Download URI $downloadUri")
var downloadItemPart = DownloadItemPart(DeviceManager.getBase64Id(destinationFile.absolutePath), destinationFilename, destinationFile.absolutePath, bookTitle, serverPath, localFolder.name, localFolder.id, audioTrack, null, false, downloadUri, destinationUri, null, 0)
downloadItem.downloadItemParts.add(downloadItemPart)
var dlRequest = downloadItemPart.getDownloadRequest()
var downloadId = downloadManager.enqueue(dlRequest)
downloadItemPart.downloadId = downloadId
}
if (downloadItem.downloadItemParts.isNotEmpty()) {
// Add cover download item
if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) {
var serverPath = "/api/items/${libraryItem.id}/cover?format=jpeg"
var destinationFilename = "cover.jpg"
var destinationFile = File("$itemFolderPath/$destinationFilename")
if (destinationFile.exists()) {
Log.d(tag, "Cover already exists, removing it from ${destinationFile.absolutePath}")
destinationFile.delete()
}
var destinationUri = Uri.fromFile(destinationFile)
var downloadUri = Uri.parse("${DeviceManager.serverAddress}${serverPath}&token=${DeviceManager.token}")
var downloadItemPart = DownloadItemPart(DeviceManager.getBase64Id(destinationFile.absolutePath), destinationFilename, destinationFile.absolutePath, bookTitle, serverPath, localFolder.name, localFolder.id, null,null, false, downloadUri, destinationUri, null, 0)
downloadItem.downloadItemParts.add(downloadItemPart)
var dlRequest = downloadItemPart.getDownloadRequest()
var downloadId = downloadManager.enqueue(dlRequest)
downloadItemPart.downloadId = downloadId
}
// TODO: Cannot create new text file here but can download here... ??
// var abmetadataFile = File(itemFolderPath, "abmetadata.abs")
// abmetadataFile.createNewFileIfPossible()
// abmetadataFile.writeText(getAbMetadataText(libraryItem))
downloadQueue.add(downloadItem)
startWatchingDownloads(downloadItem)
DeviceManager.dbManager.saveDownloadItem(downloadItem)
}
} else {
// Podcast episode download
var podcastTitle = libraryItem.media.metadata.title
var audioTrack = episode?.audioTrack
Log.d(tag, "Starting podcast episode download")
var itemFolderPath = localFolder.absolutePath + "/" + podcastTitle
var downloadItemId = "${libraryItem.id}-${episode?.id}"
var downloadItem = DownloadItem(downloadItemId, libraryItem.id, episode?.id, DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, podcastTitle, libraryItem.media, mutableListOf())
var serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioTrack?.relPath ?: "")}"
var destinationFilename = getFilenameFromRelPath(audioTrack?.relPath ?: "")
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack?.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName ${destinationFilename}")
var destinationFile = File("$itemFolderPath/$destinationFilename")
if (destinationFile.exists()) {
Log.d(tag, "Audio file already exists, removing it from ${destinationFile.absolutePath}")
destinationFile.delete()
}
var destinationUri = Uri.fromFile(destinationFile)
var downloadUri = Uri.parse("${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}")
Log.d(tag, "Audio File Destination Uri $destinationUri | Download URI $downloadUri")
var downloadItemPart = DownloadItemPart(DeviceManager.getBase64Id(destinationFile.absolutePath), destinationFilename, destinationFile.absolutePath, podcastTitle, serverPath, localFolder.name, localFolder.id, audioTrack, episode,false, downloadUri, destinationUri, null, 0)
downloadItem.downloadItemParts.add(downloadItemPart)
var dlRequest = downloadItemPart.getDownloadRequest()
var downloadId = downloadManager.enqueue(dlRequest)
downloadItemPart.downloadId = downloadId
if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) {
var serverPath = "/api/items/${libraryItem.id}/cover?format=jpeg"
var destinationFilename = "cover.jpg"
var destinationFile = File("$itemFolderPath/$destinationFilename")
if (destinationFile.exists()) {
Log.d(tag, "Podcast cover already exists - not downloading cover again")
} else {
var destinationUri = Uri.fromFile(destinationFile)
var downloadUri = Uri.parse("${DeviceManager.serverAddress}${serverPath}&token=${DeviceManager.token}")
var downloadItemPart = DownloadItemPart(DeviceManager.getBase64Id(destinationFile.absolutePath), destinationFilename, destinationFile.absolutePath, podcastTitle, serverPath, localFolder.name, localFolder.id, null,null, false, downloadUri, destinationUri, null, 0)
downloadItem.downloadItemParts.add(downloadItemPart)
var dlRequest = downloadItemPart.getDownloadRequest()
var downloadId = downloadManager.enqueue(dlRequest)
downloadItemPart.downloadId = downloadId
}
}
downloadQueue.add(downloadItem)
startWatchingDownloads(downloadItem)
DeviceManager.dbManager.saveDownloadItem(downloadItem)
}
}
fun startWatchingDownloads(downloadItem: DownloadItem) {
GlobalScope.launch(Dispatchers.IO) {
while (downloadItem.downloadItemParts.find { !it.completed } != null) { // While some item is not completed
var numPartsBefore = downloadItem.downloadItemParts.size
checkDownloads(downloadItem)
// Keep database updated as item parts finish downloading
if (downloadItem.downloadItemParts.size > 0 && downloadItem.downloadItemParts.size != numPartsBefore) {
Log.d(tag, "Save download item on num parts changed from $numPartsBefore to ${downloadItem.downloadItemParts.size}")
DeviceManager.dbManager.saveDownloadItem(downloadItem)
}
notifyListeners("onItemDownloadUpdate", JSObject(jacksonObjectMapper().writeValueAsString(downloadItem)))
delay(500)
}
var localLibraryItem = folderScanner.scanDownloadItem(downloadItem)
DeviceManager.dbManager.removeDownloadItem(downloadItem.id)
downloadQueue.remove(downloadItem)
Log.d(tag, "Item download complete ${downloadItem.itemTitle} | local library item id: ${localLibraryItem?.id} | Items remaining in Queue ${downloadQueue.size}")
var jsobj = JSObject()
jsobj.put("libraryItemId", downloadItem.id)
jsobj.put("localFolderId", downloadItem.localFolder.id)
if (localLibraryItem != null) {
jsobj.put("localLibraryItem", JSObject(jacksonObjectMapper().writeValueAsString(localLibraryItem)))
}
notifyListeners("onItemDownloadComplete", jsobj)
}
}
fun checkDownloads(downloadItem: DownloadItem) {
var itemParts = downloadItem.downloadItemParts.map { it }
for (downloadItemPart in itemParts) {
if (downloadItemPart.downloadId != null) {
var dlid = downloadItemPart.downloadId!!
val query = DownloadManager.Query().setFilterById(dlid)
downloadManager.query(query).use {
if (it.moveToFirst()) {
val 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))
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} bytes $totalBytes | bytes dled $bytesDownloadedSoFar | downloadStatus $downloadStatus")
if (downloadStatus == DownloadManager.STATUS_SUCCESSFUL) {
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Done")
// downloadItem.downloadItemParts.remove(downloadItemPart)
downloadItemPart.completed = true
} else if (downloadStatus == DownloadManager.STATUS_FAILED) {
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Failed")
downloadItem.downloadItemParts.remove(downloadItemPart)
// downloadItemPart.completed = true
} else {
//update progress
val percentProgress = if (totalBytes > 0) ((bytesDownloadedSoFar * 100L) / totalBytes) else 0
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Progress = $percentProgress%")
downloadItemPart.progress = percentProgress
}
} else {
Log.d(tag, "Download ${downloadItemPart.filename} not found in dlmanager")
downloadItem.downloadItemParts.remove(downloadItemPart)
}
}
}
}
}
}

View file

@ -0,0 +1,251 @@
package com.audiobookshelf.app.plugins
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.documentfile.provider.DocumentFile
import com.anggrayudi.storage.SimpleStorage
import com.anggrayudi.storage.callback.FolderPickerCallback
import com.anggrayudi.storage.callback.StorageAccessCallback
import com.anggrayudi.storage.file.*
import com.audiobookshelf.app.MainActivity
import com.audiobookshelf.app.data.LocalFolder
import com.audiobookshelf.app.data.LocalLibraryItem
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.device.FolderScanner
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.getcapacitor.*
import com.getcapacitor.annotation.CapacitorPlugin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
@CapacitorPlugin(name = "AbsFileSystem")
class AbsFileSystem : Plugin() {
private val TAG = "AbsFileSystem"
private val tag = "AbsFileSystem"
lateinit var mainActivity: MainActivity
override fun load() {
mainActivity = (activity as MainActivity)
mainActivity.storage.storageAccessCallback = object : StorageAccessCallback {
override fun onRootPathNotSelected(
requestCode: Int,
rootPath: String,
uri: Uri,
selectedStorageType: StorageType,
expectedStorageType: StorageType
) {
Log.d(TAG, "STORAGE ACCESS CALLBACK")
}
override fun onCanceledByUser(requestCode: Int) {
Log.d(TAG, "STORAGE ACCESS CALLBACK")
}
override fun onExpectedStorageNotSelected(requestCode: Int, selectedFolder: DocumentFile, selectedStorageType: StorageType, expectedBasePath: String, expectedStorageType: StorageType) {
Log.d(TAG, "STORAGE ACCESS CALLBACK")
}
override fun onStoragePermissionDenied(requestCode: Int) {
Log.d(TAG, "STORAGE ACCESS CALLBACK")
}
override fun onRootPathPermissionGranted(requestCode: Int, root: DocumentFile) {
Log.d(TAG, "STORAGE ACCESS CALLBACK")
}
}
}
@PluginMethod
fun selectFolder(call: PluginCall) {
var mediaType = call.data.getString("mediaType", "book").toString()
mainActivity.storage.folderPickerCallback = object : FolderPickerCallback {
override fun onFolderSelected(requestCode: Int, folder: DocumentFile) {
Log.d(TAG, "ON FOLDER SELECTED ${folder.uri} ${folder.name}")
var absolutePath = folder.getAbsolutePath(activity)
var storageType = folder.getStorageType(activity)
var simplePath = folder.getSimplePath(activity)
var basePath = folder.getBasePath(activity)
var folderId = android.util.Base64.encodeToString(folder.id.toByteArray(), android.util.Base64.DEFAULT)
var localFolder = LocalFolder(folderId, folder.name ?: "", folder.uri.toString(),basePath,absolutePath, simplePath, storageType.toString(), mediaType)
DeviceManager.dbManager.saveLocalFolder(localFolder)
call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(localFolder)))
}
override fun onStorageAccessDenied(requestCode: Int, folder: DocumentFile?, storageType: StorageType) {
Log.e(TAG, "STORAGE ACCESS DENIED")
var jsobj = JSObject()
jsobj.put("error", "Access Denied")
call.resolve(jsobj)
}
override fun onStoragePermissionDenied(requestCode: Int) {
Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode")
var jsobj = JSObject()
jsobj.put("error", "Permission Denied")
call.resolve(jsobj)
}
}
mainActivity.storage.openFolderPicker(6)
}
@RequiresApi(Build.VERSION_CODES.R)
@PluginMethod
fun requestStoragePermission(call: PluginCall) {
Log.d(TAG, "Request Storage Permissions")
mainActivity.storageHelper.requestStorageAccess()
call.resolve()
}
@PluginMethod
fun checkStoragePermission(call: PluginCall) {
var res = false
if (Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.P) {
res = SimpleStorage.hasStoragePermission(context)
Log.d(TAG, "checkStoragePermission: Check Storage Access $res")
} else {
Log.d(TAG, "checkStoragePermission: Has permission on Android 10 or up")
res = true
}
var jsobj = JSObject()
jsobj.put("value", res)
call.resolve(jsobj)
}
@PluginMethod
fun checkFolderPermissions(call: PluginCall) {
var folderUrl = call.data.getString("folderUrl", "").toString()
Log.d(TAG, "Check Folder Permissions for $folderUrl")
var hasAccess = SimpleStorage.hasStorageAccess(context,folderUrl,true)
var jsobj = JSObject()
jsobj.put("value", hasAccess)
call.resolve(jsobj)
}
@PluginMethod
fun scanFolder(call: PluginCall) {
var folderId = call.data.getString("folderId", "").toString()
var forceAudioProbe = call.data.getBoolean("forceAudioProbe")
Log.d(TAG, "Scan Folder $folderId | Force Audio Probe $forceAudioProbe")
var folder: LocalFolder? = DeviceManager.dbManager.getLocalFolder(folderId)
folder?.let {
var folderScanner = FolderScanner(context)
var folderScanResult = folderScanner.scanForMediaItems(it, forceAudioProbe)
if (folderScanResult == null) {
Log.d(TAG, "NO Scan DATA")
return call.resolve(JSObject())
} else {
Log.d(TAG, "Scan DATA ${jacksonObjectMapper().writeValueAsString(folderScanResult)}")
return call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(folderScanResult)))
}
} ?: call.resolve(JSObject())
}
@PluginMethod
fun removeFolder(call: PluginCall) {
var folderId = call.data.getString("folderId", "").toString()
DeviceManager.dbManager.removeLocalFolder(folderId)
call.resolve()
}
@PluginMethod
fun removeLocalLibraryItem(call: PluginCall) {
var localLibraryItemId = call.data.getString("localLibraryItemId", "").toString()
DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId)
call.resolve()
}
@PluginMethod
fun scanLocalLibraryItem(call: PluginCall) {
var localLibraryItemId = call.data.getString("localLibraryItemId", "").toString()
var forceAudioProbe = call.data.getBoolean("forceAudioProbe")
Log.d(TAG, "Scan Local library item $localLibraryItemId | Force Audio Probe $forceAudioProbe")
GlobalScope.launch(Dispatchers.IO) {
var localLibraryItem: LocalLibraryItem? = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
localLibraryItem?.let {
var folderScanner = FolderScanner(context)
var scanResult = folderScanner.scanLocalLibraryItem(it, forceAudioProbe)
if (scanResult == null) {
Log.d(TAG, "NO Scan DATA")
call.resolve(JSObject())
} else {
Log.d(TAG, "Scan DATA ${jacksonObjectMapper().writeValueAsString(scanResult)}")
call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(scanResult)))
}
} ?: call.resolve(JSObject())
}
}
@PluginMethod
fun deleteItem(call: PluginCall) {
var localLibraryItemId = call.data.getString("id", "").toString()
var absolutePath = call.data.getString("absolutePath", "").toString()
var contentUrl = call.data.getString("contentUrl", "").toString()
Log.d(tag, "deleteItem $absolutePath | $contentUrl")
var docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
var success = docfile?.delete() == true
if (success) {
DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId)
}
call.resolve(JSObject("{\"success\":$success}"))
}
@PluginMethod
fun deleteTrackFromItem(call: PluginCall) {
var localLibraryItemId = call.data.getString("id", "").toString()
var trackLocalFileId = call.data.getString("trackLocalFileId", "").toString()
var contentUrl = call.data.getString("trackContentUrl", "").toString()
Log.d(tag, "deleteTrackFromItem $contentUrl")
var localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
if (localLibraryItem == null) {
Log.e(tag, "deleteTrackFromItem: LLI does not exist $localLibraryItemId")
return call.resolve(JSObject("{\"success\":false}"))
}
var docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
var success = docfile?.delete() == true
if (success) {
localLibraryItem?.media?.removeAudioTrack(trackLocalFileId)
localLibraryItem?.removeLocalFile(trackLocalFileId)
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(localLibraryItem)))
} else {
call.resolve(JSObject("{\"success\":false}"))
}
}
fun checkUriExists(uri: Uri?): Boolean {
if (uri == null) return false
val resolver = context.contentResolver
var cursor: Cursor? = null
return try {
cursor = resolver.query(uri, null, null, null, null)
//cursor null: content Uri was invalid or some other error occurred
//cursor.moveToFirst() false: Uri was ok but no entry found.
(cursor != null && cursor.moveToFirst())
} catch (t: Throwable) {
false
} finally {
try {
cursor?.close()
} catch (t: Throwable) {
}
false
}
}
}

View file

@ -0,0 +1,220 @@
package com.audiobookshelf.app.server
import android.content.Context
import android.content.SharedPreferences
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.util.Log
import androidx.core.content.ContextCompat.getSystemService
import com.audiobookshelf.app.data.Library
import com.audiobookshelf.app.data.LibraryItem
import com.audiobookshelf.app.data.LocalMediaProgress
import com.audiobookshelf.app.data.PlaybackSession
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.player.MediaProgressSyncData
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.getcapacitor.JSArray
import com.getcapacitor.JSObject
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
class ApiHandler {
val tag = "ApiHandler"
private var client = OkHttpClient()
var ctx: Context
var storageSharedPreferences: SharedPreferences? = null
data class LocalMediaProgressSyncPayload(val localMediaProgress:List<LocalMediaProgress>)
@JsonIgnoreProperties(ignoreUnknown = true)
data class MediaProgressSyncResponsePayload(val numServerProgressUpdates:Int, val localProgressUpdates:List<LocalMediaProgress>)
data class LocalMediaProgressSyncResultsPayload(var numLocalMediaProgressForServer:Int, var numServerProgressUpdates:Int, var numLocalProgressUpdates:Int)
constructor(_ctx: Context) {
ctx = _ctx
}
fun getRequest(endpoint:String, cb: (JSObject) -> Unit) {
val request = Request.Builder()
.url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}")
.build()
makeRequest(request, cb)
}
fun postRequest(endpoint:String, payload: JSObject, cb: (JSObject) -> Unit) {
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = payload.toString().toRequestBody(mediaType)
val request = Request.Builder().post(requestBody)
.url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}")
.build()
makeRequest(request, cb)
}
fun isOnline(): Boolean {
val connectivityManager = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
if (connectivityManager != null) {
val capabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
if (capabilities != null) {
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
Log.i("Internet", "NetworkCapabilities.TRANSPORT_CELLULAR")
return true
} else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
Log.i("Internet", "NetworkCapabilities.TRANSPORT_WIFI")
return true
} else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) {
Log.i("Internet", "NetworkCapabilities.TRANSPORT_ETHERNET")
return true
}
}
}
return false
}
fun makeRequest(request:Request, cb: (JSObject) -> Unit) {
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
Log.d(tag, "FAILURE TO CONNECT")
e.printStackTrace()
cb(JSObject())
}
override fun onResponse(call: Call, response: Response) {
response.use {
if (!it.isSuccessful) throw IOException("Unexpected code $response")
var bodyString = it.body!!.string()
if (bodyString == "OK") {
cb(JSObject())
} else {
var jsonObj = JSObject()
if (bodyString.startsWith("[")) {
var array = JSArray(bodyString)
jsonObj.put("value", array)
} else {
jsonObj = JSObject(bodyString)
}
cb(jsonObj)
}
}
}
})
}
fun getLibraries(cb: (List<Library>) -> Unit) {
val mapper = jacksonObjectMapper()
getRequest("/api/libraries") {
val libraries = mutableListOf<Library>()
if (it.has("value")) {
var array = it.getJSONArray("value")!!
for (i in 0 until array.length()) {
val library = mapper.readValue<Library>(array.get(i).toString())
libraries.add(library)
}
}
cb(libraries)
}
}
fun getLibraryItem(libraryItemId:String, cb: (LibraryItem) -> Unit) {
getRequest("/api/items/$libraryItemId?expanded=1") {
val libraryItem = jacksonObjectMapper().readValue<LibraryItem>(it.toString())
cb(libraryItem)
}
}
fun getLibraryItems(libraryId:String, cb: (List<LibraryItem>) -> Unit) {
getRequest("/api/libraries/$libraryId/items?limit=100&minified=1") {
val items = mutableListOf<LibraryItem>()
if (it.has("results")) {
var array = it.getJSONArray("results")
for (i in 0 until array.length()) {
val item = jacksonObjectMapper().readValue<LibraryItem>(array.get(i).toString())
items.add(item)
}
}
cb(items)
}
}
fun playLibraryItem(libraryItemId:String, episodeId:String, forceTranscode:Boolean, cb: (PlaybackSession) -> Unit) {
var payload = JSObject()
payload.put("mediaPlayer", "exo-player")
// Only if direct play fails do we force transcode
// TODO: Fallback to transcode
if (!forceTranscode) payload.put("forceDirectPlay", true)
else payload.put("forceTranscode", true)
val endpoint = if (episodeId.isNullOrEmpty()) "/api/items/$libraryItemId/play" else "/api/items/$libraryItemId/play/$episodeId"
postRequest(endpoint, payload) {
it.put("serverConnectionConfigId", DeviceManager.serverConnectionConfig?.id)
it.put("serverAddress", DeviceManager.serverAddress)
val playbackSession = jacksonObjectMapper().readValue<PlaybackSession>(it.toString())
cb(playbackSession)
}
}
fun sendProgressSync(sessionId:String, syncData: MediaProgressSyncData, cb: () -> Unit) {
var payload = JSObject(jacksonObjectMapper().writeValueAsString(syncData))
postRequest("/api/session/$sessionId/sync", payload) {
cb()
}
}
fun sendLocalProgressSync(playbackSession:PlaybackSession, cb: () -> Unit) {
var payload = JSObject(jacksonObjectMapper().writeValueAsString(playbackSession))
postRequest("/api/session/local", payload) {
cb()
}
}
fun syncMediaProgress(cb: (LocalMediaProgressSyncResultsPayload) -> Unit) {
if (!isOnline()) {
Log.d(tag, "Error not online")
cb(LocalMediaProgressSyncResultsPayload(0,0,0))
return
}
// Get all local media progress connected to items on the current connected server
var localMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress().filter {
it.serverConnectionConfigId == DeviceManager.serverConnectionConfig?.id
}
var localSyncResultsPayload = LocalMediaProgressSyncResultsPayload(localMediaProgress.size,0, 0)
if (localMediaProgress.isNotEmpty()) {
Log.d(tag, "Sending sync local progress request with ${localMediaProgress.size} progress items")
var payload = JSObject(jacksonObjectMapper().writeValueAsString(LocalMediaProgressSyncPayload(localMediaProgress)))
postRequest("/api/me/sync-local-progress", payload) {
Log.d(tag, "Media Progress Sync payload $payload - response ${it.toString()}")
if (it.toString() == "{}") {
Log.e(tag, "Progress sync received empty object")
} else {
val progressSyncResponsePayload = jacksonObjectMapper().readValue<MediaProgressSyncResponsePayload>(it.toString())
localSyncResultsPayload.numLocalProgressUpdates = progressSyncResponsePayload.localProgressUpdates.size
localSyncResultsPayload.numServerProgressUpdates = progressSyncResponsePayload.numServerProgressUpdates
Log.d(tag, "Media Progress Sync | Local Updates: $localSyncResultsPayload")
if (progressSyncResponsePayload.localProgressUpdates.isNotEmpty()) {
// Update all local media progress
progressSyncResponsePayload.localProgressUpdates.forEach { localMediaProgress ->
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
}
}
}
cb(localSyncResultsPayload)
}
} else {
Log.d(tag, "No local media progress to sync")
cb(localSyncResultsPayload)
}
}
}

View file

@ -1,7 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">AudioBookshelf</string>
<string name="title_activity_main">AudioBookshelf</string>
<string name="app_name">audiobookshelf</string>
<string name="title_activity_main">audiobookshelf</string>
<string name="package_name">com.audiobookshelf.app</string>
<string name="custom_url_scheme">com.audiobookshelf.app</string>
</resources>

View file

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

View file

@ -1,5 +1,5 @@
ext {
minSdkVersion = 23
minSdkVersion = 24
compileSdkVersion = 30
targetSdkVersion = 30
androidxActivityVersion = '1.2.0'

View file

@ -7,16 +7,18 @@
<a v-if="showBack" @click="back" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 mr-2 cursor-pointer">
<span class="material-icons text-3xl text-white">arrow_back</span>
</a>
<div v-if="socketConnected">
<div v-if="user">
<div class="px-4 py-2 bg-bg bg-opacity-30 rounded-md flex items-center" @click="clickShowLibraryModal">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
<p class="text-lg font-book leading-4 ml-2">{{ currentLibraryName }}</p>
<p class="text-base font-book leading-4 ml-2">{{ currentLibraryName }}</p>
</div>
</div>
<div class="flex-grow" />
<widgets-download-progress-indicator />
<nuxt-link class="h-7 mx-2" to="/search">
<span class="material-icons" style="font-size: 1.75rem">search</span>
</nuxt-link>
@ -44,6 +46,7 @@ export default {
return this.currentLibrary ? this.currentLibrary.name : 'Main'
},
showBack() {
if (!this.$route.name) return true
return this.$route.name !== 'index' && !this.$route.name.startsWith('bookshelf')
},
user() {

View file

@ -1,5 +1,5 @@
<template>
<div class="fixed top-0 left-0 layout-wrapper right-0 z-50 pointer-events-none" :class="showFullscreen ? 'fullscreen' : ''">
<div v-if="playbackSession" id="streamContainer" class="fixed top-0 left-0 layout-wrapper right-0 z-50 pointer-events-none" :class="showFullscreen ? 'fullscreen' : ''">
<div v-if="showFullscreen" class="w-full h-full z-10 bg-bg absolute top-0 left-0 pointer-events-auto">
<div class="top-2 left-4 absolute cursor-pointer">
<span class="material-icons text-5xl" @click="collapseFullscreen">expand_more</span>
@ -31,13 +31,13 @@
<div class="cover-wrapper absolute z-30 pointer-events-auto" :class="bookCoverAspectRatio === 1 ? 'square-cover' : ''" @click="clickContainer">
<div class="cover-container bookCoverWrapper bg-black bg-opacity-75 w-full h-full">
<covers-book-cover :audiobook="audiobook" :download-cover="downloadedCover" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<covers-book-cover v-if="libraryItem || localLibraryItemCoverSrc" :library-item="libraryItem" :download-cover="localLibraryItemCoverSrc" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
</div>
<div class="title-author-texts absolute z-30 left-0 right-0 overflow-hidden">
<p class="title-text font-book truncate">{{ title }}</p>
<p class="author-text text-white text-opacity-75 truncate">by {{ authorFL }}</p>
<p class="author-text text-white text-opacity-75 truncate">by {{ authorName }}</p>
</div>
<div id="streamContainer" class="w-full z-20 bg-primary absolute bottom-0 left-0 right-0 p-2 pointer-events-auto transition-all" @click="clickContainer">
@ -52,25 +52,25 @@
<p class="text-xl font-mono text-success">{{ sleepTimeRemainingPretty }}</p>
</div>
<span class="material-icons text-3xl text-white cursor-pointer" :class="chapters.length ? 'text-opacity-75' : 'text-opacity-10'" @click="$emit('selectChapter')">format_list_bulleted</span>
<span class="material-icons text-3xl text-white cursor-pointer" :class="chapters.length ? 'text-opacity-75' : 'text-opacity-10'" @click="showChapterModal = true">format_list_bulleted</span>
</div>
</div>
<div id="playerControls" class="absolute right-0 bottom-0 py-2">
<div class="flex items-center justify-center">
<span v-show="showFullscreen" class="material-icons next-icon text-white text-opacity-75 cursor-pointer" :class="loading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
<span class="material-icons jump-icon text-white cursor-pointer" :class="loading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="backward10">replay_10</span>
<span v-show="showFullscreen" class="material-icons next-icon text-white text-opacity-75 cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
<span class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="backward10">replay_10</span>
<div class="play-btn cursor-pointer shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-4" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
<span v-if="!loading" class="material-icons">{{ seekLoading ? 'autorenew' : isPaused ? 'play_arrow' : 'pause' }}</span>
<span v-if="!isLoading" class="material-icons">{{ seekLoading ? 'autorenew' : isPaused ? 'play_arrow' : 'pause' }}</span>
<widgets-spinner-icon v-else class="h-8 w-8" />
</div>
<span class="material-icons jump-icon text-white cursor-pointer" :class="loading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="forward10">forward_10</span>
<span v-show="showFullscreen" class="material-icons next-icon text-white cursor-pointer" :class="nextChapter && !loading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
<span class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="forward10">forward_10</span>
<span v-show="showFullscreen" class="material-icons next-icon text-white cursor-pointer" :class="nextChapter && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
</div>
</div>
<div id="playerTrack" class="absolute bottom-0 left-0 w-full px-3">
<div ref="track" class="h-2 w-full bg-gray-500 bg-opacity-50 relative" :class="loading ? 'animate-pulse' : ''" @click="clickTrack">
<div ref="track" class="h-2 w-full bg-gray-500 bg-opacity-50 relative" :class="isLoading ? 'animate-pulse' : ''" @click="clickTrack">
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
<div ref="bufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 pointer-events-none" />
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" />
@ -84,33 +84,29 @@
</div>
</div>
</div>
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
</div>
</template>
<script>
import MyNativeAudio from '@/plugins/my-native-audio'
import { Capacitor } from '@capacitor/core'
import { AbsAudioPlayer } from '@/plugins/capacitor'
export default {
props: {
playing: Boolean,
audiobook: {
type: Object,
default: () => {}
},
download: {
type: Object,
default: () => {}
},
bookmarks: {
type: Array,
default: () => []
},
loading: Boolean,
sleepTimerRunning: Boolean,
sleepTimeRemaining: Number
},
data() {
return {
playbackSession: null,
showChapterModal: false,
showCastBtn: false,
showFullscreen: false,
totalDuration: 0,
@ -118,9 +114,6 @@ export default {
currentTime: 0,
bufferedTime: 0,
isResetting: false,
initObject: null,
streamId: null,
audiobookId: null,
stateName: 'idle',
playInterval: null,
trackWidth: 0,
@ -131,16 +124,15 @@ export default {
playedTrackWidth: 0,
seekedTime: 0,
seekLoading: false,
onPlaybackSessionListener: null,
onPlaybackClosedListener: null,
onPlayingUpdateListener: null,
onMetadataListener: null,
// noSyncUpdateTime: false,
touchStartY: 0,
touchStartTime: 0,
touchEndY: 0,
listenTimeInterval: null,
listeningTimeSinceLastUpdate: 0,
totalListeningTimeInSession: 0,
useChapterTrack: false
useChapterTrack: false,
isLoading: true
}
},
computed: {
@ -175,25 +167,42 @@ export default {
}
return this.showFullscreen ? 200 : 60
},
book() {
return this.audiobook.book || {}
mediaMetadata() {
return this.playbackSession ? this.playbackSession.mediaMetadata : null
},
libraryItem() {
return this.playbackSession ? this.playbackSession.libraryItem || null : null
},
localLibraryItem() {
return this.playbackSession ? this.playbackSession.localLibraryItem || null : null
},
localLibraryItemCoverSrc() {
var localItemCover = this.localLibraryItem ? this.localLibraryItem.coverContentUrl : null
if (localItemCover) return Capacitor.convertFileSrc(localItemCover)
return null
},
playMethod() {
return this.playbackSession ? this.playbackSession.playMethod : null
},
isLocalPlayMethod() {
return this.playMethod == this.$constants.PlayMethod.LOCAL
},
title() {
return this.book.title
if (this.playbackSession) return this.playbackSession.displayTitle
return this.mediaMetadata ? this.mediaMetadata.title : 'Title'
},
authorFL() {
return this.book.authorFL
authorName() {
if (this.playbackSession) return this.playbackSession.displayAuthor
return this.mediaMetadata ? this.mediaMetadata.authorName : 'Author'
},
chapters() {
return (this.audiobook ? this.audiobook.chapters || [] : []).map((chapter) => {
var chap = { ...chapter }
chap.start = Number(chap.start)
chap.end = Number(chap.end)
return chap
})
if (this.playbackSession && this.playbackSession.chapters) {
return this.playbackSession.chapters
}
return []
},
currentChapter() {
if (!this.audiobook || !this.chapters.length) return null
if (!this.chapters.length) return null
return this.chapters.find((ch) => Number(Number(ch.start).toFixed(2)) <= this.currentTime && Number(Number(ch.end).toFixed(2)) > this.currentTime)
},
nextChapter() {
@ -206,9 +215,6 @@ export default {
currentChapterDuration() {
return this.currentChapter ? this.currentChapter.end - this.currentChapter.start : this.totalDuration
},
downloadedCover() {
return this.download ? this.download.cover : null
},
totalDurationPretty() {
return this.$secondsToTimestamp(this.totalDuration)
},
@ -241,10 +247,6 @@ export default {
if (!this.currentChapter) return 0
return this.currentChapter.end - this.currentTime
},
// sleepTimeRemaining() {
// if (!this.sleepTimerEndTime) return 0
// return Math.max(0, this.sleepTimerEndTime / 1000 - this.currentTime)
// },
sleepTimeRemainingPretty() {
if (!this.sleepTimeRemaining) return '0s'
var secondsRemaining = Math.round(this.sleepTimeRemaining)
@ -256,65 +258,13 @@ export default {
}
},
methods: {
selectChapter(chapter) {
this.seek(chapter.start)
this.showChapterModal = false
},
castClick() {
console.log('Cast Btn Click')
MyNativeAudio.requestSession()
},
sendStreamSync(timeListened = 0) {
var syncData = {
timeListened,
currentTime: this.currentTime,
streamId: this.streamId,
audiobookId: this.audiobookId,
totalDuration: this.totalDuration
}
this.$emit('sync', syncData)
},
sendAddListeningTime() {
var listeningTimeToAdd = Math.floor(this.listeningTimeSinceLastUpdate)
this.listeningTimeSinceLastUpdate = Math.max(0, this.listeningTimeSinceLastUpdate - listeningTimeToAdd)
this.sendStreamSync(listeningTimeToAdd)
},
cancelListenTimeInterval() {
this.sendAddListeningTime()
clearInterval(this.listenTimeInterval)
this.listenTimeInterval = null
},
startListenTimeInterval() {
clearInterval(this.listenTimeInterval)
var lastTime = this.currentTime
var lastTick = Date.now()
var noProgressCount = 0
this.listenTimeInterval = setInterval(() => {
var timeSinceLastTick = Date.now() - lastTick
lastTick = Date.now()
var expectedAudioTime = lastTime + timeSinceLastTick / 1000
var currentTime = this.currentTime
var differenceFromExpected = expectedAudioTime - currentTime
if (currentTime === lastTime) {
noProgressCount++
if (noProgressCount > 3) {
console.error('Audio current time has not increased - cancel interval and pause player')
this.pause()
}
} else if (Math.abs(differenceFromExpected) > 0.1) {
noProgressCount = 0
console.warn('Invalid time between interval - resync last', differenceFromExpected)
lastTime = currentTime
} else {
noProgressCount = 0
var exactPlayTimeDifference = currentTime - lastTime
// console.log('Difference from expected', differenceFromExpected, 'Exact play time diff', exactPlayTimeDifference)
lastTime = currentTime
this.listeningTimeSinceLastUpdate += exactPlayTimeDifference
this.totalListeningTimeInSession += exactPlayTimeDifference
// console.log('Time since last update:', this.listeningTimeSinceLastUpdate, 'Session listening time:', this.totalListeningTimeInSession)
if (this.listeningTimeSinceLastUpdate > 5) {
this.sendAddListeningTime()
}
}
}, 1000)
AbsAudioPlayer.requestSession()
},
clickContainer() {
this.showFullscreen = true
@ -329,18 +279,18 @@ export default {
this.forceCloseDropdownMenu()
},
jumpNextChapter() {
if (this.loading) return
if (this.isLoading) return
if (!this.nextChapter) return
this.seek(this.nextChapter.start)
},
jumpChapterStart() {
if (this.loading) return
if (this.isLoading) return
if (!this.currentChapter) {
return this.restart()
}
// If 1 second or less into current chapter, then go to previous
if (this.currentTime - this.currentChapter.start <= 1) {
// If 4 seconds or less into current chapter, then go to previous
if (this.currentTime - this.currentChapter.start <= 4) {
var currChapterIndex = this.chapters.findIndex((ch) => Number(ch.start) <= this.currentTime && Number(ch.end) >= this.currentTime)
if (currChapterIndex > 0) {
var prevChapter = this.chapters[currChapterIndex - 1]
@ -356,18 +306,18 @@ export default {
setPlaybackSpeed(speed) {
console.log(`[AudioPlayer] Set Playback Rate: ${speed}`)
this.currentPlaybackRate = speed
MyNativeAudio.setPlaybackSpeed({ speed: speed })
AbsAudioPlayer.setPlaybackSpeed({ speed: speed })
},
restart() {
this.seek(0)
},
backward10() {
if (this.loading) return
MyNativeAudio.seekBackward({ amount: '10000' })
if (this.isLoading) return
AbsAudioPlayer.seekBackward({ amount: '10000' })
},
forward10() {
if (this.loading) return
MyNativeAudio.seekForward({ amount: '10000' })
if (this.isLoading) return
AbsAudioPlayer.seekForward({ amount: '10000' })
},
setStreamReady() {
this.readyTrackWidth = this.trackWidth
@ -461,7 +411,7 @@ export default {
}
},
seek(time) {
if (this.loading) return
if (this.isLoading) return
if (this.seekLoading) {
console.error('Already seek loading', this.seekedTime)
return
@ -469,7 +419,7 @@ export default {
this.seekedTime = time
this.seekLoading = true
MyNativeAudio.seekPlayer({ timeMs: String(Math.floor(time * 1000)) })
AbsAudioPlayer.seekPlayer({ timeMs: String(Math.floor(time * 1000)) })
if (this.$refs.playedTrack) {
var perc = time / this.totalDuration
@ -482,7 +432,7 @@ export default {
}
},
clickTrack(e) {
if (this.loading) return
if (this.isLoading) return
if (!this.showFullscreen) {
// Track not clickable on mini-player
return
@ -503,120 +453,24 @@ export default {
}
this.seek(time)
},
playPauseClick() {
if (this.loading) return
if (this.isPaused) {
console.log('playPause PLAY')
this.play()
} else {
console.log('playPause PAUSE')
this.pause()
}
},
calcSeekBackTime(lastUpdate) {
var time = Date.now() - lastUpdate
var seekback = 0
if (time < 60000) seekback = 0
else if (time < 120000) seekback = 10000
else if (time < 300000) seekback = 15000
else if (time < 1800000) seekback = 20000
else if (time < 3600000) seekback = 25000
else seekback = 29500
return seekback
},
async set(audiobookStreamData, stream, fromAppDestroy) {
this.isResetting = false
this.bufferedTime = 0
this.streamId = stream ? stream.id : null
this.audiobookId = audiobookStreamData.audiobookId
this.initObject = { ...audiobookStreamData }
console.log('[AudioPlayer] Set Audio Player', !!stream)
var init = true
if (!!stream) {
//console.log(JSON.stringify(stream))
var data = await MyNativeAudio.getStreamSyncData()
console.log('getStreamSyncData', JSON.stringify(data))
console.log('lastUpdate', stream.lastUpdate || 0)
//Same audiobook
if (data.id == stream.id && (data.isPlaying || data.lastPauseTime >= (stream.lastUpdate || 0))) {
console.log('Same audiobook')
this.isPaused = !data.isPlaying
this.currentTime = Number((data.currentTime / 1000).toFixed(2))
this.totalDuration = Number((data.duration / 1000).toFixed(2))
this.$emit('setTotalDuration', this.totalDuration)
this.timeupdate()
if (data.isPlaying) {
console.log('playing - continue')
if (fromAppDestroy) this.startPlayInterval()
} else console.log('paused and newer')
if (!fromAppDestroy) return
init = false
this.initObject.startTime = String(Math.floor(this.currentTime * 1000))
}
//new audiobook stream or sync from other client
else if (stream.clientCurrentTime > 0) {
console.log('new audiobook stream or sync from other client')
if (!!stream.lastUpdate) {
var backTime = this.calcSeekBackTime(stream.lastUpdate)
var currentTime = Math.floor(stream.clientCurrentTime * 1000)
if (backTime >= currentTime) backTime = currentTime - 500
console.log('SeekBackTime', backTime)
this.initObject.startTime = String(Math.floor(currentTime - backTime))
}
}
}
this.currentPlaybackRate = this.initObject.playbackSpeed
console.log(`[AudioPlayer] Set Stream Playback Rate: ${this.currentPlaybackRate}`)
if (init)
MyNativeAudio.initPlayer(this.initObject).then((res) => {
if (res && res.success) {
console.log('Success init audio player')
} else {
console.error('Failed to init audio player')
}
})
if (audiobookStreamData.isLocal) {
this.setStreamReady()
}
},
setFromObj() {
if (!this.initObject) {
console.error('Cannot set from obj')
return
}
this.isResetting = false
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()
}
async playPauseClick() {
if (this.isLoading) return
this.isPlaying = !!((await AbsAudioPlayer.playPause()) || {}).playing
},
play() {
MyNativeAudio.playPlayer()
AbsAudioPlayer.playPlayer()
this.startPlayInterval()
this.isPlaying = true
},
pause() {
MyNativeAudio.pausePlayer()
AbsAudioPlayer.pausePlayer()
this.stopPlayInterval()
this.isPlaying = false
},
startPlayInterval() {
this.startListenTimeInterval()
clearInterval(this.playInterval)
this.playInterval = setInterval(async () => {
var data = await MyNativeAudio.getCurrentTime()
var data = await AbsAudioPlayer.getCurrentTime()
this.currentTime = Number((data.value / 1000).toFixed(2))
this.bufferedTime = Number((data.bufferedTime / 1000).toFixed(2))
console.log('[AudioPlayer] Got Current Time', this.currentTime)
@ -624,24 +478,20 @@ export default {
}, 1000)
},
stopPlayInterval() {
this.cancelListenTimeInterval()
clearInterval(this.playInterval)
},
resetStream(startTime) {
var _time = String(Math.floor(startTime * 1000))
if (!this.initObject) {
console.error('Terminate stream when no init object is set...')
return
}
this.isResetting = true
this.initObject.currentTime = _time
this.terminateStream()
},
terminateStream() {
MyNativeAudio.terminateStream()
if (!this.playbackSession) return
AbsAudioPlayer.terminateStream()
},
onPlayingUpdate(data) {
console.log('onPlayingUpdate', JSON.stringify(data))
this.isPaused = !data.value
this.$store.commit('setPlayerPlaying', !this.isPaused)
if (!this.isPaused) {
this.startPlayInterval()
} else {
@ -649,8 +499,11 @@ export default {
}
},
onMetadata(data) {
this.totalDuration = Number((data.duration / 1000).toFixed(2))
this.$emit('setTotalDuration', this.totalDuration)
console.log('onMetadata', JSON.stringify(data))
this.isLoading = false
// this.totalDuration = Number((data.duration / 1000).toFixed(2))
this.totalDuration = Number(data.duration.toFixed(2))
this.currentTime = Number((data.currentTime / 1000).toFixed(2))
this.stateName = data.stateName
@ -664,17 +517,35 @@ export default {
this.timeupdate()
},
// When a playback session is started the native android/ios will send the session
onPlaybackSession(playbackSession) {
console.log('onPlaybackSession received', JSON.stringify(playbackSession))
this.playbackSession = playbackSession
this.$store.commit('setPlayerItem', this.playbackSession)
// Set track width
this.$nextTick(() => {
if (this.$refs.track) {
this.trackWidth = this.$refs.track.clientWidth
} else {
console.error('Track not loaded', this.$refs)
}
})
},
onPlaybackClosed() {
console.log('Received onPlaybackClosed evt')
this.$store.commit('setPlayerItem', null)
this.showFullscreen = false
this.playbackSession = null
},
async init() {
this.useChapterTrack = await this.$localStore.getUseChapterTrack()
this.onPlayingUpdateListener = MyNativeAudio.addListener('onPlayingUpdate', this.onPlayingUpdate)
this.onMetadataListener = MyNativeAudio.addListener('onMetadata', this.onMetadata)
if (this.$refs.track) {
this.trackWidth = this.$refs.track.clientWidth
} else {
console.error('Track not loaded', this.$refs)
}
this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession)
this.onPlaybackClosedListener = AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed)
this.onPlayingUpdateListener = AbsAudioPlayer.addListener('onPlayingUpdate', this.onPlayingUpdate)
this.onMetadataListener = AbsAudioPlayer.addListener('onMetadata', this.onMetadata)
},
handleGesture() {
var touchDistance = this.touchEndY - this.touchStartY
@ -714,7 +585,7 @@ export default {
})
this.$localStore.setUseChapterTrack(this.useChapterTrack)
} else if (action === 'close') {
this.$emit('close')
this.terminateStream()
}
},
forceCloseDropdownMenu() {
@ -736,6 +607,8 @@ export default {
if (this.onPlayingUpdateListener) this.onPlayingUpdateListener.remove()
if (this.onMetadataListener) this.onMetadataListener.remove()
if (this.onPlaybackSessionListener) this.onPlaybackSessionListener.remove()
if (this.onPlaybackClosedListener) this.onPlaybackClosedListener.remove()
clearInterval(this.playInterval)
}
}

View file

@ -1,37 +1,15 @@
<template>
<div>
<div v-if="audiobook" id="streamContainer">
<app-audio-player
ref="audioPlayer"
:playing.sync="isPlaying"
:audiobook="audiobook"
:download="download"
:loading="isLoading"
:bookmarks="bookmarks"
:sleep-timer-running="isSleepTimerRunning"
:sleep-time-remaining="sleepTimeRemaining"
@close="cancelStream"
@sync="sync"
@setTotalDuration="setTotalDuration"
@selectPlaybackSpeed="showPlaybackSpeedModal = true"
@selectChapter="clickChapterBtn"
@updateTime="(t) => (currentTime = t)"
@showSleepTimer="showSleepTimer"
@showBookmarks="showBookmarks"
@hook:mounted="audioPlayerMounted"
/>
</div>
<app-audio-player ref="audioPlayer" :playing.sync="isPlaying" :bookmarks="bookmarks" :sleep-timer-running="isSleepTimerRunning" :sleep-time-remaining="sleepTimeRemaining" @selectPlaybackSpeed="showPlaybackSpeedModal = true" @updateTime="(t) => (currentTime = t)" @showSleepTimer="showSleepTimer" @showBookmarks="showBookmarks" />
<modals-playback-speed-modal v-model="showPlaybackSpeedModal" :playback-rate.sync="playbackSpeed" @update:playbackRate="updatePlaybackSpeed" @change="changePlaybackSpeed" />
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
<modals-sleep-timer-modal v-model="showSleepTimerModal" :current-time="sleepTimeRemaining" :sleep-timer-running="isSleepTimerRunning" :current-end-of-chapter-time="currentEndOfChapterTime" @change="selectSleepTimeout" @cancel="cancelSleepTimer" @increase="increaseSleepTimer" @decrease="decreaseSleepTimer" />
<modals-bookmarks-modal v-model="showBookmarksModal" :audiobook-id="audiobookId" :bookmarks="bookmarks" :current-time="currentTime" @select="selectBookmark" />
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="currentTime" @select="selectBookmark" />
</div>
</template>
<script>
import { Dialog } from '@capacitor/dialog'
import MyNativeAudio from '@/plugins/my-native-audio'
import { AbsAudioPlayer } from '@/plugins/capacitor'
export default {
data() {
@ -40,21 +18,19 @@ export default {
audioPlayerReady: false,
stream: null,
download: null,
lastProgressTimeUpdate: 0,
showPlaybackSpeedModal: false,
showBookmarksModal: false,
showSleepTimerModal: false,
playbackSpeed: 1,
showChapterModal: false,
currentTime: 0,
isSleepTimerRunning: false,
sleepTimerEndTime: 0,
sleepTimerRemaining: 0,
sleepTimeRemaining: 0,
onLocalMediaProgressUpdateListener: null,
onSleepTimerEndedListener: null,
onSleepTimerSetListener: null,
sleepInterval: null,
currentEndOfChapterTime: 0,
totalDuration: 0
currentEndOfChapterTime: 0
}
},
watch: {
@ -66,95 +42,13 @@ export default {
}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
userAudiobook() {
if (!this.audiobookId) return
return this.$store.getters['user/getUserAudiobookData'](this.audiobookId)
},
bookmarks() {
if (!this.userAudiobook) return []
return this.userAudiobook.bookmarks || []
},
currentChapter() {
if (!this.audiobook || !this.chapters.length) return null
return this.chapters.find((ch) => Number(ch.start) <= this.currentTime && Number(ch.end) > this.currentTime)
// return this.$store.getters['user/getUserBookmarksForItem'](this.)
return []
},
socketConnected() {
return this.$store.state.socketConnected
},
isLoading() {
if (this.playingDownload) return false
if (!this.streamAudiobook) return false
return !this.stream || this.streamAudiobook.id !== this.stream.audiobook.id
},
playingDownload() {
return this.$store.state.playingDownload
},
audiobook() {
if (this.playingDownload) return this.playingDownload.audiobook
return this.streamAudiobook
},
audiobookId() {
return this.audiobook ? this.audiobook.id : null
},
streamAudiobook() {
return this.$store.state.streamAudiobook
},
book() {
return this.audiobook ? this.audiobook.book || {} : {}
},
title() {
return this.book ? this.book.title : ''
},
author() {
return this.book ? this.book.author : ''
},
cover() {
return this.book ? this.book.cover : ''
},
series() {
return this.book ? this.book.series : ''
},
chapters() {
return this.audiobook ? this.audiobook.chapters || [] : []
},
volumeNumber() {
return this.book ? this.book.volumeNumber : ''
},
seriesTxt() {
if (!this.series) return ''
if (!this.volumeNumber) return this.series
return `${this.series} #${this.volumeNumber}`
},
duration() {
return this.audiobook ? this.audiobook.duration || 0 : 0
},
coverForNative() {
if (!this.cover) {
return `${this.$store.state.serverUrl}/Logo.png`
}
if (this.cover.startsWith('http')) return this.cover
var coverSrc = this.$store.getters['audiobooks/getBookCoverSrc'](this.audiobook)
return coverSrc
},
tracksForCast() {
if (!this.audiobook || !this.audiobook.tracks) {
return []
}
var abpath = this.audiobook.path
var tracks = this.audiobook.tracks.map((t) => {
var trelpath = t.path.replace(abpath, '')
if (trelpath.startsWith('/')) trelpath = trelpath.substr(1)
return `${this.$store.state.serverUrl}/s/book/${this.audiobook.id}/${trelpath}?token=${this.userToken}`
})
return tracks
}
// sleepTimeRemaining() {
// if (!this.sleepTimerEndTime) return 0
// return Math.max(0, this.sleepTimerEndTime / 1000 - this.currentTime)
// }
},
methods: {
showBookmarks() {
@ -173,7 +67,7 @@ export default {
if (currentPosition) {
console.log('Sleep Timer Ended Current Position: ' + currentPosition)
var currentTime = Math.floor(currentPosition / 1000)
this.updateTime(currentTime)
// TODO: Was syncing to the server here before
}
},
onSleepTimerSet({ value: sleepTimeRemaining }) {
@ -188,8 +82,8 @@ export default {
this.sleepTimeRemaining = sleepTimeRemaining
},
showSleepTimer() {
if (this.currentChapter) {
this.currentEndOfChapterTime = Math.floor(this.currentChapter.end)
if (this.$refs.audioPlayer && this.$refs.audioPlayer.currentChapter) {
this.currentEndOfChapterTime = Math.floor(this.$refs.audioPlayer.currentChapter.end)
} else {
this.currentEndOfChapterTime = 0
}
@ -197,101 +91,24 @@ export default {
},
async selectSleepTimeout({ time, isChapterTime }) {
console.log('Setting sleep timer', time, isChapterTime)
var res = await MyNativeAudio.setSleepTimer({ time: String(time), isChapterTime })
var res = await AbsAudioPlayer.setSleepTimer({ time: String(time), isChapterTime })
if (!res.success) {
return this.$toast.error('Sleep timer did not set, invalid time')
}
},
increaseSleepTimer() {
// Default time to increase = 5 min
MyNativeAudio.increaseSleepTime({ time: '300000' })
AbsAudioPlayer.increaseSleepTime({ time: '300000' })
},
decreaseSleepTimer() {
MyNativeAudio.decreaseSleepTime({ time: '300000' })
AbsAudioPlayer.decreaseSleepTime({ time: '300000' })
},
async cancelSleepTimer() {
console.log('Canceling sleep timer')
await MyNativeAudio.cancelSleepTimer()
await AbsAudioPlayer.cancelSleepTimer()
},
clickChapterBtn() {
if (!this.chapters.length) return
this.showChapterModal = true
},
selectChapter(chapter) {
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.seek(chapter.start)
}
this.showChapterModal = false
},
async cancelStream() {
this.currentTime = 0
if (this.download) {
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.terminateStream()
}
this.download = null
this.$store.commit('setPlayingDownload', null)
this.$localStore.setCurrent(null)
} else {
const { value } = await Dialog.confirm({
title: 'Confirm',
message: 'Cancel this stream?'
})
if (value) {
this.$server.socket.emit('close_stream')
this.$store.commit('setStreamAudiobook', null)
this.$server.stream = null
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.terminateStream()
}
}
}
},
sync(syncData) {
var diff = syncData.currentTime - this.lastServerUpdateSentSeconds
if (Math.abs(diff) < 1 && !syncData.timeListened) {
// No need to sync
return
}
if (this.stream) {
this.$server.socket.emit('stream_sync', syncData)
} else {
var progressUpdate = {
audiobookId: syncData.audiobookId,
currentTime: syncData.currentTime,
totalDuration: syncData.totalDuration,
progress: syncData.totalDuration ? Number((syncData.currentTime / syncData.totalDuration).toFixed(3)) : 0,
lastUpdate: Date.now(),
isRead: false
}
if (this.$server.connected) {
this.$server.socket.emit('progress_update', progressUpdate)
} else {
this.$store.dispatch('user/updateUserAudiobookData', progressUpdate)
}
}
},
updateTime(currentTime) {
this.sync({
currentTime,
audiobookId: this.audiobookId,
streamId: this.stream ? this.stream.id : null,
timeListened: 0,
totalDuration: this.totalDuration || 0
})
},
setTotalDuration(duration) {
this.totalDuration = duration
},
streamClosed(audiobookId) {
streamClosed() {
console.log('Stream Closed')
if (this.stream.audiobook.id === audiobookId || audiobookId === 'n/a') {
this.$store.commit('setStreamAudiobook', null)
}
},
streamProgress(data) {
if (!data.numSegments) return
@ -307,131 +124,13 @@ export default {
}
},
streamReset({ streamId, startTime }) {
console.log('received stream reset', streamId, startTime)
if (this.$refs.audioPlayer) {
if (this.stream && this.stream.id === streamId) {
this.$refs.audioPlayer.resetStream(startTime)
}
}
},
async getDownloadStartTime() {
var userAudiobook = this.$store.getters['user/getUserAudiobookData'](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.audioPlayer) {
this.$refs.audioPlayer.terminateStream()
}
this.stream = null
}
this.lastProgressTimeUpdate = 0
console.log('[StreamContainer] Playing local', this.playingDownload)
if (!this.$refs.audioPlayer) {
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
this.currentTime = currentTime
// Update local current time
this.$localStore.setCurrent({
audiobookId: this.download.id,
lastUpdate: Date.now()
})
var audiobookStreamData = {
id: 'download',
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.userToken,
contentUrl: this.playingDownload.contentUrl,
isLocal: true,
audiobookId: this.download.id
}
this.$refs.audioPlayer.set(audiobookStreamData, null, false)
},
streamOpen(stream) {
if (this.download) {
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.terminateStream()
}
this.download = null
}
this.lastProgressTimeUpdate = 0
console.log('[StreamContainer] Stream Open: ' + this.title)
if (!this.$refs.audioPlayer) {
console.error('[StreamContainer] No Audio Player Mini')
return
}
// Update local remove current
this.$localStore.setCurrent(null)
var playlistUrl = stream.clientPlaylistUri
var currentTime = stream.clientCurrentTime || 0
this.currentTime = currentTime
var playOnLoad = this.$store.state.playOnLoad
if (playOnLoad) this.$store.commit('setPlayOnLoad', false)
var audiobookStreamData = {
id: stream.id,
title: this.title,
author: this.author,
playWhenReady: !!playOnLoad,
startTime: String(Math.floor(currentTime * 1000)),
playbackSpeed: this.playbackSpeed || 1,
cover: this.coverForNative,
duration: String(Math.floor(this.duration * 1000)),
series: this.seriesTxt,
playlistUrl: this.$server.url + playlistUrl,
token: this.userToken,
audiobookId: this.audiobookId,
tracks: this.tracksForCast
}
console.log('[StreamContainer] Set Audio Player', JSON.stringify(audiobookStreamData))
if (!this.$refs.audioPlayer) {
console.error('[StreamContainer] Invalid no audio player')
} else {
console.log('[StreamContainer] Has Audio Player Ref')
}
this.$refs.audioPlayer.set(audiobookStreamData, stream, !this.stream)
this.stream = stream
},
audioPlayerMounted() {
console.log('Audio Player Mounted', this.$server.stream)
this.audioPlayerReady = true
if (this.playingDownload) {
console.log('[StreamContainer] Play download on audio mount')
if (!this.download) {
this.download = { ...this.playingDownload }
}
this.playDownload()
} else if (this.$server.stream) {
console.log('[StreamContainer] Open stream on audio mount')
this.streamOpen(this.$server.stream)
}
},
updatePlaybackSpeed(speed) {
if (this.$refs.audioPlayer) {
console.log(`[AudioPlayerContainer] Update Playback Speed: ${speed}`)
@ -450,66 +149,76 @@ export default {
this.$refs.audioPlayer.setPlaybackSpeed(this.playbackSpeed)
}
},
streamUpdated(type, data) {
if (type === 'download') {
if (data) {
this.download = { ...data }
if (this.audioPlayerReady) {
this.playDownload()
}
} else if (this.download) {
this.cancelStream()
}
}
},
setListeners() {
if (!this.$server.socket) {
console.error('Invalid server socket not set')
return
}
this.$server.socket.on('stream_open', this.streamOpen)
this.$server.socket.on('stream_closed', this.streamClosed)
this.$server.socket.on('stream_progress', this.streamProgress)
this.$server.socket.on('stream_ready', this.streamReady)
this.$server.socket.on('stream_reset', this.streamReset)
// if (!this.$server.socket) {
// console.error('Invalid server socket not set')
// return
// }
// this.$server.socket.on('stream_open', this.streamOpen)
// this.$server.socket.on('stream_closed', this.streamClosed)
// this.$server.socket.on('stream_progress', this.streamProgress)
// this.$server.socket.on('stream_ready', this.streamReady)
// this.$server.socket.on('stream_reset', this.streamReset)
},
closeStreamOnly() {
// If user logs out or disconnects from server, close audio if streaming
if (!this.download) {
this.$store.commit('setStreamAudiobook', null)
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.terminateStream()
}
// If user logs out or disconnects from server and not playing local
if (this.$refs.audioPlayer && !this.$refs.audioPlayer.isLocalPlayMethod) {
this.$refs.audioPlayer.terminateStream()
}
},
async playLibraryItem(payload) {
var libraryItemId = payload.libraryItemId
var episodeId = payload.episodeId
console.log('Called playLibraryItem', libraryItemId)
AbsAudioPlayer.prepareLibraryItem({ libraryItemId, episodeId, playWhenReady: true })
.then((data) => {
console.log('Library item play response', JSON.stringify(data))
})
.catch((error) => {
console.error('Failed', error)
})
},
pauseItem() {
if (this.$refs.audioPlayer && !this.$refs.audioPlayer.isPaused) {
this.$refs.audioPlayer.pause()
}
},
onLocalMediaProgressUpdate(localMediaProgress) {
console.log('Got local media progress update', localMediaProgress.progress, JSON.stringify(localMediaProgress))
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
}
},
mounted() {
this.onSleepTimerEndedListener = MyNativeAudio.addListener('onSleepTimerEnded', this.onSleepTimerEnded)
this.onSleepTimerSetListener = MyNativeAudio.addListener('onSleepTimerSet', this.onSleepTimerSet)
this.onLocalMediaProgressUpdateListener = AbsAudioPlayer.addListener('onLocalMediaProgressUpdate', this.onLocalMediaProgressUpdate)
this.onSleepTimerEndedListener = AbsAudioPlayer.addListener('onSleepTimerEnded', this.onSleepTimerEnded)
this.onSleepTimerSetListener = AbsAudioPlayer.addListener('onSleepTimerSet', this.onSleepTimerSet)
this.playbackSpeed = this.$store.getters['user/getUserSetting']('playbackRate')
console.log(`[AudioPlayerContainer] Init Playback Speed: ${this.playbackSpeed}`)
this.setListeners()
this.$eventBus.$on('close_stream', this.closeStreamOnly)
this.$eventBus.$on('play-item', this.playLibraryItem)
this.$eventBus.$on('pause-item', this.pauseItem)
this.$eventBus.$on('close-stream', this.closeStreamOnly)
this.$store.commit('user/addSettingsListener', { id: 'streamContainer', meth: this.settingsUpdated })
this.$store.commit('setStreamListener', this.streamUpdated)
},
beforeDestroy() {
if (this.onLocalMediaProgressUpdateListener) this.onLocalMediaProgressUpdateListener.remove()
if (this.onSleepTimerEndedListener) this.onSleepTimerEndedListener.remove()
if (this.onSleepTimerSetListener) this.onSleepTimerSetListener.remove()
if (this.$server.socket) {
this.$server.socket.off('stream_open', this.streamOpen)
this.$server.socket.off('stream_closed', this.streamClosed)
this.$server.socket.off('stream_progress', this.streamProgress)
this.$server.socket.off('stream_ready', this.streamReady)
this.$server.socket.off('stream_reset', this.streamReset)
}
this.$eventBus.$off('close_stream', this.closeStreamOnly)
// if (this.$server.socket) {
// this.$server.socket.off('stream_open', this.streamOpen)
// this.$server.socket.off('stream_closed', this.streamClosed)
// this.$server.socket.off('stream_progress', this.streamProgress)
// this.$server.socket.off('stream_ready', this.streamReady)
// this.$server.socket.off('stream_reset', this.streamReset)
// }
this.$eventBus.$off('play-item', this.playLibraryItem)
this.$eventBus.$off('pause-item', this.pauseItem)
this.$eventBus.$off('close-stream', this.closeStreamOnly)
this.$store.commit('user/removeSettingsListener', 'streamContainer')
this.$store.commit('removeStreamListener')
}
}
</script>

View file

@ -3,25 +3,31 @@
<div class="absolute top-0 left-0 w-full h-full bg-black transition-opacity duration-200" :class="show ? 'bg-opacity-60 pointer-events-auto' : 'bg-opacity-0'" @click="clickBackground" />
<div class="absolute top-0 right-0 w-64 h-full bg-primary transform transition-transform py-6 pointer-events-auto" :class="show ? '' : 'translate-x-64'" @click.stop>
<div class="px-6 mb-4">
<p v-if="socketConnected" class="text-base">
<p v-if="user" class="text-base">
Welcome,
<strong>{{ username }}</strong>
</p>
</div>
<div class="w-full overflow-y-auto">
<template v-for="item in navItems">
<nuxt-link :to="item.to" :key="item.text" class="w-full hover:bg-bg hover:bg-opacity-60 flex items-center py-3 px-6 text-gray-300">
<nuxt-link :to="item.to" :key="item.text" class="w-full hover:bg-bg hover:bg-opacity-60 flex items-center py-3 px-6 text-gray-300" :class="currentRoutePath.startsWith(item.to) ? 'bg-bg bg-opacity-60' : ''">
<span class="text-lg" :class="item.iconOutlined ? 'material-icons-outlined' : 'material-icons'">{{ item.icon }}</span>
<p class="pl-4">{{ item.text }}</p>
</nuxt-link>
</template>
</div>
<div class="absolute bottom-0 left-0 w-full flex items-center py-6 px-6 text-gray-300">
<p class="text-xs">{{ $config.version }}</p>
<div class="flex-grow" />
<div v-if="socketConnected" class="flex items-center" @click="logout">
<p class="text-xs pr-2">Logout</p>
<span class="material-icons text-sm">logout</span>
<div class="absolute bottom-0 left-0 w-full py-6 px-6 text-gray-300">
<div v-if="serverConnectionConfig" class="mb-4 flex justify-center">
<p class="text-xs">{{ serverConnectionConfig.address }}</p>
</div>
<div class="flex items-center">
<p class="text-xs">{{ $config.version }}</p>
<div class="flex-grow" />
<div v-if="user" class="flex items-center" @click="logout">
<p class="text-xs pr-2">Logout</p>
<span class="material-icons text-sm">logout</span>
</div>
</div>
</div>
</div>
@ -62,6 +68,9 @@ export default {
user() {
return this.$store.state.user.user
},
serverConnectionConfig() {
return this.$store.state.user.serverConnectionConfig
},
username() {
return this.user ? this.user.username : ''
},
@ -74,25 +83,9 @@ export default {
icon: 'home',
text: 'Home',
to: '/bookshelf'
},
{
icon: 'person',
text: 'Account',
to: '/account'
},
{
icon: 'folder',
iconOutlined: true,
text: 'Downloads',
to: '/downloads'
}
// {
// icon: 'settings',
// text: 'Settings',
// to: '/config'
// }
]
if (!this.socketConnected) {
if (!this.serverConnectionConfig) {
items = [
{
icon: 'cloud_off',
@ -100,8 +93,24 @@ export default {
to: '/connect'
}
].concat(items)
} else {
items.push({
icon: 'person',
text: 'Account',
to: '/account'
})
}
items.push({
icon: 'folder',
iconOutlined: true,
text: 'Local Media',
to: '/localMedia/folders'
})
return items
},
currentRoutePath() {
return this.$route.path
}
},
methods: {
@ -112,7 +121,9 @@ export default {
await this.$axios.$post('/logout').catch((error) => {
console.error(error)
})
this.$server.logout()
this.$socket.logout()
await this.$db.logout()
this.$store.commit('user/logout')
this.$router.push('/connect')
},
touchstart(e) {

View file

@ -1,8 +1,8 @@
<template>
<div id="bookshelf" class="w-full max-w-full h-full">
<template v-for="shelf in totalShelves">
<div :key="shelf" class="w-full px-2 bookshelfRow relative" :id="`shelf-${shelf - 1}`" :style="{ height: shelfHeight + 'px' }">
<div class="bookshelfDivider w-full absolute bottom-0 left-0 z-30" style="min-height: 16px" :class="`h-${shelfDividerHeightIndex}`" />
<div :key="shelf" class="w-full px-2 relative" :class="showBookshelfListView ? '' : 'bookshelfRow'" :id="`shelf-${shelf - 1}`" :style="{ height: shelfHeight + 'px' }">
<div v-if="!showBookshelfListView" class="bookshelfDivider w-full absolute bottom-0 left-0 z-30" style="min-height: 16px" :class="`h-${shelfDividerHeightIndex}`" />
</div>
</template>
@ -28,9 +28,8 @@ export default {
bookshelfWidth: 0,
bookshelfMarginLeft: 0,
shelvesPerPage: 0,
entitiesPerShelf: 8,
entitiesPerShelf: 2,
currentPage: 0,
currentBookWidth: 0,
booksPerFetch: 20,
initialized: false,
currentSFQueryString: null,
@ -42,12 +41,18 @@ export default {
entityIndexesMounted: [],
pagesLoaded: {},
isFirstInit: false,
pendingReset: false
pendingReset: false,
localLibraryItems: []
}
},
watch: {
showBookshelfListView(newVal) {
this.resetEntities()
}
},
computed: {
isSocketConnected() {
return this.$store.state.socketConnected
user() {
return this.$store.state.user.user
},
isBookEntity() {
return this.entityName === 'books' || this.entityName === 'series-books'
@ -56,21 +61,18 @@ export default {
if (this.isBookEntity) return 4
return 6
},
bookshelfListView() {
return this.$store.state.globals.bookshelfListView
},
showBookshelfListView() {
return this.isBookEntity && this.bookshelfListView
},
entityName() {
return this.page
},
bookshelfView() {
return this.$store.state.bookshelfView
},
hasFilter() {
return this.filterBy !== 'all'
},
isListView() {
return this.bookshelfView === 'list'
},
books() {
return this.$store.getters['downloads/getAudiobooks']
},
orderBy() {
return this.$store.getters['user/getUserSetting']('mobileOrderBy')
},
@ -101,33 +103,28 @@ export default {
return this.bookWidth * 1.6
},
entityWidth() {
if (this.showBookshelfListView) return this.bookshelfWidth - 16
if (this.isBookEntity) return this.bookWidth
return this.bookWidth * 2
},
entityHeight() {
if (this.showBookshelfListView) return 88
return this.bookHeight
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
currentLibraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
shelfHeight() {
if (this.showBookshelfListView) return this.entityHeight
return this.entityHeight + 40
},
totalEntityCardWidth() {
if (this.showBookshelfListView) return this.entityWidth
// Includes margin
return this.entityWidth + 24
},
downloads() {
return this.$store.getters['downloads/getDownloads']
},
downloadedBooks() {
return this.downloads.map((dl) => {
var download = { ...dl }
var ab = { ...download.audiobook }
delete download.audiobook
ab.download = download
return ab
})
}
},
methods: {
@ -144,19 +141,16 @@ export default {
if (!this.initialized) {
this.currentSFQueryString = this.buildSearchParams()
}
var entityPath = this.entityName === 'books' ? `books/all` : this.entityName
var entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `items` : this.entityName
var sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
var queryString = `?${sfQueryString}&limit=${this.booksPerFetch}&page=${page}`
var fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1`
if (this.entityName === 'series-books') {
entityPath = `series/${this.seriesId}`
queryString = ''
}
var payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${queryString}`).catch((error) => {
var payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
console.error('failed to fetch books', error)
return null
})
this.isFetchingEntities = false
if (this.pendingReset) {
this.pendingReset = false
@ -174,25 +168,26 @@ export default {
}
for (let i = 0; i < payload.results.length; i++) {
if (this.entityName === 'books' || this.entityName === 'series-books') {
// Check if has download and append download obj
var download = this.downloads.find((dl) => dl.id === payload.results[i].id)
if (download) {
var dl = { ...download }
delete dl.audiobook
payload.results[i].download = dl
}
}
var index = i + startIndex
this.entities[index] = payload.results[i]
if (this.entityComponentRefs[index]) {
this.entityComponentRefs[index].setEntity(this.entities[index])
if (this.isBookEntity) {
var localLibraryItem = this.localLibraryItems.find((lli) => lli.libraryItemId == this.entities[index].id)
if (localLibraryItem) {
this.entityComponentRefs[index].setLocalLibraryItem(localLibraryItem)
}
}
}
}
}
},
async loadPage(page) {
if (!this.currentLibraryId) {
console.error('[LazyBookshelf] loadPage current library id not set')
return
}
this.pagesLoaded[page] = true
await this.fetchEntities(page)
},
@ -224,6 +219,7 @@ export default {
this.loadPage(lastBookPage)
}
// Remove entities out of view
this.entityIndexesMounted = this.entityIndexesMounted.filter((_index) => {
if (_index < firstBookIndex || _index >= lastBookIndex) {
var el = document.getElementById(`book-card-${_index}`)
@ -243,7 +239,7 @@ export default {
},
setDownloads() {
if (this.entityName === 'books') {
this.entities = this.downloadedBooks
this.entities = []
// TOOD: Sort and filter here
this.totalEntities = this.entities.length
this.totalShelves = Math.ceil(this.totalEntities / this.entitiesPerShelf)
@ -271,14 +267,12 @@ export default {
this.initialized = false
this.initSizeData()
if (this.isSocketConnected) {
if (this.user) {
await this.loadPage(0)
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
this.mountEntites(0, lastBookIndex)
} else {
this.setDownloads()
this.mountEntites(0, this.totalEntities - 1)
// Local only
}
},
remountEntities() {
@ -304,12 +298,11 @@ export default {
var { clientHeight, clientWidth } = bookshelf
this.bookshelfHeight = clientHeight
this.bookshelfWidth = clientWidth
this.entitiesPerShelf = Math.floor((this.bookshelfWidth - 16) / this.totalEntityCardWidth)
this.entitiesPerShelf = this.showBookshelfListView ? 1 : Math.floor((this.bookshelfWidth - 16) / this.totalEntityCardWidth)
this.shelvesPerPage = Math.ceil(this.bookshelfHeight / this.shelfHeight) + 2
this.bookshelfMarginLeft = (this.bookshelfWidth - this.entitiesPerShelf * this.totalEntityCardWidth) / 2
this.currentBookWidth = this.bookWidth
if (this.totalEntities) {
this.totalShelves = Math.ceil(this.totalEntities / this.entitiesPerShelf)
}
@ -317,42 +310,43 @@ export default {
},
async init() {
if (this.isFirstInit) return
this.localLibraryItems = await this.$db.getLocalLibraryItems(this.currentLibraryMediaType)
console.log('Local library items loaded for lazy bookshelf', this.localLibraryItems.length)
this.isFirstInit = true
this.initSizeData()
await this.loadPage(0)
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
this.mountEntites(0, lastBookIndex)
},
initDownloads() {
this.initSizeData()
this.setDownloads()
this.$nextTick(() => {
console.log('Mounting downloads', this.totalEntities, 'total shelves', this.totalShelves)
this.mountEntites(0, this.totalEntities)
})
},
scroll(e) {
if (!e || !e.target) return
if (!this.isSocketConnected) return // Offline books are all mounted at once
if (!this.user) return
var { scrollTop } = e.target
this.handleScroll(scrollTop)
},
socketInit(isConnected) {
if (isConnected) {
this.init()
} else {
this.isFirstInit = false
this.resetEntities()
}
},
buildSearchParams() {
let searchParams = new URLSearchParams()
if (this.filterBy && this.filterBy !== 'all') {
searchParams.set('filter', this.filterBy)
if (this.page === 'search' || this.page === 'series' || this.page === 'collections') {
return ''
}
if (this.orderBy) {
searchParams.set('sort', this.orderBy)
searchParams.set('desc', this.orderDesc ? 1 : 0)
let searchParams = new URLSearchParams()
if (this.page === 'series-books') {
searchParams.set('filter', `series.${this.$encode(this.seriesId)}`)
searchParams.set('sort', 'book.volumeNumber')
searchParams.set('desc', 0)
} else {
if (this.filterBy && this.filterBy !== 'all') {
searchParams.set('filter', this.filterBy)
}
if (this.orderBy) {
searchParams.set('sort', this.orderBy)
searchParams.set('desc', this.orderDesc ? 1 : 0)
}
if (this.collapseSeries) {
searchParams.set('collapseseries', 1)
}
}
return searchParams.toString()
},
@ -385,47 +379,49 @@ export default {
this.resetEntities()
}
},
downloadsLoaded() {
if (!this.isSocketConnected) {
this.resetEntities()
}
},
audiobookAdded(audiobook) {
console.log('Audiobook added', audiobook)
// TODO: Check if audiobook would be on this shelf
libraryItemAdded(libraryItem) {
console.log('libraryItem added', libraryItem)
// TODO: Check if item would be on this shelf
this.resetEntities()
},
audiobookUpdated(audiobook) {
console.log('Audiobook updated', audiobook)
libraryItemUpdated(libraryItem) {
console.log('Item updated', libraryItem)
if (this.entityName === 'books' || this.entityName === 'series-books') {
var indexOf = this.entities.findIndex((ent) => ent && ent.id === audiobook.id)
var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
if (indexOf >= 0) {
this.entities[indexOf] = audiobook
this.entities[indexOf] = libraryItem
if (this.entityComponentRefs[indexOf]) {
this.entityComponentRefs[indexOf].setEntity(audiobook)
this.entityComponentRefs[indexOf].setEntity(libraryItem)
if (this.isBookEntity) {
var localLibraryItem = this.localLibraryItems.find((lli) => lli.libraryItemId == libraryItem.id)
if (localLibraryItem) {
this.entityComponentRefs[indexOf].setLocalLibraryItem(localLibraryItem)
}
}
}
}
}
},
audiobookRemoved(audiobook) {
libraryItemRemoved(libraryItem) {
if (this.entityName === 'books' || this.entityName === 'series-books') {
var indexOf = this.entities.findIndex((ent) => ent && ent.id === audiobook.id)
var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
if (indexOf >= 0) {
this.entities = this.entities.filter((ent) => ent.id !== audiobook.id)
this.entities = this.entities.filter((ent) => ent.id !== libraryItem.id)
this.totalEntities = this.entities.length
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
this.remountEntities()
this.executeRebuild()
}
}
},
audiobooksAdded(audiobooks) {
console.log('audiobooks added', audiobooks)
// TODO: Check if audiobook would be on this shelf
libraryItemsAdded(libraryItems) {
console.log('items added', libraryItems)
// TODO: Check if item would be on this shelf
this.resetEntities()
},
audiobooksUpdated(audiobooks) {
audiobooks.forEach((ab) => {
this.audiobookUpdated(ab)
libraryItemsUpdated(libraryItems) {
libraryItems.forEach((ab) => {
this.libraryItemUpdated(ab)
})
},
initListeners() {
@ -433,54 +429,37 @@ export default {
if (bookshelf) {
bookshelf.addEventListener('scroll', this.scroll)
}
// this.$eventBus.$on('bookshelf-clear-selection', this.clearSelectedEntities)
// this.$eventBus.$on('bookshelf-select-all', this.selectAllEntities)
// this.$eventBus.$on('bookshelf-keyword-filter', this.updateKeywordFilter)
this.$eventBus.$on('library-changed', this.libraryChanged)
this.$eventBus.$on('downloads-loaded', this.downloadsLoaded)
this.$store.commit('user/addSettingsListener', { id: 'lazy-bookshelf', meth: this.settingsUpdated })
if (this.$server.socket) {
this.$server.socket.on('audiobook_updated', this.audiobookUpdated)
this.$server.socket.on('audiobook_added', this.audiobookAdded)
this.$server.socket.on('audiobook_removed', this.audiobookRemoved)
this.$server.socket.on('audiobooks_updated', this.audiobooksUpdated)
this.$server.socket.on('audiobooks_added', this.audiobooksAdded)
} else {
console.error('Bookshelf - Socket not initialized')
}
this.$socket.$on('item_updated', this.libraryItemUpdated)
this.$socket.$on('item_added', this.libraryItemAdded)
this.$socket.$on('item_removed', this.libraryItemRemoved)
this.$socket.$on('items_updated', this.libraryItemsUpdated)
this.$socket.$on('items_added', this.libraryItemsAdded)
},
removeListeners() {
var bookshelf = document.getElementById('bookshelf-wrapper')
if (bookshelf) {
bookshelf.removeEventListener('scroll', this.scroll)
}
this.$eventBus.$off('library-changed', this.libraryChanged)
this.$eventBus.$off('downloads-loaded', this.downloadsLoaded)
this.$store.commit('user/removeSettingsListener', 'lazy-bookshelf')
if (this.$server.socket) {
this.$server.socket.off('audiobook_updated', this.audiobookUpdated)
this.$server.socket.off('audiobook_added', this.audiobookAdded)
this.$server.socket.off('audiobook_removed', this.audiobookRemoved)
this.$server.socket.off('audiobooks_updated', this.audiobooksUpdated)
this.$server.socket.off('audiobooks_added', this.audiobooksAdded)
} else {
console.error('Bookshelf - Socket not initialized')
}
this.$socket.$off('item_updated', this.libraryItemUpdated)
this.$socket.$off('item_added', this.libraryItemAdded)
this.$socket.$off('item_removed', this.libraryItemRemoved)
this.$socket.$off('items_updated', this.libraryItemsUpdated)
this.$socket.$off('items_added', this.libraryItemsAdded)
}
},
mounted() {
if (this.$server.initialized) {
this.init()
} else {
this.initDownloads()
}
this.$server.on('initialized', this.socketInit)
this.init()
this.initListeners()
},
beforeDestroy() {
this.$server.off('initialized', this.socketInit)
this.removeListeners()
}
}

View file

@ -2,8 +2,9 @@
<div class="w-full relative">
<div class="bookshelfRow flex items-end px-3 max-w-full overflow-x-auto" :style="{ height: shelfHeight + 'px' }">
<template v-for="(entity, index) in entities">
<cards-lazy-book-card v-if="type === 'books'" :key="entity.id" :index="index" :book-mount="entity" :width="bookWidth" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" is-categorized class="mx-2 relative" />
<cards-lazy-book-card v-if="type === 'book' || type === 'podcast'" :key="entity.id" :index="index" :book-mount="entity" :width="bookWidth" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" is-categorized class="mx-2 relative" />
<cards-lazy-series-card v-else-if="type === 'series'" :key="entity.id" :index="index" :series-mount="entity" :width="bookWidth * 2" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" is-categorized class="mx-2 relative" />
<cards-author-card v-else-if="type === 'authors'" :key="entity.id" :width="bookWidth / 1.25" :height="bookWidth" :author="entity" :size-multiplier="1" class="mx-2" />
</template>
</div>

View file

@ -0,0 +1,96 @@
<template>
<div @mouseover="mouseover" @mouseout="mouseout">
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
<!-- Image or placeholder -->
<covers-author-image :author="author" />
<!-- Author name & num books overlay -->
<div v-show="!searching && !nameBelow" class="absolute bottom-0 left-0 w-full py-1 bg-black bg-opacity-60 px-2">
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
</div>
<!-- Search icon btn -->
<div v-show="!searching && isHovering" class="absolute top-0 left-0 p-2 cursor-pointer hover:text-white text-gray-200 transform transition-transform hover:scale-125" @click.prevent.stop="searchAuthor">
<span class="material-icons text-lg">search</span>
</div>
<div v-show="isHovering && !searching" class="absolute top-0 right-0 p-2 cursor-pointer hover:text-white text-gray-200 transform transition-transform hover:scale-125" @click.prevent.stop="$emit('edit', author)">
<span class="material-icons text-lg">edit</span>
</div>
<!-- Loading spinner -->
<div v-show="searching" class="absolute top-0 left-0 z-10 w-full h-full bg-black bg-opacity-50 flex items-center justify-center">
<widgets-loading-spinner size="" />
</div>
</div>
<div v-show="nameBelow" class="w-full py-1 px-2">
<p class="text-center font-semibold truncate text-gray-200" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
author: {
type: Object,
default: () => {}
},
width: Number,
height: Number,
sizeMultiplier: {
type: Number,
default: 1
},
nameBelow: Boolean
},
data() {
return {
searching: false,
isHovering: false
}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
_author() {
return this.author || {}
},
authorId() {
return this._author.id
},
name() {
return this._author.name || ''
},
numBooks() {
return this._author.numBooks || 0
}
},
methods: {
mouseover() {
this.isHovering = true
},
mouseout() {
this.isHovering = false
},
async searchAuthor() {
this.searching = true
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, { q: this.name }).catch((error) => {
console.error('Failed', error)
return null
})
if (!response) {
this.$toast.error('Author not found')
} else if (response.updated) {
if (response.author.imagePath) this.$toast.success('Author was updated')
else this.$toast.success('Author was updated (no image found)')
} else {
this.$toast.info('No updates were made for Author')
}
this.searching = false
}
},
mounted() {}
}
</script>

View file

@ -1,16 +1,27 @@
<template>
<div ref="card" :id="`book-card-${index}`" :style="{ width: width + 'px', minWidth: width + 'px', height: height + 'px' }" class="rounded-sm z-10 bg-primary cursor-pointer box-shadow-book" @click="clickCard">
<div ref="card" :id="`book-card-${index}`" :style="{ minWidth: width + 'px', maxWidth: width + 'px', height: height + 'px' }" class="rounded-sm z-10 bg-primary cursor-pointer box-shadow-book" @click="clickCard">
<!-- When cover image does not fill -->
<div v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
<div class="absolute cover-bg" ref="coverBg" />
</div>
<!-- Alternative bookshelf title/author/sort -->
<!-- <div v-if="isAlternativeBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
<p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
{{ displayTitle }}
</p>
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayAuthor }}</p>
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
</div> -->
<div v-if="booksInSeries" class="absolute z-20 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ booksInSeries }}</div>
<div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10">
<div v-show="audiobook && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: sizeMultiplier * 0.5 + 'rem' }">
<div v-show="libraryItem && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: sizeMultiplier * 0.5 + 'rem' }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }" class="font-book text-gray-300 text-center">{{ title }}</p>
</div>
<img v-show="audiobook" ref="cover" :src="bookCoverSrc" class="w-full h-full transition-opacity duration-300" :class="hasCover ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
<img v-show="libraryItem" ref="cover" :src="bookCoverSrc" class="w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
<!-- Placeholder Cover Title & Author -->
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem' }">
@ -23,25 +34,35 @@
</div>
</div>
<!-- Downloaded indicator icon -->
<div v-if="hasDownload" class="absolute z-10" :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>
<!-- No progress shown for collapsed series in library -->
<div v-if="!collapsedSeries && !isPodcast" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
<div v-if="localLibraryItem || isLocal" class="absolute top-0 right-0 z-20" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<span class="material-icons text-2xl text-success">{{ isLocalOnly ? 'task' : 'download_done' }}</span>
</div>
<!-- Progress bar -->
<div class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
<!-- Error widget -->
<ui-tooltip v-if="showError" :text="errorText" class="absolute bottom-4 left-0 z-10">
<div :style="{ height: 1.5 * sizeMultiplier + 'rem', width: 2.5 * sizeMultiplier + 'rem' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300">
<span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span>
</div>
</ui-tooltip>
<div v-if="showError" :style="{ height: 1.5 * sizeMultiplier + 'rem', width: 2.5 * sizeMultiplier + 'rem' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300">
<span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span>
<!-- Volume number -->
<div v-if="seriesSequence && showSequence && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequence }}</p>
</div>
<div v-if="volumeNumber && showVolumeNumber && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ volumeNumber }}</p>
<!-- Podcast Num Episodes -->
<div v-if="numEpisodes && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodes }}</p>
</div>
</div>
</template>
<script>
import { Capacitor } from '@capacitor/core'
export default {
props: {
index: Number,
@ -54,110 +75,195 @@ export default {
default: 192
},
bookCoverAspectRatio: Number,
showVolumeNumber: Boolean,
showSequence: Boolean,
bookshelfView: Number,
bookMount: {
// Book can be passed as prop or set with setEntity()
type: Object,
default: () => null
}
},
orderBy: String,
filterBy: String,
sortingIgnorePrefix: Boolean
},
data() {
return {
isHovering: false,
isMoreMenuOpen: false,
isProcessingReadUpdate: false,
audiobook: null,
libraryItem: null,
imageReady: false,
rescanning: false,
selected: false,
isSelectionMode: false,
showCoverBg: false
showCoverBg: false,
localLibraryItem: null
}
},
watch: {
bookMount: {
handler(newVal) {
if (newVal) {
this.libraryItem = newVal
}
}
}
},
computed: {
_audiobook() {
return this.audiobook || {}
showExperimentalFeatures() {
return this.store.state.showExperimentalFeatures
},
_libraryItem() {
return this.libraryItem || {}
},
isLocal() {
return !!this._libraryItem.isLocal
},
isLocalOnly() {
// Local item with no server match
return this.isLocal && !this._libraryItem.libraryItemId
},
media() {
return this._libraryItem.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
},
mediaType() {
return this._libraryItem.mediaType
},
isPodcast() {
return this.mediaType === 'podcast'
},
placeholderUrl() {
return '/book_placeholder.jpg'
},
hasDownload() {
return !!this._audiobook.download
},
downloadedCover() {
if (!this._audiobook.download) return null
return this._audiobook.download.cover
},
bookCoverSrc() {
if (this.downloadedCover) return this.downloadedCover
return this.store.getters['audiobooks/getBookCoverSrc'](this._audiobook, this.placeholderUrl)
if (this.isLocal) {
if (this.libraryItem.coverContentUrl) return Capacitor.convertFileSrc(this.libraryItem.coverContentUrl)
return this.placeholderUrl
}
return this.store.getters['globals/getLibraryItemCoverSrc'](this._libraryItem, this.placeholderUrl)
},
audiobookId() {
return this._audiobook.id
libraryItemId() {
return this._libraryItem.id
},
libraryId() {
return this._libraryItem.libraryId
},
hasEbook() {
return this._audiobook.numEbooks
return this.media.ebookFile
},
hasTracks() {
return this._audiobook.numTracks
numTracks() {
return this.media.numTracks
},
book() {
return this._audiobook.book || {}
numEpisodes() {
return this.media.numEpisodes
},
processingBatch() {
return this.store.state.processingBatch
},
booksInSeries() {
// Only added to audiobook object when collapseSeries is enabled
return this._libraryItem.booksInSeries
},
hasCover() {
return !!this.book.cover
return !!this.media.coverPath
},
squareAspectRatio() {
return this.bookCoverAspectRatio === 1
},
sizeMultiplier() {
var baseSize = this.squareAspectRatio ? 160 : 100
var baseSize = this.squareAspectRatio ? 192 : 120
return this.width / baseSize
},
title() {
return this.book.title || ''
return this.mediaMetadata.title || ''
},
playIconFontSize() {
return Math.max(2, 3 * this.sizeMultiplier)
},
authors() {
return this.mediaMetadata.authors || []
},
author() {
return this.book.author
},
authorFL() {
return this.book.authorFL || this.author
if (this.isPodcast) return this.mediaMetadata.author
return this.mediaMetadata.authorName
},
authorLF() {
return this.book.authorLF || this.author
return this.mediaMetadata.authorNameLF
},
volumeNumber() {
return this.book.volumeNumber || null
series() {
// Only included when filtering by series or collapse series
return this.mediaMetadata.series
},
seriesSequence() {
return this.series ? this.series.sequence : null
},
collapsedSeries() {
// Only added to item object when collapseSeries is enabled
return this._libraryItem.collapsedSeries
},
booksInSeries() {
// Only added to item object when collapseSeries is enabled
return this.collapsedSeries ? this.collapsedSeries.numBooks : 0
},
displayTitle() {
if (this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix && this.title.toLowerCase().startsWith('the ')) {
return this.title.substr(4) + ', The'
}
return this.title
},
displayAuthor() {
if (this.orderBy === 'media.metadata.authorNameLF') return this.authorLF
return this.author
},
displaySortLine() {
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs)
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs)
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt)
if (this.orderBy === 'duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
return null
},
userProgress() {
return this.store.getters['user/getUserAudiobook'](this.audiobookId)
if (this.isLocal) return this.store.getters['globals/getLocalMediaProgressById'](this.libraryItemId)
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
userProgressPercent() {
return this.userProgress ? this.userProgress.progress || 0 : 0
},
userIsRead() {
return this.userProgress ? !!this.userProgress.isRead : false
itemIsFinished() {
return this.userProgress ? !!this.userProgress.isFinished : false
},
showError() {
return this.hasMissingParts || this.hasInvalidParts || this.isMissing || this.isInvalid
},
isStreaming() {
return this.store.getters['getAudiobookIdStreaming'] === this.audiobookId
return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId
},
showReadButton() {
return !this.isSelectionMode && this.showExperimentalFeatures && !this.showPlayButton && this.hasEbook
},
showPlayButton() {
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && this.numTracks && !this.isStreaming
},
showSmallEBookIcon() {
return !this.isSelectionMode && this.showExperimentalFeatures && this.hasEbook
},
isMissing() {
return this._audiobook.isMissing
return this._libraryItem.isMissing
},
isInvalid() {
return this._audiobook.isInvalid
return this._libraryItem.isInvalid
},
hasMissingParts() {
return this._audiobook.hasMissingParts
return this._libraryItem.hasMissingParts
},
hasInvalidParts() {
return this._audiobook.hasInvalidParts
return this._libraryItem.hasInvalidParts
},
errorText() {
if (this.isMissing) return 'Audiobook directory is missing!'
else if (this.isInvalid) return 'Audiobook has no audio tracks & ebook'
if (this.isMissing) return 'Item directory is missing!'
else if (this.isInvalid) return 'Item has no media files'
var txt = ''
if (this.hasMissingParts) {
txt = `${this.hasMissingParts} missing parts.`
@ -168,6 +274,15 @@ export default {
}
return txt || 'Unknown Error'
},
overlayWrapperClasslist() {
var classes = []
if (this.isSelectionMode) classes.push('bg-opacity-60')
else classes.push('bg-opacity-40')
if (this.selected) {
classes.push('border-2 border-yellow-400')
}
return classes
},
store() {
return this.$store || this.$nuxt.$store
},
@ -206,11 +321,11 @@ export default {
return this.title
},
authorCleaned() {
if (!this.authorFL) return ''
if (this.authorFL.length > 30) {
return this.authorFL.slice(0, 27) + '...'
if (!this.author) return ''
if (this.author.length > 30) {
return this.author.slice(0, 27) + '...'
}
return this.authorFL
return this.author
}
},
methods: {
@ -218,8 +333,12 @@ export default {
this.isSelectionMode = val
if (!val) this.selected = false
},
setEntity(audiobook) {
this.audiobook = audiobook
setEntity(libraryItem) {
this.libraryItem = libraryItem
},
setLocalLibraryItem(localLibraryItem) {
// Server books may have a local library item
this.localLibraryItem = localLibraryItem
},
clickCard(e) {
if (this.isSelectionMode) {
@ -228,12 +347,84 @@ export default {
this.selectBtnClick()
} else {
var router = this.$router || this.$nuxt.$router
if (router) router.push(`/audiobook/${this.audiobookId}`)
if (router) {
if (this.collapsedSeries) router.push(`/library/${this.libraryId}/series/${this.collapsedSeries.id}`)
else router.push(`/item/${this.libraryItemId}`)
}
}
},
editClick() {
this.$emit('edit', this.libraryItem)
},
toggleFinished() {
var updatePayload = {
isFinished: !this.itemIsFinished
}
this.isProcessingReadUpdate = true
var toast = this.$toast || this.$nuxt.$toast
var axios = this.$axios || this.$nuxt.$axios
axios
.$patch(`/api/me/progress/${this.libraryItemId}`, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
})
.catch((error) => {
console.error('Failed', error)
this.isProcessingReadUpdate = false
toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
})
},
rescan() {
this.rescanning = true
this.$axios
.$get(`/api/items/${this.libraryItemId}/scan`)
.then((data) => {
this.rescanning = false
var result = data.result
if (!result) {
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
} else if (result === 'UPDATED') {
this.$toast.success(`Re-Scan complete item was updated`)
} else if (result === 'UPTODATE') {
this.$toast.success(`Re-Scan complete item was up to date`)
} else if (result === 'REMOVED') {
this.$toast.error(`Re-Scan complete item was removed`)
}
})
.catch((error) => {
console.error('Failed to scan library item', error)
this.$toast.error('Failed to scan library item')
this.rescanning = false
})
},
showEditModalTracks() {
// More menu func
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'tracks' })
},
showEditModalMatch() {
// More menu func
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'match' })
},
showEditModalDownload() {
// More menu func
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'download' })
},
openCollections() {
this.store.commit('setSelectedLibraryItem', this.libraryItem)
this.store.commit('globals/setShowUserCollectionsModal', true)
},
clickReadEBook() {
this.store.commit('showEReader', this.libraryItem)
},
selectBtnClick() {
if (this.processingBatch) return
this.selected = !this.selected
this.$emit('select', this.audiobook)
this.$emit('select', this.libraryItem)
},
play() {
var eventBus = this.$eventBus || this.$nuxt.$eventBus
eventBus.$emit('play-item', { libraryItemId: this.libraryItemId })
},
destroy() {
// destroy the vue listeners, etc
@ -272,6 +463,10 @@ export default {
mounted() {
if (this.bookMount) {
this.setEntity(this.bookMount)
if (this.bookMount.localLibraryItem) {
this.setLocalLibraryItem(this.bookMount.localLibraryItem)
}
}
}
}

View file

@ -0,0 +1,446 @@
<template>
<div ref="card" :id="`book-card-${index}`" :style="{ minWidth: width + 'px', maxWidth: width + 'px', height: height + 'px' }" class="rounded-sm z-10 cursor-pointer py-1" @click="clickCard">
<div class="h-full flex">
<div class="w-20 h-20 relative" style="min-width: 80px; max-width: 80px">
<!-- When cover image does not fill -->
<div v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
<div class="absolute cover-bg" ref="coverBg" />
</div>
<div class="w-full h-full absolute top-0 left-0">
<img v-show="libraryItem" ref="cover" :src="bookCoverSrc" class="w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
</div>
<!-- No progress shown for collapsed series or podcasts in library -->
<div v-if="!isPodcast && !collapsedSeries" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: 80 * userProgressPercent + 'px' }"></div>
<div v-if="localLibraryItem || isLocal" class="absolute top-0 right-0 z-20" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<span class="material-icons text-2xl text-success">{{ isLocalOnly ? 'task' : 'download_done' }}</span>
</div>
</div>
<div class="flex-grow px-2">
<p class="whitespace-normal" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">
{{ displayTitle }}<span v-if="seriesSequence">&nbsp;#{{ seriesSequence }}</span>
</p>
<p class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ displayAuthor }}</p>
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
</div>
</div>
</div>
</template>
<script>
import { Capacitor } from '@capacitor/core'
export default {
props: {
index: Number,
width: {
type: Number,
default: 120
},
height: {
type: Number,
default: 192
},
bookCoverAspectRatio: Number,
showSequence: Boolean,
bookshelfView: Number,
bookMount: {
// Book can be passed as prop or set with setEntity()
type: Object,
default: () => null
},
orderBy: String,
filterBy: String,
sortingIgnorePrefix: Boolean
},
data() {
return {
isProcessingReadUpdate: false,
libraryItem: null,
imageReady: false,
rescanning: false,
selected: false,
isSelectionMode: false,
showCoverBg: false,
localLibraryItem: null
}
},
watch: {
bookMount: {
handler(newVal) {
if (newVal) {
this.libraryItem = newVal
}
}
}
},
computed: {
showExperimentalFeatures() {
return this.store.state.showExperimentalFeatures
},
_libraryItem() {
return this.libraryItem || {}
},
isLocal() {
return !!this._libraryItem.isLocal
},
isLocalOnly() {
// Local item with no server match
return this.isLocal && !this._libraryItem.libraryItemId
},
media() {
return this._libraryItem.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
},
mediaType() {
return this._libraryItem.mediaType
},
isPodcast() {
return this.mediaType === 'podcast'
},
placeholderUrl() {
return '/book_placeholder.jpg'
},
bookCoverSrc() {
if (this.isLocal) {
if (this.libraryItem.coverContentUrl) return Capacitor.convertFileSrc(this.libraryItem.coverContentUrl)
return this.placeholderUrl
}
return this.store.getters['globals/getLibraryItemCoverSrc'](this._libraryItem, this.placeholderUrl)
},
libraryItemId() {
return this._libraryItem.id
},
series() {
return this.mediaMetadata.series
},
libraryId() {
return this._libraryItem.libraryId
},
hasEbook() {
return this.media.ebookFile
},
numTracks() {
return this.media.numTracks
},
processingBatch() {
return this.store.state.processingBatch
},
booksInSeries() {
// Only added to audiobook object when collapseSeries is enabled
return this._libraryItem.booksInSeries
},
hasCover() {
return !!this.media.coverPath
},
squareAspectRatio() {
return this.bookCoverAspectRatio === 1
},
sizeMultiplier() {
return this.width / 364
},
title() {
return this.mediaMetadata.title || ''
},
playIconFontSize() {
return Math.max(2, 3 * this.sizeMultiplier)
},
author() {
return this.mediaMetadata.authorName || ''
},
authorLF() {
return this.mediaMetadata.authorNameLF || ''
},
series() {
// Only included when filtering by series or collapse series
return this.mediaMetadata.series
},
seriesSequence() {
return this.series ? this.series.sequence : null
},
collapsedSeries() {
// Only added to item object when collapseSeries is enabled
return this._libraryItem.collapsedSeries
},
booksInSeries() {
// Only added to item object when collapseSeries is enabled
return this.collapsedSeries ? this.collapsedSeries.numBooks : 0
},
displayTitle() {
if (this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix && this.title.toLowerCase().startsWith('the ')) {
return this.title.substr(4) + ', The'
}
return this.title
},
displayAuthor() {
if (this.orderBy === 'media.metadata.authorNameLF') return this.authorLF
return this.author
},
displaySortLine() {
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs)
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs)
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt)
if (this.orderBy === 'duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
return null
},
userProgress() {
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
userProgressPercent() {
return this.userProgress ? this.userProgress.progress || 0 : 0
},
itemIsFinished() {
return this.userProgress ? !!this.userProgress.isFinished : false
},
showError() {
return this.hasMissingParts || this.hasInvalidParts || this.isMissing || this.isInvalid
},
isStreaming() {
return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId
},
showReadButton() {
return !this.isSelectionMode && this.showExperimentalFeatures && !this.showPlayButton && this.hasEbook
},
showPlayButton() {
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && this.numTracks && !this.isStreaming
},
showSmallEBookIcon() {
return !this.isSelectionMode && this.showExperimentalFeatures && this.hasEbook
},
isMissing() {
return this._libraryItem.isMissing
},
isInvalid() {
return this._libraryItem.isInvalid
},
hasMissingParts() {
return this._libraryItem.hasMissingParts
},
hasInvalidParts() {
return this._libraryItem.hasInvalidParts
},
errorText() {
if (this.isMissing) return 'Item directory is missing!'
else if (this.isInvalid) return 'Item has no media files'
var txt = ''
if (this.hasMissingParts) {
txt = `${this.hasMissingParts} missing parts.`
}
if (this.hasInvalidParts) {
if (this.hasMissingParts) txt += ' '
txt += `${this.hasInvalidParts} invalid parts.`
}
return txt || 'Unknown Error'
},
overlayWrapperClasslist() {
var classes = []
if (this.isSelectionMode) classes.push('bg-opacity-60')
else classes.push('bg-opacity-40')
if (this.selected) {
classes.push('border-2 border-yellow-400')
}
return classes
},
store() {
return this.$store || this.$nuxt.$store
},
userCanUpdate() {
return this.store.getters['user/getUserCanUpdate']
},
userCanDelete() {
return this.store.getters['user/getUserCanDelete']
},
userCanDownload() {
return this.store.getters['user/getUserCanDownload']
},
userIsRoot() {
return this.store.getters['user/getIsRoot']
},
_socket() {
return this.$root.socket || this.$nuxt.$root.socket
},
titleFontSize() {
return 0.75 * this.sizeMultiplier
},
authorFontSize() {
return 0.6 * this.sizeMultiplier
},
placeholderCoverPadding() {
return 0.8 * this.sizeMultiplier
},
authorBottom() {
return 0.75 * this.sizeMultiplier
},
titleCleaned() {
if (!this.title) return ''
if (this.title.length > 60) {
return this.title.slice(0, 57) + '...'
}
return this.title
},
authorCleaned() {
if (!this.author) return ''
if (this.author.length > 30) {
return this.author.slice(0, 27) + '...'
}
return this.author
},
isAlternativeBookshelfView() {
return false
// var constants = this.$constants || this.$nuxt.$constants
// return this.bookshelfView === constants.BookshelfView.TITLES
},
titleDisplayBottomOffset() {
if (!this.isAlternativeBookshelfView) return 0
else if (!this.displaySortLine) return 3 * this.sizeMultiplier
return 4.25 * this.sizeMultiplier
}
},
methods: {
setSelectionMode(val) {
this.isSelectionMode = val
if (!val) this.selected = false
},
setEntity(libraryItem) {
this.libraryItem = libraryItem
},
setLocalLibraryItem(localLibraryItem) {
// Server books may have a local library item
this.localLibraryItem = localLibraryItem
},
clickCard(e) {
if (this.isSelectionMode) {
e.stopPropagation()
e.preventDefault()
this.selectBtnClick()
} else {
var router = this.$router || this.$nuxt.$router
if (router) {
if (this.collapsedSeries) router.push(`/library/${this.libraryId}/series/${this.collapsedSeries.id}`)
else router.push(`/item/${this.libraryItemId}`)
}
}
},
editClick() {
this.$emit('edit', this.libraryItem)
},
toggleFinished() {
var updatePayload = {
isFinished: !this.itemIsFinished
}
this.isProcessingReadUpdate = true
var toast = this.$toast || this.$nuxt.$toast
var axios = this.$axios || this.$nuxt.$axios
axios
.$patch(`/api/me/progress/${this.libraryItemId}`, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
})
.catch((error) => {
console.error('Failed', error)
this.isProcessingReadUpdate = false
toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
})
},
rescan() {
this.rescanning = true
this.$axios
.$get(`/api/items/${this.libraryItemId}/scan`)
.then((data) => {
this.rescanning = false
var result = data.result
if (!result) {
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
} else if (result === 'UPDATED') {
this.$toast.success(`Re-Scan complete item was updated`)
} else if (result === 'UPTODATE') {
this.$toast.success(`Re-Scan complete item was up to date`)
} else if (result === 'REMOVED') {
this.$toast.error(`Re-Scan complete item was removed`)
}
})
.catch((error) => {
console.error('Failed to scan library item', error)
this.$toast.error('Failed to scan library item')
this.rescanning = false
})
},
showEditModalTracks() {
// More menu func
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'tracks' })
},
showEditModalMatch() {
// More menu func
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'match' })
},
showEditModalDownload() {
// More menu func
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'download' })
},
openCollections() {
this.store.commit('setSelectedLibraryItem', this.libraryItem)
this.store.commit('globals/setShowUserCollectionsModal', true)
},
clickReadEBook() {
this.store.commit('showEReader', this.libraryItem)
},
selectBtnClick() {
if (this.processingBatch) return
this.selected = !this.selected
this.$emit('select', this.libraryItem)
},
play() {
var eventBus = this.$eventBus || this.$nuxt.$eventBus
eventBus.$emit('play-item', { libraryItemId: this.libraryItemId })
},
destroy() {
// destroy the vue listeners, etc
this.$destroy()
// remove the element from the DOM
if (this.$el && this.$el.parentNode) {
this.$el.parentNode.removeChild(this.$el)
} else if (this.$el && this.$el.remove) {
this.$el.remove()
}
},
setCoverBg() {
if (this.$refs.coverBg) {
this.$refs.coverBg.style.backgroundImage = `url("${this.bookCoverSrc}")`
}
},
imageLoaded() {
this.imageReady = true
if (this.$refs.cover && this.bookCoverSrc !== this.placeholderUrl) {
var { naturalWidth, naturalHeight } = this.$refs.cover
var aspectRatio = naturalHeight / naturalWidth
var arDiff = Math.abs(aspectRatio - this.bookCoverAspectRatio)
// If image aspect ratio is <= 1.45 or >= 1.75 then use cover bg, otherwise stretch to fit
if (arDiff > 0.15) {
this.showCoverBg = true
this.$nextTick(this.setCoverBg)
} else {
this.showCoverBg = false
}
}
}
},
mounted() {
if (this.bookMount) {
this.setEntity(this.bookMount)
if (this.bookMount.localLibraryItem) {
this.setLocalLibraryItem(this.bookMount.localLibraryItem)
}
}
}
}
</script>

View file

@ -56,7 +56,7 @@ export default {
return this.store.state.libraries.currentLibraryId
},
seriesId() {
return this.series ? this.$encode(this.series.id) : null
return this.series ? this.series.id : null
},
hasValidCovers() {
var validCovers = this.books.map((bookItem) => bookItem.book.cover)

View file

@ -0,0 +1,319 @@
<template>
<div class="w-full max-w-md mx-auto px-4 sm:px-6 lg:px-8 z-10">
<div v-show="!loggedIn" class="mt-8 bg-primary overflow-hidden shadow rounded-lg p-6 w-full">
<template v-if="!showForm">
<div v-for="config in serverConnectionConfigs" :key="config.id" class="flex items-center py-4 my-1 border-b border-white border-opacity-10 relative" @click="connectToServer(config)">
<span class="material-icons-outlined text-xl text-gray-300">dns</span>
<p class="pl-3 pr-6 text-base text-gray-200">{{ config.name }}</p>
<div class="absolute top-0 right-0 h-full px-4 flex items-center" @click.stop="editServerConfig(config)">
<span class="material-icons text-lg text-gray-300">more_vert</span>
</div>
</div>
<div class="my-1 py-4 w-full">
<ui-btn class="w-full" @click="newServerConfigClick">Add New Server</ui-btn>
</div>
</template>
<template v-else>
<form v-show="!showAuth" @submit.prevent="submit" novalidate class="w-full">
<h2 class="text-lg leading-7 mb-2">Server address</h2>
<ui-text-input v-model="serverConfig.address" :disabled="processing || !networkConnected || serverConfig.id" placeholder="http://55.55.55.55:13378" type="url" class="w-full sm:w-72 h-10" />
<div class="flex justify-end">
<ui-btn :disabled="processing || !networkConnected" type="submit" :padding-x="3" class="h-10 mt-4">{{ networkConnected ? 'Submit' : 'No Internet' }}</ui-btn>
</div>
</form>
<template v-if="showAuth">
<div v-if="serverConfig.id" class="flex items-center mb-4" @click="showServerList">
<span class="material-icons text-gray-300">arrow_back</span>
</div>
<div class="flex items-center">
<p class="text-gray-300">{{ serverConfig.address }}</p>
<div class="flex-grow" />
<span v-if="!serverConfig.id" class="material-icons" style="font-size: 1.1rem" @click="editServerAddress">edit</span>
</div>
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
<form @submit.prevent="submitAuth" class="pt-3">
<ui-text-input v-model="serverConfig.username" :disabled="processing" placeholder="username" class="w-full mb-2 text-lg" />
<ui-text-input v-model="password" type="password" :disabled="processing" placeholder="password" class="w-full mb-2 text-lg" />
<div class="flex items-center pt-2">
<ui-icon-btn v-if="serverConfig.id" small bg-color="error" icon="delete" @click="removeServerConfigClick" />
<div class="flex-grow" />
<ui-btn :disabled="processing || !networkConnected" type="submit" class="mt-1 h-10">{{ networkConnected ? 'Submit' : 'No Internet' }}</ui-btn>
</div>
</form>
</template>
<div v-show="error" class="w-full rounded-lg bg-red-600 bg-opacity-10 border border-error border-opacity-50 py-3 px-2 flex items-center mt-4">
<span class="material-icons mr-2 text-error" style="font-size: 1.1rem">warning</span>
<p class="text-error">{{ error }}</p>
</div>
</template>
</div>
<div :class="processing ? 'opacity-100' : 'opacity-0 pointer-events-none'" class="fixed w-full h-full top-0 left-0 bg-black bg-opacity-75 flex items-center justify-center z-30 transition-opacity duration-500">
<div>
<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" />
</div>
<svg class="animate-spin w-16 h-16" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>
</div>
</div>
</div>
</template>
<script>
import { Dialog } from '@capacitor/dialog'
export default {
props: {},
data() {
return {
deviceData: null,
// serverConnectionConfigs: [
// {
// id: 'test1',
// name: 'http://192.168.0.1:3333 (root)',
// address: 'http://192.168.0.1:3333',
// username: 'root',
// token: 'asdf'
// },
// {
// id: 'test2',
// name: 'https://someserver.com (user)',
// address: 'https://someserver.com',
// username: 'user',
// token: 'asdf'
// }
// ],
loggedIn: false,
showAuth: false,
processing: false,
serverConfig: {
address: null,
username: null
},
password: null,
error: null,
showForm: false
}
},
computed: {
networkConnected() {
return this.$store.state.networkConnected
},
serverConnectionConfigs() {
return this.deviceData ? this.deviceData.serverConnectionConfigs || [] : []
},
lastServerConnectionConfigId() {
return this.deviceData ? this.deviceData.lastServerConnectionConfigId : null
},
lastServerConnectionConfig() {
if (!this.lastServerConnectionConfigId || !this.serverConnectionConfigs.length) return null
return this.serverConnectionConfigs.find((s) => s.id == this.lastServerConnectionConfigId)
}
},
methods: {
showServerList() {
this.showForm = false
this.showAuth = false
this.error = null
this.serverConfig = {
address: null,
userId: null,
username: null
}
},
async connectToServer(config) {
this.processing = true
this.serverConfig = {
...config
}
this.showForm = true
var success = await this.pingServerAddress(config.address)
if (!success) {
return
}
this.error = null
this.processing = false
var payload = await this.authenticateToken()
if (payload) {
this.setUserAndConnection(payload.user, payload.userDefaultLibraryId)
} else {
this.showAuth = true
}
},
async removeServerConfigClick() {
if (!this.serverConfig.id) return
const { value } = await Dialog.confirm({
title: 'Confirm',
message: `Remove this server config?`
})
if (value) {
this.processing = true
await this.$db.removeServerConnectionConfig(this.serverConfig.id)
this.deviceData.serverConnectionConfigs = this.deviceData.serverConnectionConfigs.filter((scc) => scc.id != this.serverConfig.id)
this.serverConfig = {
address: null,
userId: null,
username: null
}
this.password = null
this.processing = false
this.showAuth = false
this.showForm = !this.serverConnectionConfigs.length
}
},
editServerConfig(serverConfig) {
this.serverConfig = {
...serverConfig
}
this.showForm = true
this.showAuth = true
console.log('Edit server config', serverConfig)
},
newServerConfigClick() {
this.showForm = true
this.showAuth = false
},
editServerAddress() {
this.error = null
this.showAuth = false
},
validateServerUrl(url) {
try {
var urlObject = new URL(url)
var address = `${urlObject.protocol}//${urlObject.hostname}`
if (urlObject.port) address += ':' + urlObject.port
return address
} catch (error) {
console.error('Invalid URL', error)
return null
}
},
pingServerAddress(address) {
return this.$axios
.$get(`${address}/ping`, { timeout: 1000 })
.then((data) => data.success)
.catch((error) => {
console.error('Server check failed', error)
this.error = 'Failed to ping server'
return false
})
},
requestServerLogin() {
return this.$axios
.$post(`${this.serverConfig.address}/login`, { username: this.serverConfig.username, password: this.password })
.then((data) => {
if (!data.user) {
console.error(data.error)
this.error = data.error || 'Unknown Error'
return false
}
return data
})
.catch((error) => {
console.error('Server auth failed', error)
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
this.error = errorMsg
return false
})
},
async submit() {
if (!this.networkConnected) return
if (!this.serverConfig.address) return
if (!this.serverConfig.address.startsWith('http')) {
this.serverConfig.address = 'http://' + this.serverConfig.address
}
var validServerAddress = this.validateServerUrl(this.serverConfig.address)
if (!validServerAddress) {
this.error = 'Invalid server address'
return
}
this.serverConfig.address = validServerAddress
this.processing = true
this.error = null
var success = await this.pingServerAddress(this.serverConfig.address)
this.processing = false
if (success) this.showAuth = true
},
async submitAuth() {
if (!this.networkConnected) return
if (!this.serverConfig.username) {
this.error = 'Invalid username'
return
}
this.error = null
this.processing = true
var payload = await this.requestServerLogin()
this.processing = false
if (payload) {
this.setUserAndConnection(payload.user, payload.userDefaultLibraryId)
}
},
async setUserAndConnection(user, userDefaultLibraryId) {
if (!user) return
console.log('Successfully logged in', JSON.stringify(user))
// Set library - Use last library if set and available fallback to default user library
var lastLibraryId = await this.$localStore.getLastLibraryId()
if (lastLibraryId && (!user.librariesAccessible.length || user.librariesAccessible.includes(lastLibraryId))) {
this.$store.commit('libraries/setCurrentLibrary', lastLibraryId)
} else if (userDefaultLibraryId) {
this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId)
}
this.serverConfig.userId = user.id
this.serverConfig.token = user.token
var serverConnectionConfig = await this.$db.setServerConnectionConfig(this.serverConfig)
this.$store.commit('user/setUser', user)
this.$store.commit('user/setServerConnectionConfig', serverConnectionConfig)
this.$socket.connect(this.serverConfig.address, this.serverConfig.token)
this.$router.replace('/bookshelf')
},
async authenticateToken() {
if (!this.networkConnected) return
if (!this.serverConfig.token) {
this.error = 'No token'
return
}
this.error = null
this.processing = true
var authRes = await this.$axios.$post(`${this.serverConfig.address}/api/authorize`, null, { headers: { Authorization: `Bearer ${this.serverConfig.token}` } }).catch((error) => {
console.error('[Server] Server auth failed', error)
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
this.error = errorMsg
return false
})
this.processing = false
return authRes
},
async init() {
this.deviceData = await this.$db.getDeviceData()
if (this.lastServerConnectionConfig) {
this.connectToServer(this.lastServerConnectionConfig)
} else {
this.showForm = !this.serverConnectionConfigs.length
}
}
},
mounted() {
this.init()
}
}
</script>

View file

@ -0,0 +1,87 @@
<template>
<nuxt-link :to="`/bookshelf/library?filter=authors.${$encode(authorId)}`" ref="wrapper" :class="`rounded-${rounded}`" class="w-full h-full bg-primary overflow-hidden">
<svg v-if="!imagePath" width="140%" height="140%" style="margin-left: -20%; margin-top: -20%; opacity: 0.6" viewBox="0 0 177 266" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="white" d="M40.7156 165.47C10.2694 150.865 -31.5407 148.629 -38.0532 155.529L63.3191 204.159L76.9443 190.899C66.828 181.394 54.006 171.846 40.7156 165.47Z" stroke="white" stroke-width="4" transform="translate(-2 -1)" />
<path d="M-38.0532 155.529C-31.5407 148.629 10.2694 150.865 40.7156 165.47C54.006 171.846 66.828 181.394 76.9443 190.899L95.0391 173.37C80.6681 159.403 64.7526 149.155 51.5747 142.834C21.3549 128.337 -46.2471 114.563 -60.6897 144.67L-71.5489 167.307L44.5864 223.019L63.3191 204.159L-38.0532 155.529Z" fill="white" />
<path
d="M105.87 29.6508C80.857 17.6515 50.8784 28.1923 38.879 53.2056C26.8797 78.219 37.4205 108.198 62.4338 120.197C87.4472 132.196 117.426 121.656 129.425 96.6422C141.425 71.6288 130.884 41.6502 105.87 29.6508ZM106.789 85.783C112.761 73.3329 107.461 58.2599 95.0112 52.2874C82.5611 46.3148 67.4881 51.6147 61.5156 64.0648C55.543 76.5149 60.8429 91.5879 73.293 97.5604C85.7431 103.533 100.816 98.2331 106.789 85.783Z"
fill="white"
/>
<path
d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01ZM181.725 108.497C179.624 108.491 177.436 109.326 175.835 110.918L160.415 126.257L191.848 157.856L207.268 142.517C210.554 139.248 210.568 133.954 207.299 130.667L187.685 110.95C186.009 109.264 183.91 108.502 181.725 108.497ZM151.399 135.226L58.2034 227.931L58.1203 259.447L89.6359 259.53L182.831 166.825L151.399 135.226Z"
fill="white"
/>
<path d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01Z" fill="white" stroke="white" stroke-width="10px" />
</svg>
<div v-else class="w-full h-full relative">
<div v-if="showCoverBg" class="cover-bg absolute" :style="{ backgroundImage: `url(${imgSrc})` }" />
<img ref="img" :src="imgSrc" @load="imageLoaded" class="absolute top-0 left-0 h-full w-full" :class="coverContain ? 'object-contain' : 'object-cover'" />
</div>
</nuxt-link>
</template>
<script>
export default {
props: {
author: {
type: Object,
default: () => {}
},
rounded: {
type: String,
default: 'lg'
}
},
data() {
return {
showCoverBg: false,
coverContain: true
}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
_author() {
return this.author || {}
},
authorId() {
return this._author.id
},
imagePath() {
return this._author.imagePath
},
updatedAt() {
return this._author.updatedAt
},
imgSrc() {
if (!this.imagePath) return null
if (process.env.NODE_ENV !== 'production') {
// Testing
return `http://localhost:3333/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}`
}
return `/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}`
}
},
methods: {
imageLoaded() {
var aspectRatio = 1.25
if (this.$refs.wrapper) {
aspectRatio = this.$refs.wrapper.clientHeight / this.$refs.wrapper.clientWidth
}
if (this.$refs.img) {
var { naturalWidth, naturalHeight } = this.$refs.img
var imgAr = naturalHeight / naturalWidth
var arDiff = Math.abs(imgAr - aspectRatio)
if (arDiff > 0.15) {
this.showCoverBg = true
} else {
this.showCoverBg = false
this.coverContain = false
}
}
}
},
mounted() {}
}
</script>

View file

@ -5,20 +5,11 @@
<div class="absolute cover-bg" ref="coverBg" />
</div>
<img v-if="audiobook" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg ? 'object-contain' : 'object-fill'" />
<div v-show="loading && audiobook" class="absolute top-0 left-0 h-full w-full flex items-center justify-center">
<img v-if="fullCoverUrl" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg ? 'object-contain' : 'object-fill'" />
<div v-show="loading && libraryItem" class="absolute top-0 left-0 h-full w-full flex items-center justify-center">
<p class="font-book text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p>
<div class="absolute top-2 right-2">
<div class="la-ball-spin-clockwise la-sm">
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
</div>
<widgets-loading-spinner />
</div>
</div>
</div>
@ -42,13 +33,14 @@
</template>
<script>
import { Capacitor } from '@capacitor/core'
export default {
props: {
audiobook: {
libraryItem: {
type: Object,
default: () => {}
},
authorOverride: String,
width: {
type: Number,
default: 120
@ -70,18 +62,28 @@ export default {
}
},
computed: {
isLocal() {
if (!this.libraryItem) return false
return this.libraryItem.isLocal
},
localCover() {
return this.libraryItem ? this.libraryItem.coverContentUrl : null
},
squareAspectRatio() {
return this.bookCoverAspectRatio === 1
},
height() {
return this.width * this.bookCoverAspectRatio
},
book() {
if (!this.audiobook) return {}
return this.audiobook.book || {}
media() {
if (!this.libraryItem) return {}
return this.libraryItem.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
},
title() {
return this.book.title || 'No Title'
return this.mediaMetadata.title || 'No Title'
},
titleCleaned() {
if (this.title.length > 60) {
@ -89,9 +91,11 @@ export default {
}
return this.title
},
authors() {
return this.mediaMetadata.authors || []
},
author() {
if (this.authorOverride) return this.authorOverride
return this.book.author || 'Unknown'
return this.authors.map((au) => au.name).join(', ')
},
authorCleaned() {
if (this.author.length > 30) {
@ -103,16 +107,20 @@ export default {
return '/book_placeholder.jpg'
},
fullCoverUrl() {
if (this.isLocal) {
if (this.localCover) return Capacitor.convertFileSrc(this.localCover)
return this.placeholderUrl
}
if (this.downloadCover) return this.downloadCover
if (!this.audiobook) return null
if (!this.libraryItem) return null
var store = this.$store || this.$nuxt.$store
return store.getters['audiobooks/getBookCoverSrc'](this.audiobook, this.placeholderUrl)
return store.getters['globals/getLibraryItemCoverSrc'](this.libraryItem, this.placeholderUrl)
},
cover() {
return this.book.cover || this.placeholderUrl
return this.media.coverPath || this.placeholderUrl
},
hasCover() {
return !!this.book.cover
return !!this.media.coverPath || this.localCover
},
sizeMultiplier() {
var baseSize = this.squareAspectRatio ? 192 : 120
@ -140,7 +148,6 @@ export default {
this.$refs.coverBg.style.backgroundImage = `url("${this.fullCoverUrl}")`
}
},
hideCoverBg() {},
imageLoaded() {
this.loading = false
this.$nextTick(() => {
@ -170,214 +177,3 @@ export default {
}
</script>
<style>
/*!
* Load Awesome v1.1.0 (http://github.danielcardoso.net/load-awesome/)
* Copyright 2015 Daniel Cardoso <@DanielCardoso>
* Licensed under MIT
*/
.la-ball-spin-clockwise,
.la-ball-spin-clockwise > div {
position: relative;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
.la-ball-spin-clockwise {
display: block;
font-size: 0;
color: #fff;
}
.la-ball-spin-clockwise.la-dark {
color: #262626;
}
.la-ball-spin-clockwise > div {
display: inline-block;
float: none;
background-color: currentColor;
border: 0 solid currentColor;
}
.la-ball-spin-clockwise {
width: 32px;
height: 32px;
}
.la-ball-spin-clockwise > div {
position: absolute;
top: 50%;
left: 50%;
width: 8px;
height: 8px;
margin-top: -4px;
margin-left: -4px;
border-radius: 100%;
-webkit-animation: ball-spin-clockwise 1s infinite ease-in-out;
-moz-animation: ball-spin-clockwise 1s infinite ease-in-out;
-o-animation: ball-spin-clockwise 1s infinite ease-in-out;
animation: ball-spin-clockwise 1s infinite ease-in-out;
}
.la-ball-spin-clockwise > div:nth-child(1) {
top: 5%;
left: 50%;
-webkit-animation-delay: -0.875s;
-moz-animation-delay: -0.875s;
-o-animation-delay: -0.875s;
animation-delay: -0.875s;
}
.la-ball-spin-clockwise > div:nth-child(2) {
top: 18.1801948466%;
left: 81.8198051534%;
-webkit-animation-delay: -0.75s;
-moz-animation-delay: -0.75s;
-o-animation-delay: -0.75s;
animation-delay: -0.75s;
}
.la-ball-spin-clockwise > div:nth-child(3) {
top: 50%;
left: 95%;
-webkit-animation-delay: -0.625s;
-moz-animation-delay: -0.625s;
-o-animation-delay: -0.625s;
animation-delay: -0.625s;
}
.la-ball-spin-clockwise > div:nth-child(4) {
top: 81.8198051534%;
left: 81.8198051534%;
-webkit-animation-delay: -0.5s;
-moz-animation-delay: -0.5s;
-o-animation-delay: -0.5s;
animation-delay: -0.5s;
}
.la-ball-spin-clockwise > div:nth-child(5) {
top: 94.9999999966%;
left: 50.0000000005%;
-webkit-animation-delay: -0.375s;
-moz-animation-delay: -0.375s;
-o-animation-delay: -0.375s;
animation-delay: -0.375s;
}
.la-ball-spin-clockwise > div:nth-child(6) {
top: 81.8198046966%;
left: 18.1801949248%;
-webkit-animation-delay: -0.25s;
-moz-animation-delay: -0.25s;
-o-animation-delay: -0.25s;
animation-delay: -0.25s;
}
.la-ball-spin-clockwise > div:nth-child(7) {
top: 49.9999750815%;
left: 5.0000051215%;
-webkit-animation-delay: -0.125s;
-moz-animation-delay: -0.125s;
-o-animation-delay: -0.125s;
animation-delay: -0.125s;
}
.la-ball-spin-clockwise > div:nth-child(8) {
top: 18.179464974%;
left: 18.1803700518%;
-webkit-animation-delay: 0s;
-moz-animation-delay: 0s;
-o-animation-delay: 0s;
animation-delay: 0s;
}
.la-ball-spin-clockwise.la-sm {
width: 16px;
height: 16px;
}
.la-ball-spin-clockwise.la-sm > div {
width: 4px;
height: 4px;
margin-top: -2px;
margin-left: -2px;
}
.la-ball-spin-clockwise.la-2x {
width: 64px;
height: 64px;
}
.la-ball-spin-clockwise.la-2x > div {
width: 16px;
height: 16px;
margin-top: -8px;
margin-left: -8px;
}
.la-ball-spin-clockwise.la-3x {
width: 96px;
height: 96px;
}
.la-ball-spin-clockwise.la-3x > div {
width: 24px;
height: 24px;
margin-top: -12px;
margin-left: -12px;
}
/*
* Animation
*/
@-webkit-keyframes ball-spin-clockwise {
0%,
100% {
opacity: 1;
-webkit-transform: scale(1);
transform: scale(1);
}
20% {
opacity: 1;
}
80% {
opacity: 0;
-webkit-transform: scale(0);
transform: scale(0);
}
}
@-moz-keyframes ball-spin-clockwise {
0%,
100% {
opacity: 1;
-moz-transform: scale(1);
transform: scale(1);
}
20% {
opacity: 1;
}
80% {
opacity: 0;
-moz-transform: scale(0);
transform: scale(0);
}
}
@-o-keyframes ball-spin-clockwise {
0%,
100% {
opacity: 1;
-o-transform: scale(1);
transform: scale(1);
}
20% {
opacity: 1;
}
80% {
opacity: 0;
-o-transform: scale(0);
transform: scale(0);
}
}
@keyframes ball-spin-clockwise {
0%,
100% {
opacity: 1;
-webkit-transform: scale(1);
-moz-transform: scale(1);
-o-transform: scale(1);
transform: scale(1);
}
20% {
opacity: 1;
}
80% {
opacity: 0;
-webkit-transform: scale(0);
-moz-transform: scale(0);
-o-transform: scale(0);
transform: scale(0);
}
}
</style>

View file

@ -9,8 +9,8 @@
<div v-else-if="books.length" class="flex justify-center h-full relative bg-primary bg-opacity-95 rounded-sm">
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
<covers-book-cover :audiobook="books[0]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<covers-book-cover v-if="books.length > 1" :audiobook="books[1]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<covers-book-cover :library-item="books[0]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<covers-book-cover v-if="books.length > 1" :library-item="books[1]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
<div v-else class="relative w-full h-full flex items-center justify-center p-2 bg-primary rounded-sm">
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />

View file

@ -17,6 +17,7 @@ export default {
},
width: Number,
height: Number,
groupTo: String,
bookCoverAspectRatio: Number
},
data() {
@ -31,7 +32,6 @@ export default {
isFannedOut: false,
isDetached: false,
isAttaching: false,
windowWidth: 0,
isInit: false
}
},
@ -48,8 +48,11 @@ export default {
},
computed: {
sizeMultiplier() {
if (this.bookCoverAspectRatio === 1) return this.width / (100 * 1.6 * 2)
return this.width / 200
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
return this.width / 240
},
showExperimentalFeatures() {
return this.store.state.showExperimentalFeatures
},
store() {
return this.$store || this.$nuxt.$store
@ -59,44 +62,8 @@ export default {
}
},
methods: {
detchCoverWrapper() {
if (!this.coverWrapperEl || !this.$refs.wrapper || this.isDetached) return
this.coverWrapperEl.remove()
this.isDetached = true
document.body.appendChild(this.coverWrapperEl)
this.coverWrapperEl.addEventListener('mouseleave', this.mouseleaveCover)
this.coverWrapperEl.style.position = 'absolute'
this.coverWrapperEl.style.zIndex = 40
this.updatePosition()
},
attachCoverWrapper() {
if (!this.coverWrapperEl || !this.$refs.wrapper || !this.isDetached) return
this.coverWrapperEl.remove()
this.coverWrapperEl.style.position = 'relative'
this.coverWrapperEl.style.left = 'unset'
this.coverWrapperEl.style.top = 'unset'
this.coverWrapperEl.style.width = this.$refs.wrapper.clientWidth + 'px'
this.$refs.wrapper.appendChild(this.coverWrapperEl)
this.isDetached = false
},
updatePosition() {
var rect = this.$refs.wrapper.getBoundingClientRect()
this.coverWrapperEl.style.top = rect.top + window.scrollY + 'px'
this.coverWrapperEl.style.left = rect.left + window.scrollX + 4 + 'px'
this.coverWrapperEl.style.height = rect.height + 'px'
this.coverWrapperEl.style.width = rect.width + 'px'
},
getCoverUrl(book) {
return this.store.getters['audiobooks/getBookCoverSrc'](book, '')
return this.store.getters['globals/getLibraryItemCoverSrc'](book, '')
},
async buildCoverImg(coverData, bgCoverWidth, offsetLeft, zIndex, forceCoverBg = false) {
var src = coverData.coverUrl
@ -156,6 +123,22 @@ export default {
imgdiv.appendChild(img)
return imgdiv
},
createSeriesNameCover(offsetLeft) {
var imgdiv = document.createElement('div')
imgdiv.style.height = this.height + 'px'
imgdiv.style.width = this.height / this.bookCoverAspectRatio + 'px'
imgdiv.style.left = offsetLeft + 'px'
imgdiv.className = 'absolute top-0 box-shadow-book transition-transform flex items-center justify-center'
imgdiv.style.boxShadow = '4px 0px 4px #11111166'
imgdiv.style.backgroundColor = '#111'
var innerP = document.createElement('p')
innerP.textContent = this.name
innerP.className = 'text-sm font-book text-white'
imgdiv.appendChild(innerP)
return imgdiv
},
async init() {
if (this.isInit) return
this.isInit = true
@ -168,7 +151,6 @@ export default {
.map((bookItem) => {
return {
id: bookItem.id,
volumeNumber: bookItem.book ? bookItem.book.volumeNumber : null,
coverUrl: this.getCoverUrl(bookItem)
}
})
@ -179,6 +161,8 @@ export default {
}
this.noValidCovers = false
validCovers = validCovers.slice(0, 10)
var coverWidth = this.width
var widthPer = this.width
if (validCovers.length > 1) {
@ -189,7 +173,7 @@ export default {
this.offsetIncrement = widthPer
var outerdiv = document.createElement('div')
outerdiv.id = `group-cover-${this.id || this.$encode(this.name)}`
outerdiv.id = `group-cover-${this.id}`
this.coverWrapperEl = outerdiv
outerdiv.className = 'w-full h-full relative box-shadow-book'
@ -211,9 +195,7 @@ export default {
}
}
},
mounted() {
this.windowWidth = window.innerWidth
},
mounted() {},
beforeDestroy() {
if (this.coverWrapperEl) this.coverWrapperEl.remove()
if (this.coverImageEls && this.coverImageEls.length) {
@ -222,4 +204,4 @@ export default {
if (this.coverDiv) this.coverDiv.remove()
}
}
</script>
</script>

View file

@ -1,17 +1,8 @@
<template>
<div class="w-full h-9 bg-bg relative">
<div id="bookshelf-navbar" class="absolute z-10 top-0 left-0 w-full h-full flex bg-secondary text-gray-200">
<nuxt-link to="/bookshelf" class="w-1/4 h-full flex items-center justify-center" :class="routeName === 'bookshelf' ? 'bg-primary' : 'text-gray-400'">
<p>Home</p>
</nuxt-link>
<nuxt-link to="/bookshelf/library" class="w-1/4 h-full flex items-center justify-center" :class="routeName === 'bookshelf-library' ? 'bg-primary' : 'text-gray-400'">
<p>Library</p>
</nuxt-link>
<nuxt-link to="/bookshelf/series" class="w-1/4 h-full flex items-center justify-center" :class="routeName === 'bookshelf-series' ? 'bg-primary' : 'text-gray-400'">
<p>Series</p>
</nuxt-link>
<nuxt-link to="/bookshelf/collections" class="w-1/4 h-full flex items-center justify-center" :class="routeName === 'bookshelf-collections' ? 'bg-primary' : 'text-gray-400'">
<p>Collections</p>
<nuxt-link v-for="item in items" :key="item.to" :to="item.to" class="h-full flex items-center justify-center" :style="{ width: isPodcast ? '50%' : '25%' }" :class="routeName === item.routeName ? 'bg-primary' : 'text-gray-400'">
<p>{{ item.text }}</p>
</nuxt-link>
</div>
</div>
@ -23,8 +14,52 @@ export default {
return {}
},
computed: {
items() {
if (this.isPodcast) {
return [
{
to: '/bookshelf',
routeName: 'bookshelf',
text: 'Home'
},
{
to: '/bookshelf/library',
routeName: 'bookshelf-library',
text: 'Library'
}
]
}
return [
{
to: '/bookshelf',
routeName: 'bookshelf',
text: 'Home'
},
{
to: '/bookshelf/library',
routeName: 'bookshelf-library',
text: 'Library'
},
{
to: '/bookshelf/series',
routeName: 'bookshelf-series',
text: 'Series'
},
{
to: '/bookshelf/collections',
routeName: 'bookshelf-collections',
text: 'Collections'
}
]
},
routeName() {
return this.$route.name
},
isPodcast() {
return this.libraryMediaType == 'podcast'
},
libraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
}
},
methods: {},

View file

@ -8,8 +8,8 @@
<p v-show="!selectedSeriesName" class="font-book pt-1">{{ totalEntities }} {{ entityTitle }}</p>
<p v-show="selectedSeriesName" class="ml-2 font-book pt-1">{{ selectedSeriesName }} ({{ totalEntities }})</p>
<div class="flex-grow" />
<span v-if="page == 'library' || seriesBookPage" class="material-icons px-2" @click="bookshelfListView = !bookshelfListView">{{ bookshelfListView ? 'view_list' : 'grid_view' }}</span>
<template v-if="page === 'library'">
<!-- <span class="material-icons px-2" @click="changeView">{{ viewIcon }}</span> -->
<div class="relative flex items-center px-2">
<span class="material-icons" @click="showFilterModal = true">filter_alt</span>
<div v-show="hasFilters" class="absolute top-0 right-2 w-2 h-2 rounded-full bg-success border border-green-300 shadow-sm z-10 pointer-events-none" />
@ -31,11 +31,19 @@ export default {
showSortModal: false,
showFilterModal: false,
settings: {},
isListView: false,
totalEntities: 0
}
},
computed: {
bookshelfListView: {
get() {
return this.$store.state.globals.bookshelfListView
},
set(val) {
this.$localStore.setBookshelfListView(val)
this.$store.commit('globals/setBookshelfListView', val)
}
},
hasFilters() {
return this.$store.getters['user/getUserSetting']('mobileFilterBy') !== 'all'
},
@ -43,6 +51,9 @@ export default {
var routeName = this.$route.name || ''
return routeName.split('-')[1]
},
seriesBookPage() {
return this.$route.name == 'bookshelf-series-id'
},
routeQuery() {
return this.$route.query || {}
},
@ -56,23 +67,13 @@ export default {
return ''
},
selectedSeriesName() {
if (this.page === 'series' && this.$route.params.id) {
return this.$decode(this.$route.params.id)
if (this.page === 'series' && this.$route.params.id && this.$store.state.globals.series) {
return this.$store.state.globals.series.name
}
return null
},
viewIcon() {
return this.isListView ? 'grid_view' : 'view_stream'
}
},
methods: {
changeView() {
this.isListView = !this.isListView
var bookshelfView = this.isListView ? 'list' : 'grid'
this.$localStore.setBookshelfView(bookshelfView)
this.$store.commit('setBookshelfView', bookshelfView)
},
updateOrder() {
this.saveSettings()
},
@ -81,15 +82,12 @@ export default {
},
saveSettings() {
this.$store.commit('user/setSettings', this.settings) // Immediate update
this.$store.dispatch('user/updateUserSettings', this.settings)
this.$store.dispatch('user/updateUserSettings', this.settings) // TODO: No need to update settings on server...
},
async init() {
this.bookshelfListView = await this.$localStore.getBookshelfListView()
this.settings = { ...this.$store.state.user.settings }
var bookshelfView = await this.$localStore.getBookshelfView()
this.isListView = bookshelfView === 'list'
this.bookshelfReady = true
this.$store.commit('setBookshelfView', bookshelfView)
},
settingsUpdated(settings) {
for (const key in settings) {

View file

@ -0,0 +1,55 @@
<template>
<modals-modal v-model="show" :width="300" height="100%">
<template #outer>
<div v-if="title" class="absolute top-7 left-4 z-40" style="max-width: 80%">
<p class="text-white text-lg truncate">{{ title }}</p>
</div>
</template>
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items">
<li :key="item.value" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption(item.value)">
<div class="relative flex items-center px-3">
<p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p>
</div>
</li>
</template>
</ul>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
title: String,
items: {
type: Array,
default: () => []
}
},
data() {
return {}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
}
},
methods: {
clickedOption(action) {
this.$emit('action', action)
}
},
mounted() {}
}
</script>

View file

@ -1,254 +0,0 @@
<template>
<modals-modal v-model="show" width="100%" height="100%">
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
<p class="absolute top-6 left-2 text-2xl">Downloads</p>
<div class="absolute top-16 left-0 right-0 w-full px-2 py-1" :class="hasStoragePermission ? '' : 'text-error'">
<div class="flex items-center">
<span class="material-icons" @click="changeDownloadFolderClick">{{ hasStoragePermission ? 'folder' : 'error' }}</span>
<p v-if="hasStoragePermission" class="text-sm px-4" @click="changeDownloadFolderClick">{{ downloadFolderSimplePath || 'No Download Folder Selected' }}</p>
<p v-else class="text-sm px-4" @click="changeDownloadFolderClick">No Storage Permissions. Click here</p>
</div>
<!-- <p v-if="hasStoragePermission" class="text-xs text-gray-400 break-all max-w-full">{{ downloadFolderUri }}</p> -->
</div>
<div v-if="totalSize" class="absolute bottom-0 left-0 right-0 w-full py-3 text-center">
<p class="text-sm text-center text-gray-300">Total: {{ $bytesPretty(totalSize) }}</p>
</div>
<div v-if="downloadFolder && hasStoragePermission" class="w-full relative mt-10" @click.stop>
<div class="w-full h-10 relative">
<div class="absolute top-px left-0 z-10 w-full h-full flex">
<div class="flex-grow h-full bg-primary rounded-t-md mr-px" @click="showingDownloads = true">
<div class="flex items-center justify-center rounded-t-md border-t border-l border-r border-white border-opacity-20 h-full" :class="showingDownloads ? 'text-gray-100' : 'border-b bg-black bg-opacity-20 text-gray-400'">
<p>Downloads</p>
</div>
</div>
<div class="flex-grow h-full bg-primary rounded-t-md ml-px" @click="showingDownloads = false">
<div class="flex items-center justify-center h-full rounded-t-md border-t border-l border-r border-white border-opacity-20" :class="!showingDownloads ? 'text-gray-100' : 'border-b bg-black bg-opacity-20 text-gray-400'">
<p>Files</p>
</div>
</div>
</div>
</div>
<div class="list-content-body relative w-full overflow-x-hidden overflow-y-auto bg-primary rounded-b-lg border border-white border-opacity-20" style="max-height: 70vh; height: 70vh">
<template v-if="showingDownloads">
<div v-if="!totalDownloads" class="flex items-center justify-center h-40">
<p>No Downloads</p>
</div>
<ul v-else class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="download in downloadsDownloading">
<li :key="download.id" class="text-gray-400 select-none relative px-4 py-5 border-b border-white border-opacity-10 bg-black bg-opacity-10">
<div class="flex items-center justify-center">
<div class="w-3/4">
<span class="text-xs">({{ downloadingProgress[download.id] || 0 }}%) {{ download.isPreparing ? 'Preparing' : 'Downloading' }}...</span>
<p class="font-normal truncate text-sm">{{ download.audiobook.book.title }}</p>
</div>
<div class="flex-grow" />
<div class="shadow-sm text-white flex items-center justify-center rounded-full animate-spin">
<span class="material-icons">refresh</span>
</div>
</div>
</li>
</template>
<template v-for="download in downloadsReady">
<li :key="download.id" class="text-gray-50 select-none relative pr-4 pl-2 py-5 border-b border-white border-opacity-10" @click="jumpToAudiobook(download)">
<modals-downloads-download-item :download="download" @play="playDownload" @delete="clickDeleteDownload" />
</li>
</template>
</ul>
</template>
<template v-else>
<div class="w-full h-full">
<div class="w-full flex justify-around py-4 px-2">
<ui-btn small @click="searchFolder">Re-Scan</ui-btn>
<ui-btn small @click="changeDownloadFolderClick">Change Folder</ui-btn>
<ui-btn small color="error" @click="resetFolder">Reset</ui-btn>
</div>
<p v-if="isScanning" class="text-center my-8">Scanning Folder..</p>
<p v-else-if="!mediaScanResults" class="text-center my-8">No Files Found</p>
<template v-else>
<template v-for="mediaFolder in mediaScanResults.folders">
<div :key="mediaFolder.uri" class="w-full px-2 py-2">
<div class="flex items-center">
<span class="material-icons text-base text-white text-opacity-50">folder</span>
<p class="ml-1 py-0.5">{{ mediaFolder.name }}</p>
</div>
<div v-for="mediaFile in mediaFolder.files" :key="mediaFile.uri" class="ml-3 flex items-center">
<span class="material-icons text-base text-white text-opacity-50">{{ mediaFile.isAudio ? 'music_note' : 'image' }}</span>
<p class="ml-1 py-0.5">{{ mediaFile.name }}</p>
</div>
</div>
</template>
<template v-for="mediaFile in mediaScanResults.files">
<div :key="mediaFile.uri" class="w-full px-2 py-2">
<div class="flex items-center">
<span class="material-icons text-base text-white text-opacity-50">{{ mediaFile.isAudio ? 'music_note' : 'image' }}</span>
<p class="ml-1 py-0.5">{{ mediaFile.name }}</p>
</div>
</div>
</template>
</template>
</div>
</template>
</div>
</div>
<div v-else class="list-content-body relative w-full overflow-x-hidden overflow-y-auto bg-primary rounded-b-lg border border-white border-opacity-20 py-8 px-4" @click.stop>
<ui-btn class="w-full" color="info" @click="changeDownloadFolderClick">Select Folder</ui-btn>
</div>
</div>
</modals-modal>
</template>
<script>
import { Dialog } from '@capacitor/dialog'
import AudioDownloader from '@/plugins/audio-downloader'
import StorageManager from '@/plugins/storage-manager'
export default {
data() {
return {
downloadingProgress: {},
totalSize: 0,
showingDownloads: true,
isScanning: false
}
},
watch: {
async show(newValue) {
if (newValue) {
await this.$localStore.getDownloadFolder()
this.setTotalSize()
}
}
},
computed: {
show: {
get() {
return this.$store.state.downloads.showModal
},
set(val) {
this.$store.commit('downloads/setShowModal', val)
}
},
hasStoragePermission() {
return this.$store.state.hasStoragePermission
},
downloadFolder() {
return this.$store.state.downloadFolder
},
downloadFolderSimplePath() {
return this.downloadFolder ? this.downloadFolder.simplePath : null
},
downloadFolderUri() {
return this.downloadFolder ? this.downloadFolder.uri : null
},
totalDownloads() {
return this.downloadsReady.length + this.downloadsDownloading.length
},
downloadsDownloading() {
return this.downloads.filter((d) => d.isDownloading || d.isPreparing)
},
downloadsReady() {
return this.downloads.filter((d) => !d.isDownloading && !d.isPreparing)
},
downloads() {
return this.$store.state.downloads.downloads
},
mediaScanResults() {
return this.$store.state.downloads.mediaScanResults
}
},
methods: {
setTotalSize() {
var totalSize = 0
this.downloadsReady.forEach((dl) => {
totalSize += dl.size && !isNaN(dl.size) ? Number(dl.size) : 0
})
this.totalSize = totalSize
},
async changeDownloadFolderClick() {
if (!this.hasStoragePermission) {
console.log('Requesting Storage Permission')
StorageManager.requestStoragePermission()
} else {
var folderObj = await StorageManager.selectFolder()
if (folderObj.error) {
return this.$toast.error(`Error: ${folderObj.error || 'Unknown Error'}`)
}
var permissionsGood = await StorageManager.checkFolderPermissions({ folderUrl: folderObj.uri })
console.log('Storage Permission check folder ' + permissionsGood)
if (!permissionsGood) {
this.$toast.error('Folder permissions failed')
return
} else {
this.$toast.success('Folder permission success')
}
await this.$localStore.setDownloadFolder(folderObj)
this.searchFolder()
}
},
async searchFolder() {
this.isScanning = true
var response = await StorageManager.searchFolder({ folderUrl: this.downloadFolderUri })
var searchResults = response
searchResults.folders = JSON.parse(searchResults.folders)
searchResults.files = JSON.parse(searchResults.files)
if (searchResults.folders.length) {
console.log('Search results folders length', searchResults.folders.length)
searchResults.folders = searchResults.folders.map((sr) => {
if (sr.files) {
sr.files = JSON.parse(sr.files)
}
return sr
})
this.$store.commit('downloads/setMediaScanResults', searchResults)
} else {
this.$toast.warning('No audio or image files found')
}
this.isScanning = false
},
async resetFolder() {
await this.$localStore.setDownloadFolder(null)
this.$store.commit('downloads/setMediaScanResults', {})
this.$toast.info('Unlinked Folder')
},
updateDownloadProgress({ audiobookId, progress }) {
this.$set(this.downloadingProgress, audiobookId, progress)
},
jumpToAudiobook(download) {
this.show = false
this.$router.push(`/audiobook/${download.id}`)
},
async clickDeleteDownload(download) {
const { value } = await Dialog.confirm({
title: 'Confirm',
message: 'Delete this download?'
})
if (value) {
this.$emit('deleteDownload', download)
}
},
playDownload(download) {
this.$store.commit('setPlayOnLoad', true)
this.$store.commit('setPlayingDownload', download)
this.show = false
}
},
mounted() {}
}
</script>
<style>
.list-content-body {
max-height: calc(75% - 40px);
}
</style>

View file

@ -143,13 +143,20 @@ export default {
return this.filterData.narrators || []
},
progress() {
return ['Read', 'Unread', 'In Progress']
return ['Finished', 'In Progress', 'Not Started']
},
sublistItems() {
return (this[this.sublist] || []).map((item) => {
return {
text: item,
value: this.$encode(item)
if (typeof item === 'string') {
return {
text: item,
value: this.$encode(item)
}
} else {
return {
text: item.name,
value: this.$encode(item.id)
}
}
})
},

View file

@ -55,7 +55,7 @@ export default {
this.show = false
await this.$store.dispatch('libraries/fetch', lib.id)
this.$eventBus.$emit('library-changed', lib.id)
this.$localStore.setCurrentLibrary(lib)
this.$localStore.setLastLibraryId(lib.id)
}
},
mounted() {}

View file

@ -29,35 +29,23 @@ export default {
items: [
{
text: 'Title',
value: 'book.title'
value: 'media.metadata.title'
},
{
text: 'Author (First Last)',
value: 'book.authorFL'
value: 'media.metadata.authorName'
},
{
text: 'Author (Last, First)',
value: 'book.authorLF'
value: 'media.metadata.authorNameLF'
},
{
text: 'Added At',
value: 'addedAt'
},
{
text: 'Volume #',
value: 'book.volumeNumber'
},
{
text: 'Duration',
value: 'duration'
},
{
text: 'Size',
value: 'size'
},
{
text: 'Last Read',
value: 'recent'
}
]
}

View file

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

View file

@ -2,12 +2,12 @@
<div class="w-full px-2 py-2 overflow-hidden relative">
<div v-if="book" class="flex h-20">
<div class="h-full relative" :style="{ width: bookWidth + 'px' }">
<covers-book-cover :audiobook="book" :width="bookWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<covers-book-cover :library-item="book" :width="bookWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
<div class="w-80 h-full px-2 flex items-center">
<div>
<nuxt-link :to="`/audiobook/${book.id}`" class="truncate hover:underline">{{ bookTitle }}</nuxt-link>
<nuxt-link :to="`/bookshelf/library?filter=authors.${$encode(bookAuthor)}`" class="truncate block text-gray-400 text-sm hover:underline">{{ bookAuthor }}</nuxt-link>
<nuxt-link :to="`/item/${book.id}`" class="truncate text-sm">{{ bookTitle }}</nuxt-link>
<p class="truncate block text-gray-400 text-xs">{{ bookAuthor }}</p>
</div>
</div>
</div>
@ -29,15 +29,25 @@ export default {
processingRemove: false
}
},
watch: {
userIsRead: {
immediate: true,
handler(newVal) {
this.isRead = newVal
}
}
},
computed: {
media() {
return this.book.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
},
tracks() {
return this.media.tracks || []
},
bookTitle() {
return this.mediaMetadata.title || ''
},
bookAuthor() {
return this.mediaMetadata.authorName || ''
},
bookDuration() {
return this.$secondsToTimestamp(this.media.duration)
},
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
},
@ -45,18 +55,6 @@ export default {
if (this.bookCoverAspectRatio === 1) return 80
return 50
},
_book() {
return this.book.book || {}
},
bookTitle() {
return this._book.title || ''
},
bookAuthor() {
return this._book.authorFL || ''
},
bookDuration() {
return this.$secondsToTimestamp(this.book.duration)
},
isMissing() {
return this.book.isMissing
},
@ -67,61 +65,15 @@ export default {
return this.book.numTracks
},
isStreaming() {
return this.$store.getters['getAudiobookIdStreaming'] === this.book.id
return this.$store.getters['getIsItemStreaming'](this.book.id)
},
showPlayBtn() {
return !this.isMissing && !this.isIncomplete && !this.isStreaming && this.numTracks
},
userAudiobooks() {
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
},
userAudiobook() {
return this.userAudiobooks[this.book.id] || null
},
userIsRead() {
return this.userAudiobook ? !!this.userAudiobook.isRead : false
}
},
methods: {
playClick() {
// this.$store.commit('setStreamAudiobook', this.book)
// this.$root.socket.emit('open_stream', this.book.id)
},
clickEdit() {
this.$emit('edit', this.book)
},
toggleRead() {
var updatePayload = {
isRead: !this.isRead
}
this.isProcessingReadUpdate = true
this.$axios
.$patch(`/api/me/audiobook/${this.book.id}`, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
this.$toast.success(`"${this.bookTitle}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
})
.catch((error) => {
console.error('Failed', error)
this.isProcessingReadUpdate = false
this.$toast.error(`Failed to mark as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
})
},
removeClick() {
this.processingRemove = true
this.$axios
.$delete(`/api/collections/${this.collectionId}/book/${this.book.id}`)
.then((updatedCollection) => {
console.log(`Book removed from collection`, updatedCollection)
this.$toast.success('Book removed from collection')
this.processingRemove = false
})
.catch((error) => {
console.error('Failed to remove book from collection', error)
this.$toast.error('Failed to remove book from collection')
this.processingRemove = false
})
}
},
mounted() {}

View file

@ -0,0 +1,183 @@
<template>
<div class="w-full px-2 py-3 overflow-hidden relative border-b border-white border-opacity-10">
<div v-if="episode" class="flex items-center h-24">
<!-- <div class="w-12 min-w-12 max-w-16 h-full">
<div class="flex h-full items-center justify-center">
<span class="material-icons drag-handle text-lg text-white text-opacity-50 hover:text-opacity-100">menu</span>
</div>
</div> -->
<div class="flex-grow px-2">
<p class="text-sm font-semibold">
{{ title }}
</p>
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5">
{{ description }}
</p>
<div class="flex items-center pt-2">
<div class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click="playClick">
<span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
<p class="pl-2 pr-1 text-sm font-semibold">{{ timeRemaining }}</p>
</div>
<span class="material-icons px-2" :class="downloadItem ? 'animate-bounce text-warning text-opacity-75' : ''" @click="downloadClick">{{ downloadItem ? 'downloading' : 'download' }}</span>
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
<p v-if="publishedAt" class="px-4 text-sm text-gray-300">Published {{ $formatDate(publishedAt, 'MMM do, yyyy') }}</p>
</div>
</div>
</div>
<div v-if="!userIsFinished" class="absolute bottom-0 left-0 h-0.5 bg-warning" :style="{ width: itemProgressPercent * 100 + '%' }" />
</div>
</template>
<script>
import { Dialog } from '@capacitor/dialog'
import { AbsDownloader } from '@/plugins/capacitor'
export default {
props: {
libraryItemId: String,
isLocal: Boolean,
episode: {
type: Object,
default: () => {}
}
},
data() {
return {
isProcessingReadUpdate: false
}
},
computed: {
mediaType() {
return 'podcast'
},
audioFile() {
return this.episode.audioFile
},
title() {
return this.episode.title || ''
},
description() {
if (this.episode.subtitle) return this.episode.subtitle
var desc = this.episode.description || ''
return desc
},
duration() {
return this.$secondsToTimestamp(this.episode.duration)
},
isStreaming() {
return this.$store.getters['getIsEpisodeStreaming'](this.libraryItemId, this.episode.id)
},
streamIsPlaying() {
return this.$store.state.playerIsPlaying && this.isStreaming
},
itemProgress() {
if (this.isLocal) return this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId, this.episode.id)
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, this.episode.id)
},
itemProgressPercent() {
return this.itemProgress ? this.itemProgress.progress : 0
},
userIsFinished() {
return this.itemProgress ? !!this.itemProgress.isFinished : false
},
timeRemaining() {
if (this.streamIsPlaying) return 'Playing'
if (!this.itemProgressPercent) return this.$elapsedPretty(this.episode.duration)
if (this.userIsFinished) return 'Finished'
var remaining = Math.floor(this.itemProgress.duration - this.itemProgress.currentTime)
return `${this.$elapsedPretty(remaining)} left`
},
publishedAt() {
return this.episode.publishedAt
},
downloadItem() {
return this.$store.getters['globals/getDownloadItem'](this.libraryItemId, this.episode.id)
}
},
methods: {
selectFolder() {
this.$toast.error('Folder selector not implemented for podcasts yet')
},
downloadClick() {
if (this.downloadItem) return
this.download()
},
async download(selectedLocalFolder = null) {
var localFolder = selectedLocalFolder
if (!localFolder) {
var localFolders = (await this.$db.getLocalFolders()) || []
console.log('Local folders loaded', localFolders.length)
var foldersWithMediaType = localFolders.filter((lf) => {
console.log('Checking local folder', lf.mediaType)
return lf.mediaType == this.mediaType
})
console.log('Folders with media type', this.mediaType, foldersWithMediaType.length)
if (!foldersWithMediaType.length) {
// No local folders or no local folders with this media type
localFolder = await this.selectFolder()
} else if (foldersWithMediaType.length == 1) {
console.log('Only 1 local folder with this media type - auto select it')
localFolder = foldersWithMediaType[0]
} else {
console.log('Multiple folders with media type')
// this.showSelectLocalFolder = true
return
}
if (!localFolder) {
return this.$toast.error('Invalid download folder')
}
}
console.log('Local folder', JSON.stringify(localFolder))
var startDownloadMessage = `Start download for "${this.title}" to folder ${localFolder.name}?`
const { value } = await Dialog.confirm({
title: 'Confirm',
message: startDownloadMessage
})
if (value) {
this.startDownload(localFolder)
}
},
async startDownload(localFolder) {
var downloadRes = await AbsDownloader.downloadLibraryItem({ libraryItemId: this.libraryItemId, localFolderId: localFolder.id, episodeId: this.episode.id })
if (downloadRes && downloadRes.error) {
var errorMsg = downloadRes.error || 'Unknown error'
console.error('Download error', errorMsg)
this.$toast.error(errorMsg)
}
},
playClick() {
if (this.streamIsPlaying) {
this.$eventBus.$emit('pause-item')
} else {
this.$eventBus.$emit('play-item', {
libraryItemId: this.libraryItemId,
episodeId: this.episode.id
})
}
},
toggleFinished() {
var updatePayload = {
isFinished: !this.userIsFinished
}
this.isProcessingReadUpdate = true
this.$axios
.$patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
this.$toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
})
.catch((error) => {
console.error('Failed', error)
this.isProcessingReadUpdate = false
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
})
}
},
mounted() {}
}
</script>

View file

@ -0,0 +1,25 @@
<template>
<div class="w-full">
<template v-for="episode in episodes">
<tables-podcast-episode-row :episode="episode" :library-item-id="libraryItemId" :key="episode.id" />
</template>
</div>
</template>
<script>
export default {
props: {
libraryItemId: String,
episodes: {
type: Array,
default: () => []
}
},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>

View file

@ -1,8 +1,15 @@
<template>
<button class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :type="type" :class="classList" @click="click">
<nuxt-link v-if="to" :to="to" class="btn outline-none rounded-md shadow-md relative border border-gray-600 text-center" :disabled="disabled || loading" :class="classList">
<slot />
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>
</div>
</nuxt-link>
<button v-else class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :type="type" :class="classList" @mousedown.prevent @click="click">
<slot />
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
<!-- <span class="material-icons animate-spin">refresh</span> -->
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>
@ -13,6 +20,7 @@
<script>
export default {
props: {
to: String,
color: {
type: String,
default: 'primary'
@ -46,6 +54,9 @@ export default {
if (this.paddingX !== undefined) {
list.push(`px-${this.paddingX}`)
}
if (this.disabled) {
list.push('cursor-not-allowed')
}
return list
}
},
@ -59,7 +70,7 @@ export default {
</script>
<style>
button.btn::before {
.btn::before {
content: '';
position: absolute;
border-radius: 6px;
@ -70,7 +81,7 @@ button.btn::before {
background-color: rgba(255, 255, 255, 0);
transition: all 0.1s ease-in-out;
}
button.btn:hover:not(:disabled)::before {
.btn:hover:not(:disabled)::before {
background-color: rgba(255, 255, 255, 0.1);
}
button:disabled::before {

View file

@ -0,0 +1,71 @@
<template>
<label class="flex justify-start items-center" :class="!disabled ? 'cursor-pointer' : ''">
<div class="border-2 rounded flex flex-shrink-0 justify-center items-center" :class="wrapperClass">
<input v-model="selected" :disabled="disabled" type="checkbox" class="opacity-0 absolute" :class="!disabled ? 'cursor-pointer' : ''" />
<svg v-if="selected" class="fill-current pointer-events-none" :class="svgClass" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
</div>
<div v-if="label" class="select-none text-gray-100" :class="labelClassname">{{ label }}</div>
</label>
</template>
<script>
export default {
props: {
value: Boolean,
label: String,
small: Boolean,
checkboxBg: {
type: String,
default: 'white'
},
borderColor: {
type: String,
default: 'gray-400'
},
checkColor: {
type: String,
default: 'green-500'
},
labelClass: {
type: String,
default: ''
},
disabled: Boolean
},
data() {
return {}
},
computed: {
selected: {
get() {
return this.value
},
set(val) {
this.$emit('input', !!val)
}
},
wrapperClass() {
var classes = [`bg-${this.checkboxBg} border-${this.borderColor}`]
if (this.small) classes.push('w-4 h-4')
else classes.push('w-6 h-6')
return classes.join(' ')
},
labelClassname() {
if (this.labelClass) return this.labelClass
var classes = ['pl-1']
if (this.small) classes.push('text-xs md:text-sm')
return classes.join(' ')
},
svgClass() {
var classes = [`text-${this.checkColor}`]
if (this.small) classes.push('w-3 h-3')
else classes.push('w-4 h-4')
return classes.join(' ')
}
},
methods: {},
mounted() {}
}
</script>

View file

@ -0,0 +1,94 @@
<template>
<div class="relative w-full" v-click-outside="clickOutsideObj">
<p class="text-sm font-semibold" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
<button type="button" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
<span class="flex items-center" :class="!selectedText ? 'text-gray-300' : 'text-white'">
<span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText || placeholder || '' }}</span>
</span>
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<span class="material-icons">expand_more</span>
</span>
</button>
<transition name="menu">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-gray-600 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
<template v-for="item in items">
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
<div class="flex items-center">
<span class="font-normal ml-3 block truncate font-sans text-sm">{{ item.text }}</span>
</div>
</li>
</template>
</ul>
</transition>
</div>
</template>
<script>
export default {
props: {
value: [String, Number],
label: {
type: String,
default: ''
},
items: {
type: Array,
default: () => []
},
disabled: Boolean,
small: Boolean,
placeholder: String
},
data() {
return {
clickOutsideObj: {
handler: this.clickedOutside,
events: ['mousedown'],
isActive: true
},
showMenu: false
}
},
computed: {
selected: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
selectedItem() {
return this.items.find((i) => i.value === this.selected)
},
selectedText() {
return this.selectedItem ? this.selectedItem.text : ''
},
buttonClass() {
var classes = []
if (this.small) classes.push('h-9')
else classes.push('h-10')
if (this.disabled) classes.push('cursor-not-allowed border-gray-600 bg-primary bg-opacity-70 border-opacity-70 text-gray-400')
else classes.push('cursor-pointer border-gray-600 bg-primary text-gray-100')
return classes.join(' ')
}
},
methods: {
clickShowMenu() {
if (this.disabled) return
this.showMenu = !this.showMenu
},
clickedOutside() {
this.showMenu = false
},
clickedOption(itemValue) {
this.selected = itemValue
this.showMenu = false
}
},
mounted() {}
}
</script>

80
components/ui/IconBtn.vue Normal file
View file

@ -0,0 +1,80 @@
<template>
<button class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" @mousedown.prevent :disabled="disabled || loading" :class="className" @click="clickBtn">
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>
</div>
<span v-else :class="outlined ? 'material-icons-outlined' : 'material-icons'" :style="{ fontSize }">{{ icon }}</span>
</button>
</template>
<script>
export default {
props: {
icon: String,
disabled: Boolean,
bgColor: {
type: String,
default: 'primary'
},
outlined: Boolean,
borderless: Boolean,
loading: Boolean
},
data() {
return {}
},
computed: {
className() {
var classes = []
if (!this.borderless) {
classes.push(`bg-${this.bgColor} border border-gray-600`)
}
return classes.join(' ')
},
fontSize() {
if (this.icon === 'edit') return '1.25rem'
return '1.4rem'
}
},
methods: {
clickBtn(e) {
if (this.disabled || this.loading) {
e.preventDefault()
return
}
e.preventDefault()
this.$emit('click')
e.stopPropagation()
}
},
mounted() {}
}
</script>
<style>
button.icon-btn:disabled {
cursor: not-allowed;
}
button.icon-btn::before {
content: '';
position: absolute;
border-radius: 6px;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0);
transition: all 0.1s ease-in-out;
}
button.icon-btn:hover:not(:disabled)::before {
background-color: rgba(255, 255, 255, 0.1);
}
button.icon-btn:disabled::before {
background-color: rgba(0, 0, 0, 0.2);
}
button.icon-btn:disabled span {
color: #777;
}
</style>

View file

@ -0,0 +1,57 @@
<template>
<button class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" :class="borderless ? '' : 'bg-primary border border-gray-600'" @click="clickBtn">
<div class="w-5 h-5 text-white relative">
<svg v-if="isRead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="rgb(63, 181, 68)">
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-7 19.6l-7-4.66V3h14v12.93l-7 4.67zm-2.01-7.42l-2.58-2.59L6 12l4 4 8-8-1.42-1.42z" />
</svg>
</div>
</button>
</template>
<script>
export default {
props: {
isRead: Boolean,
disabled: Boolean,
borderless: Boolean
},
data() {
return {}
},
computed: {},
methods: {
clickBtn(e) {
if (this.disabled) {
e.preventDefault()
return
}
this.$emit('click')
e.stopPropagation()
}
},
mounted() {}
}
</script>
<style>
button.icon-btn::before {
content: '';
position: absolute;
border-radius: 6px;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0);
transition: all 0.1s ease-in-out;
}
button.icon-btn:hover:not(:disabled)::before {
background-color: rgba(255, 255, 255, 0.1);
}
button.icon-btn:disabled::before {
background-color: rgba(0, 0, 0, 0.2);
}
</style>

View file

@ -1,7 +1,7 @@
<template>
<div class="w-full">
<p class="px-1 pb-1 text-sm font-semibold">{{ label }}</p>
<ui-text-input v-model="inputValue" :disabled="disabled" :type="type" class="w-full px-4 py-2" />
<p class="pb-0.5 text-sm font-semibold">{{ label }}</p>
<ui-text-input v-model="inputValue" :disabled="disabled" :type="type" text-size="base" class="w-full" />
</div>
</template>

View file

@ -0,0 +1,143 @@
<template>
<div ref="progressbar" class="progressbar">
<svg class="progressbar__svg">
<circle cx="20" cy="20" r="17.5" ref="circle" class="progressbar__svg-circle circle-anim"></circle>
<circle cx="20" cy="20" r="17.5" class="progressbar__svg-circlebg"></circle>
</svg>
<p class="progressbar__text text-sm text-warning">{{ count }}</p>
<!-- <span class="material-icons progressbar__text text-xl">arrow_downward</span> -->
<!-- <div class="w-4 h-4 rounded-full bg-red-600 absolute bottom-1 right-1 flex items-center justify-center transform rotate-90">
<p class="text-xs text-white">4</p>
</div> -->
</div>
</template>
<script>
export default {
props: {
value: Number,
count: Number
},
data() {
return {
lastProgress: 0,
updateTimeout: null
}
},
watch: {
value: {
handler(newVal, oldVal) {
this.updateProgress()
}
}
},
computed: {},
methods: {
updateProgress() {
var progbar = this.$refs.progressbar
var circle = this.$refs.circle
if (!progbar || !circle) return
clearTimeout(this.updateTimeout)
var progress = Math.min(this.value || 0, 1)
progbar.style.setProperty('--progress-percent-before', this.lastProgress)
progbar.style.setProperty('--progress-percent', progress)
this.lastProgress = progress
circle.classList.remove('circle-static')
circle.classList.add('circle-anim')
this.updateTimeout = setTimeout(() => {
circle.classList.remove('circle-anim')
circle.classList.add('circle-static')
}, 500)
}
},
mounted() {}
}
</script>
<style scoped>
/* https://codepen.io/alvarotrigo/pen/VwMvydQ */
.progressbar {
position: relative;
width: 42.5px;
height: 42.5px;
margin: 0.25em;
transform: rotate(-90deg);
box-sizing: border-box;
--progress-percent-before: 0;
--progress-percent: 0;
}
.progressbar__svg {
position: relative;
width: 100%;
height: 100%;
}
.progressbar__svg-circlebg {
width: 100%;
height: 100%;
fill: none;
stroke-width: 4;
/* stroke-dasharray: 110;
stroke-dashoffset: 110; */
stroke: #fb8c0022;
stroke-linecap: round;
transform: translate(2px, 2px);
}
.progressbar__svg-circle {
width: 100%;
height: 100%;
fill: none;
stroke-width: 4;
stroke-dasharray: 110;
stroke-dashoffset: 110;
/* stroke: hsl(0, 0%, 100%); */
stroke: #fb8c00;
stroke-linecap: round;
transform: translate(2px, 2px);
}
.circle-anim {
animation: anim_circle 0.5s ease-in-out forwards;
}
.circle-static {
stroke-dashoffset: calc(110px - (110px * var(--progress-percent)));
}
@keyframes anim_circle {
from {
stroke-dashoffset: calc(110px - (110px * var(--progress-percent-before)));
}
to {
stroke-dashoffset: calc(110px - (110px * var(--progress-percent)));
}
}
.progressbar__text {
position: absolute;
top: 50%;
left: 50%;
margin-top: 1px;
transform: translate(-50%, -50%) rotate(90deg);
animation: bounce 0.75s infinite;
}
@keyframes bounce {
0%,
100% {
transform: translate(-35%, -50%) rotate(90deg);
-webkit-animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
}
50% {
transform: translate(-50%, -50%) rotate(90deg);
-webkit-animation-timing-function: cubic-bezier(0, 0, 0.2, 1);
animation-timing-function: cubic-bezier(0, 0, 0.2, 1);
}
}
</style>

View file

@ -0,0 +1,110 @@
<template>
<div v-if="numPartsRemaining > 0">
<widgets-circle-progress :value="progress" :count="numPartsRemaining" />
</div>
</template>
<script>
import { AbsDownloader } from '@/plugins/capacitor'
export default {
data() {
return {
updateListener: null,
completeListener: null,
itemDownloadingMap: {}
}
},
computed: {
numItemPartsComplete() {
var total = 0
Object.values(this.itemDownloadingMap).map((item) => (total += item.partsCompleted))
return total
},
numPartsRemaining() {
return this.numTotalParts - this.numItemPartsComplete
},
numTotalParts() {
var total = 0
Object.values(this.itemDownloadingMap).map((item) => (total += item.totalParts))
return total
},
progress() {
var numItems = Object.keys(this.itemDownloadingMap).length
if (!numItems) return 0
var totalProg = 0
Object.values(this.itemDownloadingMap).map((item) => (totalProg += item.itemProgress))
return totalProg / numItems
}
},
methods: {
onItemDownloadUpdate(data) {
console.log('DownloadProgressIndicator onItemDownloadUpdate', JSON.stringify(data))
if (!data || !data.downloadItemParts) {
console.error('Invalid item update payload')
return
}
var downloadItemParts = data.downloadItemParts
var partsCompleted = 0
var totalPartsProgress = 0
var partsRemaining = 0
downloadItemParts.forEach((dip) => {
if (dip.completed) {
totalPartsProgress += 1
partsCompleted++
} else {
var progPercent = dip.progress / 100
totalPartsProgress += progPercent
partsRemaining++
}
})
var itemProgress = totalPartsProgress / downloadItemParts.length
var update = {
id: data.id,
libraryItemId: data.libraryItemId,
partsRemaining,
partsCompleted,
totalParts: downloadItemParts.length,
itemProgress
}
data.itemProgress = itemProgress
data.episodes = downloadItemParts.filter((dip) => dip.episode).map((dip) => dip.episode)
console.log('Saving item update download payload', JSON.stringify(update))
this.$set(this.itemDownloadingMap, update.id, update)
this.$store.commit('globals/addUpdateItemDownload', data)
},
onItemDownloadComplete(data) {
console.log('DownloadProgressIndicator onItemDownloadComplete', JSON.stringify(data))
if (!data || !data.libraryItemId) {
console.error('Invalid item downlaod complete payload')
return
}
if (this.itemDownloadingMap[data.libraryItemId]) {
delete this.itemDownloadingMap[data.libraryItemId]
} else {
console.warn('Item download complete but not found in item downloading map', data.libraryItemId)
}
if (!data.localLibraryItem) {
this.$toast.error('Item download complete but failed to create library item')
} else {
this.$toast.success(`Item "${data.localLibraryItem.media.metadata.title}" download finished`)
this.$eventBus.$emit('new-local-library-item', data.localLibraryItem)
}
this.$store.commit('globals/removeItemDownload', data.libraryItemId)
}
},
mounted() {
this.updateListener = AbsDownloader.addListener('onItemDownloadUpdate', (data) => this.onItemDownloadUpdate(data))
this.completeListener = AbsDownloader.addListener('onItemDownloadComplete', (data) => this.onItemDownloadComplete(data))
},
beforeDestroy() {
if (this.updateListener) this.updateListener.remove()
if (this.completeListener) this.completeListener.remove()
}
}
</script>

View file

@ -0,0 +1,241 @@
<template>
<div class="la-ball-spin-clockwise" :class="`${size}`">
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
</div>
</template>
<script>
export default {
props: {
size: {
type: String,
default: 'la-sm'
}
},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
<style>
/*!
* Load Awesome v1.1.0 (http://github.danielcardoso.net/load-awesome/)
* Copyright 2015 Daniel Cardoso <@DanielCardoso>
* Licensed under MIT
*/
.la-ball-spin-clockwise,
.la-ball-spin-clockwise > div {
position: relative;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
.la-ball-spin-clockwise {
display: block;
font-size: 0;
color: #fff;
}
.la-ball-spin-clockwise.la-dark {
color: #262626;
}
.la-ball-spin-clockwise > div {
display: inline-block;
float: none;
background-color: currentColor;
border: 0 solid currentColor;
}
.la-ball-spin-clockwise {
width: 32px;
height: 32px;
}
.la-ball-spin-clockwise > div {
position: absolute;
top: 50%;
left: 50%;
width: 8px;
height: 8px;
margin-top: -4px;
margin-left: -4px;
border-radius: 100%;
-webkit-animation: ball-spin-clockwise 1s infinite ease-in-out;
-moz-animation: ball-spin-clockwise 1s infinite ease-in-out;
-o-animation: ball-spin-clockwise 1s infinite ease-in-out;
animation: ball-spin-clockwise 1s infinite ease-in-out;
}
.la-ball-spin-clockwise > div:nth-child(1) {
top: 5%;
left: 50%;
-webkit-animation-delay: -0.875s;
-moz-animation-delay: -0.875s;
-o-animation-delay: -0.875s;
animation-delay: -0.875s;
}
.la-ball-spin-clockwise > div:nth-child(2) {
top: 18.1801948466%;
left: 81.8198051534%;
-webkit-animation-delay: -0.75s;
-moz-animation-delay: -0.75s;
-o-animation-delay: -0.75s;
animation-delay: -0.75s;
}
.la-ball-spin-clockwise > div:nth-child(3) {
top: 50%;
left: 95%;
-webkit-animation-delay: -0.625s;
-moz-animation-delay: -0.625s;
-o-animation-delay: -0.625s;
animation-delay: -0.625s;
}
.la-ball-spin-clockwise > div:nth-child(4) {
top: 81.8198051534%;
left: 81.8198051534%;
-webkit-animation-delay: -0.5s;
-moz-animation-delay: -0.5s;
-o-animation-delay: -0.5s;
animation-delay: -0.5s;
}
.la-ball-spin-clockwise > div:nth-child(5) {
top: 94.9999999966%;
left: 50.0000000005%;
-webkit-animation-delay: -0.375s;
-moz-animation-delay: -0.375s;
-o-animation-delay: -0.375s;
animation-delay: -0.375s;
}
.la-ball-spin-clockwise > div:nth-child(6) {
top: 81.8198046966%;
left: 18.1801949248%;
-webkit-animation-delay: -0.25s;
-moz-animation-delay: -0.25s;
-o-animation-delay: -0.25s;
animation-delay: -0.25s;
}
.la-ball-spin-clockwise > div:nth-child(7) {
top: 49.9999750815%;
left: 5.0000051215%;
-webkit-animation-delay: -0.125s;
-moz-animation-delay: -0.125s;
-o-animation-delay: -0.125s;
animation-delay: -0.125s;
}
.la-ball-spin-clockwise > div:nth-child(8) {
top: 18.179464974%;
left: 18.1803700518%;
-webkit-animation-delay: 0s;
-moz-animation-delay: 0s;
-o-animation-delay: 0s;
animation-delay: 0s;
}
.la-ball-spin-clockwise.la-sm {
width: 16px;
height: 16px;
}
.la-ball-spin-clockwise.la-sm > div {
width: 4px;
height: 4px;
margin-top: -2px;
margin-left: -2px;
}
.la-ball-spin-clockwise.la-2x {
width: 64px;
height: 64px;
}
.la-ball-spin-clockwise.la-2x > div {
width: 16px;
height: 16px;
margin-top: -8px;
margin-left: -8px;
}
.la-ball-spin-clockwise.la-3x {
width: 96px;
height: 96px;
}
.la-ball-spin-clockwise.la-3x > div {
width: 24px;
height: 24px;
margin-top: -12px;
margin-left: -12px;
}
/*
* Animation
*/
@-webkit-keyframes ball-spin-clockwise {
0%,
100% {
opacity: 1;
-webkit-transform: scale(1);
transform: scale(1);
}
20% {
opacity: 1;
}
80% {
opacity: 0;
-webkit-transform: scale(0);
transform: scale(0);
}
}
@-moz-keyframes ball-spin-clockwise {
0%,
100% {
opacity: 1;
-moz-transform: scale(1);
transform: scale(1);
}
20% {
opacity: 1;
}
80% {
opacity: 0;
-moz-transform: scale(0);
transform: scale(0);
}
}
@-o-keyframes ball-spin-clockwise {
0%,
100% {
opacity: 1;
-o-transform: scale(1);
transform: scale(1);
}
20% {
opacity: 1;
}
80% {
opacity: 0;
-o-transform: scale(0);
transform: scale(0);
}
}
@keyframes ball-spin-clockwise {
0%,
100% {
opacity: 1;
-webkit-transform: scale(1);
-moz-transform: scale(1);
-o-transform: scale(1);
transform: scale(1);
}
20% {
opacity: 1;
}
80% {
opacity: 0;
-webkit-transform: scale(0);
-moz-transform: scale(0);
-o-transform: scale(0);
transform: scale(0);
}
}
</style>

View file

@ -9,14 +9,13 @@ install! 'cocoapods', :disable_input_output_paths => true
def capacitor_pods
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCommunitySqlite', :path => '../../node_modules/@capacitor-community/sqlite'
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
pod 'CapacitorDialog', :path => '../../node_modules/@capacitor/dialog'
pod 'CapacitorNetwork', :path => '../../node_modules/@capacitor/network'
pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar'
pod 'CapacitorStorage', :path => '../../node_modules/@capacitor/storage'
pod 'RobingenzCapacitorAppUpdate', :path => '../../node_modules/@robingenz/capacitor-app-update'
pod 'CapacitorDataStorageSqlite', :path => '../../node_modules/capacitor-data-storage-sqlite'
pod 'CapacitorApp', :path => '..\..\node_modules\@capacitor\app'
pod 'CapacitorDialog', :path => '..\..\node_modules\@capacitor\dialog'
pod 'CapacitorHaptics', :path => '..\..\node_modules\@capacitor\haptics'
pod 'CapacitorNetwork', :path => '..\..\node_modules\@capacitor\network'
pod 'CapacitorStatusBar', :path => '..\..\node_modules\@capacitor\status-bar'
pod 'CapacitorStorage', :path => '..\..\node_modules\@capacitor\storage'
pod 'RobingenzCapacitorAppUpdate', :path => '..\..\node_modules\@robingenz\capacitor-app-update'
end
target 'App' do

View file

@ -12,27 +12,50 @@
</template>
<script>
import { Capacitor } from '@capacitor/core'
import { AppUpdate } from '@robingenz/capacitor-app-update'
import AudioDownloader from '@/plugins/audio-downloader'
import StorageManager from '@/plugins/storage-manager'
export default {
data() {
return {}
return {
attemptingConnection: false,
inittingLibraries: false,
hasMounted: false,
disconnectTime: 0
}
},
watch: {
networkConnected: {
handler(newVal) {
handler(newVal, oldVal) {
if (!this.hasMounted) {
// watcher runs before mount, handling libraries/connection should be handled in mount
return
}
if (newVal) {
this.attemptConnection()
console.log(`[default] network connected changed ${oldVal} -> ${newVal}`)
if (!this.user) {
this.attemptConnection()
} else if (!this.currentLibraryId) {
this.initLibraries()
} else {
var timeSinceDisconnect = Date.now() - this.disconnectTime
if (timeSinceDisconnect > 5000) {
console.log('Time since disconnect was', timeSinceDisconnect, 'sync with server')
setTimeout(() => {
// TODO: Some issue here
this.syncLocalMediaProgress()
}, 4000)
}
}
} else {
console.log(`[default] lost network connection`)
this.disconnectTime = Date.now()
}
}
}
},
computed: {
playerIsOpen() {
return this.$store.getters['playerIsOpen']
return this.$store.state.playerLibraryItemId
},
routeName() {
return this.$route.name
@ -40,6 +63,9 @@ export default {
networkConnected() {
return this.$store.state.networkConnected
},
user() {
return this.$store.state.user.user
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
@ -48,32 +74,6 @@ export default {
}
},
methods: {
async connected(isConnected) {
if (isConnected) {
console.log('[Default] Connected socket sync user ab data')
this.$store.dispatch('user/syncUserAudiobookData')
this.initSocketListeners()
// Load libraries
await this.$store.dispatch('libraries/load')
this.$eventBus.$emit('library-changed')
this.$store.dispatch('libraries/fetch', this.currentLibraryId)
} else {
this.removeSocketListeners()
}
},
socketConnectionFailed(err) {
this.$toast.error('Socket connection error: ' + err.message)
},
currentUserAudiobookUpdate({ id, data }) {
if (data) {
console.log(`Current User Audiobook Updated ${id} ${JSON.stringify(data)}`)
this.$sqlStore.setUserAudiobookData(data)
} else {
this.$sqlStore.removeUserAudiobookData(id)
}
},
initialStream(stream) {
if (this.$refs.streamContainer && this.$refs.streamContainer.audioPlayerReady) {
this.$refs.streamContainer.streamOpen(stream)
@ -88,6 +88,7 @@ export default {
}
},
async checkForUpdate() {
if (this.$platform == 'web') return
console.log('Checking for app update')
const result = await AppUpdate.getAppUpdateInfo()
if (!result) {
@ -108,180 +109,6 @@ export default {
}, 5000)
}
},
onDownloadProgress(data) {
var progress = data.progress
var audiobookId = data.audiobookId
var downloadObj = this.$store.getters['downloads/getDownload'](audiobookId)
if (downloadObj) {
this.$toast.update(downloadObj.toastId, { content: `${progress}% Downloading ${downloadObj.audiobook.book.title}` })
}
},
onDownloadFailed(data) {
if (!data.audiobookId) {
console.error('Download failed invalid audiobook id', data)
return
}
var downloadObj = this.$store.getters['downloads/getDownload'](data.audiobookId)
if (!downloadObj) {
console.error('Failed to find download for audiobook', data.audiobookId)
return
}
var message = data.error || 'Unknown Error'
this.$toast.update(downloadObj.toastId, { content: `Failed. ${message}.`, options: { timeout: 5000, type: 'error' } }, true)
this.$store.commit('downloads/removeDownload', downloadObj)
},
onDownloadComplete(data) {
if (!data.audiobookId) {
console.error('Download compelte invalid audiobook id', data)
return
}
var downloadId = data.downloadId
var contentUrl = data.contentUrl
var folderUrl = data.folderUrl
var folderName = data.folderName
var storageId = data.storageId
var storageType = data.storageType
var simplePath = data.simplePath
var filename = data.filename
var audiobookId = data.audiobookId
var size = data.size || 0
var isCover = !!data.isCover
console.log(`Download complete "${contentUrl}" | ${filename} | DlId: ${downloadId} | Is Cover? ${isCover}`)
var downloadObj = this.$store.getters['downloads/getDownload'](audiobookId)
if (!downloadObj) {
console.error('Failed to find download for audiobook', audiobookId)
return
}
if (!isCover) {
// Notify server to remove prepared download
if (this.$server.socket) {
this.$server.socket.emit('remove_download', audiobookId)
}
this.$toast.update(downloadObj.toastId, { content: `Success! ${downloadObj.audiobook.book.title} downloaded.`, options: { timeout: 5000, type: 'success' } }, true)
delete downloadObj.isDownloading
delete downloadObj.isPreparing
downloadObj.contentUrl = contentUrl
downloadObj.simplePath = simplePath
downloadObj.folderUrl = folderUrl
downloadObj.folderName = folderName
downloadObj.storageType = storageType
downloadObj.storageId = storageId
downloadObj.basePath = data.basePath || null
downloadObj.size = size
this.$store.commit('downloads/addUpdateDownload', downloadObj)
} else {
downloadObj.coverUrl = contentUrl
downloadObj.cover = Capacitor.convertFileSrc(contentUrl)
downloadObj.coverSize = size
downloadObj.coverBasePath = data.basePath || null
console.log('Updating download with cover', downloadObj.cover)
this.$store.commit('downloads/addUpdateDownload', downloadObj)
}
},
async checkLoadCurrent() {
var currentObj = await this.$localStore.getCurrent()
if (!currentObj) return
console.log('Has Current playing', currentObj.audiobookId)
var download = this.$store.getters['downloads/getDownload'](currentObj.audiobookId)
if (download) {
this.$store.commit('setPlayingDownload', download)
} else {
console.warn('Download not available for previous current playing', currentObj.audiobookId)
this.$localStore.setCurrent(null)
}
},
async searchFolder(downloadFolder) {
try {
var response = await StorageManager.searchFolder({ folderUrl: downloadFolder.uri })
var searchResults = response
searchResults.folders = JSON.parse(searchResults.folders)
searchResults.files = JSON.parse(searchResults.files)
console.log('Search folders results length', searchResults.folders.length)
searchResults.folders = searchResults.folders.map((sr) => {
if (sr.files) {
sr.files = JSON.parse(sr.files)
}
return sr
})
return searchResults
} catch (error) {
console.error('Failed', error)
this.$toast.error('Failed to search downloads folder')
return {}
}
},
async syncDownloads(downloads, downloadFolder) {
console.log('Syncing downloads ' + downloads.length)
var mediaScanResults = await this.searchFolder(downloadFolder)
this.$store.commit('downloads/setMediaScanResults', mediaScanResults)
// Filter out media folders without any audio files
var mediaFolders = mediaScanResults.folders.filter((sr) => {
if (!sr.files) return false
var audioFiles = sr.files.filter((mf) => !!mf.isAudio)
return audioFiles.length
})
downloads.forEach((download) => {
var mediaFolder = mediaFolders.find((mf) => mf.name === download.folderName)
if (mediaFolder) {
console.log('Found download ' + download.folderName)
if (download.isMissing) {
download.isMissing = false
this.$store.commit('downloads/addUpdateDownload', download)
}
} else {
console.error('Download not found ' + download.folderName)
if (!download.isMissing) {
download.isMissing = true
this.$store.commit('downloads/addUpdateDownload', download)
}
}
})
// Match media scanned folders with books from server
if (this.isSocketConnected) {
await this.$store.dispatch('downloads/linkOrphanDownloads')
}
},
async initMediaStore() {
// Request and setup listeners for media files on native
AudioDownloader.addListener('onDownloadComplete', (data) => {
this.onDownloadComplete(data)
})
AudioDownloader.addListener('onDownloadFailed', (data) => {
this.onDownloadFailed(data)
})
AudioDownloader.addListener('onDownloadProgress', (data) => {
this.onDownloadProgress(data)
})
var downloads = await this.$store.dispatch('downloads/loadFromStorage')
var downloadFolder = await this.$localStore.getDownloadFolder()
if (downloadFolder) {
await this.syncDownloads(downloads, downloadFolder)
}
this.$eventBus.$emit('downloads-loaded')
var checkPermission = await StorageManager.checkStoragePermission()
console.log('Storage Permission is' + checkPermission.value)
if (!checkPermission.value) {
console.log('Will require permissions')
} else {
console.log('Has Storage Permission')
this.$store.commit('setHasStoragePermission', true)
}
},
async loadSavedSettings() {
var userSavedServerSettings = await this.$localStore.getServerSettings()
if (userSavedServerSettings) {
@ -292,125 +119,149 @@ export default {
if (userSavedSettings) {
this.$store.commit('user/setSettings', userSavedSettings)
}
console.log('Loading offline user audiobook data')
await this.$store.dispatch('user/loadOfflineUserAudiobookData')
},
showErrorToast(message) {
this.$toast.error(message)
},
showSuccessToast(message) {
this.$toast.success(message)
},
async attemptConnection() {
if (!this.$server) return
console.warn('[default] attemptConnection')
if (!this.networkConnected) {
console.warn('No network connection')
console.warn('[default] No network connection')
return
}
if (this.attemptingConnection) {
return
}
this.attemptingConnection = true
var deviceData = await this.$db.getDeviceData()
var serverConfig = null
if (deviceData && deviceData.lastServerConnectionConfigId && deviceData.serverConnectionConfigs.length) {
serverConfig = deviceData.serverConnectionConfigs.find((scc) => scc.id == deviceData.lastServerConnectionConfigId)
}
if (!serverConfig) {
// No last server config set
this.attemptingConnection = false
return
}
var localServerUrl = await this.$localStore.getServerUrl()
var localUserToken = await this.$localStore.getToken()
if (localServerUrl) {
// Server and Token are stored
if (localUserToken) {
var isSocketAlreadyEstablished = this.$server.socket
var success = await this.$server.connect(localServerUrl, localUserToken)
if (!success && !this.$server.url) {
// Bad URL
} else if (!success) {
// Failed to connect
} else if (isSocketAlreadyEstablished) {
// No need to wait for connect event
}
}
console.log(`[default] Got server config, attempt authorize ${serverConfig.address}`)
var authRes = await this.$axios.$post(`${serverConfig.address}/api/authorize`, null, { headers: { Authorization: `Bearer ${serverConfig.token}` } }).catch((error) => {
console.error('[Server] Server auth failed', error)
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
this.error = errorMsg
return false
})
if (!authRes) {
this.attemptingConnection = false
return
}
const { user, userDefaultLibraryId } = authRes
// Set library - Use last library if set and available fallback to default user library
var lastLibraryId = await this.$localStore.getLastLibraryId()
if (lastLibraryId && (!user.librariesAccessible.length || user.librariesAccessible.includes(lastLibraryId))) {
this.$store.commit('libraries/setCurrentLibrary', lastLibraryId)
} else if (userDefaultLibraryId) {
this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId)
}
var serverConnectionConfig = await this.$db.setServerConnectionConfig(serverConfig)
this.$store.commit('user/setUser', user)
this.$store.commit('user/setServerConnectionConfig', serverConnectionConfig)
this.$socket.connect(serverConnectionConfig.address, serverConnectionConfig.token)
console.log('[default] Successful connection on last saved connection config', JSON.stringify(serverConnectionConfig))
await this.initLibraries()
this.attemptingConnection = false
},
audiobookAdded(audiobook) {
this.$store.commit('libraries/updateFilterDataWithAudiobook', audiobook)
},
audiobookUpdated(audiobook) {
this.$store.commit('libraries/updateFilterDataWithAudiobook', audiobook)
},
audiobookRemoved(audiobook) {
if (this.$route.name.startsWith('audiobook')) {
if (this.$route.params.id === audiobook.id) {
itemRemoved(libraryItem) {
if (this.$route.name.startsWith('item')) {
if (this.$route.params.id === libraryItem.id) {
this.$router.replace(`/bookshelf`)
}
}
},
audiobooksAdded(audiobooks) {
audiobooks.forEach((ab) => {
this.audiobookAdded(ab)
})
},
audiobooksUpdated(audiobooks) {
audiobooks.forEach((ab) => {
this.audiobookUpdated(ab)
})
},
userLoggedOut() {
// Only cancels stream if streamining not playing downloaded
this.$eventBus.$emit('close_stream')
this.$eventBus.$emit('close-stream')
},
initSocketListeners() {
if (this.$server.socket) {
// Audiobook Listeners
this.$server.socket.on('audiobook_updated', this.audiobookUpdated)
this.$server.socket.on('audiobook_added', this.audiobookAdded)
this.$server.socket.on('audiobook_removed', this.audiobookRemoved)
this.$server.socket.on('audiobooks_updated', this.audiobooksUpdated)
this.$server.socket.on('audiobooks_added', this.audiobooksAdded)
socketConnectionUpdate(isConnected) {
console.log('Socket connection update', isConnected)
},
socketConnectionFailed(err) {
this.$toast.error('Socket connection error: ' + err.message)
},
socketInit(data) {},
async initLibraries() {
if (this.inittingLibraries) {
return
}
this.inittingLibraries = true
await this.$store.dispatch('libraries/load')
console.log(`[default] initLibraries loaded ${this.currentLibraryId}`)
this.$eventBus.$emit('library-changed')
this.$store.dispatch('libraries/fetch', this.currentLibraryId)
this.inittingLibraries = false
},
async syncLocalMediaProgress() {
if (!this.user) {
console.log('[default] No need to sync local media progress - not connected to server')
return
}
console.log('[default] Calling syncLocalMediaProgress')
var response = await this.$db.syncLocalMediaProgressWithServer()
if (!response) {
if (this.$platform != 'web') this.$toast.error('Failed to sync local media with server')
return
}
const { numLocalMediaProgressForServer, numServerProgressUpdates, numLocalProgressUpdates } = response
if (numLocalMediaProgressForServer > 0) {
if (numServerProgressUpdates > 0 || numLocalProgressUpdates > 0) {
console.log(`[default] ${numServerProgressUpdates} Server progress updates | ${numLocalProgressUpdates} Local progress updates`)
} else {
console.log('[default] syncLocalMediaProgress No updates were necessary')
}
} else {
console.log('[default] syncLocalMediaProgress No local media progress to sync')
}
},
removeSocketListeners() {
if (this.$server.socket) {
// Audiobook Listeners
this.$server.socket.off('audiobook_updated', this.audiobookUpdated)
this.$server.socket.off('audiobook_added', this.audiobookAdded)
this.$server.socket.off('audiobook_removed', this.audiobookRemoved)
this.$server.socket.off('audiobooks_updated', this.audiobooksUpdated)
this.$server.socket.off('audiobooks_added', this.audiobooksAdded)
userUpdated(user) {
if (this.user && this.user.id == user.id) {
this.$store.commit('user/setUser', user)
}
}
},
async mounted() {
if (!this.$server) return console.error('No Server')
// console.log(`Default Mounted set SOCKET listeners ${this.$server.connected}`)
if (this.$server.connected) {
console.log('Syncing on default mount')
this.connected(true)
}
this.$server.on('logout', this.userLoggedOut)
this.$server.on('connected', this.connected)
this.$server.on('connectionFailed', this.socketConnectionFailed)
this.$server.on('initialStream', this.initialStream)
this.$server.on('currentUserAudiobookUpdate', this.currentUserAudiobookUpdate)
this.$server.on('show_error_toast', this.showErrorToast)
this.$server.on('show_success_toast', this.showSuccessToast)
this.$socket.on('connection-update', this.socketConnectionUpdate)
this.$socket.on('initialized', this.socketInit)
this.$socket.on('user_updated', this.userUpdated)
if (this.$store.state.isFirstLoad) {
this.$store.commit('setIsFirstLoad', false)
await this.$store.dispatch('setupNetworkListener')
this.attemptConnection()
if (this.$store.state.user.serverConnectionConfig) {
console.log(`[default] server connection config set - call init libraries`)
await this.initLibraries()
} else {
console.log(`[default] no server connection config - call attempt connection`)
await this.attemptConnection()
}
console.log(`[default] finished connection attempt or already connected ${!!this.user}`)
await this.syncLocalMediaProgress()
this.$store.dispatch('globals/loadLocalMediaProgress')
this.checkForUpdate()
this.loadSavedSettings()
this.initMediaStore()
this.hasMounted = true
}
},
beforeDestroy() {
if (!this.$server) {
console.error('No Server beforeDestroy')
return
}
this.removeSocketListeners()
this.$server.off('logout', this.userLoggedOut)
this.$server.off('connected', this.connected)
this.$server.off('connectionFailed', this.socketConnectionFailed)
this.$server.off('initialStream', this.initialStream)
this.$server.off('show_error_toast', this.showErrorToast)
this.$server.off('show_success_toast', this.showSuccessToast)
this.$socket.off('connection-update', this.socketConnectionUpdate)
this.$socket.off('initialized', this.socketInit)
this.$socket.off('user_updated', this.userUpdated)
}
}
</script>

View file

@ -1,5 +1,6 @@
import Vue from 'vue'
import LazyBookCard from '@/components/cards/LazyBookCard'
import LazyListBookCard from '@/components/cards/LazyListBookCard'
import LazySeriesCard from '@/components/cards/LazySeriesCard'
import LazyCollectionCard from '@/components/cards/LazyCollectionCard'
@ -15,6 +16,7 @@ export default {
getComponentClass() {
if (this.entityName === 'series') return Vue.extend(LazySeriesCard)
if (this.entityName === 'collections') return Vue.extend(LazyCollectionCard)
if (this.showBookshelfListView) return Vue.extend(LazyListBookCard)
return Vue.extend(LazyBookCard)
},
async mountEntityCard(index) {
@ -28,23 +30,14 @@ export default {
if (this.entityComponentRefs[index]) {
var bookComponent = this.entityComponentRefs[index]
shelfEl.appendChild(bookComponent.$el)
if (this.isSelectionMode) {
bookComponent.setSelectionMode(true)
if (this.selectedAudiobooks.includes(bookComponent.audiobookId) || this.isSelectAll) {
bookComponent.selected = true
} else {
bookComponent.selected = false
}
} else {
bookComponent.setSelectionMode(false)
}
bookComponent.setSelectionMode(false)
bookComponent.isHovering = false
return
}
var shelfOffsetY = this.isBookEntity ? 24 : 16
var shelfOffsetY = this.showBookshelfListView ? 8 : this.isBookEntity ? 24 : 16
var row = index % this.entitiesPerShelf
var marginShiftLeft = 12
var marginShiftLeft = this.showBookshelfListView ? 0 : 12
var shelfOffsetX = row * this.totalEntityCardWidth + this.bookshelfMarginLeft + marginShiftLeft
var ComponentClass = this.getComponentClass()
@ -54,7 +47,7 @@ export default {
height: this.entityHeight,
bookCoverAspectRatio: this.bookCoverAspectRatio
}
if (this.entityName === 'series-books') props.showVolumeNumber = true
if (this.entityName === 'series-books') props.showSequence = true
var _this = this
var instance = new ComponentClass({
@ -76,12 +69,14 @@ export default {
shelfEl.appendChild(instance.$el)
if (this.entities[index]) {
instance.setEntity(this.entities[index])
}
if (this.isSelectionMode) {
instance.setSelectionMode(true)
if (instance.audiobookId && this.selectedAudiobooks.includes(instance.audiobookId) || this.isSelectAll) {
instance.selected = true
var entity = this.entities[index]
instance.setEntity(entity)
if (this.isBookEntity && !entity.isLocal) {
var localLibraryItem = this.localLibraryItems.find(lli => lli.libraryItemId == entity.id)
if (localLibraryItem) {
instance.setLocalLibraryItem(localLibraryItem)
}
}
}
},

View file

@ -36,16 +36,14 @@ export default {
plugins: [
'@/plugins/server.js',
'@/plugins/sqlStore.js',
'@/plugins/db.js',
'@/plugins/localStore.js',
'@/plugins/init.client.js',
'@/plugins/axios.js',
'@/plugins/my-native-audio.js',
'@/plugins/audio-downloader.js',
'@/plugins/storage-manager.js',
'@/plugins/capacitor/index.js',
'@/plugins/toast.js',
'@/plugins/constants.js'
'@/plugins/constants.js',
'@/plugins/haptics.js'
],
components: true,

28311
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -10,24 +10,21 @@
"icons-android": "cordova-res android --skip-config --copy"
},
"dependencies": {
"@capacitor-community/sqlite": "^3.2.0",
"@capacitor/android": "^3.2.2",
"@capacitor/app": "^1.0.7",
"@capacitor/cli": "^3.1.2",
"@capacitor/core": "^3.2.2",
"@capacitor/dialog": "^1.0.3",
"@capacitor/haptics": "^1.1.4",
"@capacitor/ios": "^3.2.2",
"@capacitor/network": "^1.0.3",
"@capacitor/status-bar": "^1.0.6",
"@capacitor/storage": "^1.1.0",
"@nuxtjs/axios": "^5.13.6",
"@robingenz/capacitor-app-update": "^1.0.0",
"axios": "^0.21.1",
"capacitor-data-storage-sqlite": "^3.2.0",
"core-js": "^3.15.1",
"date-fns": "^2.25.0",
"epubjs": "^0.3.88",
"hls.js": "^1.0.9",
"libarchive.js": "^1.3.0",
"nuxt": "^2.15.7",
"socket.io-client": "^4.1.3",
@ -39,4 +36,4 @@
"@nuxtjs/tailwindcss": "^4.2.0",
"postcss": "^8.3.5"
}
}
}

View file

@ -1,12 +1,10 @@
<template>
<div class="w-full h-full p-4">
<div class="w-full max-w-xs mx-auto">
<ui-text-input-with-label :value="serverUrl" label="Server Url" disabled class="my-4" />
<ui-text-input-with-label :value="serverConnConfigName" label="Connection Config Name" disabled class="my-2" />
<ui-text-input-with-label :value="username" label="Username" disabled class="my-4" />
<ui-text-input-with-label :value="username" label="Username" disabled class="my-2" />
<ui-btn color="primary flex items-center justify-between text-base w-full mt-8" @click="logout">Logout<span class="material-icons" style="font-size: 1.1rem">logout</span></ui-btn>
</div>
<ui-btn color="primary flex items-center justify-between text-base w-full mt-8" @click="logout">Logout<span class="material-icons" style="font-size: 1.1rem">logout</span></ui-btn>
<div class="flex items-center pt-8">
<div class="flex-grow" />
@ -32,6 +30,7 @@
<script>
import { AppUpdate } from '@robingenz/capacitor-app-update'
import { AbsAudioPlayer } from '@/plugins/capacitor'
export default {
asyncData({ redirect, store }) {
@ -51,8 +50,14 @@ export default {
user() {
return this.$store.state.user.user
},
serverUrl() {
return this.$server.url
serverConnectionConfig() {
return this.$store.state.user.serverConnectionConfig || {}
},
serverConnConfigName() {
return this.serverConnectionConfig.name
},
serverAddress() {
return this.serverConnectionConfig.address
},
appUpdateInfo() {
return this.$store.state.appUpdateInfo

View file

@ -1,442 +0,0 @@
<template>
<div class="w-full h-full px-3 py-4 overflow-y-auto">
<div class="flex">
<div class="w-32">
<div class="relative">
<covers-book-cover :audiobook="audiobook" :download-cover="downloadedCover" :width="128" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div class="absolute bottom-0 left-0 h-1.5 bg-yellow-400 shadow-sm z-10" :style="{ width: 128 * progressPercent + 'px' }"></div>
</div>
<div class="flex my-4">
<p v-if="numTracks" class="text-sm">{{ numTracks }} Tracks</p>
</div>
</div>
<div class="flex-grow px-3">
<h1 class="text-lg">{{ title }}</h1>
<h3 v-if="series" class="font-book text-gray-300 text-lg leading-7">{{ seriesText }}</h3>
<p class="text-sm text-gray-400">by {{ author }}</p>
<p v-if="numTracks" class="text-gray-300 text-sm my-1">
{{ $elapsedPretty(duration) }}
<span class="px-4">{{ $bytesPretty(size) }}</span>
</p>
<div v-if="progressPercent > 0" class="px-4 py-2 bg-primary text-sm font-semibold rounded-md text-gray-200 mt-4 relative" :class="resettingProgress ? 'opacity-25' : ''">
<p class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
<p v-if="progressPercent < 1" class="text-gray-400 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
<div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick">
<span class="material-icons text-sm">close</span>
</div>
</div>
<div v-if="(isConnected && (showPlay || showRead)) || isDownloadPlayable" class="flex mt-4 -mr-2">
<ui-btn v-if="showPlay" color="success" :disabled="isPlaying" class="flex items-center justify-center flex-grow mr-2" :padding-x="4" @click="playClick">
<span v-show="!isPlaying" class="material-icons">play_arrow</span>
<span class="px-1 text-sm">{{ isPlaying ? (isStreaming ? 'Streaming' : 'Playing') : isDownloadPlayable ? 'Play local' : 'Play stream' }}</span>
</ui-btn>
<ui-btn v-if="showRead && isConnected" color="info" class="flex items-center justify-center mr-2" :class="showPlay ? '' : 'flex-grow'" :padding-x="2" @click="readBook">
<span class="material-icons">auto_stories</span>
<span v-if="!showPlay" class="px-2 text-base">Read {{ ebookFormat }}</span>
</ui-btn>
<ui-btn v-if="isConnected && showPlay && !isIos" color="primary" class="flex items-center justify-center" :padding-x="2" @click="downloadClick">
<span class="material-icons" :class="downloadObj ? 'animate-pulse' : ''">{{ downloadObj ? (isDownloading || isDownloadPreparing ? 'downloading' : 'download_done') : 'download' }}</span>
</ui-btn>
</div>
</div>
</div>
<div class="w-full py-4">
<p>{{ description }}</p>
</div>
</div>
</template>
<script>
import Path from 'path'
import { Dialog } from '@capacitor/dialog'
import AudioDownloader from '@/plugins/audio-downloader'
import StorageManager from '@/plugins/storage-manager'
export default {
async asyncData({ store, params, redirect, app }) {
var audiobookId = params.id
var audiobook = null
if (app.$server.connected) {
audiobook = await app.$axios.$get(`/api/books/${audiobookId}`).catch((error) => {
console.error('Failed', error)
return false
})
} else {
var download = store.getters['downloads/getDownload'](audiobookId)
if (download) {
audiobook = download.audiobook
}
}
if (!audiobook) {
console.error('No audiobook...', params.id)
return redirect('/')
}
return {
audiobook
}
},
data() {
return {
resettingProgress: false
}
},
computed: {
isIos() {
return this.$platform === 'ios'
},
isConnected() {
return this.$store.state.socketConnected
},
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
},
audiobookId() {
return this.audiobook.id
},
book() {
return this.audiobook.book || {}
},
title() {
return this.book.title
},
author() {
return this.book.author || 'Unknown'
},
description() {
return this.book.description || ''
},
series() {
return this.book.series || null
},
volumeNumber() {
return this.book.volumeNumber || null
},
seriesText() {
if (!this.series) return ''
if (!this.volumeNumber) return this.series
return `${this.series} #${this.volumeNumber}`
},
duration() {
return this.audiobook.duration
},
size() {
return this.audiobook.size
},
userAudiobook() {
return this.$store.getters['user/getUserAudiobook'](this.audiobookId)
},
userToken() {
return this.$store.getters['user/getToken']
},
userCurrentTime() {
return this.userAudiobook ? this.userAudiobook.currentTime : 0
},
userTimeRemaining() {
return Math.max(0, this.duration - this.userCurrentTime)
},
progressPercent() {
return this.userAudiobook ? this.userAudiobook.progress : 0
},
isStreaming() {
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
},
isMissing() {
return this.audiobook.isMissing
},
isIncomplete() {
return this.audiobook.isIncomplete
},
isDownloading() {
return this.downloadObj ? this.downloadObj.isDownloading : false
},
showPlay() {
return !this.isMissing && !this.isIncomplete && this.numTracks
},
showRead() {
return this.hasEbook && this.ebookFormat !== '.pdf'
},
hasEbook() {
return this.audiobook.numEbooks
},
ebookFormat() {
if (!this.audiobook || !this.audiobook.ebooks || !this.audiobook.ebooks.length) return null
return this.audiobook.ebooks[0].ext.substr(1)
},
isDownloadPreparing() {
return this.downloadObj ? this.downloadObj.isPreparing : false
},
isDownloadPlayable() {
return this.downloadObj && !this.isDownloading && !this.isDownloadPreparing
},
downloadedCover() {
return this.downloadObj ? this.downloadObj.cover : null
},
downloadObj() {
return this.$store.getters['downloads/getDownload'](this.audiobookId)
},
hasStoragePermission() {
return this.$store.state.hasStoragePermission
}
},
methods: {
readBook() {
this.$store.commit('openReader', this.audiobook)
},
playClick() {
this.$store.commit('setPlayOnLoad', true)
if (!this.isDownloadPlayable) {
// Stream
console.log('[PLAYCLICK] Set Playing STREAM ' + this.title)
this.$store.commit('setStreamAudiobook', this.audiobook)
this.$server.socket.emit('open_stream', this.audiobook.id)
} else {
// Local
console.log('[PLAYCLICK] Set Playing Local Download ' + this.title)
this.$store.commit('setPlayingDownload', this.downloadObj)
}
},
async clearProgressClick() {
const { value } = await Dialog.confirm({
title: 'Confirm',
message: 'Are you sure you want to reset your progress?'
})
if (value) {
this.resettingProgress = true
this.$store.dispatch('user/updateUserAudiobookData', {
audiobookId: this.audiobookId,
currentTime: 0,
totalDuration: this.duration,
progress: 0,
lastUpdate: Date.now(),
isRead: false
})
if (this.$server.connected) {
await this.$axios
.$patch(`/api/me/audiobook/${this.audiobookId}/reset-progress`)
.then(() => {
console.log('Progress reset complete')
this.$toast.success(`Your progress was reset`)
})
.catch((error) => {
console.error('Progress reset failed', error)
})
}
this.resettingProgress = false
}
},
audiobookUpdated(audiobook) {
if (audiobook.id === this.audiobookId) {
console.log('Audiobook Updated - Fetch full audiobook')
this.$axios
.$get(`/api/books/${this.audiobookId}`)
.then((audiobook) => {
this.audiobook = audiobook
})
.catch((error) => {
console.error('Failed', error)
})
}
},
downloadClick() {
console.log('downloadClick ' + this.$server.connected + ' | ' + !!this.downloadObj)
if (!this.$server.connected) return
if (this.downloadObj) {
console.log('Already downloaded', this.downloadObj)
} else {
this.prepareDownload()
}
},
async changeDownloadFolderClick() {
if (!this.hasStoragePermission) {
console.log('Requesting Storage Permission')
await StorageManager.requestStoragePermission()
} else {
var folderObj = await StorageManager.selectFolder()
if (folderObj.error) {
return this.$toast.error(`Error: ${folderObj.error || 'Unknown Error'}`)
}
var permissionsGood = await StorageManager.checkFolderPermissions({ folderUrl: folderObj.uri })
console.log('Storage Permission check folder ' + permissionsGood)
if (!permissionsGood) {
this.$toast.error('Folder permissions failed')
return
} else {
this.$toast.success('Folder permission success')
}
await this.$localStore.setDownloadFolder(folderObj)
}
},
async prepareDownload() {
var audiobook = this.audiobook
if (!audiobook) {
return
}
// Download Path
var dlFolder = this.$localStore.downloadFolder
console.log('Prepare download: ' + this.hasStoragePermission + ' | ' + dlFolder)
if (!this.hasStoragePermission || !dlFolder) {
console.log('No download folder, request from user')
// User to select download folder from download modal to ensure permissions
// this.$store.commit('downloads/setShowModal', true)
this.changeDownloadFolderClick()
return
} else {
console.log('Has Download folder: ' + JSON.stringify(dlFolder))
}
var downloadObject = {
id: this.audiobookId,
downloadFolderUrl: dlFolder.uri,
audiobook: {
...audiobook
},
isPreparing: true,
isDownloading: false,
toastId: this.$toast(`Preparing download for "${this.title}"`, { timeout: false })
}
if (audiobook.tracks.length === 1) {
// Single track should not need preparation
console.log('Single track, start download no prep needed')
var track = audiobook.tracks[0]
var fileext = track.ext
console.log('Download Single Track Path: ' + track.path)
var relTrackPath = track.path.replace('\\', '/').replace(this.audiobook.path.replace('\\', '/'), '')
var url = `${this.$store.state.serverUrl}/s/book/${this.audiobookId}${relTrackPath}?token=${this.userToken}`
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 coverSrc = this.$store.getters['audiobooks/getBookCoverSrc'](this.audiobook)
return coverSrc
// 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}?token=${this.userToken}&ts=${Date.now()}`
// } else if (_clean.startsWith('/metadata')) {
// return `${this.$store.state.serverUrl}${_clean}?token=${this.userToken}&ts=${Date.now()}`
// }
// return _clean
},
async startDownload(url, fileext, download) {
this.$toast.update(download.toastId, { content: `Downloading "${download.audiobook.book.title}"...` })
var coverDownloadUrl = this.getCoverUrlForDownload()
var coverFilename = null
if (coverDownloadUrl) {
var coverNoQueryString = coverDownloadUrl.split('?')[0]
var coverExt = Path.extname(coverNoQueryString) || '.jpg'
coverFilename = `cover-${download.id}${coverExt}`
}
download.isDownloading = true
download.isPreparing = false
download.filename = `${download.audiobook.book.title}${fileext}`
this.$store.commit('downloads/addUpdateDownload', download)
console.log('Starting Download URL', url)
var downloadRequestPayload = {
audiobookId: download.id,
filename: download.filename,
coverFilename,
coverDownloadUrl,
downloadUrl: url,
title: download.audiobook.book.title,
downloadFolderUrl: download.downloadFolderUrl
}
var downloadRes = await AudioDownloader.download(downloadRequestPayload)
if (downloadRes.error) {
var errorMsg = downloadRes.error || 'Unknown error'
console.error('Download error', errorMsg)
this.$toast.update(download.toastId, { content: `Error: ${errorMsg}`, options: { timeout: 5000, type: 'error' } })
this.$store.commit('downloads/removeDownload', download)
}
},
downloadReady(prepareDownload) {
var download = this.$store.getters['downloads/getDownload'](prepareDownload.audiobookId)
if (download) {
var fileext = prepareDownload.ext
var url = `${this.$store.state.serverUrl}/downloads/${prepareDownload.id}/${prepareDownload.filename}?token=${this.userToken}`
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() {
if (!this.$server.socket) {
console.warn('Audiobook Page mounted: Server socket not set')
} else {
this.$server.socket.on('download_ready', this.downloadReady)
this.$server.socket.on('download_killed', this.downloadKilled)
this.$server.socket.on('download_failed', this.downloadFailed)
this.$server.socket.on('audiobook_updated', this.audiobookUpdated)
}
},
beforeDestroy() {
if (!this.$server.socket) {
console.warn('Audiobook Page beforeDestroy: Server socket not set')
} else {
this.$server.socket.off('download_ready', this.downloadReady)
this.$server.socket.off('download_killed', this.downloadKilled)
this.$server.socket.off('download_failed', this.downloadFailed)
this.$server.socket.off('audiobook_updated', this.audiobookUpdated)
}
}
}
</script>

View file

@ -1,27 +1,29 @@
<template>
<div class="w-full h-full min-h-full relative">
<template v-for="(shelf, index) in shelves">
<bookshelf-shelf :key="shelf.id" :label="shelf.label" :entities="shelf.entities" :type="shelf.type" :style="{ zIndex: shelves.length - index }" />
</template>
<div v-if="!loading" class="w-full">
<template v-for="(shelf, index) in shelves">
<bookshelf-shelf :key="shelf.id" :label="shelf.label" :entities="shelf.entities" :type="shelf.type" :style="{ zIndex: shelves.length - index }" />
</template>
</div>
<div v-if="!shelves.length" class="absolute top-0 left-0 w-full h-full flex items-center justify-center">
<div>
<p class="mb-4 text-center text-xl">
Bookshelf empty
<span v-show="isSocketConnected">
<span v-show="user">
for library
<strong>{{ currentLibraryName }}</strong>
</span>
</p>
<div class="w-full" v-if="!isSocketConnected">
<div class="w-full" v-if="!user">
<div class="flex justify-center items-center mb-3">
<span class="material-icons text-error text-lg">cloud_off</span>
<p class="pl-2 text-error text-sm">Audiobookshelf server not connected.</p>
</div>
<p class="px-4 text-center text-error absolute bottom-12 left-0 right-0 mx-auto"><strong>Important!</strong> This app requires that you are running <u>your own server</u> and does not provide any content.</p>
<!-- <p class="px-4 text-center text-error absolute bottom-12 left-0 right-0 mx-auto"><strong>Important!</strong> This app requires that you are running <u>your own server</u> and does not provide any content.</p> -->
</div>
<div class="flex justify-center">
<ui-btn v-if="!isSocketConnected" small @click="$router.push('/connect')" class="w-32">Connect</ui-btn>
<ui-btn v-if="!user" small @click="$router.push('/connect')" class="w-32">Connect</ui-btn>
</div>
</div>
</div>
@ -33,18 +35,13 @@ export default {
data() {
return {
shelves: [],
loading: true
loading: false,
localLibraryItems: []
}
},
computed: {
books() {
return this.$store.getters['downloads/getDownloads'].map((dl) => {
var download = { ...dl }
var ab = { ...download.audiobook }
delete download.audiobook
ab.download = download
return ab
})
user() {
return this.$store.state.user.user
},
isSocketConnected() {
return this.$store.state.socketConnected
@ -52,106 +49,93 @@ export default {
currentLibraryName() {
return this.$store.getters['libraries/getCurrentLibraryName']
},
booksWithUserAbData() {
var books = this.books.map((b) => {
var userAbData = this.$store.getters['user/getUserAudiobookData'](b.id)
return { ...b, userAbData }
})
return books
},
booksCurrentlyReading() {
var books = this.booksWithUserAbData
.map((b) => ({ ...b }))
.filter((b) => b.userAbData && !b.userAbData.isRead && b.userAbData.progress > 0)
.sort((a, b) => {
return b.userAbData.lastUpdate - a.userAbData.lastUpdate
})
return books
},
booksRecentlyAdded() {
var books = this.books
.map((b) => {
return { ...b }
})
.sort((a, b) => b.addedAt - a.addedAt)
return books.slice(0, 10)
},
booksRead() {
var books = this.booksWithUserAbData
.filter((b) => b.userAbData && b.userAbData.isRead)
.sort((a, b) => {
return b.userAbData.lastUpdate - a.userAbData.lastUpdate
})
return books.slice(0, 10)
},
downloadOnlyShelves() {
var shelves = []
if (this.booksCurrentlyReading.length) {
shelves.push({
id: 'recent',
label: 'Continue Reading',
type: 'books',
entities: this.booksCurrentlyReading
})
}
if (this.booksRecentlyAdded.length) {
shelves.push({
id: 'added',
label: 'Recently Added',
type: 'books',
entities: this.booksRecentlyAdded
})
}
if (this.booksRead.length) {
shelves.push({
id: 'read',
label: 'Read Again',
type: 'books',
entities: this.booksRead
})
}
return shelves
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
}
},
methods: {
async fetchCategories() {
var categories = await this.$axios
.$get(`/api/libraries/${this.currentLibraryId}/categories`)
.then((data) => {
return data
async getLocalMediaItemCategories() {
var localMedia = await this.$db.getLocalLibraryItems()
console.log('Got local library items', localMedia ? localMedia.length : 'N/A')
if (!localMedia || !localMedia.length) return []
var categories = []
var books = []
var podcasts = []
localMedia.forEach((item) => {
if (item.mediaType == 'book') {
books.push(item)
} else if (item.mediaType == 'podcast') {
podcasts.push(item)
}
})
if (books.length) {
categories.push({
id: 'local-books',
label: 'Local Books',
type: 'book',
entities: books
})
.catch((error) => {
}
if (podcasts.length) {
categories.push({
id: 'local-podcasts',
label: 'Local Podcasts',
type: 'podcast',
entities: podcasts
})
}
return categories
},
async fetchCategories() {
if (this.loading) {
console.log('Already loading categories')
return
}
this.loading = true
this.shelves = []
this.localLibraryItems = await this.$db.getLocalLibraryItems()
var localCategories = await this.getLocalMediaItemCategories()
this.shelves = this.shelves.concat(localCategories)
if (this.user && this.currentLibraryId) {
var categories = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/personalized?minified=1`).catch((error) => {
console.error('Failed to fetch categories', error)
return []
})
this.shelves = categories
},
async socketInit(isConnected) {
if (isConnected && this.currentLibraryId) {
console.log('Connected - Load from server')
await this.fetchCategories()
} else {
console.log('Disconnected - Reset to local storage')
this.shelves = this.downloadOnlyShelves
categories = categories.map((cat) => {
console.log('[breadcrumb] Personalized category from server', cat.type)
if (cat.type == 'book' || cat.type == 'podcast') {
// Map localLibraryItem to entities
cat.entities = cat.entities.map((entity) => {
var localLibraryItem = this.localLibraryItems.find((lli) => {
return lli.libraryItemId == entity.id
})
if (localLibraryItem) {
entity.localLibraryItem = localLibraryItem
}
return entity
})
}
return cat
})
// Put continue listening shelf first
var continueListeningShelf = categories.find((c) => c.id == 'continue-listening')
if (continueListeningShelf) {
this.shelves = [continueListeningShelf, ...this.shelves]
console.log(this.shelves)
}
this.shelves = this.shelves.concat(categories.filter((c) => c.id != 'continue-listening'))
}
this.loading = false
},
async libraryChanged(libid) {
if (this.isSocketConnected && this.currentLibraryId) {
if (this.currentLibraryId) {
await this.fetchCategories()
} else {
this.shelves = this.downloadOnlyShelves
}
},
downloadsLoaded() {
if (!this.isSocketConnected) {
this.shelves = this.downloadOnlyShelves
}
},
audiobookAdded(audiobook) {
@ -196,57 +180,25 @@ export default {
}
})
},
audiobookRemoved(audiobook) {
this.removeBookFromShelf(audiobook)
},
audiobooksAdded(audiobooks) {
console.log('audiobooks added', audiobooks)
// TODO: Check if audiobook would be on this shelf
this.fetchCategories()
},
audiobooksUpdated(audiobooks) {
audiobooks.forEach((ab) => {
this.audiobookUpdated(ab)
})
},
initListeners() {
this.$server.on('initialized', this.socketInit)
// this.$server.on('initialized', this.socketInit)
this.$eventBus.$on('library-changed', this.libraryChanged)
this.$eventBus.$on('downloads-loaded', this.downloadsLoaded)
if (this.$server.socket) {
this.$server.socket.on('audiobook_updated', this.audiobookUpdated)
this.$server.socket.on('audiobook_added', this.audiobookAdded)
this.$server.socket.on('audiobook_removed', this.audiobookRemoved)
this.$server.socket.on('audiobooks_updated', this.audiobooksUpdated)
this.$server.socket.on('audiobooks_added', this.audiobooksAdded)
} else {
console.error('Error socket not initialized')
}
// this.$eventBus.$on('downloads-loaded', this.downloadsLoaded)
},
removeListeners() {
this.$server.off('initialized', this.socketInit)
// this.$server.off('initialized', this.socketInit)
this.$eventBus.$off('library-changed', this.libraryChanged)
this.$eventBus.$off('downloads-loaded', this.downloadsLoaded)
if (this.$server.socket) {
this.$server.socket.off('audiobook_updated', this.audiobookUpdated)
this.$server.socket.off('audiobook_added', this.audiobookAdded)
this.$server.socket.off('audiobook_removed', this.audiobookRemoved)
this.$server.socket.off('audiobooks_updated', this.audiobooksUpdated)
this.$server.socket.off('audiobooks_added', this.audiobooksAdded)
} else {
console.error('Error socket not initialized')
}
// this.$eventBus.$off('downloads-loaded', this.downloadsLoaded)
}
},
mounted() {
this.initListeners()
if (this.$server.initialized && this.currentLibraryId) {
this.fetchCategories()
} else {
this.shelves = this.downloadOnlyShelves
}
this.fetchCategories()
// if (this.$server.initialized && this.currentLibraryId) {
// this.fetchCategories()
// } else {
// this.shelves = this.downloadOnlyShelves
// }
},
beforeDestroy() {
this.removeListeners()

View file

@ -4,10 +4,11 @@
<script>
export default {
asyncData({ store, params, query }) {
async asyncData({ store, params, query }) {
// Set filter by
if (query.filter) {
store.dispatch('user/updateUserSettings', { mobileFilterBy: query.filter })
store.commit('user/setSettings', { mobileFilterBy: query.filter })
await store.dispatch('user/updateUserSettings', { mobileFilterBy: query.filter })
}
}
}

View file

@ -4,8 +4,17 @@
<script>
export default {
asyncData({ params }) {
async asyncData({ params, app, store }) {
var series = await app.$axios.$get(`/api/series/${params.id}`).catch((error) => {
console.error('Failed', error)
return false
})
if (!series) {
return redirect('/oops?message=Series not found')
}
store.commit('globals/setSeries', series)
return {
series,
seriesId: params.id
}
},

View file

@ -18,10 +18,6 @@
</ui-btn>
</div>
<!-- <ui-icon-btn icon="edit" class="mx-0.5" @click="editClick" />
<ui-icon-btn icon="delete" class="mx-0.5" @click="removeClick" /> -->
<div class="my-8 max-w-2xl">
<p class="text-base text-gray-100">{{ description }}</p>
</div>
@ -75,36 +71,24 @@ export default {
},
playableBooks() {
return this.bookItems.filter((book) => {
return !book.isMissing && !book.isIncomplete && book.numTracks
return !book.isMissing && !book.isInvalid && book.media.tracks.length
})
},
streaming() {
return !!this.playableBooks.find((b) => b.id === this.$store.getters['getAudiobookIdStreaming'])
return !!this.playableBooks.find((b) => this.$store.getters['getIsItemStreaming'](b.id))
},
showPlayButton() {
return this.playableBooks.length
},
userAudiobooks() {
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
}
},
methods: {
clickPlay() {
var nextBookNotRead = this.playableBooks.find((pb) => !this.userAudiobooks[pb.id] || !this.userAudiobooks[pb.id].isRead)
var nextBookNotRead = this.playableBooks.find((pb) => {
var prog = this.$store.getters['user/getUserMediaProgress'](pb.id)
return !prog || !prog.isFinished
})
if (nextBookNotRead) {
var dlObj = this.$store.getters['downloads/getDownload'](nextBookNotRead.id)
this.$store.commit('setPlayOnLoad', true)
if (dlObj && !dlObj.isDownloading && !dlObj.isPreparing) {
// Local
console.log('[PLAYCLICK] Set Playing Local Download ' + nextBookNotRead.book.title)
this.$store.commit('setPlayingDownload', dlObj)
} else {
// Stream
console.log('[PLAYCLICK] Set Playing STREAM ' + nextBookNotRead.book.title)
this.$store.commit('setStreamAudiobook', nextBookNotRead)
this.$server.socket.emit('open_stream', nextBookNotRead.id)
}
this.$eventBus.$emit('play-item', { libraryItemId: nextBookNotRead.id })
}
}
},

View file

@ -6,57 +6,15 @@
</nuxt-link>
<div class="absolute top-0 left-0 w-full p-6 flex items-center flex-col justify-center z-0 short:hidden">
<img src="/Logo.png" class="h-20 w-20 mb-2" />
<h1 class="text-2xl font-book">Audiobookshelf</h1>
<h1 class="text-2xl font-book">audiobookshelf</h1>
</div>
<p class="hidden absolute short:block top-1.5 left-12 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>
<p class="absolute bottom-16 left-0 right-0 px-2 text-center text-error"><strong>Important!</strong> This app requires that you are running <u>your own server</u> and does not provide any content.</p>
<!-- <p class="absolute bottom-16 left-0 right-0 px-2 text-center text-error"><strong>Important!</strong> This app requires that you are running <u>your own server</u> and does not provide any content.</p> -->
<div class="w-full max-w-md mx-auto px-4 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">
<p class="text-success text-xl mb-2">Login Success!</p>
<p>Connecting socket..</p>
</div>
<div v-show="!loggedIn" class="mt-8 bg-primary overflow-hidden shadow rounded-lg p-6 w-full">
<h2 class="text-lg 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 class="w-full">
<ui-text-input v-model="serverUrl" :disabled="processing || !networkConnected" placeholder="http://55.55.55.55:13378" type="url" class="w-full sm:w-72 h-10" />
<div class="flex justify-end">
<ui-btn :disabled="processing || !networkConnected" type="submit" :padding-x="3" class="h-10 mt-4">{{ networkConnected ? 'Submit' : 'No Internet' }}</ui-btn>
</div>
</form>
<template v-if="showAuth">
<div class="flex items-center">
<p class="">{{ serverUrl }}</p>
<div class="flex-grow" />
<span class="material-icons" style="font-size: 1.1rem" @click="editServerUrl">edit</span>
</div>
<div class="w-full h-px bg-gray-200 my-2" />
<form @submit.prevent="submitAuth" class="pt-3">
<ui-text-input v-model="username" :disabled="processing" placeholder="username" class="w-full my-1 text-lg" />
<ui-text-input v-model="password" type="password" :disabled="processing" placeholder="password" class="w-full my-1 text-lg" />
<ui-btn :disabled="processing || !networkConnected" type="submit" class="mt-1 h-10">{{ networkConnected ? 'Submit' : 'No Internet' }}</ui-btn>
</form>
</template>
<div v-show="error" class="w-full rounded-lg bg-red-600 bg-opacity-10 border border-error border-opacity-50 py-3 px-2 flex items-center mt-4">
<span class="material-icons mr-2 text-error" style="font-size: 1.1rem">warning</span>
<p class="text-error">{{ error }}</p>
</div>
</div>
</div>
</div>
<div :class="processing ? 'opacity-100' : 'opacity-0 pointer-events-none'" class="fixed w-full h-full top-0 left-0 bg-black bg-opacity-75 flex items-center justify-center z-30 transition-opacity duration-500">
<div>
<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" />
</div>
<svg class="animate-spin w-16 h-16" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>
</div>
<connection-server-connect-form />
</div>
<div class="flex items-center justify-center pt-4 fixed bottom-4 left-0 right-0">
<a href="https://github.com/advplyr/audiobookshelf-app" target="_blank" class="text-sm pr-2">Follow the project on Github</a>
<a href="https://github.com/advplyr/audiobookshelf-app" target="_blank"
@ -74,15 +32,7 @@
export default {
layout: 'blank',
data() {
return {
serverUrl: null,
processing: false,
showAuth: false,
username: null,
password: null,
error: null,
loggedIn: false
}
return {}
},
computed: {
networkConnected() {
@ -90,110 +40,15 @@ export default {
}
},
methods: {
async submit() {
if (!this.networkConnected) {
return
}
if (!this.serverUrl.startsWith('http')) {
this.serverUrl = 'http://' + this.serverUrl
}
this.processing = true
this.error = null
var response = await this.$server.check(this.serverUrl)
this.processing = false
if (!response || response.error) {
console.error('Server invalid')
this.error = response ? response.error : 'Invalid Server'
} else {
this.showAuth = true
}
},
async submitAuth() {
if (!this.networkConnected) {
return
}
if (!this.username) {
this.error = 'Invalid username'
return
}
this.error = null
this.processing = true
var response = await this.$server.login(this.serverUrl, this.username, this.password)
this.processing = false
if (response.error) {
console.error('Login failed')
this.error = response.error
} else {
console.log('Login Success!')
this.loggedIn = true
}
},
editServerUrl() {
this.error = null
this.showAuth = false
},
redirect() {
if (this.$route.query && this.$route.query.redirect) {
this.$router.replace(this.$route.query.redirect)
} else {
this.$router.replace('/bookshelf')
}
},
socketConnected() {
console.log('Socket connected')
this.redirect()
},
async init() {
await this.$store.dispatch('setupNetworkListener')
if (!this.$server) {
console.error('Invalid server not initialized')
return
}
if (this.$server.connected) {
console.warn('Server already connected')
return this.redirect()
}
this.$server.on('connected', this.socketConnected)
var localServerUrl = await this.$localStore.getServerUrl()
var localUserToken = await this.$localStore.getToken()
if (!this.networkConnected) return
if (localServerUrl) {
this.serverUrl = localServerUrl
if (localUserToken) {
this.processing = true
var response = await this.$server.connect(localServerUrl, localUserToken)
if (!response || response.error) {
var errorMsg = response ? response.error : 'Unknown Error'
this.processing = false
this.error = errorMsg
if (!this.$server.url) {
this.serverUrl = null
this.showAuth = false
}
return
}
console.log('Server connect success')
this.showAuth = true
} else {
this.submit()
}
}
}
},
mounted() {
// Reset data on logouts
this.$store.commit('libraries/reset')
this.$store.commit('setIsFirstLoad', true)
this.init()
},
beforeDestroy() {
if (!this.$server) {
console.error('Connected beforeDestroy: No Server')
return
}
this.$server.off('connected', this.socketConnected)
}
}
</script>

View file

@ -1,240 +0,0 @@
<template>
<div class="w-full h-full py-6">
<h1 class="text-2xl px-4">Downloads</h1>
<div v-if="!isIos" class="w-full px-2 py-2" :class="hasStoragePermission ? '' : 'text-error'">
<div class="flex items-center">
<span class="material-icons" @click="changeDownloadFolderClick">{{ hasStoragePermission ? 'folder' : 'error' }}</span>
<p v-if="hasStoragePermission" class="text-sm px-4" @click="changeDownloadFolderClick">{{ downloadFolderSimplePath || 'No Download Folder Selected' }}</p>
<p v-else class="text-sm px-4" @click="changeDownloadFolderClick">No Storage Permissions. Click here</p>
</div>
<!-- <p v-if="hasStoragePermission" class="text-xs text-gray-400 break-all max-w-full">{{ downloadFolderUri }}</p> -->
</div>
<div v-if="!isIos" class="w-full h-10 relative">
<div class="absolute top-px left-0 z-10 w-full h-full flex">
<div class="flex-grow h-full bg-primary rounded-t-md mr-px" @click="showingDownloads = true">
<div class="flex items-center justify-center rounded-t-md border-t border-l border-r border-white border-opacity-20 h-full" :class="showingDownloads ? 'text-gray-100' : 'border-b bg-black bg-opacity-20 text-gray-400'">
<p>Downloads</p>
</div>
</div>
<div class="flex-grow h-full bg-primary rounded-t-md ml-px" @click="showingDownloads = false">
<div class="flex items-center justify-center h-full rounded-t-md border-t border-l border-r border-white border-opacity-20" :class="!showingDownloads ? 'text-gray-100' : 'border-b bg-black bg-opacity-20 text-gray-400'">
<p>Files</p>
</div>
</div>
</div>
</div>
<div v-if="!isIos" class="list-content-body relative w-full overflow-x-hidden bg-primary">
<template v-if="showingDownloads">
<div v-if="!totalDownloads" class="flex items-center justify-center h-40">
<p>No Downloads</p>
</div>
<ul v-else class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<li v-for="download in downloadsDownloading" :key="download.id" class="text-gray-400 select-none relative px-4 py-5 border-b border-white border-opacity-10 bg-black bg-opacity-10">
<div class="flex items-center justify-center">
<div class="w-3/4">
<span class="text-xs">({{ downloadingProgress[download.id] || 0 }}%) {{ download.isPreparing ? 'Preparing' : 'Downloading' }}...</span>
<p class="font-normal truncate text-sm">{{ download.audiobook.book.title }}</p>
</div>
<div class="flex-grow" />
<div class="shadow-sm text-white flex items-center justify-center rounded-full animate-spin">
<span class="material-icons">refresh</span>
</div>
</div>
</li>
<li v-for="download in downloadsReady" :key="download.id" class="text-gray-50 select-none relative pr-4 pl-2 py-5 border-b border-white border-opacity-10" @click="jumpToAudiobook(download)">
<modals-downloads-download-item :download="download" @play="playDownload" @delete="clickDeleteDownload" />
</li>
</ul>
</template>
<template v-else>
<div class="w-full h-full">
<div class="w-full flex justify-around py-4 px-2">
<ui-btn small @click="searchFolder">Re-Scan</ui-btn>
<ui-btn small @click="changeDownloadFolderClick">Change Folder</ui-btn>
<ui-btn small color="error" @click="resetFolder">Reset</ui-btn>
</div>
<p v-if="isScanning" class="text-center my-8">Scanning Folder..</p>
<p v-else-if="!mediaScanResults" class="text-center my-8">No Files Found</p>
<div v-else>
<div v-for="mediaFolder in mediaScanResults.folders" :key="mediaFolder.uri" class="w-full px-2 py-2">
<div class="flex items-center">
<span class="material-icons text-base text-white text-opacity-50">folder</span>
<p class="ml-1 py-0.5">{{ mediaFolder.name }}</p>
</div>
<div v-for="mediaFile in mediaFolder.files" :key="mediaFile.uri" class="ml-3 flex items-center">
<span class="material-icons text-base text-white text-opacity-50">{{ mediaFile.isAudio ? 'music_note' : 'image' }}</span>
<p class="ml-1 py-0.5">{{ mediaFile.name }}</p>
</div>
</div>
<div v-for="mediaFile in mediaScanResults.files" :key="mediaFile.uri" class="w-full px-2 py-2">
<div class="flex items-center">
<span class="material-icons text-base text-white text-opacity-50">{{ mediaFile.isAudio ? 'music_note' : 'image' }}</span>
<p class="ml-1 py-0.5">{{ mediaFile.name }}</p>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
<script>
import { Dialog } from '@capacitor/dialog'
import AudioDownloader from '@/plugins/audio-downloader'
import StorageManager from '@/plugins/storage-manager'
export default {
data() {
return {
downloadingProgress: {},
totalSize: 0,
showingDownloads: true,
isScanning: false
}
},
computed: {
isIos() {
return this.$platform === 'ios'
},
isSocketConnected() {
return this.$store.state.socketConnected
},
hasStoragePermission() {
return this.$store.state.hasStoragePermission
},
downloadFolder() {
return this.$store.state.downloadFolder
},
downloadFolderSimplePath() {
return this.downloadFolder ? this.downloadFolder.simplePath : null
},
downloadFolderUri() {
return this.downloadFolder ? this.downloadFolder.uri : null
},
totalDownloads() {
return this.downloadsReady.length + this.downloadsDownloading.length
},
downloadsDownloading() {
return this.downloads.filter((d) => d.isDownloading || d.isPreparing)
},
downloadsReady() {
return this.downloads.filter((d) => !d.isDownloading && !d.isPreparing)
},
downloads() {
return this.$store.state.downloads.downloads
},
mediaScanResults() {
return this.$store.state.downloads.mediaScanResults
}
},
methods: {
async changeDownloadFolderClick() {
if (!this.hasStoragePermission) {
console.log('Requesting Storage Permission')
StorageManager.requestStoragePermission()
} else {
var folderObj = await StorageManager.selectFolder()
if (folderObj.error) {
return this.$toast.error(`Error: ${folderObj.error || 'Unknown Error'}`)
}
var permissionsGood = await StorageManager.checkFolderPermissions({ folderUrl: folderObj.uri })
console.log('Storage Permission check folder ' + permissionsGood)
if (!permissionsGood) {
this.$toast.error('Folder permissions failed')
return
} else {
this.$toast.success('Folder permission success')
}
await this.$localStore.setDownloadFolder(folderObj)
await this.searchFolder()
if (this.isSocketConnected) {
this.$store.dispatch('downloads/linkOrphanDownloads')
}
}
},
async searchFolder() {
this.isScanning = true
var response = await StorageManager.searchFolder({ folderUrl: this.downloadFolderUri })
var searchResults = response
searchResults.folders = JSON.parse(searchResults.folders)
searchResults.files = JSON.parse(searchResults.files)
if (searchResults.folders.length) {
console.log('Search results folders length', searchResults.folders.length)
searchResults.folders = searchResults.folders.map((sr) => {
if (sr.files) {
sr.files = JSON.parse(sr.files)
}
return sr
})
this.$store.commit('downloads/setMediaScanResults', searchResults)
} else {
this.$toast.warning('No audio or image files found')
}
this.isScanning = false
},
async resetFolder() {
await this.$localStore.setDownloadFolder(null)
this.$store.commit('downloads/setMediaScanResults', {})
this.$toast.info('Unlinked Folder')
},
jumpToAudiobook(download) {
this.show = false
this.$router.push(`/audiobook/${download.id}`)
},
async clickDeleteDownload(download) {
const { value } = await Dialog.confirm({
title: 'Confirm',
message: 'Delete this download?'
})
if (value) {
this.deleteDownload(download)
}
},
playDownload(download) {
this.$store.commit('setPlayOnLoad', true)
this.$store.commit('setPlayingDownload', download)
this.show = false
},
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 StorageManager.delete(download)
}
this.$store.commit('downloads/removeDownload', download)
},
onDownloadProgress(data) {
var progress = data.progress
var audiobookId = data.audiobookId
var downloadObj = this.$store.getters['downloads/getDownload'](audiobookId)
if (downloadObj) {
this.$set(this.downloadingProgress, audiobookId, progress)
}
},
init() {
AudioDownloader.addListener('onDownloadProgress', this.onDownloadProgress)
}
},
beforeDestroy() {
AudioDownloader.removeListener('onDownloadProgress', this.onDownloadProgress)
}
}
</script>

360
pages/item/_id.vue Normal file
View file

@ -0,0 +1,360 @@
<template>
<div class="w-full h-full px-3 py-4 overflow-y-auto">
<div class="flex">
<div class="w-32">
<div class="relative">
<covers-book-cover :library-item="libraryItem" :width="128" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div v-if="!isPodcast" class="absolute bottom-0 left-0 h-1.5 bg-yellow-400 shadow-sm z-10" :style="{ width: 128 * progressPercent + 'px' }"></div>
</div>
<div class="flex my-4">
<p v-if="numTracks" class="text-sm">{{ numTracks }} Tracks</p>
</div>
</div>
<div class="flex-grow px-3">
<h1 class="text-lg">{{ title }}</h1>
<!-- <h3 v-if="series" class="font-book text-gray-300 text-lg leading-7">{{ seriesText }}</h3> -->
<p class="text-sm text-gray-400">by {{ author }}</p>
<p v-if="numTracks" class="text-gray-300 text-sm my-1">
{{ $elapsedPretty(duration) }}
<span class="px-4">{{ $bytesPretty(size) }}</span>
</p>
<div v-if="!isPodcast && 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' : ''">
<p class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
<p v-if="progressPercent < 1" class="text-gray-400 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
<div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick">
<span class="material-icons text-sm">close</span>
</div>
</div>
<div v-if="isLocal" class="flex mt-4 -mr-2">
<ui-btn color="success" :disabled="isPlaying" class="flex items-center justify-center flex-grow mr-2" :padding-x="4" @click="playClick">
<span v-show="!isPlaying" class="material-icons">play_arrow</span>
<span class="px-1 text-sm">{{ isPlaying ? 'Playing' : 'Play Local' }}</span>
</ui-btn>
<ui-btn v-if="showRead && isConnected" color="info" class="flex items-center justify-center mr-2" :class="showPlay ? '' : 'flex-grow'" :padding-x="2" @click="readBook">
<span class="material-icons">auto_stories</span>
<span v-if="!showPlay" class="px-2 text-base">Read {{ ebookFormat }}</span>
</ui-btn>
</div>
<div v-else-if="(user && (showPlay || showRead)) || hasLocal" class="flex mt-4 -mr-2">
<ui-btn v-if="showPlay" color="success" :disabled="isPlaying" class="flex items-center justify-center flex-grow mr-2" :padding-x="4" @click="playClick">
<span v-show="!isPlaying" class="material-icons">play_arrow</span>
<span class="px-1 text-sm">{{ isPlaying ? (isStreaming ? 'Streaming' : 'Playing') : hasLocal ? 'Play Local' : 'Play Stream' }}</span>
</ui-btn>
<ui-btn v-if="showRead && user" color="info" class="flex items-center justify-center mr-2" :class="showPlay ? '' : 'flex-grow'" :padding-x="2" @click="readBook">
<span class="material-icons">auto_stories</span>
<span v-if="!showPlay" class="px-2 text-base">Read {{ ebookFormat }}</span>
</ui-btn>
<ui-btn v-if="user && showPlay && !isIos && !hasLocal" :color="downloadItem ? 'warning' : 'primary'" class="flex items-center justify-center" :padding-x="2" @click="downloadClick">
<span class="material-icons" :class="downloadItem ? 'animate-pulse' : ''">{{ downloadItem ? 'downloading' : 'download' }}</span>
</ui-btn>
</div>
</div>
</div>
<div v-if="downloadItem" class="py-3">
<p class="text-center text-lg">Downloading! ({{ Math.round(downloadItem.itemProgress * 100) }}%)</p>
</div>
<div class="w-full py-4">
<p>{{ description }}</p>
</div>
<tables-podcast-episodes-table v-if="isPodcast" :library-item-id="libraryItemId" :episodes="episodes" />
<modals-select-local-folder-modal v-model="showSelectLocalFolder" :media-type="mediaType" @select="selectedLocalFolder" />
</div>
</template>
<script>
import { Dialog } from '@capacitor/dialog'
import { AbsFileSystem, AbsDownloader } from '@/plugins/capacitor'
export default {
async asyncData({ store, params, redirect, app }) {
var libraryItemId = params.id
var libraryItem = null
console.log(libraryItemId)
if (libraryItemId.startsWith('local')) {
libraryItem = await app.$db.getLocalLibraryItem(libraryItemId)
console.log('Got lli', libraryItem)
} else if (store.state.user.serverConnectionConfig) {
libraryItem = await app.$axios.$get(`/api/items/${libraryItemId}?expanded=1`).catch((error) => {
console.error('Failed', error)
return false
})
// Check if
if (libraryItem) {
var localLibraryItem = await app.$db.getLocalLibraryItemByLLId(libraryItemId)
if (localLibraryItem) {
console.log('Library item has local library item also', localLibraryItem.id)
libraryItem.localLibraryItem = localLibraryItem
}
}
}
if (!libraryItem) {
console.error('No item...', params.id)
return redirect('/')
}
return {
libraryItem
}
},
data() {
return {
resettingProgress: false,
showSelectLocalFolder: false
}
},
computed: {
isIos() {
return this.$platform === 'ios'
},
isLocal() {
return this.libraryItem.isLocal
},
hasLocal() {
// Server library item has matching local library item
return this.isLocal || this.libraryItem.localLibraryItem
},
localLibraryItem() {
if (this.isLocal) return this.libraryItem
return this.libraryItem.localLibraryItem || null
},
isConnected() {
return this.$store.state.socketConnected
},
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
},
libraryItemId() {
return this.libraryItem.id
},
mediaType() {
return this.libraryItem.mediaType
},
isPodcast() {
return this.mediaType == 'podcast'
},
media() {
return this.libraryItem.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
},
title() {
return this.mediaMetadata.title
},
author() {
if (this.isPodcast) return this.mediaMetadata.author
return this.mediaMetadata.authorName
},
description() {
return this.mediaMetadata.description || ''
},
series() {
return this.mediaMetadata.series || []
},
duration() {
return this.media.duration
},
size() {
return this.media.size
},
user() {
return this.$store.state.user.user
},
userToken() {
return this.$store.getters['user/getToken']
},
userItemProgress() {
if (this.isLocal) return this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId)
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
userIsFinished() {
return this.userItemProgress ? !!this.userItemProgress.isFinished : false
},
userTimeRemaining() {
if (!this.userItemProgress) return 0
var duration = this.userItemProgress.duration || this.duration
return duration - this.userItemProgress.currentTime
},
progressPercent() {
return this.userItemProgress ? Math.max(Math.min(1, this.userItemProgress.progress), 0) : 0
},
userProgressStartedAt() {
return this.userItemProgress ? this.userItemProgress.startedAt : 0
},
userProgressFinishedAt() {
return this.userItemProgress ? this.userItemProgress.finishedAt : 0
},
isStreaming() {
return this.isPlaying && !this.$store.state.playerIsLocal
},
isPlaying() {
return this.$store.getters['getIsItemStreaming'](this.libraryItemId)
},
numTracks() {
if (!this.media.tracks) return 0
return this.media.tracks.length || 0
},
isMissing() {
return this.libraryItem.isMissing
},
isIncomplete() {
return this.libraryItem.isIncomplete
},
showPlay() {
return !this.isMissing && !this.isIncomplete && this.numTracks
},
showRead() {
return this.ebookFile && this.ebookFormat !== '.pdf'
},
ebookFile() {
return this.media.ebookFile
},
ebookFormat() {
if (!this.ebookFile) return null
return this.ebookFile.ebookFormat
},
hasStoragePermission() {
return this.$store.state.hasStoragePermission
},
downloadItem() {
return this.$store.getters['globals/getDownloadItem'](this.libraryItemId)
},
episodes() {
return this.media.episodes || []
}
},
methods: {
readBook() {
this.$store.commit('openReader', this.libraryItem)
},
playClick() {
// Todo: Allow playing local or streaming
if (this.hasLocal) return this.$eventBus.$emit('play-item', { libraryItemId: this.localLibraryItem.id })
this.$eventBus.$emit('play-item', { libraryItemId: this.libraryItemId })
},
async clearProgressClick() {
const { value } = await Dialog.confirm({
title: 'Confirm',
message: 'Are you sure you want to reset your progress?'
})
if (value) {
this.resettingProgress = true
if (this.isLocal) {
// TODO: If connected to server also sync with server
await this.$db.removeLocalMediaProgress(this.libraryItemId)
this.$store.commit('globals/removeLocalMediaProgress', this.libraryItemId)
} else {
var progressId = this.userItemProgress.id
await this.$axios
.$delete(`/api/me/progress/${this.libraryItemId}`)
.then(() => {
console.log('Progress reset complete')
this.$toast.success(`Your progress was reset`)
this.$store.commit('user/removeMediaProgress', progressId)
})
.catch((error) => {
console.error('Progress reset failed', error)
})
}
this.resettingProgress = false
}
},
itemUpdated(libraryItem) {
if (libraryItem.id === this.libraryItemId) {
console.log('Item Updated')
this.libraryItem = libraryItem
}
},
async selectFolder() {
// Select and save the local folder for media type
var folderObj = await AbsFileSystem.selectFolder({ mediaType: this.mediaType })
if (folderObj.error) {
return this.$toast.error(`Error: ${folderObj.error || 'Unknown Error'}`)
}
return folderObj
},
selectedLocalFolder(localFolder) {
this.showSelectLocalFolder = false
this.download(localFolder)
},
downloadClick() {
if (this.downloadItem) {
return
}
this.download()
},
async download(selectedLocalFolder = null) {
if (!this.numTracks) {
return
}
// Get the local folder to download to
var localFolder = selectedLocalFolder
if (!localFolder) {
var localFolders = (await this.$db.getLocalFolders()) || []
console.log('Local folders loaded', localFolders.length)
var foldersWithMediaType = localFolders.filter((lf) => {
console.log('Checking local folder', lf.mediaType)
return lf.mediaType == this.mediaType
})
console.log('Folders with media type', this.mediaType, foldersWithMediaType.length)
if (!foldersWithMediaType.length) {
// No local folders or no local folders with this media type
localFolder = await this.selectFolder()
} else if (foldersWithMediaType.length == 1) {
console.log('Only 1 local folder with this media type - auto select it')
localFolder = foldersWithMediaType[0]
} else {
console.log('Multiple folders with media type')
this.showSelectLocalFolder = true
return
}
if (!localFolder) {
return this.$toast.error('Invalid download folder')
}
}
console.log('Local folder', JSON.stringify(localFolder))
var startDownloadMessage = `Start download for "${this.title}" with ${this.numTracks} audio track${this.numTracks == 1 ? '' : 's'} to folder ${localFolder.name}?`
const { value } = await Dialog.confirm({
title: 'Confirm',
message: startDownloadMessage
})
if (value) {
this.startDownload(localFolder)
}
},
async startDownload(localFolder) {
console.log('Starting download to local folder', localFolder.name)
var downloadRes = await AbsDownloader.downloadLibraryItem({ libraryItemId: this.libraryItemId, localFolderId: localFolder.id })
if (downloadRes && downloadRes.error) {
var errorMsg = downloadRes.error || 'Unknown error'
console.error('Download error', errorMsg)
this.$toast.error(errorMsg)
}
},
newLocalLibraryItem(item) {
if (item.libraryItemId == this.libraryItemId) {
console.log('New local library item', item.id)
this.$set(this.libraryItem, 'localLibraryItem', item)
}
}
},
mounted() {
this.$eventBus.$on('new-local-library-item', this.newLocalLibraryItem)
// this.$server.socket.on('item_updated', this.itemUpdated)
},
beforeDestroy() {
this.$eventBus.$off('new-local-library-item', this.newLocalLibraryItem)
// this.$server.socket.off('item_updated', this.itemUpdated)
}
}
</script>

View file

@ -0,0 +1,195 @@
<template>
<div class="w-full h-full py-6 px-4">
<div class="flex items-center mb-2">
<p class="text-base font-semibold">Folder: {{ folderName }}</p>
<div class="flex-grow" />
<span class="material-icons" @click="showDialog = true">more_vert</span>
</div>
<p class="text-sm mb-4 text-white text-opacity-60">Media Type: {{ mediaType }}</p>
<p class="mb-2 text-base text-white">Local Library Items ({{ localLibraryItems.length }})</p>
<div v-if="isScanning" class="w-full text-center p-4">
<p>Scanning...</p>
</div>
<div v-else class="w-full media-item-container overflow-y-auto">
<template v-for="mediaItem in localLibraryItems">
<nuxt-link :to="`/localMedia/item/${mediaItem.id}`" :key="mediaItem.id" class="flex my-1">
<div class="w-12 h-12 bg-primary">
<img v-if="mediaItem.coverPathSrc" :src="mediaItem.coverPathSrc" class="w-full h-full object-contain" />
</div>
<div class="flex-grow px-2">
<p class="text-sm">{{ mediaItem.media.metadata.title }}</p>
<p v-if="mediaItem.mediaType == 'book'" class="text-xs text-gray-300">{{ mediaItem.media.tracks.length }} Track{{ mediaItem.media.tracks.length == 1 ? '' : 's' }}</p>
<p v-else-if="mediaItem.mediaType == 'podcast'" class="text-xs text-gray-300">{{ mediaItem.media.episodes.length }} Episode{{ mediaItem.media.episodes.length == 1 ? '' : 's' }}</p>
</div>
<div class="w-12 h-12 flex items-center justify-center">
<span class="material-icons text-xl text-gray-300">arrow_right</span>
</div>
</nuxt-link>
</template>
</div>
<modals-dialog v-model="showDialog" :items="dialogItems" @action="dialogAction" />
</div>
</template>
<script>
import { Capacitor } from '@capacitor/core'
import { Dialog } from '@capacitor/dialog'
import { AbsFileSystem } from '@/plugins/capacitor'
export default {
asyncData({ params, query }) {
return {
folderId: params.id,
shouldScan: !!query.scan
}
},
data() {
return {
localLibraryItems: [],
folder: null,
isScanning: false,
removingFolder: false,
showDialog: false
}
},
computed: {
folderName() {
return this.folder ? this.folder.name : null
},
mediaType() {
return this.folder ? this.folder.mediaType : null
},
dialogItems() {
return [
{
text: 'Scan',
value: 'scan'
},
{
text: 'Force Re-Scan',
value: 'rescan'
},
{
text: 'Remove',
value: 'remove'
}
].filter((i) => i.value != 'rescan' || this.localLibraryItems.length) // Filter out rescan if there are no local library items
}
},
methods: {
dialogAction(action) {
console.log('Dialog action', action)
if (action == 'scan') {
this.scanFolder()
} else if (action == 'rescan') {
this.scanFolder(true)
} else if (action == 'remove') {
this.removeFolder()
}
this.showDialog = false
},
async removeFolder() {
var deleteMessage = 'Are you sure you want to remove this folder? (does not delete anything in your file system)'
if (this.localLibraryItems.length) {
deleteMessage = `Are you sure you want to remove this folder and ${this.localLibraryItems.length} items? (does not delete anything in your file system)`
}
const { value } = await Dialog.confirm({
title: 'Confirm',
message: deleteMessage
})
if (value) {
this.removingFolder = true
await AbsFileSystem.removeFolder({ folderId: this.folderId })
this.removingFolder = false
this.$router.replace('/localMedia/folders')
}
},
play(mediaItem) {
this.$eventBus.$emit('play-item', { libraryItemId: mediaItem.id })
},
async scanFolder(forceAudioProbe = false) {
this.isScanning = true
var response = await AbsFileSystem.scanFolder({ folderId: this.folderId, forceAudioProbe })
if (response && response.localLibraryItems) {
var itemsAdded = response.itemsAdded
var itemsUpdated = response.itemsUpdated
var itemsRemoved = response.itemsRemoved
var itemsUpToDate = response.itemsUpToDate
var toastMessages = []
if (itemsAdded) toastMessages.push(`${itemsAdded} Added`)
if (itemsUpdated) toastMessages.push(`${itemsUpdated} Updated`)
if (itemsRemoved) toastMessages.push(`${itemsRemoved} Removed`)
if (itemsUpToDate) toastMessages.push(`${itemsUpToDate} Up-to-date`)
this.$toast.info(`Folder scan complete:\n${toastMessages.join(' | ')}`)
// When all items are up-to-date then local media items are not returned
if (response.localLibraryItems.length) {
this.localLibraryItems = response.localLibraryItems.map((mi) => {
if (mi.coverContentUrl) {
mi.coverPathSrc = Capacitor.convertFileSrc(mi.coverContentUrl)
}
return mi
})
console.log('Set Local Media Items', this.localLibraryItems.length)
}
} else {
console.log('No Local media items found')
}
this.isScanning = false
},
async init() {
var folder = await this.$db.getLocalFolder(this.folderId)
this.folder = folder
var items = (await this.$db.getLocalLibraryItemsInFolder(this.folderId)) || []
console.log('Init folder', this.folderId, items)
this.localLibraryItems = items.map((lmi) => {
console.log('Local library item', JSON.stringify(lmi))
return {
...lmi,
coverPathSrc: lmi.coverContentUrl ? Capacitor.convertFileSrc(lmi.coverContentUrl) : null
}
})
if (this.shouldScan) {
this.scanFolder()
}
},
newLocalLibraryItem(item) {
if (item.folderId == this.folderId) {
console.log('New local library item', item.id)
if (this.localLibraryItems.find((li) => li.id == item.id)) {
console.warn('Item already added', item.id)
return
}
var _item = {
...item,
coverPathSrc: item.coverContentUrl ? Capacitor.convertFileSrc(item.coverContentUrl) : null
}
this.localLibraryItems.push(_item)
}
}
},
mounted() {
this.$eventBus.$on('new-local-library-item', this.newLocalLibraryItem)
this.init()
},
beforeDestroy() {
this.$eventBus.$off('new-local-library-item', this.newLocalLibraryItem)
}
}
</script>
<style scoped>
.media-item-container {
height: calc(100vh - 200px);
max-height: calc(100vh - 200px);
}
</style>

View file

@ -0,0 +1,93 @@
<template>
<div class="w-full h-full py-6">
<h1 class="text-base font-semibold px-3 mb-2">Local Folders</h1>
<div v-if="!isIos" class="w-full max-w-full px-3 py-2">
<template v-for="folder in localFolders">
<nuxt-link :to="`/localMedia/folders/${folder.id}`" :key="folder.id" class="flex items-center px-2 py-4 bg-primary rounded-md border-bg mb-1">
<span class="material-icons text-xl text-yellow-400">folder</span>
<p class="ml-2">{{ folder.name }}</p>
<div class="flex-grow" />
<p class="text-sm italic text-gray-300 px-3 capitalize">{{ folder.mediaType }}s</p>
<span class="material-icons text-xl text-gray-300">arrow_right</span>
</nuxt-link>
</template>
<div v-if="!localFolders.length" class="flex justify-center">
<p class="text-center">No Media Folders</p>
</div>
<div class="flex border-t border-white border-opacity-10 my-4 py-4">
<div class="flex-grow pr-1">
<ui-dropdown v-model="newFolderMediaType" placeholder="Select media type" :items="mediaTypeItems" />
</div>
<ui-btn small class="w-28" color="success" @click="selectFolder">New Folder</ui-btn>
</div>
</div>
</div>
</template>
<script>
import { AbsFileSystem } from '@/plugins/capacitor'
export default {
data() {
return {
localFolders: [],
newFolderMediaType: null,
mediaTypeItems: [
{
value: 'book',
text: 'Books'
},
{
value: 'podcast',
text: 'Podcasts'
}
]
}
},
computed: {
isIos() {
return this.$platform === 'ios'
},
isSocketConnected() {
return this.$store.state.socketConnected
}
},
methods: {
async selectFolder() {
if (!this.newFolderMediaType) {
return this.$toast.error('Must select a media type')
}
var folderObj = await AbsFileSystem.selectFolder({ mediaType: this.newFolderMediaType })
if (!folderObj) return
if (folderObj.error) {
return this.$toast.error(`Error: ${folderObj.error || 'Unknown Error'}`)
}
var indexOfExisting = this.localFolders.findIndex((lf) => lf.id == folderObj.id)
if (indexOfExisting >= 0) {
this.localFolders.splice(indexOfExisting, 1, folderObj)
} else {
this.localFolders.push(folderObj)
}
var permissionsGood = await AbsFileSystem.checkFolderPermissions({ folderUrl: folderObj.contentUrl })
if (!permissionsGood) {
this.$toast.error('Folder permissions failed')
return
} else {
this.$toast.success('Folder permission success')
}
this.$router.push(`/localMedia/folders/${folderObj.id}?scan=1`)
},
async init() {
this.localFolders = (await this.$db.getLocalFolders()) || []
}
},
mounted() {
this.init()
}
}
</script>

View file

@ -0,0 +1,340 @@
<template>
<div class="w-full h-full py-6 px-2">
<div v-if="localLibraryItem" class="w-full h-full">
<div class="px-2 flex items-center mb-2">
<p class="text-base font-book font-semibold">{{ mediaMetadata.title }}</p>
<div class="flex-grow" />
<button v-if="audioTracks.length && !isPodcast" class="shadow-sm text-accent flex items-center justify-center rounded-full mx-2" @click.stop="play">
<span class="material-icons" style="font-size: 2rem">play_arrow</span>
</button>
<span class="material-icons" @click="showItemDialog">more_vert</span>
</div>
<p class="px-2 text-sm mb-0.5 text-white text-opacity-75">Folder: {{ folderName }}</p>
<p class="px-2 mb-4 text-xs text-gray-400">{{ libraryItemId ? 'Linked to item on server ' + liServerAddress : 'Not linked to server item' }}</p>
<div v-if="isScanning" class="w-full text-center p-4">
<p>Scanning...</p>
</div>
<div v-else class="w-full media-item-container overflow-y-auto">
<div v-if="!isPodcast" class="w-full">
<p class="text-base mb-2">Audio Tracks ({{ audioTracks.length }})</p>
<template v-for="track in audioTracks">
<div :key="track.localFileId" class="flex items-center my-1">
<div class="w-10 h-12 flex items-center justify-center" style="min-width: 48px">
<p class="font-mono font-bold text-xl">{{ track.index }}</p>
</div>
<div class="flex-grow px-2">
<p class="text-xs">{{ track.title }}</p>
</div>
<div class="w-20 text-center text-gray-300" style="min-width: 80px">
<p class="text-xs">{{ track.mimeType }}</p>
<p class="text-sm">{{ $elapsedPretty(track.duration) }}</p>
</div>
<div class="w-12 h-12 flex items-center justify-center" style="min-width: 48px">
<span class="material-icons" @click="showTrackDialog(track)">more_vert</span>
</div>
</div>
</template>
</div>
<div v-else class="w-full">
<p class="text-base mb-2">Episodes ({{ audioTracks.length }})</p>
<template v-for="episode in audioTracks">
<div :key="episode.id" class="flex items-center my-1">
<div class="w-10 h-12 flex items-center justify-center" style="min-width: 48px">
<p class="font-mono font-bold text-xl">{{ episode.index }}</p>
</div>
<div class="flex-grow px-2">
<p class="text-xs">{{ episode.title }}</p>
</div>
<div class="w-20 text-center text-gray-300" style="min-width: 80px">
<p class="text-xs">{{ episode.audioTrack.mimeType }}</p>
<p class="text-sm">{{ $elapsedPretty(episode.audioTrack.duration) }}</p>
</div>
<div class="w-12 h-12 flex items-center justify-center" style="min-width: 48px">
<span class="material-icons" @click="showTrackDialog(episode)">more_vert</span>
</div>
</div>
</template>
</div>
<p v-if="otherFiles.length" class="text-lg mb-2 pt-8">Other Files</p>
<template v-for="file in otherFiles">
<div :key="file.id" class="flex items-center my-1">
<div class="w-12 h-12 flex items-center justify-center">
<img v-if="(file.mimeType || '').startsWith('image')" :src="getCapImageSrc(file.contentUrl)" class="w-full h-full object-contain" />
<span v-else class="material-icons">music_note</span>
</div>
<div class="flex-grow px-2">
<p class="text-sm">{{ file.filename }}</p>
</div>
<div class="w-20 text-center text-gray-300">
<p class="text-xs">{{ file.mimeType }}</p>
<p class="text-sm">{{ $bytesPretty(file.size) }}</p>
</div>
</div>
</template>
</div>
</div>
<div v-else class="px-2 w-full h-full">
<p class="text-lg text-center px-8">{{ failed ? 'Failed to get local library item ' + localLibraryItemId : 'Loading..' }}</p>
</div>
<modals-dialog v-model="showDialog" :items="dialogItems" @action="dialogAction" />
</div>
</template>
<script>
import { Capacitor } from '@capacitor/core'
import { Dialog } from '@capacitor/dialog'
import { AbsFileSystem } from '@/plugins/capacitor'
export default {
asyncData({ params }) {
return {
localLibraryItemId: params.id
}
},
data() {
return {
failed: false,
localLibraryItem: null,
removingItem: false,
folderId: null,
folder: null,
isScanning: false,
showDialog: false,
selectedAudioTrack: null,
selectedEpisode: null
}
},
computed: {
basePath() {
return this.localLibraryItem ? this.localLibraryItem.basePath : null
},
localFiles() {
return this.localLibraryItem ? this.localLibraryItem.localFiles : []
},
otherFiles() {
if (!this.localFiles.filter) {
console.error('Invalid local files', this.localFiles)
return []
}
return this.localFiles.filter((lf) => {
if (this.isPodcast) return !this.audioTracks.find((episode) => episode.audioTrack.localFileId == lf.id)
return !this.audioTracks.find((at) => at.localFileId == lf.id)
})
},
folderName() {
return this.folder ? this.folder.name : null
},
mediaType() {
return this.localLibraryItem ? this.localLibraryItem.mediaType : null
},
isPodcast() {
return this.mediaType == 'podcast'
},
libraryItemId() {
return this.localLibraryItem ? this.localLibraryItem.libraryItemId : null
},
liServerAddress() {
return this.localLibraryItem ? this.localLibraryItem.serverAddress : null
},
media() {
return this.localLibraryItem ? this.localLibraryItem.media : null
},
mediaMetadata() {
return this.media ? this.media.metadata || {} : {}
},
audioTracks() {
if (!this.media) return []
if (this.mediaType == 'book') {
return this.media.tracks || []
} else {
return this.media.episodes || []
}
},
dialogItems() {
if (this.selectedAudioTrack) {
return [
{
text: 'Hard Delete',
value: 'track-delete'
}
]
} else {
return [
{
text: 'Scan',
value: 'scan'
},
{
text: 'Force Re-Scan',
value: 'rescan'
},
{
text: 'Remove',
value: 'remove'
},
{
text: 'Hard Delete',
value: 'delete'
}
]
}
}
},
methods: {
showItemDialog() {
this.selectedAudioTrack = null
this.showDialog = true
},
showTrackDialog(track) {
if (this.isPodcast) {
this.selectedAudioTrack = null
this.selectedEpisode = track
} else {
this.selectedEpisode = null
this.selectedAudioTrack = track
}
this.showDialog = true
},
play() {
this.$eventBus.$emit('play-item', { libraryItemId: this.localLibraryItemId })
},
getCapImageSrc(contentUrl) {
return Capacitor.convertFileSrc(contentUrl)
},
dialogAction(action) {
console.log('Dialog action', action)
if (action == 'scan') {
this.scanItem()
} else if (action == 'rescan') {
this.scanItem(true)
} else if (action == 'remove') {
this.removeItem()
} else if (action == 'delete') {
this.deleteItem()
} else if (action == 'track-delete') {
if (this.isPodcast) this.deleteEpisode()
else this.deleteTrack()
}
this.showDialog = false
},
getLocalFileForTrack(localFileId) {
return this.localFiles.find((lf) => lf.id == localFileId)
},
async deleteEpisode() {
if (!this.selectedEpisode) return
var localFile = this.getLocalFileForTrack(this.selectedEpisode.audioTrack.localFileId)
if (!localFile) {
this.$toast.error('Audio track does not have matching local file..')
return
}
var trackPath = localFile ? localFile.basePath : this.selectedEpisode.title
const { value } = await Dialog.confirm({
title: 'Confirm',
message: `Warning! This will delete the audio file "${trackPath}" from your file system. Are you sure?`
})
if (value) {
var res = await AbsFileSystem.deleteTrackFromItem({ id: this.localLibraryItem.id, trackLocalFileId: localFile.id, trackContentUrl: this.selectedEpisode.audioTrack.contentUrl })
if (res && res.id) {
this.$toast.success('Deleted track successfully')
this.localLibraryItem = res
} else this.$toast.error('Failed to delete')
}
},
async deleteTrack() {
if (!this.selectedAudioTrack) {
return
}
var localFile = this.getLocalFileForTrack(this.selectedAudioTrack.localFileId)
if (!localFile) {
this.$toast.error('Audio track does not have matching local file..')
return
}
var trackPath = localFile ? localFile.basePath : this.selectedAudioTrack.title
const { value } = await Dialog.confirm({
title: 'Confirm',
message: `Warning! This will delete the audio file "${trackPath}" from your file system. Are you sure?`
})
if (value) {
var res = await AbsFileSystem.deleteTrackFromItem({ id: this.localLibraryItem.id, trackLocalFileId: this.selectedAudioTrack.localFileId, trackContentUrl: this.selectedAudioTrack.contentUrl })
if (res && res.id) {
this.$toast.success('Deleted track successfully')
this.localLibraryItem = res
} else this.$toast.error('Failed to delete')
}
},
async deleteItem() {
const { value } = await Dialog.confirm({
title: 'Confirm',
message: `Warning! This will delete the folder "${this.basePath}" and all contents. Are you sure?`
})
if (value) {
var res = await AbsFileSystem.deleteItem(this.localLibraryItem)
if (res && res.success) {
this.$toast.success('Deleted Successfully')
this.$router.replace(`/localMedia/folders/${this.folderId}`)
} else this.$toast.error('Failed to delete')
}
},
async removeItem() {
var deleteMessage = 'Are you sure you want to remove this local library item? (does not delete anything in your file system)'
const { value } = await Dialog.confirm({
title: 'Confirm',
message: deleteMessage
})
if (value) {
this.removingItem = true
await AbsFileSystem.removeLocalLibraryItem({ localLibraryItemId: this.localLibraryItemId })
this.removingItem = false
this.$router.replace(`/localMedia/folders/${this.folderId}`)
}
},
async scanItem(forceAudioProbe = false) {
if (this.isScanning) return
this.isScanning = true
var response = await AbsFileSystem.scanLocalLibraryItem({ localLibraryItemId: this.localLibraryItemId, forceAudioProbe })
if (response && response.localLibraryItem) {
if (response.updated) {
this.$toast.success('Local item was updated')
this.localLibraryItem = response.localLibraryItem
} else {
this.$toast.info('Local item was up to date')
}
} else {
console.log('Failed')
this.$toast.error('Something went wrong..')
}
this.isScanning = false
},
async init() {
this.localLibraryItem = await this.$db.getLocalLibraryItem(this.localLibraryItemId)
if (!this.localLibraryItem) {
console.error('Failed to get local library item', this.localLibraryItemId)
this.failed = true
return
}
this.folderId = this.localLibraryItem.folderId
this.folder = await this.$db.getLocalFolder(this.folderId)
}
},
mounted() {
this.init()
}
}
</script>
<style scoped>
.media-item-container {
height: calc(100vh - 200px);
max-height: calc(100vh - 200px);
}
</style>

Some files were not shown because too many files have changed in this diff Show more