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" applicationId "com.audiobookshelf.app"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 38 versionCode 39
versionName "0.9.19-beta" versionName "0.9.20-beta"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <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" xmlns:tools="http://schemas.android.com/tools"
package="com.audiobookshelf.app"> package="com.audiobookshelf.app">
@ -20,6 +21,14 @@
android:networkSecurityConfig="@xml/network_security_config" android:networkSecurityConfig="@xml/network_security_config"
android:requestLegacyExternalStorage="true"> 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 <activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:name="com.audiobookshelf.app.MainActivity" android:name="com.audiobookshelf.app.MainActivity"
@ -31,14 +40,13 @@
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity> </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" > <receiver android:name="androidx.media.session.MediaButtonReceiver" >
<intent-filter> <intent-filter>

View file

@ -66,7 +66,7 @@ class Audiobook {
// return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon) // return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
return Uri.parse(localCoverUrl) 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}") return Uri.parse("$serverUrl${book.cover}?token=$token&ts=${book.lastUpdate}")
} }
@ -74,29 +74,6 @@ class Audiobook {
return duration.toLong() * 1000L 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 { fun toMediaMetadata():MediaMetadataCompat {
return MediaMetadataCompat.Builder().apply { return MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id) putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)

View file

@ -2,15 +2,10 @@ package com.audiobookshelf.app
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.MediaDescriptionCompat
import android.util.Log import android.util.Log
import androidx.media.MediaBrowserServiceCompat
import androidx.media.utils.MediaConstants
import com.getcapacitor.JSArray import com.getcapacitor.JSArray
import com.getcapacitor.JSObject import com.getcapacitor.JSObject
import com.jeep.plugin.capacitor.capacitordatastoragesqlite.CapacitorDataStorageSqlite import com.jeep.plugin.capacitor.capacitordatastoragesqlite.CapacitorDataStorageSqlite
@ -40,17 +35,22 @@ class AudiobookManager {
fun init() { fun init() {
var sharedPreferences = ctx.getSharedPreferences("CapacitorStorage", Activity.MODE_PRIVATE) 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") 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") Log.d(tag, "SHARED PREF TOKEN $token")
} }
fun loadAudiobooks(cb: (() -> Unit)) { fun loadAudiobooks(cb: (() -> Unit)) {
Log.d(tag, "LOAD AUDIBOOOSK $serverUrl | $token")
if (serverUrl == "" || token == "") { if (serverUrl == "" || token == "") {
Log.d(tag, "No Server or Token set") Log.d(tag, "No Server or Token set")
cb() cb()
return return
} else if (!serverUrl.startsWith("http")) {
Log.e(tag, "Invalid server url $serverUrl")
cb()
return
} }
var url = "$serverUrl/api/library/main/audiobooks" 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() { fun load() {
isLoading = true isLoading = true
hasLoaded = true hasLoaded = true
@ -261,4 +189,58 @@ class AudiobookManager {
var audiobookStreamData = AudiobookStreamData(abStreamDataObj) var audiobookStreamData = AudiobookStreamData(abStreamDataObj)
return audiobookStreamData 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 package com.audiobookshelf.app
import android.app.Activity
import android.app.DownloadManager import android.app.DownloadManager
import android.app.SearchManager
import android.content.* import android.content.*
import android.os.* import android.os.*
import android.util.Log import android.util.Log
import com.anggrayudi.storage.SimpleStorage import com.anggrayudi.storage.SimpleStorage
import com.anggrayudi.storage.SimpleStorageHelper import com.anggrayudi.storage.SimpleStorageHelper
import com.getcapacitor.BridgeActivity 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() { class MainActivity : BridgeActivity() {

View file

@ -1,5 +1,6 @@
package com.audiobookshelf.app package com.audiobookshelf.app
import android.annotation.SuppressLint
import android.app.* import android.app.*
import android.content.Context import android.content.Context
import android.content.Intent 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.MediaMetadataCompat
import android.support.v4.media.session.MediaControllerCompat import android.support.v4.media.session.MediaControllerCompat
import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import android.util.Log import android.util.Log
import android.view.KeyEvent
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.media.MediaBrowserServiceCompat 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.ui.PlayerNotificationManager
import com.google.android.exoplayer2.upstream.* import com.google.android.exoplayer2.upstream.*
import kotlinx.coroutines.* import kotlinx.coroutines.*
import android.view.KeyEvent import okhttp3.OkHttpClient
import java.util.* import java.util.*
import kotlin.concurrent.schedule import kotlin.concurrent.schedule
import android.annotation.SuppressLint
import okhttp3.OkHttpClient
const val NOTIFICATION_LARGE_ICON_SIZE = 144 // px const val NOTIFICATION_LARGE_ICON_SIZE = 144 // px
@ -49,8 +50,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
interface MyCustomObjectListener { interface MyCustomObjectListener {
fun onPlayingUpdate(isPlaying: Boolean) fun onPlayingUpdate(isPlaying: Boolean)
fun onMetadata(metadata: JSObject) fun onMetadata(metadata: JSObject)
fun onPrepare(audiobookId:String, playWhenReady:Boolean) fun onPrepare(audiobookId: String, playWhenReady: Boolean)
fun onSleepTimerEnded(currentPosition:Long) fun onSleepTimerEnded(currentPosition: Long)
} }
@ -63,6 +64,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
private lateinit var mediaSessionConnector: MediaSessionConnector private lateinit var mediaSessionConnector: MediaSessionConnector
private lateinit var playerNotificationManager: PlayerNotificationManager private lateinit var playerNotificationManager: PlayerNotificationManager
private lateinit var mediaSession: MediaSessionCompat private lateinit var mediaSession: MediaSessionCompat
private lateinit var transportControls:MediaControllerCompat.TransportControls
private val serviceJob = SupervisorJob() private val serviceJob = SupervisorJob()
private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob) private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob)
@ -77,8 +79,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
private var currentAudiobookStreamData:AudiobookStreamData? = null private var currentAudiobookStreamData:AudiobookStreamData? = null
// private var audiobooks = mutableListOf<AudiobookStreamData>()
private var mediaButtonClickCount: Int = 0 private var mediaButtonClickCount: Int = 0
var mediaButtonClickTimeout: Long = 1000 //ms var mediaButtonClickTimeout: Long = 1000 //ms
var seekAmount: Long = 20000 //ms var seekAmount: Long = 20000 //ms
@ -127,7 +127,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
override fun onStart(intent: Intent?, startId: Int) { override fun onStart(intent: Intent?, startId: Int) {
Log.d(tag, "onStart $startId" ) Log.d(tag, "onStart $startId")
} }
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
@ -141,6 +141,57 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
return channelId 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 // detach player
override fun onDestroy() { override fun onDestroy() {
playerNotificationManager.setPlayer(null) playerNotificationManager.setPlayer(null)
@ -193,6 +244,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
isActive = true isActive = true
} }
Log.d(tag, "Media Session Set") Log.d(tag, "Media Session Set")
val mediaController = MediaControllerCompat(ctx, mediaSession.sessionToken) val mediaController = MediaControllerCompat(ctx, mediaSession.sessionToken)
@ -247,6 +300,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
// Unknown action // Unknown action
playerNotificationManager.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE) playerNotificationManager.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE)
transportControls = mediaController.transportControls
// Color is set based on the art - cannot override // Color is set based on the art - cannot override
// playerNotificationManager.setColor(Color.RED) // playerNotificationManager.setColor(Color.RED)
// playerNotificationManager.setColorized(true) // playerNotificationManager.setColorized(true)
@ -267,114 +322,112 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
} }
// val myPlaybackPreparer:MediaSessionConnector.PlaybackPreparer = object :MediaSessionConnector.PlaybackPreparer { val myPlaybackPreparer:MediaSessionConnector.PlaybackPreparer = object :MediaSessionConnector.PlaybackPreparer {
// override fun onCommand(player: Player, controlDispatcher: ControlDispatcher, command: String, extras: Bundle?, cb: ResultReceiver?): Boolean { override fun onCommand(player: Player, controlDispatcher: ControlDispatcher, command: String, extras: Bundle?, cb: ResultReceiver?): Boolean {
// Log.d(tag, "ON COMMAND $command") Log.d(tag, "ON COMMAND $command")
// return false return false
// } }
//
// override fun getSupportedPrepareActions(): Long { override fun getSupportedPrepareActions(): Long {
// return PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID or return PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID or
// PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or
// PlaybackStateCompat.ACTION_PREPARE_FROM_SEARCH or PlaybackStateCompat.ACTION_PREPARE_FROM_SEARCH or
// PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH
// } }
//
// override fun onPrepare(playWhenReady: Boolean) { override fun onPrepare(playWhenReady: Boolean) {
// Log.d(tag, "ON PREPARE $playWhenReady") Log.d(tag, "ON PREPARE $playWhenReady")
// var audiobook = audiobookManager.audiobooks[0] playFirstAudiobook(playWhenReady)
// if (audiobook == null) { }
// Log.e(tag, "Audiobook NOT FOUND")
// return override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) {
// } Log.d(tag, "ON PREPARE FROM MEDIA ID $mediaId $playWhenReady")
// openFromMediaId(mediaId, playWhenReady)
// var streamListener = object : AudiobookManager.OnStreamData { }
// override fun onStreamReady(asd: AudiobookStreamData) {
// Log.d(tag, "Stream Ready ${asd.playlistUrl}") override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) {
// initPlayer(asd) Log.d(tag, "ON PREPARE FROM SEARCH $query")
// } openFromSearch(query, playWhenReady)
// } }
// audiobookManager.openStream(audiobook, streamListener)
// } override fun onPrepareFromUri(uri: Uri, playWhenReady: Boolean, extras: Bundle?) {
// Log.d(tag, "ON PREPARE FROM URI $uri")
// 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) { mediaSessionConnector.setEnabledPlaybackActions(
// Log.e(tag, "Audiobook NOT FOUND") PlaybackStateCompat.ACTION_PLAY_PAUSE
// return or PlaybackStateCompat.ACTION_PLAY
// } or PlaybackStateCompat.ACTION_PAUSE
// or PlaybackStateCompat.ACTION_SEEK_TO
// var streamListener = object : AudiobookManager.OnStreamData { or PlaybackStateCompat.ACTION_FAST_FORWARD
// override fun onStreamReady(asd: AudiobookStreamData) { or PlaybackStateCompat.ACTION_REWIND
// Log.d(tag, "Stream Ready ${asd.playlistUrl}") or PlaybackStateCompat.ACTION_STOP
// 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")
// }
//
// }
mediaSessionConnector.setQueueNavigator(queueNavigator) mediaSessionConnector.setQueueNavigator(queueNavigator)
// mediaSessionConnector.setPlaybackPreparer(myPlaybackPreparer) mediaSessionConnector.setPlaybackPreparer(myPlaybackPreparer)
mediaSessionConnector.setPlayer(mPlayer) mediaSessionConnector.setPlayer(mPlayer)
//attach player to playerNotificationManager //attach player to playerNotificationManager
playerNotificationManager.setPlayer(mPlayer) playerNotificationManager.setPlayer(mPlayer)
mediaSession.setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS)
mediaSession.setCallback(object : MediaSessionCompat.Callback() { mediaSession.setCallback(object : MediaSessionCompat.Callback() {
override fun onPrepare() { override fun onPrepare() {
Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT") Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT")
super.onPrepare() playFirstAudiobook(true)
} }
override fun onPlay() { override fun onPlay() {
Log.d(tag, "ON PLAY MEDIA SESSION COMPAT") Log.d(tag, "ON PLAY MEDIA SESSION COMPAT")
play() 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() { override fun onPause() {
Log.d(tag, "ON PLAY MEDIA SESSION COMPAT") Log.d(tag, "ON PLAY MEDIA SESSION COMPAT")
pause() pause()
} }
override fun onStop() { override fun onStop() {
pause() pause()
} }
override fun onSkipToPrevious() { override fun onSkipToPrevious() {
seekBackward(seekAmount) seekBackward(seekAmount)
} }
override fun onSkipToNext() { override fun onSkipToNext() {
seekForward(seekAmount) 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?) { override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
Log.d(tag, "ON PLAY FROM MEDIA ID $mediaId") Log.d(tag, "ON PLAY FROM MEDIA ID $mediaId")
if (mediaId.isNullOrEmpty()) {
var audiobook = audiobookManager.audiobooks.find { it.id == mediaId } playFirstAudiobook(true)
if (audiobook == null) {
Log.e(tag, "Audiobook NOT FOUND")
return return
} }
openFromMediaId(mediaId, true)
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)
}
} }
override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean { override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean {
@ -390,7 +443,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
if (keyEvent?.getAction() == KeyEvent.ACTION_UP) { if (keyEvent?.getAction() == KeyEvent.ACTION_UP) {
when (keyEvent?.getKeyCode()) { when (keyEvent?.getKeyCode()) {
KeyEvent.KEYCODE_HEADSETHOOK -> { KeyEvent.KEYCODE_HEADSETHOOK -> {
if(0 == mediaButtonClickCount) { if (0 == mediaButtonClickCount) {
if (mPlayer.isPlaying) if (mPlayer.isPlaying)
pause() pause()
else else
@ -399,11 +452,11 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
handleMediaButtonClickCount() handleMediaButtonClickCount()
} }
KeyEvent.KEYCODE_MEDIA_PLAY -> { KeyEvent.KEYCODE_MEDIA_PLAY -> {
if(0 == mediaButtonClickCount) play() if (0 == mediaButtonClickCount) play()
handleMediaButtonClickCount() handleMediaButtonClickCount()
} }
KeyEvent.KEYCODE_MEDIA_PAUSE -> { KeyEvent.KEYCODE_MEDIA_PAUSE -> {
if(0 == mediaButtonClickCount) pause() if (0 == mediaButtonClickCount) pause()
handleMediaButtonClickCount() handleMediaButtonClickCount()
} }
KeyEvent.KEYCODE_MEDIA_NEXT -> { KeyEvent.KEYCODE_MEDIA_NEXT -> {
@ -415,6 +468,18 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
KeyEvent.KEYCODE_MEDIA_STOP -> { KeyEvent.KEYCODE_MEDIA_STOP -> {
terminateStream() 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 -> { else -> {
Log.d(tag, "KeyCode:${keyEvent?.getKeyCode()}") Log.d(tag, "KeyCode:${keyEvent?.getKeyCode()}")
return false return false
@ -526,8 +591,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
if (lastPauseTime == 0L) { if (lastPauseTime == 0L) {
sendClientMetadata("ready_no_sync") sendClientMetadata("ready_no_sync")
lastPauseTime = -1; lastPauseTime = -1;
} } else sendClientMetadata("ready")
else sendClientMetadata("ready")
} }
if (mPlayer.playbackState == Player.STATE_BUFFERING) { if (mPlayer.playbackState == Player.STATE_BUFFERING) {
Log.d(tag, "STATE_BUFFERING : " + mPlayer.currentPosition.toString()) 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) listener?.onPlayingUpdate(player.isPlaying)
} }
} }
@ -676,6 +739,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
fun pause() { fun pause() {
mPlayer.pause() mPlayer.pause()
} }
@ -683,15 +747,15 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
mPlayer.seekTo(time) mPlayer.seekTo(time)
} }
fun seekForward(amount:Long) { fun seekForward(amount: Long) {
mPlayer.seekTo(mPlayer.currentPosition + amount) mPlayer.seekTo(mPlayer.currentPosition + amount)
} }
fun seekBackward(amount:Long) { fun seekBackward(amount: Long) {
mPlayer.seekTo(mPlayer.currentPosition - amount) mPlayer.seekTo(mPlayer.currentPosition - amount)
} }
fun setPlaybackSpeed(speed:Float) { fun setPlaybackSpeed(speed: Float) {
mPlayer.setPlaybackSpeed(speed) mPlayer.setPlaybackSpeed(speed)
} }
@ -718,10 +782,11 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
// MEDIA BROWSER STUFF (ANDROID AUTO) // MEDIA BROWSER STUFF (ANDROID AUTO)
// //
private val AUTO_MEDIA_ROOT = "/" private val AUTO_MEDIA_ROOT = "/"
private val ALL_ROOT = "__ALL__"
private lateinit var browseTree:BrowseTree private lateinit var browseTree:BrowseTree
private fun isValid(packageName:String, uid:Int) : Boolean { private fun isValid(packageName: String, uid: Int) : Boolean {
Log.d(tag, "Check package $packageName is valid with uid $uid") Log.d(tag, "Check package $packageName is valid with uid $uid")
return true return true
} }
@ -734,16 +799,15 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
// No further calls will be made to other media browsing methods. // No further calls will be made to other media browsing methods.
null null
} else { } else {
//
val maximumRootChildLimit = rootHints?.getInt( // val maximumRootChildLimit = rootHints?.getInt(
MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT, // MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT,
/* defaultValue= */ 4) // /* defaultValue= */ 4)
// val supportedRootChildFlags = rootHints.getInt( // val supportedRootChildFlags = rootHints.getInt(
// MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_SUPPORTED_FLAGS, // MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_SUPPORTED_FLAGS,
// /* defaultValue= */ android.media.browse.MediaBrowser.MediaItem.FLAG_BROWSABLE) // /* defaultValue= */ android.media.browse.MediaBrowser.MediaItem.FLAG_BROWSABLE)
val extras = Bundle() val extras = Bundle()
extras.putBoolean( extras.putBoolean(
MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true) 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>>) { override fun onLoadChildren(parentMediaId: String, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
val mediaItems: MutableList<MediaBrowserCompat.MediaItem> = mutableListOf() val mediaItems: MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
Log.d(tag, "ON LOAD CHILDREN $parentMediaId") 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) { if (!audiobookManager.hasLoaded) {
Log.d(tag, "audiobook manager loading")
result.detach() result.detach()
audiobookManager.load() audiobookManager.load()
audiobookManager.loadAudiobooks() { audiobookManager.loadAudiobooks() {
@ -772,7 +836,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
Log.d(tag, "LOADED AUDIOBOOKS") Log.d(tag, "LOADED AUDIOBOOKS")
browseTree = BrowseTree(this, audiobookManager.audiobooks, null) browseTree = BrowseTree(this, audiobookManager.audiobooks, null)
val children = browseTree[parentMediaId]?.map { item -> val children = browseTree[parentMediaId]?.map { item ->
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) MediaBrowserCompat.MediaItem(item.description, flag)
} }
if (children != null) { if (children != null) {
Log.d(tag, "BROWSE TREE CHILDREN ${children.size}") Log.d(tag, "BROWSE TREE CHILDREN ${children.size}")
@ -784,8 +848,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
Log.d(tag, "AUDIOBOOKS LOADING") Log.d(tag, "AUDIOBOOKS LOADING")
result.detach() result.detach()
return return
} else {
Log.d(tag, "ABs are loaded")
} }
if (audiobookManager.audiobooks.size == 0) { if (audiobookManager.audiobooks.size == 0) {
@ -794,9 +856,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
return return
} }
var flag = if (parentMediaId == AUTO_MEDIA_ROOT) MediaBrowserCompat.MediaItem.FLAG_BROWSABLE else MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
val children = browseTree[parentMediaId]?.map { item -> val children = browseTree[parentMediaId]?.map { item ->
MediaBrowserCompat.MediaItem(item.description, flag) MediaBrowserCompat.MediaItem(item.description, flag)
} }
@ -805,21 +864,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?) 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: // Check if this is the root menu:
if (AUTO_MEDIA_ROOT == parentMediaId) { if (AUTO_MEDIA_ROOT == parentMediaId) {
// build the MediaItem objects for the top level, // build the MediaItem objects for the top level,
@ -833,11 +877,53 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
// result.sendResult(mediaItems) // 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 // SLEEP TIMER STUFF
// //
fun setSleepTimer(time:Long, isChapterTime:Boolean) : Boolean { fun setSleepTimer(time: Long, isChapterTime: Boolean) : Boolean {
Log.d(tag, "Setting Sleep Timer for $time is chapter time $isChapterTime") Log.d(tag, "Setting Sleep Timer for $time is chapter time $isChapterTime")
sleepTimerTask?.cancel() sleepTimerTask?.cancel()
sleepChapterTime = 0L sleepChapterTime = 0L
@ -852,7 +938,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
sleepChapterTime = time sleepChapterTime = time
sleepTimerTask = Timer("SleepTimer",false).schedule(0L, 1000L) { sleepTimerTask = Timer("SleepTimer", false).schedule(0L, 1000L) {
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post() {
if (mPlayer.isPlaying && mPlayer.currentPosition > sleepChapterTime) { if (mPlayer.isPlaying && mPlayer.currentPosition > sleepChapterTime) {
Log.d(tag, "Sleep Timer Pausing Player on Chapter") Log.d(tag, "Sleep Timer Pausing Player on Chapter")
@ -864,7 +950,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
} }
} else { } else {
sleepTimerTask = Timer("SleepTimer",false).schedule(time) { sleepTimerTask = Timer("SleepTimer", false).schedule(time) {
Log.d(tag, "Sleep Timer Done") Log.d(tag, "Sleep Timer Done")
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post() {
if (mPlayer.isPlaying) { if (mPlayer.isPlaying) {

View file

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

View file

@ -19,3 +19,27 @@
.box-shadow-book { .box-shadow-book {
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166; 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-face {
font-family: 'Material Icons'; font-family: 'Material Icons';
font-style: normal; font-style: normal;
font-weight: 400; 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-family: 'Material Icons';
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
@ -27,6 +33,9 @@
.material-icons.text-lg { .material-icons.text-lg {
font-size: 1.25rem; font-size: 1.25rem;
} }
.material-icons.text-2xl {
font-size: 1.5rem;
}
.material-icons.text-3xl { .material-icons.text-3xl {
font-size: 1.875rem; font-size: 1.875rem;
} }
@ -38,6 +47,42 @@
} }
.material-icons.text-base { .material-icons.text-base {
font-size: 1rem; 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 { @font-face {
@ -45,7 +90,7 @@
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; 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; 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 */ /* latin */
@ -54,6 +99,6 @@
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; 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; 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> <template>
<div class="w-full h-16 bg-primary relative"> <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"> <nuxt-link v-show="!showBack" to="/" class="mr-3">
<img src="/Logo.png" class="h-10 w-10" /> <img src="/Logo.png" class="h-10 w-10" />
</nuxt-link> </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"> <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> <span class="material-icons text-3xl text-white">arrow_back</span>
</a> </a>
<div> <div v-if="socketConnected">
<div class="px-4 py-2 bg-bg bg-opacity-30 rounded-md flex items-center" @click="clickShowLibraryModal"> <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-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" /> <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" /> --> <!-- <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"> <div class="h-7 mx-2">
<span class="flex items-center"> <span class="material-icons" style="font-size: 1.75rem" @click="clickShowSideDrawer">menu</span>
<span class="block truncate">{{ username }}</span> </div>
</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> </div>
</div> </div>
</template> </template>
@ -54,6 +49,9 @@ export default {
} }
}, },
computed: { computed: {
socketConnected() {
return this.$store.state.socketConnected
},
currentLibrary() { currentLibrary() {
return this.$store.getters['libraries/getCurrentLibrary'] return this.$store.getters['libraries/getCurrentLibrary']
}, },
@ -61,7 +59,7 @@ export default {
return this.currentLibrary ? this.currentLibrary.name : 'Main' return this.currentLibrary ? this.currentLibrary.name : 'Main'
}, },
showBack() { showBack() {
return this.$route.name !== 'index' return this.$route.name !== 'index' && !this.$route.name.startsWith('bookshelf')
}, },
user() { user() {
return this.$store.state.user.user return this.$store.state.user.user
@ -81,6 +79,9 @@ export default {
} }
}, },
methods: { methods: {
clickShowSideDrawer() {
this.$store.commit('setShowSideDrawer', true)
},
clickShowLibraryModal() { clickShowLibraryModal() {
this.$store.commit('libraries/setShowModal', true) this.$store.commit('libraries/setShowModal', true)
}, },
@ -88,7 +89,7 @@ export default {
if (this.$route.name === 'audiobook-id-edit') { if (this.$route.name === 'audiobook-id-edit') {
this.$router.push(`/audiobook/${this.$route.params.id}`) this.$router.push(`/audiobook/${this.$route.params.id}`)
} else { } else {
this.$router.push('/') this.$router.push('/bookshelf')
} }
}, },
logout() { 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"> <div class="flex items-center">
<span class="font-normal ml-3 block truncate text-lg">{{ item.text }}</span> <span class="font-normal ml-3 block truncate text-lg">{{ item.text }}</span>
</div> </div>
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4"> <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-4xl">{{ descending ? 'expand_more' : 'expand_less' }}</span> <span class="material-icons text-3xl">{{ descending ? 'south' : 'north' }}</span>
</span> </span>
</li> </li>
</template> </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' : ''"> <div id="content" class="overflow-hidden" :class="playerIsOpen ? 'playerOpen' : ''">
<Nuxt /> <Nuxt />
</div> </div>
<!-- <app-stream-container ref="streamContainer" /> -->
<app-audio-player-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 /> <modals-libraries-modal />
<app-side-drawer />
<readers-reader /> <readers-reader />
</div> </div>
</template> </template>
@ -24,12 +24,24 @@ export default {
data() { data() {
return {} return {}
}, },
watch: {
networkConnected: {
handler(newVal) {
if (newVal) {
this.attemptConnection()
}
}
}
},
computed: { computed: {
playerIsOpen() { playerIsOpen() {
return this.$store.getters['playerIsOpen'] return this.$store.getters['playerIsOpen']
}, },
routeName() { routeName() {
return this.$route.name return this.$route.name
},
networkConnected() {
return this.$store.state.networkConnected
} }
}, },
methods: { methods: {
@ -152,9 +164,6 @@ export default {
var downloadObj = this.$store.getters['downloads/getDownload'](audiobookId) var downloadObj = this.$store.getters['downloads/getDownload'](audiobookId)
if (downloadObj) { if (downloadObj) {
if (this.$refs.downloadsModal) {
this.$refs.downloadsModal.updateDownloadProgress({ audiobookId, progress })
}
this.$toast.update(downloadObj.toastId, { content: `${progress}% Downloading ${downloadObj.audiobook.book.title}` }) this.$toast.update(downloadObj.toastId, { content: `${progress}% Downloading ${downloadObj.audiobook.book.title}` })
} }
}, },
@ -331,20 +340,6 @@ export default {
// this.checkLoadCurrent() // this.checkLoadCurrent()
// this.$store.dispatch('audiobooks/setNativeAudiobooks') // 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() { async initMediaStore() {
// Request and setup listeners for media files on native // Request and setup listeners for media files on native
AudioDownloader.addListener('onDownloadComplete', (data) => { AudioDownloader.addListener('onDownloadComplete', (data) => {
@ -420,9 +415,33 @@ export default {
}, },
showSuccessToast(message) { showSuccessToast(message) {
this.$toast.success(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') if (!this.$server) return console.error('No Server')
// console.log(`Default Mounted set SOCKET listeners ${this.$server.connected}`) // console.log(`Default Mounted set SOCKET listeners ${this.$server.connected}`)
@ -435,28 +454,33 @@ export default {
if (this.$store.state.isFirstLoad) { if (this.$store.state.isFirstLoad) {
this.$store.commit('setIsFirstLoad', false) this.$store.commit('setIsFirstLoad', false)
this.setupNetworkListener() await this.setupNetworkListener()
this.attemptConnection()
this.checkForUpdate() this.checkForUpdate()
this.initMediaStore() this.initMediaStore()
} }
MyNativeAudio.addListener('onPrepareMedia', (data) => { if (!this.$server.connected) {
var audiobookId = data.audiobookId }
var playWhenReady = data.playWhenReady
var audiobook = this.$store.getters['audiobooks/getAudiobook'](audiobookId) // Old bad attempt at AA
// MyNativeAudio.addListener('onPrepareMedia', (data) => {
// var audiobookId = data.audiobookId
// var playWhenReady = data.playWhenReady
var download = this.$store.getters['downloads/getDownloadIfReady'](audiobookId) // var audiobook = this.$store.getters['audiobooks/getAudiobook'](audiobookId)
this.$store.commit('setPlayOnLoad', playWhenReady)
if (!download) { // var download = this.$store.getters['downloads/getDownloadIfReady'](audiobookId)
// Stream // this.$store.commit('setPlayOnLoad', playWhenReady)
this.$store.commit('setStreamAudiobook', audiobook) // if (!download) {
this.$server.socket.emit('open_stream', audiobook.id) // // Stream
} else { // this.$store.commit('setStreamAudiobook', audiobook)
// Local // this.$server.socket.emit('open_stream', audiobook.id)
this.$store.commit('setPlayingDownload', download) // } else {
} // // Local
}) // this.$store.commit('setPlayingDownload', download)
// }
// })
}, },
beforeDestroy() { beforeDestroy() {
if (!this.$server) { if (!this.$server) {

View file

@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-app", "name": "audiobookshelf-app",
"version": "v0.9.19-beta", "version": "v0.9.20-beta",
"author": "advplyr", "author": "advplyr",
"scripts": { "scripts": {
"dev": "nuxt --hostname localhost --port 1337", "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> <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> </div>
</template> </template>
@ -34,11 +34,18 @@
import { AppUpdate } from '@robingenz/capacitor-app-update' import { AppUpdate } from '@robingenz/capacitor-app-update'
export default { export default {
asyncData({ redirect, store }) {
if (!store.state.socketConnected) {
return redirect('/connect')
}
return {}
},
data() { data() {
return {} return {}
}, },
computed: { computed: {
username() { username() {
if (!this.user) return ''
return this.user.username return this.user.username
}, },
user() { 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) { if (this.$route.query && this.$route.query.redirect) {
this.$router.replace(this.$route.query.redirect) this.$router.replace(this.$route.query.redirect)
} else { } else {
this.$router.replace('/') this.$router.replace('/bookshelf')
} }
}, },
socketConnected() { 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> <script>
export default { export default {
asyncData({ redirect }) {
return redirect('/bookshelf')
},
data() { data() {
return { return {
showSortModal: false, showSortModal: false,

Binary file not shown.

View file

@ -30,7 +30,11 @@ export const getters = {
var filter = decode(filterBy.replace(`${group}.`, '')) var filter = decode(filterBy.replace(`${group}.`, ''))
if (group === 'genres') filtered = filtered.filter(ab => ab.book && ab.book.genres.includes(filter)) if (group === 'genres') filtered = filtered.filter(ab => ab.book && ab.book.genres.includes(filter))
else if (group === 'tags') filtered = filtered.filter(ab => ab.tags.includes(filter)) else if (group === 'tags') filtered = filtered.filter(ab => ab.tags.includes(filter))
else if (group === 'series') filtered = filtered.filter(ab => ab.book && ab.book.series === filter) else if (group === 'series') {
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 === 'authors') filtered = filtered.filter(ab => ab.book && ab.book.author === filter)
else if (group === 'progress') { else if (group === 'progress') {
filtered = filtered.filter(ab => { 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) => { getUniqueAuthors: (state) => {
var _authors = state.audiobooks.filter(ab => !!(ab.book && ab.book.author)).map(ab => ab.book.author) var _authors = state.audiobooks.filter(ab => !!(ab.book && ab.book.author)).map(ab => ab.book.author)
return [...new Set(_authors)] return [...new Set(_authors)]

View file

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

View file

@ -12,7 +12,9 @@ export const state = () => ({
bookshelfCoverSize: 120 bookshelfCoverSize: 120
}, },
settingsListeners: [], settingsListeners: [],
userAudiobooksListeners: [] userAudiobooksListeners: [],
collections: [],
collectionsLoaded: false
}) })
export const getters = { export const getters = {
@ -38,6 +40,9 @@ export const getters = {
}, },
getFilterOrderKey: (state) => { getFilterOrderKey: (state) => {
return Object.values(state.settings).join('-') 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') console.log('Update settings without server')
commit('setSettings', payload) 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) { removeUserAudiobookListener(state, listenerId) {
state.userAudiobooksListeners = state.userAudiobooksListeners.filter(l => l.id !== 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)' } 'short': { 'raw': '(max-height: 500px)' }
}, },
colors: { colors: {
// bg: '#1e272e',
bg: '#373838', bg: '#373838',
secondary: '#2F3030',
yellowgreen: 'yellowgreen', yellowgreen: 'yellowgreen',
primary: '#262626', primary: '#262626',
accent: '#1ad691', accent: '#1ad691',