Fix: android auto requirements, Change: New UI #33

This commit is contained in:
advplyr 2021-11-14 19:59:34 -06:00
parent bf8e48fd27
commit 0abefbd9bc
43 changed files with 2336 additions and 308 deletions

View file

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

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:dist="http://schemas.android.com/apk/distribution"
xmlns:tools="http://schemas.android.com/tools"
package="com.audiobookshelf.app">
@ -20,6 +21,14 @@
android:networkSecurityConfig="@xml/network_security_config"
android:requestLegacyExternalStorage="true">
<!--Used by Android Auto-->
<meta-data android:name="com.google.android.gms.car.notification.SmallIcon"
android:resource="@drawable/icon" />
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc"/>
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:name="com.audiobookshelf.app.MainActivity"
@ -31,14 +40,13 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<!--Used by Android Auto-->
<meta-data android:name="com.google.android.gms.car.notification.SmallIcon"
android:resource="@drawable/icon" />
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc"/>
<receiver android:name="androidx.media.session.MediaButtonReceiver" >
<intent-filter>

View file

@ -66,7 +66,7 @@ class Audiobook {
// return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
return Uri.parse(localCoverUrl)
}
if (book.cover == "") return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
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}")
}
@ -74,29 +74,6 @@ class Audiobook {
return duration.toLong() * 1000L
}
fun toMediaItem():MediaBrowserCompat.MediaItem {
var builder = MediaDescriptionCompat.Builder()
.setMediaId(id)
.setTitle(book.title)
.setSubtitle(book.authorFL)
.setMediaUri(null)
.setIconUri(getCover())
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)
builder.setExtras(extras)
var mediaDescription = builder.build()
return MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
}
fun toMediaMetadata():MediaMetadataCompat {
return MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)

View file

@ -2,15 +2,10 @@ package com.audiobookshelf.app
import android.app.Activity
import android.content.Context
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.MediaDescriptionCompat
import android.util.Log
import androidx.media.MediaBrowserServiceCompat
import androidx.media.utils.MediaConstants
import com.getcapacitor.JSArray
import com.getcapacitor.JSObject
import com.jeep.plugin.capacitor.capacitordatastoragesqlite.CapacitorDataStorageSqlite
@ -40,17 +35,22 @@ class AudiobookManager {
fun init() {
var sharedPreferences = ctx.getSharedPreferences("CapacitorStorage", Activity.MODE_PRIVATE)
serverUrl = sharedPreferences.getString("serverUrl", null).toString()
serverUrl = sharedPreferences.getString("serverUrl", "").toString()
Log.d(tag, "SHARED PREF SERVERURL $serverUrl")
token = sharedPreferences.getString("token", null).toString()
token = sharedPreferences.getString("token", "").toString()
Log.d(tag, "SHARED PREF TOKEN $token")
}
fun loadAudiobooks(cb: (() -> Unit)) {
Log.d(tag, "LOAD AUDIBOOOSK $serverUrl | $token")
if (serverUrl == "" || token == "") {
Log.d(tag, "No Server or Token set")
cb()
return
} else if (!serverUrl.startsWith("http")) {
Log.e(tag, "Invalid server url $serverUrl")
cb()
return
}
var url = "$serverUrl/api/library/main/audiobooks"
@ -98,78 +98,6 @@ class AudiobookManager {
})
}
fun fetchAudiobooks(result: MediaBrowserServiceCompat.Result<MutableList<MediaBrowserCompat.MediaItem>>) {
var url = "$serverUrl/api/library/main/audiobooks"
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()
}
override fun onResponse(call: Call, response: Response) {
response.use {
if (!response.isSuccessful) throw IOException("Unexpected code $response")
var bodyString = response.body!!.string()
var json = JSArray(bodyString)
var totalBooks = json.length() - 1
for (i in 0..totalBooks) {
var abobj = json.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")
val mediaItems: MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
audiobooks.forEach {
var builder = MediaDescriptionCompat.Builder()
.setMediaId(it.id)
.setTitle(it.book.title)
.setSubtitle(it.book.authorFL)
.setMediaUri(null)
.setIconUri(it.getCover())
val extras = Bundle()
if (it.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)
builder.setExtras(extras)
var mediaDescription = builder.build()
var newMediaItem = MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
mediaItems.add(newMediaItem)
}
Log.d(tag, "AudiobookManager: Sending ${mediaItems.size} Audiobooks")
result.sendResult(mediaItems)
}
}
})
}
fun load() {
isLoading = true
hasLoaded = true
@ -261,4 +189,58 @@ class AudiobookManager {
var audiobookStreamData = AudiobookStreamData(abStreamDataObj)
return audiobookStreamData
}
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]
}
}

View file

@ -1,18 +1,13 @@
package com.audiobookshelf.app
import android.app.Activity
import android.app.DownloadManager
import android.app.SearchManager
import android.content.*
import android.os.*
import android.util.Log
import com.anggrayudi.storage.SimpleStorage
import com.anggrayudi.storage.SimpleStorageHelper
import com.getcapacitor.BridgeActivity
import com.getcapacitor.JSObject
import com.jeep.plugin.capacitor.capacitordatastoragesqlite.CapacitorDataStorageSqlite
import okhttp3.OkHttpClient
import okhttp3.Request
import java.net.URL
class MainActivity : BridgeActivity() {

View file

@ -1,5 +1,6 @@
package com.audiobookshelf.app
import android.annotation.SuppressLint
import android.app.*
import android.content.Context
import android.content.Intent
@ -12,7 +13,9 @@ 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 android.view.KeyEvent
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.media.MediaBrowserServiceCompat
@ -31,11 +34,9 @@ import com.google.android.exoplayer2.source.hls.HlsMediaSource
import com.google.android.exoplayer2.ui.PlayerNotificationManager
import com.google.android.exoplayer2.upstream.*
import kotlinx.coroutines.*
import android.view.KeyEvent
import okhttp3.OkHttpClient
import java.util.*
import kotlin.concurrent.schedule
import android.annotation.SuppressLint
import okhttp3.OkHttpClient
const val NOTIFICATION_LARGE_ICON_SIZE = 144 // px
@ -63,6 +64,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
private lateinit var mediaSessionConnector: MediaSessionConnector
private lateinit var playerNotificationManager: PlayerNotificationManager
private lateinit var mediaSession: MediaSessionCompat
private lateinit var transportControls:MediaControllerCompat.TransportControls
private val serviceJob = SupervisorJob()
private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob)
@ -77,8 +79,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
private var currentAudiobookStreamData:AudiobookStreamData? = null
// private var audiobooks = mutableListOf<AudiobookStreamData>()
private var mediaButtonClickCount: Int = 0
var mediaButtonClickTimeout: Long = 1000 //ms
var seekAmount: Long = 20000 //ms
@ -141,6 +141,57 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
return channelId
}
private fun playAudiobookFromMediaBrowser(audiobook: Audiobook, playWhenReady: Boolean) {
if (!audiobook.isDownloaded) {
var streamListener = object : AudiobookManager.OnStreamData {
override fun onStreamReady(asd: AudiobookStreamData) {
Log.d(tag, "Stream Ready ${asd.playlistUrl}")
asd.playWhenReady = playWhenReady
initPlayer(asd)
}
}
audiobookManager.openStream(audiobook, streamListener)
} else {
var asd = audiobookManager.initLocalPlay(audiobook)
asd.playWhenReady = playWhenReady
initPlayer(asd)
}
}
private fun playFirstAudiobook(playWhenReady: Boolean) {
var firstAudiobook = audiobookManager.getFirstAudiobook()
if (firstAudiobook != null) {
playAudiobookFromMediaBrowser(firstAudiobook, playWhenReady)
}
}
private fun openFromMediaId(mediaId: String, playWhenReady: Boolean) {
var audiobook = audiobookManager.audiobooks.find { it.id == mediaId }
if (audiobook == null) {
Log.e(tag, "Audiobook NOT FOUND")
return
}
playAudiobookFromMediaBrowser(audiobook, playWhenReady)
}
private fun openFromSearch(query: String?, playWhenReady: Boolean) {
if (query?.isNullOrEmpty() == true) {
Log.d(tag, "Empty search query play first audiobook")
playFirstAudiobook(playWhenReady)
return
}
var audiobook = audiobookManager.searchForAudiobook(query)
if (audiobook == null) {
Log.e(tag, "No Audiobook found for search $query")
pause()
return
}
playAudiobookFromMediaBrowser(audiobook, playWhenReady)
}
// detach player
override fun onDestroy() {
playerNotificationManager.setPlayer(null)
@ -193,6 +244,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
isActive = true
}
Log.d(tag, "Media Session Set")
val mediaController = MediaControllerCompat(ctx, mediaSession.sessionToken)
@ -247,6 +300,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
// Unknown action
playerNotificationManager.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE)
transportControls = mediaController.transportControls
// Color is set based on the art - cannot override
// playerNotificationManager.setColor(Color.RED)
// playerNotificationManager.setColorized(true)
@ -267,114 +322,112 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
}
}
// val myPlaybackPreparer:MediaSessionConnector.PlaybackPreparer = object :MediaSessionConnector.PlaybackPreparer {
// 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")
// var audiobook = audiobookManager.audiobooks[0]
// if (audiobook == null) {
// Log.e(tag, "Audiobook NOT FOUND")
// return
// }
//
// var streamListener = object : AudiobookManager.OnStreamData {
// override fun onStreamReady(asd: AudiobookStreamData) {
// Log.d(tag, "Stream Ready ${asd.playlistUrl}")
// initPlayer(asd)
// }
// }
// audiobookManager.openStream(audiobook, streamListener)
// }
//
// override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) {
// Log.d(tag, "ON PREPARE FROM MEDIA ID $mediaId $playWhenReady")
// var audiobook = audiobookManager.audiobooks.find { it.id == mediaId }
// if (audiobook == null) {
// Log.e(tag, "Audiobook NOT FOUND")
// return
// }
//
// var streamListener = object : AudiobookManager.OnStreamData {
// override fun onStreamReady(asd: AudiobookStreamData) {
// Log.d(tag, "Stream Ready ${asd.playlistUrl}")
// initPlayer(asd)
// }
// }
// audiobookManager.openStream(audiobook, streamListener)
// }
//
// override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) {
// Log.d(tag, "ON PREPARE FROM SEARCH $query")
// }
//
// override fun onPrepareFromUri(uri: Uri, playWhenReady: Boolean, extras: Bundle?) {
// Log.d(tag, "ON PREPARE FROM URI $uri")
// }
//
// }
val myPlaybackPreparer:MediaSessionConnector.PlaybackPreparer = object :MediaSessionConnector.PlaybackPreparer {
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")
playFirstAudiobook(playWhenReady)
}
override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) {
Log.d(tag, "ON PREPARE FROM MEDIA ID $mediaId $playWhenReady")
openFromMediaId(mediaId, playWhenReady)
}
override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) {
Log.d(tag, "ON PREPARE FROM SEARCH $query")
openFromSearch(query, playWhenReady)
}
override fun onPrepareFromUri(uri: Uri, playWhenReady: Boolean, extras: Bundle?) {
Log.d(tag, "ON PREPARE FROM URI $uri")
}
}
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(myPlaybackPreparer)
mediaSessionConnector.setPlaybackPreparer(myPlaybackPreparer)
mediaSessionConnector.setPlayer(mPlayer)
//attach player to playerNotificationManager
playerNotificationManager.setPlayer(mPlayer)
mediaSession.setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS)
mediaSession.setCallback(object : MediaSessionCompat.Callback() {
override fun onPrepare() {
Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT")
super.onPrepare()
playFirstAudiobook(true)
}
override fun onPlay() {
Log.d(tag, "ON PLAY MEDIA SESSION COMPAT")
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")
openFromSearch(query, true)
}
override fun onPause() {
Log.d(tag, "ON PLAY MEDIA SESSION COMPAT")
pause()
}
override fun onStop() {
pause()
}
override fun onSkipToPrevious() {
seekBackward(seekAmount)
}
override fun onSkipToNext() {
seekForward(seekAmount)
}
override fun onFastForward() {
seekForward(seekAmount)
}
override fun onRewind() {
seekForward(seekAmount)
}
override fun onSeekTo(pos: Long) {
seekPlayer(pos)
}
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
Log.d(tag, "ON PLAY FROM MEDIA ID $mediaId")
var audiobook = audiobookManager.audiobooks.find { it.id == mediaId }
if (audiobook == null) {
Log.e(tag, "Audiobook NOT FOUND")
if (mediaId.isNullOrEmpty()) {
playFirstAudiobook(true)
return
}
if (!audiobook.isDownloaded) {
var streamListener = object : AudiobookManager.OnStreamData {
override fun onStreamReady(asd: AudiobookStreamData) {
Log.d(tag, "Stream Ready ${asd.playlistUrl}")
initPlayer(asd)
}
}
audiobookManager.openStream(audiobook, streamListener)
} else {
var asd = audiobookManager.initLocalPlay(audiobook)
initPlayer(asd)
}
openFromMediaId(mediaId, true)
}
override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean {
@ -415,6 +468,18 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
KeyEvent.KEYCODE_MEDIA_STOP -> {
terminateStream()
}
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
Log.d(tag, "PLAY PAUSE TEST")
transportControls.playFromSearch("Brave New World", Bundle())
// if (mPlayer.isPlaying) {
// if (0 == mediaButtonClickCount) pause()
// handleMediaButtonClickCount()
// } else {
// if (0 == mediaButtonClickCount) play()
// handleMediaButtonClickCount()
// }
}
else -> {
Log.d(tag, "KeyCode:${keyEvent?.getKeyCode()}")
return false
@ -526,8 +591,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
if (lastPauseTime == 0L) {
sendClientMetadata("ready_no_sync")
lastPauseTime = -1;
}
else sendClientMetadata("ready")
} else sendClientMetadata("ready")
}
if (mPlayer.playbackState == Player.STATE_BUFFERING) {
Log.d(tag, "STATE_BUFFERING : " + mPlayer.currentPosition.toString())
@ -565,8 +629,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
}
}
}
}
else lastPauseTime = System.currentTimeMillis()
} else lastPauseTime = System.currentTimeMillis()
listener?.onPlayingUpdate(player.isPlaying)
}
}
@ -676,6 +739,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
}
fun pause() {
mPlayer.pause()
}
@ -718,6 +782,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
// MEDIA BROWSER STUFF (ANDROID AUTO)
//
private val AUTO_MEDIA_ROOT = "/"
private val ALL_ROOT = "__ALL__"
private lateinit var browseTree:BrowseTree
@ -734,16 +799,15 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
// No further calls will be made to other media browsing methods.
null
} else {
val maximumRootChildLimit = rootHints?.getInt(
MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT,
/* defaultValue= */ 4)
//
// val maximumRootChildLimit = rootHints?.getInt(
// MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT,
// /* defaultValue= */ 4)
// val supportedRootChildFlags = rootHints.getInt(
// MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_SUPPORTED_FLAGS,
// /* defaultValue= */ android.media.browse.MediaBrowser.MediaItem.FLAG_BROWSABLE)
val extras = Bundle()
extras.putBoolean(
MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true)
@ -761,9 +825,9 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
override fun onLoadChildren(parentMediaId: String, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
val mediaItems: MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
Log.d(tag, "ON LOAD CHILDREN $parentMediaId")
var flag = if (parentMediaId == AUTO_MEDIA_ROOT) MediaBrowserCompat.MediaItem.FLAG_BROWSABLE else MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
if (!audiobookManager.hasLoaded) {
Log.d(tag, "audiobook manager loading")
result.detach()
audiobookManager.load()
audiobookManager.loadAudiobooks() {
@ -772,7 +836,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
Log.d(tag, "LOADED AUDIOBOOKS")
browseTree = BrowseTree(this, audiobookManager.audiobooks, null)
val children = browseTree[parentMediaId]?.map { item ->
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
MediaBrowserCompat.MediaItem(item.description, flag)
}
if (children != null) {
Log.d(tag, "BROWSE TREE CHILDREN ${children.size}")
@ -784,8 +848,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
Log.d(tag, "AUDIOBOOKS LOADING")
result.detach()
return
} else {
Log.d(tag, "ABs are loaded")
}
if (audiobookManager.audiobooks.size == 0) {
@ -794,9 +856,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
return
}
var flag = if (parentMediaId == AUTO_MEDIA_ROOT) MediaBrowserCompat.MediaItem.FLAG_BROWSABLE else MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
val children = browseTree[parentMediaId]?.map { item ->
MediaBrowserCompat.MediaItem(item.description, flag)
}
@ -805,21 +864,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
}
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
// audiobookManager.audiobooks.forEach {
// var builder = MediaDescriptionCompat.Builder()
// .setMediaId(it.id)
// .setTitle(it.book.title)
// .setSubtitle(it.book.authorFL)
// .setMediaUri(null)
// .setIconUri(it.getCover(audiobookManager.serverUrl, audiobookManager.token))
//
//
//
// var mediaDescription = builder.build()
// var newMediaItem = MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
// mediaItems.add(newMediaItem)
// }
// Check if this is the root menu:
if (AUTO_MEDIA_ROOT == parentMediaId) {
// build the MediaItem objects for the top level,
@ -833,6 +877,48 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
// result.sendResult(mediaItems)
}
override fun onSearch(query: String, extras: Bundle?, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
val mediaItems: MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
if (!audiobookManager.hasLoaded) {
result.detach()
audiobookManager.load()
audiobookManager.loadAudiobooks() {
audiobookManager.isLoading = false
Log.d(tag, "LOADED AUDIOBOOKS")
browseTree = BrowseTree(this, audiobookManager.audiobooks, null)
val children = browseTree[ALL_ROOT]?.map { item ->
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
}
if (children != null) {
Log.d(tag, "BROWSE TREE CHILDREN ${children.size}")
}
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
}
return
} else if (audiobookManager.isLoading) {
Log.d(tag, "AUDIOBOOKS LOADING")
result.detach()
return
}
if (audiobookManager.audiobooks.size == 0) {
Log.d(tag, "AudiobookManager: Sending no items")
result.sendResult(mediaItems)
return
}
val children = browseTree[ALL_ROOT]?.map { item ->
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
}
if (children != null) {
Log.d(tag, "NO CHILDREN ON SEARCH ${children.size}")
}
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
}
//
// SLEEP TIMER STUFF
//

View file

@ -1,3 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<automotiveApp>
<uses name="media" />
</automotiveApp>

View file

@ -19,3 +19,27 @@
.box-shadow-book {
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
}
.bookshelfRow {
background-image: url(/wood_panels.jpg);
}
.bookshelfDivider {
background: rgb(149, 119, 90);
background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%);
box-shadow: 2px 10px 8px #1111117e;
}
/*
Bookshelf Label
*/
.categoryPlacard {
background-image: url(https://image.freepik.com/free-photo/brown-wooden-textured-flooring-background_53876-128537.jpg);
letter-spacing: 1px;
}
.shinyBlack {
background-color: #2d3436;
background-image: linear-gradient(315deg, #19191a 0%, rgb(15, 15, 15) 74%);
border-color: rgba(255, 244, 182, 0.6);
border-style: solid;
color: #fce3a6;
}

View file

@ -1,12 +1,18 @@
/* fallback */
@font-face {
font-family: 'Material Icons';
font-style: normal;
font-weight: 400;
src: url(/material-icons.woff2) format('woff2');
src: url(/fonts/MaterialIcons.woff2) format('woff2');
}
@font-face {
font-family: 'Material Icons Outlined';
font-style: normal;
font-weight: 400;
src: url(/fonts/MaterialIconsOutlined.woff2) format('woff2');
}
.material-icons {
/* .material-icons {
font-family: 'Material Icons';
font-weight: normal;
font-style: normal;
@ -27,6 +33,9 @@
.material-icons.text-lg {
font-size: 1.25rem;
}
.material-icons.text-2xl {
font-size: 1.5rem;
}
.material-icons.text-3xl {
font-size: 1.875rem;
}
@ -38,6 +47,42 @@
}
.material-icons.text-base {
font-size: 1rem;
} */
.material-icons {
font-family: 'Material Icons';
font-weight: normal;
font-style: normal;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
}
.material-icons:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl):not(.text-7xl):not(.text-8xl) {
font-size: 1.5rem;
}
.material-icons-outlined {
font-family: 'Material Icons Outlined';
font-weight: normal;
font-style: normal;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
}
.material-icons-outlined:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl):not(.text-7xl):not(.text-8xl) {
font-size: 1.5rem;
}
@font-face {
@ -45,7 +90,7 @@
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/GentiumBookBasic.woff2) format('woff2');
src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@ -54,6 +99,6 @@
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/GentiumBookBasic.woff2) format('woff2');
src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

View file

@ -1,13 +1,13 @@
<template>
<div class="w-full h-16 bg-primary relative">
<div id="appbar" class="absolute top-0 left-0 w-full h-full z-30 flex items-center px-2">
<div id="appbar" class="absolute top-0 left-0 w-full h-full z-10 flex items-center px-2">
<nuxt-link v-show="!showBack" to="/" class="mr-3">
<img src="/Logo.png" class="h-10 w-10" />
</nuxt-link>
<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>
<div v-if="socketConnected">
<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">
<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" />
@ -20,18 +20,13 @@
<!-- <ui-menu :label="username" :items="menuItems" @action="menuAction" class="ml-5" /> -->
<span class="material-icons cursor-pointer mx-4" :class="hasDownloadsFolder ? '' : 'text-warning'" @click="$store.commit('downloads/setShowModal', true)">source</span>
<!-- <span class="material-icons cursor-pointer mx-4" :class="hasDownloadsFolder ? '' : 'text-warning'" @click="$store.commit('downloads/setShowModal', true)">source</span> -->
<widgets-connection-icon />
<!-- <widgets-connection-icon /> -->
<!-- <nuxt-link to="/account" class="relative w-28 bg-fg border border-gray-500 rounded shadow-sm ml-5 pl-3 pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer hover:bg-bg hover:bg-opacity-40" aria-haspopup="listbox" aria-expanded="true">
<span class="flex items-center">
<span class="block truncate">{{ username }}</span>
</span>
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<span class="material-icons text-gray-100">person</span>
</span>
</nuxt-link> -->
<div class="h-7 mx-2">
<span class="material-icons" style="font-size: 1.75rem" @click="clickShowSideDrawer">menu</span>
</div>
</div>
</div>
</template>
@ -54,6 +49,9 @@ export default {
}
},
computed: {
socketConnected() {
return this.$store.state.socketConnected
},
currentLibrary() {
return this.$store.getters['libraries/getCurrentLibrary']
},
@ -61,7 +59,7 @@ export default {
return this.currentLibrary ? this.currentLibrary.name : 'Main'
},
showBack() {
return this.$route.name !== 'index'
return this.$route.name !== 'index' && !this.$route.name.startsWith('bookshelf')
},
user() {
return this.$store.state.user.user
@ -81,6 +79,9 @@ export default {
}
},
methods: {
clickShowSideDrawer() {
this.$store.commit('setShowSideDrawer', true)
},
clickShowLibraryModal() {
this.$store.commit('libraries/setShowModal', true)
},
@ -88,7 +89,7 @@ export default {
if (this.$route.name === 'audiobook-id-edit') {
this.$router.push(`/audiobook/${this.$route.params.id}`)
} else {
this.$router.push('/')
this.$router.push('/bookshelf')
}
},
logout() {

View file

@ -0,0 +1,116 @@
<template>
<div class="fixed top-0 left-0 right-0 bottom-0 w-full h-full z-50 overflow-hidden pointer-events-none">
<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">
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">
<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>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {}
},
watch: {
$route: {
handler() {
this.show = false
}
}
},
computed: {
show: {
get() {
return this.$store.state.showSideDrawer
},
set(val) {
this.$store.commit('setShowSideDrawer', val)
}
},
user() {
return this.$store.state.user.user
},
username() {
return this.user ? this.user.username : ''
},
socketConnected() {
return this.$store.state.socketConnected
},
navItems() {
var items = [
{
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) {
items = [
{
icon: 'cloud_off',
text: 'Connect to Server',
to: '/connect'
}
].concat(items)
}
return items
}
},
methods: {
clickBackground() {
this.show = false
},
async logout() {
await this.$axios.$post('/logout').catch((error) => {
console.error(error)
})
this.$server.logout()
this.$router.push('/connect')
this.$store.commit('audiobooks/reset')
this.$store.dispatch('audiobooks/useDownloaded')
}
},
mounted() {},
beforeDestroy() {
this.show = false
}
}
</script>

View file

@ -0,0 +1,35 @@
<template>
<div class="w-full relative">
<div class="bookshelfRow flex items-end justify-around px-3 max-w-full" :class="shelfHeightClass">
<template v-for="group in groups">
<cards-series-card v-if="groupType === 'series'" :key="group.id" :group="group" :width="112" class="mx-2" />
<cards-collection-card v-if="groupType === 'collection'" :key="group.id" :collection="group" :width="90" class="mx-2" />
</template>
</div>
<div class="w-full h-5 z-40 bookshelfDivider"></div>
</div>
</template>
<script>
export default {
props: {
groupType: String,
groups: {
type: Array,
default: () => []
}
},
data() {
return {}
},
computed: {
shelfHeightClass() {
if (this.groupType === 'series') return 'h-48'
return 'h-44'
}
},
methods: {},
mounted() {}
}
</script>

View file

@ -0,0 +1,28 @@
<template>
<div class="w-full relative">
<div class="bookshelfRow h-48 flex items-end justify-around px-3 max-w-full">
<template v-for="book in books">
<cards-book-card :key="book.id" :audiobook="book" :width="108" class="mx-2" />
</template>
</div>
<div class="w-full h-4 z-40 bookshelfDivider"></div>
</div>
</template>
<script>
export default {
props: {
books: {
type: Array,
default: () => []
}
},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>

View file

@ -0,0 +1,34 @@
<template>
<div class="w-full relative">
<div class="bookshelfRow h-44 flex items-end px-3 max-w-full overflow-x-auto">
<template v-for="book in books">
<cards-book-card :key="book.id" :audiobook="book" :width="100" class="mx-2" />
</template>
</div>
<div class="absolute text-center categoryPlacard font-book transform z-30 bottom-0.5 left-4 md:left-8 w-36 rounded-md" style="height: 18px">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border">
<p class="transform text-xs">{{ label }}</p>
</div>
</div>
<div class="w-full h-5 z-40 bookshelfDivider"></div>
</div>
</template>
<script>
export default {
props: {
label: String,
books: {
type: Array,
default: () => []
}
},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>

View file

@ -0,0 +1,86 @@
<template>
<div class="relative">
<div class="rounded-sm h-full relative" @click="clickCard">
<nuxt-link :to="groupTo" class="cursor-pointer">
<div class="w-full relative bg-primary" :style="{ height: coverHeight + 'px', width: coverWidth + 'px' }">
<cards-collection-cover ref="groupcover" :book-items="bookItems" :width="coverWidth" :height="coverHeight" />
</div>
</nuxt-link>
</div>
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-5 h-5 rounded-md font-book text-center" :style="{ width: Math.min(160, coverWidth) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.8 * sizeMultiplier}rem` }">
<p class="truncate pt-px" :style="{ fontSize: labelFontSize + 'rem' }">{{ collectionName }}</p>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
collection: {
type: Object,
default: () => null
},
width: {
type: Number,
default: 120
},
paddingY: {
type: Number,
default: 24
}
},
data() {
return {}
},
watch: {
width(newVal) {
this.$nextTick(() => {
if (this.$refs.groupcover && this.$refs.groupcover.init) {
this.$refs.groupcover.init()
}
})
}
},
computed: {
labelFontSize() {
if (this.coverWidth < 160) return 0.7
return 0.75
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
_collection() {
return this.collection || {}
},
groupTo() {
return `/collection/${this._collection.id}`
},
coverWidth() {
return this.width * 2
},
coverHeight() {
return this.width * 1.6
},
sizeMultiplier() {
return this.width / 120
},
paddingX() {
return 16 * this.sizeMultiplier
},
bookItems() {
return this._collection.books || []
},
collectionName() {
return this._collection.name || 'No Name'
}
},
methods: {
clickCard() {
this.$emit('click', this.collection)
}
}
}
</script>

View file

@ -0,0 +1,63 @@
<template>
<div class="relative rounded-sm overflow-hidden" :style="{ width: width + 'px', height: height + 'px' }">
<!-- <div class="absolute top-0 left-0 w-full h-full rounded-sm overflow-hidden z-10">
<div class="w-full h-full border border-white border-opacity-10" />
</div> -->
<div v-if="hasOwnCover" class="w-full h-full relative rounded-sm">
<div v-if="showCoverBg" class="bg-primary absolute top-0 left-0 w-full h-full">
<div class="w-full h-full z-0" ref="coverBg" />
</div>
<img ref="cover" :src="fullCoverUrl" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-cover'" />
</div>
<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" />
<cards-book-cover :audiobook="books[0]" :width="width / 2" />
<cards-book-cover v-if="books.length > 1" :audiobook="books[1]" :width="width / 2" />
</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" />
<p class="font-book text-white text-opacity-60 text-center" :style="{ fontSize: Math.min(1, sizeMultiplier) + 'rem' }">Empty Collection</p>
</div>
</div>
</template>
<script>
export default {
props: {
bookItems: {
type: Array,
default: () => []
},
width: Number,
height: Number
},
data() {
return {
imageFailed: false,
showCoverBg: false
}
},
computed: {
sizeMultiplier() {
return this.width / 120
},
hasOwnCover() {
return false
},
fullCoverUrl() {
return null
},
books() {
return this.bookItems || []
}
},
methods: {
imageError() {},
imageLoaded() {}
},
mounted() {}
}
</script>

View file

@ -0,0 +1,113 @@
<template>
<div class="rounded-sm relative" @click="clickCard">
<nuxt-link :to="groupTo" class="cursor-pointer">
<div class="w-full relative bg-primary" :style="{ height: coverHeight + 'px', width: coverWidth + 'px' }">
<cards-series-cover ref="groupcover" :name="groupName" :group-to="groupTo" :type="groupType" :book-items="bookItems" :width="coverWidth" :height="coverHeight" />
<div class="absolute top-2 right-2 w-7 h-7 rounded-lg bg-black bg-opacity-90 text-gray-300 box-shadow-book flex items-center justify-center border border-white border-opacity-25 pointer-events-none z-40">
<p class="font-book text-xl">{{ bookItems.length }}</p>
</div>
<div class="absolute bottom-0 left-0 w-full h-1 flex flex-nowrap z-40">
<div v-for="userProgress in userProgressItems" :key="userProgress.audiobookId" class="h-full w-full" :class="userProgress.isRead ? 'bg-success' : userProgress.progress > 0 ? 'bg-yellow-400' : ''" />
</div>
</div>
</nuxt-link>
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-5 h-5 rounded-md font-book text-center" :style="{ width: Math.min(160, coverWidth) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.8 * sizeMultiplier}rem` }">
<p class="truncate pt-px" :style="{ fontSize: labelFontSize + 'rem' }">{{ groupName }}</p>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
group: {
type: Object,
default: () => null
},
width: {
type: Number,
default: 120
}
},
data() {
return {}
},
watch: {
width(newVal) {
this.$nextTick(() => {
if (this.$refs.groupcover && this.$refs.groupcover.init) {
this.$refs.groupcover.init()
}
})
}
},
computed: {
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
labelFontSize() {
if (this.coverWidth < 160) return 0.7
return 0.75
},
_group() {
return this.group || {}
},
groupType() {
return this._group.type
},
groupTo() {
if (this.groupType === 'series') {
return `/bookshelf/series?series=${this.groupEncode}`
} else {
return `/bookshelf?filter=tags.${this.groupEncode}`
}
},
coverWidth() {
return this.coverHeight
},
coverHeight() {
return this.width * 1.6
},
sizeMultiplier() {
return this.width / 120
},
paddingX() {
return 16 * this.sizeMultiplier
},
bookItems() {
return this._group.books || []
},
userAudiobooks() {
return Object.values(this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {})
},
userProgressItems() {
return this.bookItems.map((item) => {
var userAudiobook = this.userAudiobooks.find((ab) => ab.audiobookId === item.id)
return userAudiobook || {}
})
},
groupName() {
return this._group.name || 'No Name'
},
groupEncode() {
return this.$encode(this.groupName)
},
filter() {
return `${this.groupType}.${this.$encode(this.groupName)}`
},
hasValidCovers() {
var validCovers = this.bookItems.map((bookItem) => bookItem.book.cover)
return !!validCovers.length
}
},
methods: {
clickCard() {
this.$emit('click', this.group)
}
}
}
</script>

View file

@ -0,0 +1,171 @@
<template>
<div ref="wrapper" :style="{ height: height + 'px', width: width + 'px' }" class="relative">
<div v-if="noValidCovers" class="absolute top-0 left-0 w-full h-full flex items-center justify-center box-shadow-book" :style="{ padding: `${sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier + 'rem' }">{{ name }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
name: String,
bookItems: {
type: Array,
default: () => []
},
width: Number,
height: Number
},
data() {
return {
noValidCovers: false,
coverDiv: null,
coverWrapperEl: null,
coverImageEls: [],
coverWidth: 0,
offsetIncrement: 0,
windowWidth: 0
}
},
watch: {
bookItems: {
immediate: true,
handler(newVal) {
if (newVal) {
// ensure wrapper is initialized
this.$nextTick(this.init)
}
}
}
},
computed: {
sizeMultiplier() {
return this.width / 192
}
},
methods: {
getCoverUrl(book) {
return this.$store.getters['audiobooks/getBookCoverSrc'](book, '')
},
async buildCoverImg(coverData, bgCoverWidth, offsetLeft, zIndex, forceCoverBg = false) {
var src = coverData.coverUrl
var showCoverBg =
forceCoverBg ||
(await new Promise((resolve) => {
var image = new Image()
image.onload = () => {
var { naturalWidth, naturalHeight } = image
var aspectRatio = naturalHeight / naturalWidth
var arDiff = Math.abs(aspectRatio - 1.6)
// If image aspect ratio is <= 1.45 or >= 1.75 then use cover bg, otherwise stretch to fit
if (arDiff > 0.15) {
resolve(true)
} else {
resolve(false)
}
}
image.onerror = (err) => {
console.error(err)
resolve(false)
}
image.src = src
}))
var imgdiv = document.createElement('div')
imgdiv.style.height = this.height + 'px'
imgdiv.style.width = bgCoverWidth + 'px'
imgdiv.style.left = offsetLeft + 'px'
imgdiv.style.zIndex = zIndex
imgdiv.dataset.audiobookId = coverData.id
imgdiv.dataset.volumeNumber = coverData.volumeNumber || ''
imgdiv.className = 'absolute top-0 box-shadow-book transition-transform'
imgdiv.style.boxShadow = '4px 0px 4px #11111166'
// imgdiv.style.transform = 'skew(0deg, 15deg)'
if (showCoverBg) {
var coverbgwrapper = document.createElement('div')
coverbgwrapper.className = 'absolute top-0 left-0 w-full h-full bg-primary'
var coverbg = document.createElement('div')
coverbg.className = 'w-full h-full'
coverbg.style.backgroundImage = `url("${src}")`
coverbg.style.backgroundSize = 'cover'
coverbg.style.backgroundPosition = 'center'
coverbg.style.opacity = 0.25
coverbg.style.filter = 'blur(1px)'
coverbgwrapper.appendChild(coverbg)
imgdiv.appendChild(coverbgwrapper)
}
var img = document.createElement('img')
img.src = src
img.className = 'absolute top-0 left-0 w-full h-full'
img.style.objectFit = showCoverBg ? 'contain' : 'cover'
imgdiv.appendChild(img)
return imgdiv
},
async init() {
if (this.coverDiv) {
this.coverDiv.remove()
this.coverDiv = null
}
var validCovers = this.bookItems
.map((bookItem) => {
return {
id: bookItem.id,
volumeNumber: bookItem.book ? bookItem.book.volumeNumber : null,
coverUrl: this.getCoverUrl(bookItem)
}
})
.filter((b) => b.coverUrl !== '')
if (!validCovers.length) {
this.noValidCovers = true
return
}
this.noValidCovers = false
var coverWidth = this.width
var widthPer = this.width
if (validCovers.length > 1) {
coverWidth = this.height / 1.6
widthPer = (this.width - coverWidth) / (validCovers.length - 1)
}
this.coverWidth = coverWidth
this.offsetIncrement = widthPer
var outerdiv = document.createElement('div')
this.coverWrapperEl = outerdiv
outerdiv.className = 'w-full h-full relative'
var coverImageEls = []
var offsetLeft = 0
for (let i = 0; i < validCovers.length; i++) {
offsetLeft = widthPer * i
var zIndex = validCovers.length - i
var img = await this.buildCoverImg(validCovers[i], coverWidth, offsetLeft, zIndex, validCovers.length === 1)
outerdiv.appendChild(img)
coverImageEls.push(img)
}
this.coverImageEls = coverImageEls
if (this.$refs.wrapper) {
this.coverDiv = outerdiv
this.$refs.wrapper.appendChild(outerdiv)
}
}
},
mounted() {
this.windowWidth = window.innerWidth
},
beforeDestroy() {
if (this.coverWrapperEl) this.coverWrapperEl.remove()
}
}
</script>

View file

@ -0,0 +1,42 @@
<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>
</div>
</div>
</template>
<script>
export default {
data() {
return {}
},
computed: {
routeName() {
return this.$route.name
}
},
methods: {},
mounted() {}
}
</script>
<style>
#bookshelf-navbar {
box-shadow: 0px 5px 5px #11111155;
}
#bookshelf-navbar a {
font-size: 0.9rem;
}
</style>

View file

@ -0,0 +1,138 @@
<template>
<div class="w-full h-9 bg-bg relative z-20">
<div id="bookshelf-toolbar" class="absolute top-0 left-0 w-full h-full z-20 flex items-center px-2">
<div class="flex items-center w-full text-sm">
<nuxt-link to="/bookshelf/series" v-if="selectedSeriesName" class="pt-1">
<span class="material-icons">arrow_back</span>
</nuxt-link>
<p v-show="!selectedSeriesName" class="font-book pt-1">{{ numEntities }} {{ entityTitle }}</p>
<p v-show="selectedSeriesName" class="ml-2 font-book pt-1">{{ selectedSeriesName }}</p>
<div class="flex-grow" />
<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" />
</div>
<span class="material-icons px-2" @click="showSortModal = true">sort</span>
</template>
</div>
</div>
<modals-order-modal v-model="showSortModal" :order-by.sync="settings.mobileOrderBy" :descending.sync="settings.mobileOrderDesc" @change="updateOrder" />
<modals-filter-modal v-model="showFilterModal" :filter-by.sync="settings.mobileFilterBy" @change="updateFilter" />
</div>
</template>
<script>
export default {
data() {
return {
showSortModal: false,
showFilterModal: false,
settings: {},
isListView: false
}
},
computed: {
hasFilters() {
return this.$store.getters['user/getUserSetting']('filterBy') !== 'all'
},
page() {
var routeName = this.$route.name || ''
return routeName.split('-')[1]
},
routeQuery() {
return this.$route.query || {}
},
entityTitle() {
if (this.page === 'library') return 'Audiobooks'
else if (this.page === 'series') {
if (this.selectedSeriesName) return 'Books in ' + this.selectedSeriesName
return 'Series'
} else if (this.page === 'collections') {
return 'Collections'
}
return ''
},
numEntities() {
if (this.page === 'library') return this.numAudiobooks
else if (this.page === 'series') {
if (this.selectedSeriesName) return this.numBooksInSeries
return this.series.length
} else if (this.page === 'collections') return this.numCollections
return 0
},
series() {
return this.$store.getters['audiobooks/getSeriesGroups']() || []
},
numCollections() {
return (this.$store.state.user.collections || []).length
},
numAudiobooks() {
return this.$store.getters['audiobooks/getFiltered']().length
},
numBooksInSeries() {
return this.selectedSeries ? (this.selectedSeries.books || []).length : 0
},
selectedSeries() {
if (!this.selectedSeriesName) return null
return this.series.find((s) => s.name === this.selectedSeriesName)
},
selectedSeriesName() {
if (this.page === 'series' && this.routeQuery.series) {
return this.$decode(this.routeQuery.series)
}
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)
},
updateOrder() {
this.saveSettings()
},
updateFilter() {
this.saveSettings()
},
saveSettings() {
this.$store.commit('user/setSettings', this.settings) // Immediate update
this.$store.dispatch('user/updateUserSettings', this.settings)
},
async init() {
this.settings = { ...this.$store.state.user.settings }
var bookshelfView = await this.$localStore.getBookshelfView()
this.isListView = bookshelfView === 'list'
this.bookshelfReady = true
console.log('Bookshelf view', bookshelfView)
},
settingsUpdated(settings) {
for (const key in settings) {
this.settings[key] = settings[key]
}
}
},
mounted() {
this.init()
this.$store.commit('user/addSettingsListener', { id: 'bookshelftoolbar', meth: this.settingsUpdated })
},
beforeDestroy() {
this.$store.commit('user/removeSettingsListener', 'bookshelftoolbar')
}
}
</script>
<style>
#bookshelf-toolbar {
box-shadow: 0px 5px 5px #11111155;
}
</style>

View file

@ -7,8 +7,8 @@
<div class="flex items-center">
<span class="font-normal ml-3 block truncate text-lg">{{ item.text }}</span>
</div>
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-icons text-4xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
<span v-if="item.value === selected" class="text-yellow-300 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-icons text-3xl">{{ descending ? 'south' : 'north' }}</span>
</span>
</li>
</template>

View file

@ -0,0 +1,80 @@
<template>
<div class="w-full bg-primary bg-opacity-40">
<div class="w-full h-14 flex items-center px-4 bg-primary">
<p>Collection List</p>
<div class="w-6 h-6 bg-white bg-opacity-10 flex items-center justify-center rounded-full ml-2">
<p class="font-mono text-sm">{{ books.length }}</p>
</div>
<div class="flex-grow" />
<p v-if="totalDuration">{{ totalDurationPretty }}</p>
</div>
<template v-for="book in booksCopy">
<tables-collection-book-table-row :key="book.id" :book="book" :collection-id="collectionId" class="item" :class="drag ? '' : 'collection-book-item'" @edit="editBook" />
</template>
</div>
</template>
<script>
export default {
props: {
collectionId: String,
books: {
type: Array,
default: () => []
}
},
data() {
return {
booksCopy: []
}
},
watch: {
books: {
handler(newVal) {
this.init()
}
}
},
computed: {
totalDuration() {
var _total = 0
this.books.forEach((book) => {
_total += book.duration
})
return _total
},
totalDurationPretty() {
return this.$elapsedPretty(this.totalDuration)
}
},
methods: {
editBook(book) {
var bookIds = this.books.map((b) => b.id)
this.$store.commit('setBookshelfBookIds', bookIds)
this.$store.commit('showEditModal', book)
},
init() {
this.booksCopy = this.books.map((b) => ({ ...b }))
}
},
mounted() {
this.init()
}
}
</script>
<style>
.collection-book-item {
transition: all 0.4s ease;
}
.collection-book-enter-from,
.collection-book-leave-to {
opacity: 0;
transform: translateX(30px);
}
.collection-book-leave-active {
position: absolute;
}
</style>

View file

@ -0,0 +1,122 @@
<template>
<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: '50px' }">
<cards-book-cover :audiobook="book" :width="50" />
</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>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
collectionId: String,
book: {
type: Object,
default: () => {}
}
},
data() {
return {
isProcessingReadUpdate: false,
processingRemove: false
}
},
watch: {
userIsRead: {
immediate: true,
handler(newVal) {
this.isRead = newVal
}
}
},
computed: {
_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
},
isIncomplete() {
return this.book.isIncomplete
},
numTracks() {
return this.book.numTracks
},
isStreaming() {
return this.$store.getters['getAudiobookIdStreaming'] === 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/user/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/collection/${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() {}
}
</script>

View file

@ -4,10 +4,10 @@
<div id="content" class="overflow-hidden" :class="playerIsOpen ? 'playerOpen' : ''">
<Nuxt />
</div>
<!-- <app-stream-container ref="streamContainer" /> -->
<app-audio-player-container ref="streamContainer" />
<modals-downloads-modal ref="downloadsModal" @deleteDownload="deleteDownload" />
<!-- <modals-downloads-modal ref="downloadsModal" @deleteDownload="deleteDownload" /> -->
<modals-libraries-modal />
<app-side-drawer />
<readers-reader />
</div>
</template>
@ -24,12 +24,24 @@ export default {
data() {
return {}
},
watch: {
networkConnected: {
handler(newVal) {
if (newVal) {
this.attemptConnection()
}
}
}
},
computed: {
playerIsOpen() {
return this.$store.getters['playerIsOpen']
},
routeName() {
return this.$route.name
},
networkConnected() {
return this.$store.state.networkConnected
}
},
methods: {
@ -152,9 +164,6 @@ export default {
var downloadObj = this.$store.getters['downloads/getDownload'](audiobookId)
if (downloadObj) {
if (this.$refs.downloadsModal) {
this.$refs.downloadsModal.updateDownloadProgress({ audiobookId, progress })
}
this.$toast.update(downloadObj.toastId, { content: `${progress}% Downloading ${downloadObj.audiobook.book.title}` })
}
},
@ -331,20 +340,6 @@ export default {
// this.checkLoadCurrent()
// this.$store.dispatch('audiobooks/setNativeAudiobooks')
// },
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)
},
async initMediaStore() {
// Request and setup listeners for media files on native
AudioDownloader.addListener('onDownloadComplete', (data) => {
@ -420,9 +415,33 @@ export default {
},
showSuccessToast(message) {
this.$toast.success(message)
},
async attemptConnection() {
if (!this.$server) return
if (!this.networkConnected) {
console.warn('No network connection')
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
}
}
}
}
},
mounted() {
async mounted() {
if (!this.$server) return console.error('No Server')
// console.log(`Default Mounted set SOCKET listeners ${this.$server.connected}`)
@ -435,28 +454,33 @@ export default {
if (this.$store.state.isFirstLoad) {
this.$store.commit('setIsFirstLoad', false)
this.setupNetworkListener()
await this.setupNetworkListener()
this.attemptConnection()
this.checkForUpdate()
this.initMediaStore()
}
MyNativeAudio.addListener('onPrepareMedia', (data) => {
var audiobookId = data.audiobookId
var playWhenReady = data.playWhenReady
var audiobook = this.$store.getters['audiobooks/getAudiobook'](audiobookId)
var download = this.$store.getters['downloads/getDownloadIfReady'](audiobookId)
this.$store.commit('setPlayOnLoad', playWhenReady)
if (!download) {
// Stream
this.$store.commit('setStreamAudiobook', audiobook)
this.$server.socket.emit('open_stream', audiobook.id)
} else {
// Local
this.$store.commit('setPlayingDownload', download)
if (!this.$server.connected) {
}
})
// Old bad attempt at AA
// MyNativeAudio.addListener('onPrepareMedia', (data) => {
// var audiobookId = data.audiobookId
// var playWhenReady = data.playWhenReady
// var audiobook = this.$store.getters['audiobooks/getAudiobook'](audiobookId)
// var download = this.$store.getters['downloads/getDownloadIfReady'](audiobookId)
// this.$store.commit('setPlayOnLoad', playWhenReady)
// if (!download) {
// // Stream
// this.$store.commit('setStreamAudiobook', audiobook)
// this.$server.socket.emit('open_stream', audiobook.id)
// } else {
// // Local
// this.$store.commit('setPlayingDownload', download)
// }
// })
},
beforeDestroy() {
if (!this.$server) {

View file

@ -1,6 +1,6 @@
{
"name": "audiobookshelf-app",
"version": "v0.9.19-beta",
"version": "v0.9.20-beta",
"author": "advplyr",
"scripts": {
"dev": "nuxt --hostname localhost --port 1337",

View file

@ -26,7 +26,7 @@
<ui-btn v-if="!isUpdateAvailable || immediateUpdateAllowed" class="w-full my-4" color="primary" @click="openAppStore">Open app store</ui-btn>
<p>UA: {{ updateAvailability }} | Avail: {{ availableVersion }} | Curr: {{ currentVersion }} | ImmedAllowed: {{ immediateUpdateAllowed }}</p>
<p class="text-xs text-gray-400">UA: {{ updateAvailability }} | Avail: {{ availableVersion }} | Curr: {{ currentVersion }} | ImmedAllowed: {{ immediateUpdateAllowed }}</p>
</div>
</template>
@ -34,11 +34,18 @@
import { AppUpdate } from '@robingenz/capacitor-app-update'
export default {
asyncData({ redirect, store }) {
if (!store.state.socketConnected) {
return redirect('/connect')
}
return {}
},
data() {
return {}
},
computed: {
username() {
if (!this.user) return ''
return this.user.username
},
user() {

102
pages/bookshelf.vue Normal file
View file

@ -0,0 +1,102 @@
<template>
<div class="w-full h-full">
<home-bookshelf-nav-bar />
<home-bookshelf-toolbar v-show="!isHome" />
<div class="main-content overflow-y-auto overflow-x-hidden relative" :class="isHome ? 'home-page' : ''">
<nuxt-child />
<div v-if="isLoading" class="absolute top-0 left-0 w-full h-full flex items-center justify-center">
<ui-loading-indicator />
</div>
<div v-else-if="!audiobooks.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">
for library <strong>{{ currentLibraryName }}</strong></span
>
</p>
<div v-if="!isSocketConnected" class="flex 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>
<div class="flex justify-center">
<ui-btn v-if="!isSocketConnected" small @click="$router.push('/connect')" class="w-32"> Connect </ui-btn>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {}
},
computed: {
isHome() {
return this.$route.name === 'bookshelf'
},
isLoading() {
return this.$store.state.audiobooks.isLoading
},
audiobooks() {
return this.$store.state.audiobooks.audiobooks
},
currentLibrary() {
return this.$store.getters['libraries/getCurrentLibrary']
},
currentLibraryName() {
return this.currentLibrary ? this.currentLibrary.name : 'Main'
},
isSocketConnected() {
return this.$store.state.socketConnected
}
},
methods: {
async loadAudiobooks() {
var currentLibrary = await this.$localStore.getCurrentLibrary()
if (currentLibrary) {
this.$store.commit('libraries/setCurrentLibrary', currentLibrary.id)
}
this.$store.dispatch('audiobooks/load')
},
async loadCollections() {
this.$store.dispatch('user/loadUserCollections')
},
socketConnected(isConnected) {
if (isConnected) {
console.log('Connected - Load from server')
this.loadAudiobooks()
if (this.$route.name === 'bookshelf-collections') this.loadCollections()
} else {
console.log('Disconnected - Reset to local storage')
this.$store.commit('audiobooks/reset')
this.$store.dispatch('audiobooks/useDownloaded')
}
}
},
mounted() {
this.$server.on('connected', this.socketConnected)
if (this.$server.connected) {
this.loadAudiobooks()
} else {
console.log('Bookshelf - Server not connected using downloaded')
}
},
beforeDestroy() {
this.$server.off('connected', this.socketConnected)
}
}
</script>
<style>
.main-content {
max-height: calc(100% - 72px);
min-height: calc(100% - 72px);
}
.main-content.home-page {
max-height: calc(100% - 36px);
min-height: calc(100% - 36px);
}
</style>

View file

@ -0,0 +1,51 @@
<template>
<div class="w-full h-full">
<template v-for="(shelf, index) in shelves">
<bookshelf-group-shelf :key="shelf.id" group-type="collection" :groups="shelf.groups" :style="{ zIndex: shelves.length - index }" />
</template>
</div>
</template>
<script>
export default {
data() {
return {
groupsPerRow: 2
}
},
watch: {},
computed: {
collections() {
return this.$store.state.user.collections || []
},
shelves() {
var shelves = []
var shelf = {
id: 0,
groups: []
}
for (let i = 0; i < this.collections.length; i++) {
var shelfNum = Math.floor((i + 1) / this.groupsPerRow)
shelf.id = shelfNum
shelf.groups.push(this.collections[i])
if ((i + 1) % this.groupsPerRow === 0) {
shelves.push(shelf)
shelf = {
id: 0,
groups: []
}
}
}
if (shelf.groups.length) {
shelves.push(shelf)
}
return shelves
}
},
methods: {},
mounted() {
this.$store.dispatch('user/loadUserCollections')
}
}
</script>

93
pages/bookshelf/index.vue Normal file
View file

@ -0,0 +1,93 @@
<template>
<div class="w-full h-full">
<template v-for="(shelf, index) in shelves">
<bookshelf-shelf :key="shelf.id" :label="shelf.label" :books="shelf.books" :style="{ zIndex: shelves.length - index }" />
</template>
</div>
</template>
<script>
export default {
data() {
return {
settings: {}
}
},
computed: {
books() {
return this.$store.getters['audiobooks/getFilteredAndSorted']()
},
booksWithUserAbData() {
var books = this.books.map((b) => {
var userAbData = this.$store.getters['user/getMostRecentUserAudiobookData'](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 a.userAbData.lastUpdate - b.userAbData.lastUpdate
})
return books
},
booksRecentlyAdded() {
var books = this.books
.map((b) => {
return { ...b }
})
.sort((a, b) => a.addedAt - b.addedAt)
return books.slice(0, 10)
},
booksRead() {
var books = this.booksWithUserAbData
.filter((b) => b.userAbData && b.userAbData.isRead)
.sort((a, b) => {
return a.userAbData.lastUpdate - b.userAbData.lastUpdate
})
return books.slice(0, 10)
},
shelves() {
var shelves = []
if (this.booksCurrentlyReading.length) {
shelves.push({
id: 'recent',
label: 'Continue Reading',
books: this.booksCurrentlyReading
})
}
if (this.booksRecentlyAdded.length) {
shelves.push({
id: 'added',
label: 'Recently Added',
books: this.booksRecentlyAdded
})
}
if (this.booksRead.length) {
shelves.push({
id: 'read',
label: 'Read Again',
books: this.booksRead
})
}
return shelves
}
},
methods: {
async init() {
this.settings = { ...this.$store.state.user.settings }
// var bookshelfView = await this.$localStore.getBookshelfView()
// this.isListView = bookshelfView === 'list'
// this.bookshelfReady = true
// console.log('Bookshelf view', bookshelfView)
}
},
mounted() {}
}
</script>

View file

@ -0,0 +1,48 @@
<template>
<div class="w-full h-full">
<template v-for="(shelf, index) in shelves">
<bookshelf-library-shelf :key="shelf.id" :books="shelf.books" :style="{ zIndex: shelves.length - index }" />
</template>
</div>
</template>
<script>
export default {
data() {
return {
booksPerRow: 3
}
},
computed: {
books() {
return this.$store.getters['audiobooks/getFilteredAndSorted']()
},
shelves() {
var shelves = []
var shelf = {
id: 0,
books: []
}
for (let i = 0; i < this.books.length; i++) {
var shelfNum = Math.floor((i + 1) / this.booksPerRow)
shelf.id = shelfNum
shelf.books.push(this.books[i])
if ((i + 1) % this.booksPerRow === 0) {
shelves.push(shelf)
shelf = {
id: 0,
books: []
}
}
}
if (shelf.books.length) {
shelves.push(shelf)
}
return shelves
}
},
methods: {},
mounted() {}
}
</script>

104
pages/bookshelf/series.vue Normal file
View file

@ -0,0 +1,104 @@
<template>
<div class="w-full h-full">
<template v-for="(shelf, index) in shelves">
<bookshelf-group-shelf v-if="!selectedSeriesName" :key="shelf.id" group-type="series" :groups="shelf.groups" :style="{ zIndex: shelves.length - index }" />
<bookshelf-library-shelf v-else :key="shelf.id" :books="shelf.books" :style="{ zIndex: shelves.length - index }" />
</template>
</div>
</template>
<script>
export default {
data() {
return {
groupsPerRow: 2,
booksPerRow: 3,
selectedSeriesName: null
}
},
watch: {
routeQuery: {
handler(newVal) {
if (newVal && newVal.series) {
console.log('Select series')
this.selectedSeriesName = this.$decode(newVal.series)
} else {
this.selectedSeriesName = null
}
}
}
},
computed: {
routeQuery() {
return this.$route.query
},
series() {
return this.$store.getters['audiobooks/getSeriesGroups']()
},
seriesShelves() {
var shelves = []
var shelf = {
id: 0,
groups: []
}
for (let i = 0; i < this.series.length; i++) {
var shelfNum = Math.floor((i + 1) / this.groupsPerRow)
shelf.id = shelfNum
shelf.groups.push(this.series[i])
if ((i + 1) % this.groupsPerRow === 0) {
shelves.push(shelf)
shelf = {
id: 0,
groups: []
}
}
}
if (shelf.groups.length) {
shelves.push(shelf)
}
return shelves
},
selectedSeries() {
if (!this.selectedSeriesName) return null
return this.series.find((s) => s.name === this.selectedSeriesName)
},
seriesBooksShelves() {
if (!this.selectedSeries) return []
var seriesBooks = this.selectedSeries.books || []
var shelves = []
var shelf = {
id: 0,
books: []
}
for (let i = 0; i < seriesBooks.length; i++) {
var shelfNum = Math.floor((i + 1) / this.booksPerRow)
shelf.id = shelfNum
shelf.books.push(seriesBooks[i])
if ((i + 1) % this.booksPerRow === 0) {
shelves.push(shelf)
shelf = {
id: 0,
books: []
}
}
}
if (shelf.books.length) {
shelves.push(shelf)
}
return shelves
},
shelves() {
if (this.selectedSeries) {
return this.seriesBooksShelves
} else {
return this.seriesShelves
}
}
},
methods: {},
mounted() {}
}
</script>

117
pages/collection/_id.vue Normal file
View file

@ -0,0 +1,117 @@
<template>
<div class="w-full h-full">
<div class="w-full h-full overflow-y-auto px-2 py-6 md:p-8">
<div class="w-full flex justify-center md:block sm:w-32 md:w-52" style="min-width: 240px">
<div class="relative" style="height: fit-content">
<cards-collection-cover :book-items="bookItems" :width="240" :height="120 * 1.6" />
</div>
</div>
<div class="flex-grow px-2 py-6 md:py-0 md:px-10">
<div class="flex items-center">
<h1 class="text-xl font-sans">
{{ collectionName }}
</h1>
<div class="flex-grow" />
<ui-btn v-if="showPlayButton" :disabled="streaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2 w-20" @click="clickPlay">
<span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">play_arrow</span>
{{ streaming ? 'Streaming' : 'Play' }}
</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>
<tables-collection-books-table :books="bookItems" :collection-id="collection.id" />
</div>
</div>
<div v-show="processingRemove" class="absolute top-0 left-0 w-full h-full z-10 bg-black bg-opacity-40 flex items-center justify-center">
<ui-loading-indicator />
</div>
</div>
</template>
<script>
export default {
async asyncData({ store, params, app, redirect, route }) {
if (!store.state.user.user) {
return redirect(`/connect?redirect=${route.path}`)
}
var collection = await app.$axios.$get(`/api/collection/${params.id}`).catch((error) => {
console.error('Failed', error)
return false
})
if (!collection) {
return redirect('/')
}
store.commit('user/addUpdateCollection', collection)
collection.books.forEach((book) => {
store.commit('audiobooks/addUpdate', book)
})
return {
collectionId: collection.id
}
},
data() {
return {
processingRemove: false
}
},
computed: {
bookItems() {
return this.collection.books || []
},
collectionName() {
return this.collection.name || ''
},
description() {
return this.collection.description || ''
},
collection() {
return this.$store.getters['user/getCollection'](this.collectionId)
},
playableBooks() {
return this.bookItems.filter((book) => {
return !book.isMissing && !book.isIncomplete && book.numTracks
})
},
streaming() {
return !!this.playableBooks.find((b) => b.id === this.$store.getters['getAudiobookIdStreaming'])
},
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)
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)
}
}
}
},
mounted() {}
}
</script>

16
pages/config.vue Normal file
View file

@ -0,0 +1,16 @@
<template>
<div class="w-full h-full">
<p class="text-xl text-center py-8">Under Construction...</p>
</div>
</template>
<script>
export default {
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>

View file

@ -133,7 +133,7 @@ export default {
if (this.$route.query && this.$route.query.redirect) {
this.$router.replace(this.$route.query.redirect)
} else {
this.$router.replace('/')
this.$router.replace('/bookshelf')
}
},
socketConnected() {

239
pages/downloads.vue Normal file
View file

@ -0,0 +1,239 @@
<template>
<div class="w-full h-full py-6">
<h1 class="text-2xl px-4">Downloads</h1>
<div 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 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 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">
<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>
</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: {
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.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)
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('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('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)
}
},
mounted() {},
beforeDestroy() {
AudioDownloader.removeListener('onDownloadProgress', this.onDownloadProgress)
}
}
</script>

View file

@ -27,6 +27,9 @@
<script>
export default {
asyncData({ redirect }) {
return redirect('/bookshelf')
},
data() {
return {
showSortModal: false,

Binary file not shown.

View file

@ -30,7 +30,11 @@ export const getters = {
var filter = decode(filterBy.replace(`${group}.`, ''))
if (group === 'genres') filtered = filtered.filter(ab => ab.book && ab.book.genres.includes(filter))
else if (group === 'tags') filtered = filtered.filter(ab => ab.tags.includes(filter))
else if (group === 'series') filtered = filtered.filter(ab => ab.book && ab.book.series === filter)
else if (group === 'series') {
if (filter === 'No Series') filtered = filtered.filter(ab => ab.book && !ab.book.series)
else filtered = filtered.filter(ab => ab.book && ab.book.series === filter)
}
// else if (group === 'series') filtered = filtered.filter(ab => ab.book && ab.book.series === filter)
else if (group === 'authors') filtered = filtered.filter(ab => ab.book && ab.book.author === filter)
else if (group === 'progress') {
filtered = filtered.filter(ab => {
@ -67,6 +71,36 @@ export const getters = {
})
}
},
getSeriesGroups: (state, getters, rootState) => () => {
var series = {}
state.audiobooks.forEach((audiobook) => {
if (audiobook.book && audiobook.book.series) {
if (series[audiobook.book.series]) {
var bookLastUpdate = audiobook.book.lastUpdate
if (bookLastUpdate > series[audiobook.book.series].lastUpdate) series[audiobook.book.series].lastUpdate = bookLastUpdate
series[audiobook.book.series].books.push(audiobook)
} else {
series[audiobook.book.series] = {
type: 'series',
name: audiobook.book.series || '',
books: [audiobook],
lastUpdate: audiobook.book.lastUpdate
}
}
}
})
var seriesArray = Object.values(series).map((_series) => {
_series.books = sort(_series.books)['asc']((ab) => {
return ab.book && ab.book.volumeNumber && !isNaN(ab.book.volumeNumber) ? Number(ab.book.volumeNumber) : null
})
return _series
})
if (state.keywordFilter) {
const keywordFilter = state.keywordFilter.toLowerCase()
return seriesArray.filter((_series) => _series.name.toLowerCase().includes(keywordFilter))
}
return seriesArray
},
getUniqueAuthors: (state) => {
var _authors = state.audiobooks.filter(ab => !!(ab.book && ab.book.author)).map(ab => ab.book.author)
return [...new Set(_authors)]

View file

@ -15,7 +15,8 @@ export const state = () => ({
selectedBook: null,
showReader: false,
downloadFolder: null,
mediaScanResults: {}
mediaScanResults: {},
showSideDrawer: false
})
export const getters = {
@ -27,6 +28,9 @@ export const getters = {
},
isAudiobookPlaying: (state) => id => {
return (state.playingDownload && state.playingDownload.id === id) || (state.streamAudiobook && state.streamAudiobook.id === id)
},
getAudiobookIdStreaming: state => {
return state.streamAudiobook ? state.streamAudiobook.id : null
}
}
@ -97,5 +101,8 @@ export const mutations = {
},
setMediaScanResults(state, val) {
state.mediaScanResults = val
},
setShowSideDrawer(state, val) {
state.showSideDrawer = val
}
}

View file

@ -12,7 +12,9 @@ export const state = () => ({
bookshelfCoverSize: 120
},
settingsListeners: [],
userAudiobooksListeners: []
userAudiobooksListeners: [],
collections: [],
collectionsLoaded: false
})
export const getters = {
@ -38,6 +40,9 @@ export const getters = {
},
getFilterOrderKey: (state) => {
return Object.values(state.settings).join('-')
},
getCollection: state => id => {
return state.collections.find(c => c.id === id)
}
}
@ -62,6 +67,24 @@ export const actions = {
console.log('Update settings without server')
commit('setSettings', payload)
}
},
loadUserCollections({ state, commit }) {
if (!this.$server.connected) {
console.error('Not loading collections - not connected')
return []
}
if (state.collectionsLoaded) {
console.log('Collections already loaded')
return state.collections
}
return this.$axios.$get('/api/collections').then((collections) => {
commit('setCollections', collections)
return collections
}).catch((error) => {
console.error('Failed to get collections', error)
return []
})
}
}
@ -120,5 +143,17 @@ export const mutations = {
},
removeUserAudiobookListener(state, listenerId) {
state.userAudiobooksListeners = state.userAudiobooksListeners.filter(l => l.id !== listenerId)
},
setCollections(state, collections) {
state.collections = collections
state.collectionsLoaded = true
},
addUpdateCollection(state, collection) {
var index = state.collections.findIndex(c => c.id === collection.id)
if (index >= 0) {
state.collections.splice(index, 1, collection)
} else {
state.collections.push(collection)
}
},
}

View file

@ -15,8 +15,8 @@ module.exports = {
'short': { 'raw': '(max-height: 500px)' }
},
colors: {
// bg: '#1e272e',
bg: '#373838',
secondary: '#2F3030',
yellowgreen: 'yellowgreen',
primary: '#262626',
accent: '#1ad691',