mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-07-27 15:08:40 +02:00
Compare commits
92
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7031c895b5 | ||
|
|
7c739bf3bf | ||
|
|
facf56930e | ||
|
|
2c3dff3544 | ||
|
|
b1bf68b8cd | ||
|
|
78aade7694 | ||
|
|
83a4474375 | ||
|
|
1b47c38efa | ||
|
|
922d9f22ce | ||
|
|
d7feb52dd8 | ||
|
|
fafaad8b5f | ||
|
|
928f33005a | ||
|
|
0448d20583 | ||
|
|
f9d408b33f | ||
|
|
9386ae95a3 | ||
|
|
c530e0f7e3 | ||
|
|
e39a5e1ba3 | ||
|
|
cb9e317e74 | ||
|
|
8061ac0ced | ||
|
|
daff8355d7 | ||
|
|
231289e9ac | ||
|
|
87df022af0 | ||
|
|
07fdac9746 | ||
|
|
0d482aaa42 | ||
|
|
d48d1fdde3 | ||
|
|
65b69cc020 | ||
|
|
a3794a6a6a | ||
|
|
695ab84940 | ||
|
|
5399ec67d7 | ||
|
|
d9a6ec65df | ||
|
|
e842de78dd | ||
|
|
260363bf4f | ||
|
|
6d7dffd4de | ||
|
|
ef1d8a8eb4 | ||
|
|
cdd7a1a636 | ||
|
|
6584cd1522 | ||
|
|
d01b602306 | ||
|
|
c90f2c136a | ||
|
|
15ccc7192e | ||
|
|
e4345a4438 | ||
|
|
3f4d564bad | ||
|
|
3311f66da8 | ||
|
|
e711ada0b3 | ||
|
|
840979d635 | ||
|
|
53c9109534 | ||
|
|
87ca6018d3 | ||
|
|
c590087578 | ||
|
|
3963ffa0e2 | ||
|
|
eaa0c9914a | ||
|
|
d9b0b8c33d | ||
|
|
fb4e7e6b55 | ||
|
|
78a063a669 | ||
|
|
cb5d32bbb9 | ||
|
|
f594b734c0 | ||
|
|
bf62b82037 | ||
|
|
39fbb0902e | ||
|
|
7818500f0f | ||
|
|
1ea984b915 | ||
|
|
f28405081a | ||
|
|
317f00e1b8 | ||
|
|
f8ce0b8355 | ||
|
|
9fd6ff9fd2 | ||
|
|
3931020c83 | ||
|
|
8bdb6b0b66 | ||
|
|
33bdee66a3 | ||
|
|
a81f50878e | ||
|
|
322f7d75dd | ||
|
|
2f34da4e99 | ||
|
|
5a9e30b08c | ||
|
|
04e233816d | ||
|
|
3ad1042b13 | ||
|
|
4fb9201823 | ||
|
|
02089e1489 | ||
|
|
da442f4a17 | ||
|
|
a0f8e6fb93 | ||
|
|
20cf5a6132 | ||
|
|
f5ca30620f | ||
|
|
72ec9d81b1 | ||
|
|
29568412e6 | ||
|
|
f94140ec6b | ||
|
|
51062e7403 | ||
|
|
0942bd2548 | ||
|
|
5939fee66f | ||
|
|
68560fd5f5 | ||
|
|
a38b826898 | ||
|
|
0158ccc4c8 | ||
|
|
a5ba6c64c9 | ||
|
|
d67454db9a | ||
|
|
fb35d8b1a0 | ||
|
|
960f75bfba | ||
|
|
83027a5787 | ||
|
|
bc6ef96465 |
@@ -35,8 +35,8 @@ android {
|
||||
applicationId "com.audiobookshelf.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 93
|
||||
versionName "0.9.62-beta"
|
||||
versionCode 95
|
||||
versionName "0.9.64-beta"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
@@ -74,8 +74,6 @@ configurations.all {
|
||||
force("androidx.media:media:$androidx_media_version")
|
||||
force("org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version")
|
||||
force("org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version")
|
||||
|
||||
// failOnVersionConflict()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,20 +21,21 @@
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
android:usesCleartextTraffic="true" >
|
||||
android:usesCleartextTraffic="true"
|
||||
android:largeHeap="true" >
|
||||
|
||||
<!-- TODO: Incomplete desktop widget -->
|
||||
<!-- <receiver-->
|
||||
<!-- android:name=".NewAppWidget"-->
|
||||
<!-- android:exported="true" >-->
|
||||
<!-- <intent-filter>-->
|
||||
<!-- <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />-->
|
||||
<!-- </intent-filter>-->
|
||||
<!-- Android app widget -->
|
||||
<receiver
|
||||
android:name="MediaPlayerWidget"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- <meta-data-->
|
||||
<!-- android:name="android.appwidget.provider"-->
|
||||
<!-- android:resource="@xml/new_app_widget_info" />-->
|
||||
<!-- </receiver>-->
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/media_player_widget_info" />
|
||||
</receiver>
|
||||
|
||||
<!-- Used by Android Auto -->
|
||||
<meta-data
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.appwidget.AppWidgetProvider
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.widget.RemoteViews
|
||||
import androidx.media.session.MediaButtonReceiver
|
||||
import com.audiobookshelf.app.data.PlaybackSession
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.device.WidgetEventEmitter
|
||||
import com.audiobookshelf.app.managers.DbManager
|
||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import com.bumptech.glide.request.target.AppWidgetTarget
|
||||
import com.bumptech.glide.request.transition.Transition
|
||||
|
||||
/**
|
||||
* Implementation of App Widget functionality.
|
||||
*/
|
||||
class MediaPlayerWidget : AppWidgetProvider() {
|
||||
val tag = "MediaPlayerWidget"
|
||||
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
|
||||
// There may be multiple widgets active, so update all of them
|
||||
for (appWidgetId in appWidgetIds) {
|
||||
updateAppWidget(context, appWidgetManager, appWidgetId, null, false, PlayerNotificationService.isClosed)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEnabled(context: Context) {
|
||||
Log.i(tag, "onEnabled check context ${context.packageName}")
|
||||
|
||||
DbManager.initialize(context)
|
||||
|
||||
DeviceManager.deviceData.lastPlaybackSession?.let {
|
||||
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
|
||||
val ids = appWidgetManager.getAppWidgetIds(componentName)
|
||||
Log.d(tag, "Setting initial widget state with last playback session ${it.displayTitle}")
|
||||
for (widgetId in ids) {
|
||||
updateAppWidget(context, appWidgetManager, widgetId, it, false, true)
|
||||
}
|
||||
}
|
||||
|
||||
// Enter relevant functionality for when the first widget is created
|
||||
DeviceManager.widgetUpdater = (object : WidgetEventEmitter {
|
||||
override fun onPlayerChanged(pns: PlayerNotificationService) {
|
||||
val isPlaying = pns.currentPlayer.isPlaying
|
||||
|
||||
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
|
||||
val ids = appWidgetManager.getAppWidgetIds(componentName)
|
||||
|
||||
val playbackSession = pns.getCurrentPlaybackSessionCopy()
|
||||
|
||||
for (widgetId in ids) {
|
||||
updateAppWidget(context, appWidgetManager, widgetId, playbackSession, isPlaying, PlayerNotificationService.isClosed)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun onDisabled(context: Context) {
|
||||
// Enter relevant functionality for when the last widget is disabled
|
||||
}
|
||||
}
|
||||
|
||||
internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, playbackSession: PlaybackSession?, isPlaying:Boolean, isAppClosed:Boolean) {
|
||||
val tag = "MediaPlayerWidget"
|
||||
val views = RemoteViews(context.packageName, R.layout.media_player_widget)
|
||||
|
||||
val wholeWidgetClickI = Intent(context, MainActivity::class.java)
|
||||
wholeWidgetClickI.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
val wholeWidgetClickPI = PendingIntent.getActivity(
|
||||
context,
|
||||
System.currentTimeMillis().toInt(),
|
||||
wholeWidgetClickI,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val playPausePI = MediaButtonReceiver.buildMediaButtonPendingIntent(context, PlaybackStateCompat.ACTION_PLAY_PAUSE)
|
||||
views.setOnClickPendingIntent(R.id.widgetPlayPauseButton, playPausePI)
|
||||
|
||||
val fastForwardPI = MediaButtonReceiver.buildMediaButtonPendingIntent(context, PlaybackStateCompat.ACTION_FAST_FORWARD)
|
||||
views.setOnClickPendingIntent(R.id.widgetFastForwardButton, fastForwardPI)
|
||||
|
||||
val rewindPI = MediaButtonReceiver.buildMediaButtonPendingIntent(context, PlaybackStateCompat.ACTION_REWIND)
|
||||
views.setOnClickPendingIntent(R.id.widgetRewindButton, rewindPI)
|
||||
|
||||
// Show/Hide button container
|
||||
views.setViewVisibility(R.id.widgetButtonContainer, if (isAppClosed) View.GONE else View.VISIBLE)
|
||||
|
||||
views.setOnClickPendingIntent(R.id.widgetBackground, wholeWidgetClickPI)
|
||||
|
||||
val imageUri = playbackSession?.getCoverUri() ?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||
val awt: AppWidgetTarget = object : AppWidgetTarget(context.applicationContext, R.id.widgetAlbumArt, views, appWidgetId) {
|
||||
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||
super.onResourceReady(resource, transition)
|
||||
}
|
||||
}
|
||||
|
||||
val artist = playbackSession?.displayAuthor ?: "Unknown"
|
||||
views.setTextViewText(R.id.widgetArtistText, artist)
|
||||
|
||||
val title = playbackSession?.displayTitle ?: "Unknown"
|
||||
views.setTextViewText(R.id.widgetMediaTitle, title)
|
||||
|
||||
val options = RequestOptions().override(300, 300).placeholder(R.drawable.icon).error(R.drawable.icon)
|
||||
Glide.with(context.applicationContext).asBitmap().load(imageUri).apply(options).into(awt)
|
||||
|
||||
Log.i(tag, "Update App Widget | Is Playing=$isPlaying | isAppClosed=$isAppClosed")
|
||||
|
||||
val playPauseResource = if (isPlaying) R.drawable.ic_media_pause_dark else R.drawable.ic_media_play_dark
|
||||
views.setImageViewResource(R.id.widgetPlayPauseButton, playPauseResource)
|
||||
|
||||
// Instruct the widget manager to update the widget
|
||||
appWidgetManager.updateAppWidget(appWidgetId, views)
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.appwidget.AppWidgetProvider
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.support.v4.media.session.PlaybackStateCompat.ACTION_PLAY_PAUSE
|
||||
import android.util.Log
|
||||
import android.widget.RemoteViews
|
||||
import androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.device.WidgetEventEmitter
|
||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import com.bumptech.glide.request.target.AppWidgetTarget
|
||||
import com.bumptech.glide.request.transition.Transition
|
||||
|
||||
/**
|
||||
* Implementation of App Widget functionality.
|
||||
*/
|
||||
class NewAppWidget : AppWidgetProvider() {
|
||||
val tag = "NewAppWidget"
|
||||
|
||||
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
|
||||
// There may be multiple widgets active, so update all of them
|
||||
for (appWidgetId in appWidgetIds) {
|
||||
updateAppWidget(context, appWidgetManager, appWidgetId, null,false)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEnabled(context: Context) {
|
||||
Log.w(tag, "onEnabled check context ${context.packageName}")
|
||||
|
||||
// Enter relevant functionality for when the first widget is created
|
||||
DeviceManager.widgetUpdater = (object : WidgetEventEmitter {
|
||||
override fun onPlayerChanged(pns:PlayerNotificationService) {
|
||||
val isPlaying = pns.currentPlayer.isPlaying
|
||||
Log.i(tag, "onPlayerChanged | Is Playing? $isPlaying")
|
||||
|
||||
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||
val componentName = ComponentName(context, NewAppWidget::class.java)
|
||||
val ids = appWidgetManager.getAppWidgetIds(componentName)
|
||||
|
||||
val playbackSession = pns.getCurrentPlaybackSessionCopy()
|
||||
val cover = playbackSession?.getCoverUri()
|
||||
|
||||
for (widgetId in ids) {
|
||||
updateAppWidget(context, appWidgetManager, widgetId, cover, isPlaying)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun onDisabled(context: Context) {
|
||||
// Enter relevant functionality for when the last widget is disabled
|
||||
}
|
||||
}
|
||||
|
||||
internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, coverUri:Uri?, isPlaying:Boolean) {
|
||||
|
||||
val views = RemoteViews(context.packageName, R.layout.new_app_widget)
|
||||
|
||||
val playPausePI = buildMediaButtonPendingIntent(context, ACTION_PLAY_PAUSE)
|
||||
views.setOnClickPendingIntent(R.id.playPauseIcon, playPausePI)
|
||||
|
||||
val wholeWidgetClickI = Intent(context, MainActivity::class.java)
|
||||
wholeWidgetClickI.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
val wholeWidgetClickPI = PendingIntent.getActivity(
|
||||
context,
|
||||
System.currentTimeMillis().toInt(),
|
||||
wholeWidgetClickI,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
views.setOnClickPendingIntent(R.id.appWidget, wholeWidgetClickPI)
|
||||
|
||||
val imageUri = coverUri ?: Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
|
||||
val awt: AppWidgetTarget = object : AppWidgetTarget(context.applicationContext, R.id.imageView, views, appWidgetId) {
|
||||
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||
super.onResourceReady(resource, transition)
|
||||
}
|
||||
}
|
||||
|
||||
val options = RequestOptions().override(300, 300).placeholder(R.drawable.icon).error(R.drawable.icon)
|
||||
Glide.with(context.applicationContext).asBitmap().load(imageUri).apply(options).into(awt)
|
||||
|
||||
val playPauseResource = if (isPlaying) R.drawable.ic_media_pause_dark else R.drawable.ic_media_play_dark
|
||||
views.setImageViewResource(R.id.playPauseIcon, playPauseResource)
|
||||
|
||||
// Instruct the widget manager to update the widget
|
||||
appWidgetManager.updateAppWidget(appWidgetId, views)
|
||||
}
|
||||
@@ -126,6 +126,7 @@ class Book(
|
||||
var audioFiles:List<AudioFile>?,
|
||||
var chapters:List<BookChapter>?,
|
||||
var tracks:MutableList<AudioTrack>?,
|
||||
var ebookFile: EBookFile?,
|
||||
var size:Long?,
|
||||
var duration:Double?,
|
||||
var numTracks:Int?
|
||||
@@ -179,7 +180,7 @@ class Book(
|
||||
}
|
||||
@JsonIgnore
|
||||
override fun getLocalCopy(): Book {
|
||||
return Book(metadata as BookMetadata,coverPath,tags, mutableListOf(),chapters,mutableListOf(),null,null, 0)
|
||||
return Book(metadata as BookMetadata,coverPath,tags, mutableListOf(),chapters,mutableListOf(), ebookFile, null,null, 0)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,20 @@ data class LocalFile(
|
||||
if (mimeType == "video/mp4") return true
|
||||
return mimeType?.startsWith("audio") == true
|
||||
}
|
||||
@JsonIgnore
|
||||
fun isEBookFile():Boolean {
|
||||
return getEBookFormat() != null
|
||||
}
|
||||
@JsonIgnore
|
||||
fun getEBookFormat():String? {
|
||||
if (mimeType == "application/epub+zip") return "epub"
|
||||
if (mimeType == "application/pdf") return "pdf"
|
||||
if (mimeType == "application/x-mobipocket-ebook") return "mobi"
|
||||
if (mimeType == "application/vnd.comicbook+zip") return "cbz"
|
||||
if (mimeType == "application/vnd.comicbook-rar") return "cbr"
|
||||
if (mimeType == "application/vnd.amazon.mobi8-ebook") return "azw3"
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@@ -74,6 +88,7 @@ open class LibraryItemWrapper(var id:String) {
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class DeviceInfo(
|
||||
var deviceId:String,
|
||||
var manufacturer:String,
|
||||
var model:String,
|
||||
var brand:String,
|
||||
@@ -102,6 +117,8 @@ data class DeviceSettings(
|
||||
var autoSleepTimer: Boolean,
|
||||
var autoSleepTimerStartTime: String,
|
||||
var autoSleepTimerEndTime: String,
|
||||
var autoSleepTimerAutoRewind: Boolean,
|
||||
var autoSleepTimerAutoRewindTime: Long, //Time in milliseconds
|
||||
var sleepTimerLength: Long, // Time in milliseconds
|
||||
var disableSleepTimerFadeOut: Boolean,
|
||||
var disableSleepTimerResetFeedback: Boolean
|
||||
@@ -122,6 +139,8 @@ data class DeviceSettings(
|
||||
autoSleepTimerStartTime = "22:00",
|
||||
autoSleepTimerEndTime = "06:00",
|
||||
sleepTimerLength = 900000L, // 15 minutes
|
||||
autoSleepTimerAutoRewind = false,
|
||||
autoSleepTimerAutoRewindTime = 300000L, // 5 minutes
|
||||
disableSleepTimerFadeOut = false,
|
||||
disableSleepTimerResetFeedback = false
|
||||
)
|
||||
@@ -141,6 +160,7 @@ data class DeviceSettings(
|
||||
@get:JsonIgnore
|
||||
val autoSleepTimerEndMinute get() = autoSleepTimerEndTime.split(":")[1].toInt()
|
||||
|
||||
|
||||
@JsonIgnore
|
||||
fun getShakeThresholdGravity() : Float { // Used in ShakeDetector
|
||||
return if (shakeSensitivity == ShakeSensitivitySetting.VERY_HIGH) 1.2f
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class EBookFile(
|
||||
var ino:String,
|
||||
var metadata:FileMetadata?,
|
||||
var ebookFormat:String,
|
||||
var isLocal:Boolean,
|
||||
var localFileId:String?,
|
||||
var contentUrl:String?
|
||||
)
|
||||
@@ -5,6 +5,7 @@ import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import androidx.media.utils.MediaConstants
|
||||
import com.audiobookshelf.app.BuildConfig
|
||||
import com.audiobookshelf.app.R
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
@@ -41,7 +42,7 @@ class LibraryItem(
|
||||
@JsonIgnore
|
||||
fun getCoverUri(): Uri {
|
||||
if (media.coverPath == null) {
|
||||
return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
|
||||
return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||
}
|
||||
|
||||
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}")
|
||||
|
||||
@@ -10,6 +10,7 @@ import android.provider.MediaStore
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.util.Log
|
||||
import androidx.media.utils.MediaConstants
|
||||
import com.audiobookshelf.app.BuildConfig
|
||||
import com.audiobookshelf.app.R
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
@@ -46,7 +47,7 @@ class LocalLibraryItem(
|
||||
|
||||
@JsonIgnore
|
||||
fun getCoverUri(): Uri {
|
||||
return if (coverContentUrl != null) Uri.parse(coverContentUrl) else Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
|
||||
return if (coverContentUrl != null) Uri.parse(coverContentUrl) else Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
@@ -82,18 +83,21 @@ class LocalLibraryItem(
|
||||
val mediaProgress = DeviceManager.dbManager.getLocalMediaProgress(mediaProgressId)
|
||||
val currentTime = mediaProgress?.currentTime ?: 0.0
|
||||
|
||||
// TODO: Clean up add mediaType methods for displayTitle and displayAuthor
|
||||
|
||||
val mediaMetadata = media.metadata
|
||||
val chapters = if (mediaType == "book") (media as Book).chapters else mutableListOf()
|
||||
var audioTracks = media.getAudioTracks() as MutableList<AudioTrack>
|
||||
val authorName = mediaMetadata.getAuthorDisplayName()
|
||||
val displayTitle = episode?.title ?: mediaMetadata.title
|
||||
var duration = getDuration()
|
||||
if (episode != null) { // Get podcast episode audio track
|
||||
episode.audioTrack?.let { at -> mutableListOf(at) }?.let { tracks -> audioTracks = tracks }
|
||||
duration = episode.audioTrack?.duration ?: 0.0
|
||||
Log.d("LocalLibraryItem", "getPlaybackSession: Got podcast episode audio track ${audioTracks.size}")
|
||||
}
|
||||
|
||||
val dateNow = System.currentTimeMillis()
|
||||
return PlaybackSession(sessionId,serverUserId,libraryItemId,episode?.serverEpisodeId, mediaType, mediaMetadata, chapters ?: mutableListOf(), mediaMetadata.title, authorName,null,getDuration(),PLAYMETHOD_LOCAL,dateNow,0L,0L, audioTracks,currentTime,null,this,localEpisodeId,serverConnectionConfigId, serverAddress, "exo-player")
|
||||
return PlaybackSession(sessionId,serverUserId,libraryItemId,episode?.serverEpisodeId, mediaType, mediaMetadata, chapters ?: mutableListOf(), displayTitle, authorName,null,duration,PLAYMETHOD_LOCAL,dateNow,0L,0L, audioTracks,currentTime,null,this,localEpisodeId,serverConnectionConfigId, serverAddress, "exo-player")
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
|
||||
@@ -18,6 +18,7 @@ data class LocalMediaItem(
|
||||
var basePath:String,
|
||||
var absolutePath:String,
|
||||
var audioTracks:MutableList<AudioTrack>,
|
||||
var ebookFile:EBookFile?,
|
||||
var localFiles:MutableList<LocalFile>,
|
||||
var coverContentUrl:String?,
|
||||
var coverAbsolutePath:String?
|
||||
@@ -61,7 +62,7 @@ data class LocalMediaItem(
|
||||
val mediaMetadata = getMediaMetadata()
|
||||
if (mediaType == "book") {
|
||||
val chapters = getAudiobookChapters()
|
||||
val book = Book(mediaMetadata as BookMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), chapters,audioTracks,getTotalSize(),getDuration(),audioTracks.size)
|
||||
val book = Book(mediaMetadata as BookMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), chapters,audioTracks,ebookFile,getTotalSize(),getDuration(),audioTracks.size)
|
||||
return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false,mediaType, book, localFiles, coverContentUrl, coverAbsolutePath,true,null,null,null,null)
|
||||
} else {
|
||||
val podcast = Podcast(mediaMetadata as PodcastMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), false, 0)
|
||||
|
||||
@@ -13,6 +13,8 @@ class LocalMediaProgress(
|
||||
progress:Double, // 0 to 1
|
||||
currentTime:Double,
|
||||
isFinished:Boolean,
|
||||
var ebookLocation:String?, // cfi tag
|
||||
var ebookProgress:Double?, // 0 to 1
|
||||
var lastUpdate:Long,
|
||||
var startedAt:Long,
|
||||
var finishedAt:Long?,
|
||||
@@ -58,11 +60,20 @@ class LocalMediaProgress(
|
||||
finishedAt = if (isFinished) lastUpdate else null
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun updateEbookProgress(ebookLocation:String, ebookProgress:Double) {
|
||||
lastUpdate = System.currentTimeMillis()
|
||||
this.ebookProgress = ebookProgress
|
||||
this.ebookLocation = ebookLocation
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun updateFromServerMediaProgress(serverMediaProgress:MediaProgress) {
|
||||
isFinished = serverMediaProgress.isFinished
|
||||
progress = serverMediaProgress.progress
|
||||
currentTime = serverMediaProgress.currentTime
|
||||
ebookProgress = serverMediaProgress.ebookProgress
|
||||
ebookLocation = serverMediaProgress.ebookLocation
|
||||
duration = serverMediaProgress.duration
|
||||
lastUpdate = serverMediaProgress.lastUpdate
|
||||
finishedAt = serverMediaProgress.finishedAt
|
||||
|
||||
@@ -12,6 +12,8 @@ class MediaProgress(
|
||||
progress:Double, // 0 to 1
|
||||
currentTime:Double,
|
||||
isFinished:Boolean,
|
||||
var ebookLocation:String?, // cfi tag
|
||||
var ebookProgress:Double?, // 0 to 1
|
||||
var lastUpdate:Long,
|
||||
var startedAt:Long,
|
||||
var finishedAt:Long?
|
||||
|
||||
@@ -6,6 +6,8 @@ import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import com.audiobookshelf.app.BuildConfig
|
||||
import com.audiobookshelf.app.R
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.media.MediaProgressSyncData
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
@@ -136,9 +138,9 @@ class PlaybackSession(
|
||||
|
||||
@JsonIgnore
|
||||
fun getCoverUri(): Uri {
|
||||
if (localLibraryItem?.coverContentUrl != null) return Uri.parse(localLibraryItem?.coverContentUrl) ?: Uri.parse("android.resource://com.audiobookshelf.app/" + com.audiobookshelf.app.R.drawable.icon)
|
||||
if (localLibraryItem?.coverContentUrl != null) return Uri.parse(localLibraryItem?.coverContentUrl) ?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||
|
||||
if (coverPath == null) return Uri.parse("android.resource://com.audiobookshelf.app/" + com.audiobookshelf.app.R.drawable.icon)
|
||||
if (coverPath == null) return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||
return Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")
|
||||
}
|
||||
|
||||
@@ -262,6 +264,6 @@ class PlaybackSession(
|
||||
|
||||
@JsonIgnore
|
||||
fun getNewLocalMediaProgress():LocalMediaProgress {
|
||||
return LocalMediaProgress(localMediaProgressId,localLibraryItemId,localEpisodeId,getTotalDuration(),progress,currentTime,false,updatedAt,startedAt,null,serverConnectionConfigId,serverAddress,userId,libraryItemId,episodeId)
|
||||
return LocalMediaProgress(localMediaProgressId,localLibraryItemId,localEpisodeId,getTotalDuration(),progress,currentTime,false,null,null,updatedAt,startedAt,null,serverConnectionConfigId,serverAddress,userId,libraryItemId,episodeId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,10 @@ object DeviceManager {
|
||||
if (deviceData.deviceSettings?.shakeSensitivity == null) {
|
||||
deviceData.deviceSettings?.shakeSensitivity = ShakeSensitivitySetting.MEDIUM
|
||||
}
|
||||
// Initialize auto sleep timer auto rewind added in v0.9.64
|
||||
if (deviceData.deviceSettings?.autoSleepTimerAutoRewindTime == null) {
|
||||
deviceData.deviceSettings?.autoSleepTimerAutoRewindTime = 300000L // 5 minutes
|
||||
}
|
||||
}
|
||||
|
||||
fun getBase64Id(id:String):String {
|
||||
|
||||
@@ -72,7 +72,7 @@ class FolderScanner(var ctx: Context) {
|
||||
Log.d(tag, "Iterating over Folder Found ${itemFolder.name} | ${itemFolder.getSimplePath(ctx)} | URI: ${itemFolder.uri}")
|
||||
val existingItem = existingLocalLibraryItems.find { emi -> emi.id == getLocalLibraryItemId(itemFolder.id) }
|
||||
|
||||
val filesInFolder = itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream"))
|
||||
val filesInFolder = itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/*"))
|
||||
|
||||
// Do not scan folders that have no media items and not an existing item already
|
||||
if (existingItem != null || filesInFolder.isNotEmpty()) {
|
||||
@@ -110,6 +110,8 @@ class FolderScanner(var ctx: Context) {
|
||||
var startOffset = 0.0
|
||||
var coverContentUrl:String? = null
|
||||
var coverAbsolutePath:String? = null
|
||||
var hasEBookFile = false
|
||||
var newEBookFile:EBookFile? = null
|
||||
|
||||
val existingLocalFilesRemoved = existingLocalFiles.filter { elf ->
|
||||
filesInFolder.find { fif -> DeviceManager.getBase64Id(fif.id) == elf.id } == null // File was not found in media item folder
|
||||
@@ -122,7 +124,6 @@ class FolderScanner(var ctx: Context) {
|
||||
filesInFolder.forEach { file ->
|
||||
val mimeType = file.mimeType ?: ""
|
||||
val filename = file.name ?: ""
|
||||
val isAudio = mimeType.startsWith("audio") || mimeType == "video/mp4"
|
||||
Log.d(tag, "Found $mimeType file $filename in folder $itemFolderName")
|
||||
|
||||
val localFileId = DeviceManager.getBase64Id(file.id)
|
||||
@@ -132,7 +133,7 @@ class FolderScanner(var ctx: Context) {
|
||||
|
||||
Log.d(tag, "File attributes Id:${localFileId}|ContentUrl:${localFile.contentUrl}|isDownloadsDocument:${file.isDownloadsDocument}")
|
||||
|
||||
if (isAudio) {
|
||||
if (localFile.isAudioFile()) {
|
||||
val audioTrackToAdd:AudioTrack?
|
||||
|
||||
val existingAudioTrack = existingAudioTracks.find { eat -> eat.localFileId == localFileId }
|
||||
@@ -174,6 +175,15 @@ class FolderScanner(var ctx: Context) {
|
||||
startOffset += audioTrackToAdd.duration
|
||||
index++
|
||||
audioTracks.add(audioTrackToAdd)
|
||||
} else if (localFile.isEBookFile()) {
|
||||
val existingLocalFile = existingLocalFiles.find { elf -> elf.id == localFileId }
|
||||
|
||||
if (localFolder.mediaType == "book") {
|
||||
hasEBookFile = true
|
||||
if (existingLocalFile == null) {
|
||||
newEBookFile = EBookFile(localFileId, null, localFile.getEBookFormat() ?: "", true, localFileId, localFile.contentUrl)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val existingLocalFile = existingLocalFiles.find { elf -> elf.id == localFileId }
|
||||
|
||||
@@ -198,7 +208,7 @@ class FolderScanner(var ctx: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
if (existingItem != null && audioTracks.isEmpty()) {
|
||||
if (existingItem != null && audioTracks.isEmpty() && !hasEBookFile) {
|
||||
Log.d(tag, "Local library item ${existingItem.media.metadata.title} no longer has audio tracks - removing item")
|
||||
DeviceManager.dbManager.removeLocalLibraryItem(existingItem.id)
|
||||
return ItemScanResult.REMOVED
|
||||
@@ -210,9 +220,9 @@ class FolderScanner(var ctx: Context) {
|
||||
existingItem.updateFromScan(audioTracks,localFiles)
|
||||
DeviceManager.dbManager.saveLocalLibraryItem(existingItem)
|
||||
return ItemScanResult.UPDATED
|
||||
} else if (audioTracks.isNotEmpty()) {
|
||||
} else if (audioTracks.isNotEmpty() || newEBookFile != null) {
|
||||
Log.d(tag, "Found local media item named $itemFolderName with ${audioTracks.size} tracks and ${localFiles.size} local files")
|
||||
val localMediaItem = LocalMediaItem(itemId, itemFolderName, localFolder.mediaType, localFolder.id, itemFolder.uri.toString(), itemFolder.getSimplePath(ctx), itemFolder.getBasePath(ctx), itemFolder.getAbsolutePath(ctx),audioTracks,localFiles,coverContentUrl,coverAbsolutePath)
|
||||
val localMediaItem = LocalMediaItem(itemId, itemFolderName, localFolder.mediaType, localFolder.id, itemFolder.uri.toString(), itemFolder.getSimplePath(ctx), itemFolder.getBasePath(ctx), itemFolder.getAbsolutePath(ctx),audioTracks,newEBookFile,localFiles,coverContentUrl,coverAbsolutePath)
|
||||
val localLibraryItem = localMediaItem.getLocalLibraryItem()
|
||||
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
||||
return ItemScanResult.ADDED
|
||||
@@ -257,7 +267,7 @@ class FolderScanner(var ctx: Context) {
|
||||
|
||||
// Search for files in media item folder
|
||||
// m4b files showing as mimeType application/octet-stream on Android 10 and earlier see #154
|
||||
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream"))
|
||||
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/*"))
|
||||
Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}")
|
||||
|
||||
var localEpisodeId:String? = null
|
||||
@@ -274,6 +284,7 @@ class FolderScanner(var ctx: Context) {
|
||||
}
|
||||
|
||||
val audioTracks:MutableList<AudioTrack> = mutableListOf()
|
||||
var foundEBookFile = false
|
||||
|
||||
filesFound.forEach { docFile ->
|
||||
val itemPart = downloadItem.downloadItemParts.find { itemPart ->
|
||||
@@ -304,6 +315,16 @@ class FolderScanner(var ctx: Context) {
|
||||
localEpisodeId = newEpisode.id
|
||||
Log.d(tag, "scanDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}")
|
||||
}
|
||||
} else if (itemPart.ebookFile != null) { // Ebook
|
||||
foundEBookFile = true
|
||||
Log.d(tag, "scanDownloadItem: Ebook file found with mimetype=${docFile.mimeType}")
|
||||
val localFileId = DeviceManager.getBase64Id(docFile.id)
|
||||
val localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
|
||||
val ebookFile = EBookFile(itemPart.ebookFile.ino, itemPart.ebookFile.metadata, itemPart.ebookFile.ebookFormat, true, localFileId, localFile.contentUrl)
|
||||
(localLibraryItem.media as Book).ebookFile = ebookFile
|
||||
Log.d(tag, "scanDownloadItem: Ebook file added to lli ${localFile.contentUrl}")
|
||||
} else { // Cover image
|
||||
val localFileId = DeviceManager.getBase64Id(docFile.id)
|
||||
val localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
|
||||
@@ -314,8 +335,8 @@ class FolderScanner(var ctx: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
if (audioTracks.isEmpty()) {
|
||||
Log.d(tag, "scanDownloadItem did not find any audio tracks in folder for ${downloadItem.itemFolderPath}")
|
||||
if (audioTracks.isEmpty() && !foundEBookFile) {
|
||||
Log.d(tag, "scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}")
|
||||
return cb(null)
|
||||
}
|
||||
|
||||
@@ -350,6 +371,8 @@ class FolderScanner(var ctx: Context) {
|
||||
progress = mediaProgress.progress,
|
||||
currentTime = mediaProgress.currentTime,
|
||||
isFinished = false,
|
||||
ebookLocation = mediaProgress.ebookLocation,
|
||||
ebookProgress = mediaProgress.ebookProgress,
|
||||
lastUpdate = mediaProgress.lastUpdate,
|
||||
startedAt = mediaProgress.startedAt,
|
||||
finishedAt = mediaProgress.finishedAt,
|
||||
@@ -381,7 +404,7 @@ class FolderScanner(var ctx: Context) {
|
||||
var wasUpdated = false
|
||||
|
||||
// Search for files in media item folder
|
||||
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream"))
|
||||
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/*"))
|
||||
Log.d(tag, "scanLocalLibraryItem ${filesFound.size} files found in ${localLibraryItem.absolutePath}")
|
||||
|
||||
filesFound.forEach {
|
||||
@@ -427,7 +450,7 @@ class FolderScanner(var ctx: Context) {
|
||||
val audioProbeResult = probeAudioFile(localFile.absolutePath)
|
||||
|
||||
val existingTrack = existingAudioTracks.find { audioTrack ->
|
||||
audioTrack.localFileId == localFile.id
|
||||
audioTrack.localFileId == localFileId
|
||||
}
|
||||
|
||||
if (existingTrack == null) {
|
||||
@@ -446,6 +469,16 @@ class FolderScanner(var ctx: Context) {
|
||||
|
||||
wasUpdated = true
|
||||
}
|
||||
} else if (localFile.isEBookFile()) {
|
||||
if (localLibraryItem.mediaType == "book") {
|
||||
val existingEbookFile = (localLibraryItem.media as Book).ebookFile
|
||||
if (existingEbookFile == null || existingEbookFile.localFileId != localFileId) {
|
||||
val ebookFile = EBookFile(localFileId, null, localFile.getEBookFormat() ?: "", true, localFileId, localFile.contentUrl)
|
||||
(localLibraryItem.media as Book).ebookFile = ebookFile
|
||||
Log.d(tag, "scanLocalLibraryItem: Ebook file added to lli ${localFile.contentUrl}")
|
||||
wasUpdated = true
|
||||
}
|
||||
}
|
||||
} else { // Check if cover is empty
|
||||
if (localLibraryItem.coverContentUrl == null) {
|
||||
Log.d(tag, "scanLocalLibraryItem setting cover for ${localLibraryItem.media.metadata.title}")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.audiobookshelf.app.managers
|
||||
|
||||
import android.content.Context
|
||||
import android.media.metrics.PlaybackSession
|
||||
import android.os.*
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
@@ -21,6 +22,8 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
private var sleepTimerElapsed:Long = 0L
|
||||
private var sleepTimerFinishedAt:Long = 0L
|
||||
private var isAutoSleepTimer:Boolean = false // When timer was auto-set
|
||||
private var isFirstAutoSleepTimer: Boolean = true
|
||||
private var sleepTimerSessionId:String = ""
|
||||
|
||||
private fun getCurrentTime():Long {
|
||||
return playerNotificationService.getCurrentTime()
|
||||
@@ -61,6 +64,7 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
sleepTimerRunning = true
|
||||
sleepTimerFinishedAt = 0L
|
||||
sleepTimerElapsed = 0L
|
||||
setVolume(1f)
|
||||
|
||||
// Register shake sensor
|
||||
playerNotificationService.registerSensor()
|
||||
@@ -80,10 +84,6 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
} else {
|
||||
sleepTimerLength = time
|
||||
sleepTimerEndTime = 0L
|
||||
|
||||
if (sleepTimerLength + getCurrentTime() > getDuration()) {
|
||||
sleepTimerLength = getDuration() - getCurrentTime()
|
||||
}
|
||||
}
|
||||
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds(), isAutoSleepTimer)
|
||||
@@ -107,17 +107,14 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerEnded(getCurrentTime())
|
||||
clearSleepTimer()
|
||||
sleepTimerFinishedAt = System.currentTimeMillis()
|
||||
} else if (sleepTimeSecondsRemaining <= 60) {
|
||||
if (DeviceManager.deviceData.deviceSettings?.disableSleepTimerFadeOut == true) {
|
||||
// Set volume to 1 in case setting was enabled while fading
|
||||
setVolume(1f)
|
||||
} else {
|
||||
// Start fading out audio down to 10% volume
|
||||
val percentToReduce = 1 - (sleepTimeSecondsRemaining / 60F)
|
||||
val volume = 1f - (percentToReduce * 0.9f)
|
||||
Log.d(tag, "SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining")
|
||||
setVolume(volume)
|
||||
}
|
||||
} else if (sleepTimeSecondsRemaining <= 60 && DeviceManager.deviceData.deviceSettings?.disableSleepTimerFadeOut != true) {
|
||||
// Start fading out audio down to 10% volume
|
||||
val percentToReduce = 1 - (sleepTimeSecondsRemaining / 60F)
|
||||
val volume = 1f - (percentToReduce * 0.9f)
|
||||
Log.d(tag, "SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining")
|
||||
setVolume(volume)
|
||||
} else {
|
||||
setVolume(1f)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,7 +122,8 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
return true
|
||||
}
|
||||
|
||||
fun setManualSleepTimer(time: Long, isChapterTime:Boolean):Boolean {
|
||||
fun setManualSleepTimer(playbackSessionId:String, time: Long, isChapterTime:Boolean):Boolean {
|
||||
sleepTimerSessionId = playbackSessionId
|
||||
isAutoSleepTimer = false
|
||||
return setSleepTimer(time, isChapterTime)
|
||||
}
|
||||
@@ -135,8 +133,9 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
sleepTimerTask = null
|
||||
sleepTimerEndTime = 0
|
||||
sleepTimerRunning = false
|
||||
isAutoSleepTimer = false
|
||||
playerNotificationService.unregisterSensor()
|
||||
|
||||
setVolume(1f)
|
||||
}
|
||||
|
||||
fun getSleepTimerTime():Long {
|
||||
@@ -227,6 +226,17 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
return
|
||||
}
|
||||
|
||||
// Automatically Rewind in the book if settings is enabled
|
||||
if (isAutoSleepTimer) {
|
||||
DeviceManager.deviceData.deviceSettings?.let { deviceSettings ->
|
||||
if (deviceSettings.autoSleepTimerAutoRewind && !isFirstAutoSleepTimer) {
|
||||
Log.i(tag, "Auto sleep timer auto rewind seeking back ${deviceSettings.autoSleepTimerAutoRewindTime}ms")
|
||||
playerNotificationService.seekBackward(deviceSettings.autoSleepTimerAutoRewindTime)
|
||||
}
|
||||
isFirstAutoSleepTimer = false
|
||||
}
|
||||
}
|
||||
|
||||
// Set sleep timer
|
||||
// When sleepTimerLength is 0 then use end of chapter/track time
|
||||
if (sleepTimerLength == 0L) {
|
||||
@@ -325,18 +335,31 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
endCalendar.set(Calendar.HOUR_OF_DAY, deviceSettings.autoSleepTimerEndHour)
|
||||
endCalendar.set(Calendar.MINUTE, deviceSettings.autoSleepTimerEndMinute)
|
||||
|
||||
// In cases where end time is earlier then start time then we add a day to end time
|
||||
// e.g. start time 22:00 and end time 06:00. End time will be treated as 6am the next day.
|
||||
// e.g. start time 08:00 and end time 22:00. Start and end time will be the same day.
|
||||
val currentCalendar = Calendar.getInstance()
|
||||
|
||||
// In cases where end time is before start time then we shift the time window forward or backward based on the current time.
|
||||
// e.g. start time 22:00 and end time 06:00.
|
||||
// If current time is less than start time (e.g. 00:30) then start time will be the previous day.
|
||||
// If current time is greater than start time (e.g. 23:00) then end time will be the next day.
|
||||
if (endCalendar.before(startCalendar)) {
|
||||
endCalendar.add(Calendar.DAY_OF_MONTH, 1)
|
||||
if (currentCalendar.before(startCalendar)) { // Shift start back a day
|
||||
startCalendar.add(Calendar.DAY_OF_MONTH, -1)
|
||||
} else { // Shift end forward a day
|
||||
endCalendar.add(Calendar.DAY_OF_MONTH, 1)
|
||||
}
|
||||
}
|
||||
|
||||
val currentCalendar = Calendar.getInstance()
|
||||
val currentHour = SimpleDateFormat("HH:mm", Locale.getDefault()).format(currentCalendar.time)
|
||||
if (currentCalendar.after(startCalendar) && currentCalendar.before(endCalendar)) {
|
||||
Log.i(tag, "Current hour $currentHour is between ${deviceSettings.autoSleepTimerStartTime} and ${deviceSettings.autoSleepTimerEndTime} - starting sleep timer")
|
||||
|
||||
// Automatically Rewind in the book if settings is enabled
|
||||
if (deviceSettings.autoSleepTimerAutoRewind && !isFirstAutoSleepTimer) {
|
||||
Log.i(tag, "Auto sleep timer auto rewind seeking back ${deviceSettings.autoSleepTimerAutoRewindTime}ms")
|
||||
playerNotificationService.seekBackward(deviceSettings.autoSleepTimerAutoRewindTime)
|
||||
}
|
||||
isFirstAutoSleepTimer = false
|
||||
|
||||
// Set sleep timer
|
||||
// When sleepTimerLength is 0 then use end of chapter/track time
|
||||
if (deviceSettings.sleepTimerLength == 0L) {
|
||||
@@ -352,13 +375,23 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
setSleepTimer(deviceSettings.sleepTimerLength, false)
|
||||
}
|
||||
} else {
|
||||
isFirstAutoSleepTimer = true
|
||||
Log.d(tag, "Current hour $currentHour is NOT between ${deviceSettings.autoSleepTimerStartTime} and ${deviceSettings.autoSleepTimerEndTime}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun handleMediaPlayEvent() {
|
||||
checkShouldResetSleepTimer()
|
||||
fun handleMediaPlayEvent(playbackSessionId:String) {
|
||||
// Check if the playback session has changed
|
||||
// If it hasn't changed OR the sleep timer is running then check reset the timer
|
||||
// e.g. You set a manual sleep timer for 10 mins, then decide to change books, the sleep timer will stay on and reset to 10 mins
|
||||
if (sleepTimerSessionId == playbackSessionId || sleepTimerRunning) {
|
||||
checkShouldResetSleepTimer()
|
||||
} else {
|
||||
isFirstAutoSleepTimer = true
|
||||
}
|
||||
sleepTimerSessionId = playbackSessionId
|
||||
|
||||
checkAutoSleepTimer()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.app.DownloadManager
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.data.AudioTrack
|
||||
import com.audiobookshelf.app.data.EBookFile
|
||||
import com.audiobookshelf.app.data.LocalFolder
|
||||
import com.audiobookshelf.app.data.PodcastEpisode
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
@@ -20,6 +21,7 @@ data class DownloadItemPart(
|
||||
val localFolderName: String,
|
||||
val localFolderUrl: String,
|
||||
val localFolderId: String,
|
||||
val ebookFile: EBookFile?,
|
||||
val audioTrack: AudioTrack?,
|
||||
val episode: PodcastEpisode?,
|
||||
var completed:Boolean,
|
||||
@@ -35,12 +37,12 @@ data class DownloadItemPart(
|
||||
var bytesDownloaded: Long
|
||||
) {
|
||||
companion object {
|
||||
fun make(downloadItemId:String, filename:String, fileSize: Long, destinationFile: File, finalDestinationFile: File, subfolder:String, serverPath:String, localFolder: LocalFolder, audioTrack: AudioTrack?, episode: PodcastEpisode?) :DownloadItemPart {
|
||||
fun make(downloadItemId:String, filename:String, fileSize: Long, destinationFile: File, finalDestinationFile: File, subfolder:String, serverPath:String, localFolder: LocalFolder, ebookFile: EBookFile?, audioTrack: AudioTrack?, episode: PodcastEpisode?) :DownloadItemPart {
|
||||
val destinationUri = Uri.fromFile(destinationFile)
|
||||
val finalDestinationUri = Uri.fromFile(finalDestinationFile)
|
||||
|
||||
var downloadUrl = "${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}"
|
||||
if (serverPath.endsWith("/cover")) downloadUrl += "&format=jpeg" // For cover images force to jpeg
|
||||
if (serverPath.endsWith("/cover")) downloadUrl += "&format=jpeg&raw=1" // For cover images force to jpeg
|
||||
val downloadUri = Uri.parse(downloadUrl)
|
||||
Log.d("DownloadItemPart", "Audio File Destination Uri: $destinationUri | Final Destination Uri: $finalDestinationUri | Download URI $downloadUri")
|
||||
return DownloadItemPart(
|
||||
@@ -53,6 +55,7 @@ data class DownloadItemPart(
|
||||
localFolderName = localFolder.name,
|
||||
localFolderUrl = localFolder.contentUrl,
|
||||
localFolderId = localFolder.id,
|
||||
ebookFile = ebookFile,
|
||||
audioTrack = audioTrack,
|
||||
episode = episode,
|
||||
completed = false,
|
||||
|
||||
+2
-1
@@ -8,6 +8,7 @@ import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.support.v4.media.session.MediaControllerCompat
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.BuildConfig
|
||||
import com.audiobookshelf.app.R
|
||||
import com.bumptech.glide.Glide
|
||||
import com.google.android.exoplayer2.Player
|
||||
@@ -83,7 +84,7 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
|
||||
|
||||
Glide.with(playerNotificationService)
|
||||
.asBitmap()
|
||||
.load(Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon))
|
||||
.load(Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon))
|
||||
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
|
||||
.get()
|
||||
}
|
||||
|
||||
@@ -190,6 +190,14 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
||||
handleMediaButtonClickCount()
|
||||
}
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
|
||||
Log.d(tag, "handleCallMediaButton: Media Fast Forward")
|
||||
playerNotificationService.jumpForward()
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_REWIND -> {
|
||||
Log.d(tag, "handleCallMediaButton: Media Rewind")
|
||||
playerNotificationService.jumpBackward()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,12 +232,6 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
|
||||
playerNotificationService.jumpBackward()
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
|
||||
playerNotificationService.jumpForward()
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_REWIND -> {
|
||||
playerNotificationService.jumpBackward()
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_STOP -> {
|
||||
playerNotificationService.closePlayback()
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.google.android.exoplayer2.PlaybackException
|
||||
import com.google.android.exoplayer2.Player
|
||||
|
||||
const val PAUSE_LEN_BEFORE_RECHECK = 30000 // 30 seconds
|
||||
//const val PAUSE_LEN_BEFORE_RECHECK = 30000 // 30 seconds
|
||||
|
||||
class PlayerListener(var playerNotificationService:PlayerNotificationService) : Player.Listener {
|
||||
var tag = "PlayerListener"
|
||||
@@ -74,12 +74,13 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
|
||||
Log.d(tag, "SeekBackTime $seekBackTime")
|
||||
}
|
||||
|
||||
// TODO: this needs to be reworked so that the audio doesn't start playing before it checks for updated progress
|
||||
// Check if playback session still exists or sync media progress if updated
|
||||
val pauseLength: Long = System.currentTimeMillis() - lastPauseTime
|
||||
if (pauseLength > PAUSE_LEN_BEFORE_RECHECK) {
|
||||
val shouldCarryOn = playerNotificationService.checkCurrentSessionProgress(seekBackTime)
|
||||
if (!shouldCarryOn) return
|
||||
}
|
||||
// val pauseLength: Long = System.currentTimeMillis() - lastPauseTime
|
||||
// if (pauseLength > PAUSE_LEN_BEFORE_RECHECK) {
|
||||
// val shouldCarryOn = playerNotificationService.checkCurrentSessionProgress(seekBackTime)
|
||||
// if (!shouldCarryOn) return
|
||||
// }
|
||||
|
||||
if (seekBackTime > 0L) {
|
||||
playerNotificationService.seekBackward(seekBackTime)
|
||||
@@ -92,12 +93,15 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
|
||||
|
||||
// Start/stop progress sync interval
|
||||
if (isPlaying) {
|
||||
// Handles auto-starting sleep timer and resetting sleep timer
|
||||
playerNotificationService.sleepTimerManager.handleMediaPlayEvent()
|
||||
|
||||
player.volume = 1F // Volume on sleep timer might have decreased this
|
||||
val playbackSession: PlaybackSession? = playerNotificationService.mediaProgressSyncer.currentPlaybackSession ?: playerNotificationService.currentPlaybackSession
|
||||
playbackSession?.let { playerNotificationService.mediaProgressSyncer.play(it) }
|
||||
playbackSession?.let {
|
||||
// Handles auto-starting sleep timer and resetting sleep timer
|
||||
playerNotificationService.sleepTimerManager.handleMediaPlayEvent(it.id)
|
||||
|
||||
player.volume = 1F // Volume on sleep timer might have decreased this
|
||||
|
||||
playerNotificationService.mediaProgressSyncer.play(it)
|
||||
}
|
||||
} else {
|
||||
playerNotificationService.mediaProgressSyncer.pause {
|
||||
Log.d(tag, "Media Progress Syncer paused and synced")
|
||||
|
||||
+20
-4
@@ -1,6 +1,9 @@
|
||||
package com.audiobookshelf.app.player
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.*
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
@@ -14,6 +17,7 @@ import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.os.*
|
||||
import android.provider.MediaStore
|
||||
import android.provider.Settings
|
||||
import android.support.v4.media.MediaBrowserCompat
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
@@ -21,6 +25,8 @@ import android.support.v4.media.session.MediaControllerCompat
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.widget.RemoteViews
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
@@ -170,12 +176,16 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
}
|
||||
|
||||
Log.d(tag, "onDestroy")
|
||||
isStarted = false
|
||||
isClosed = true
|
||||
DeviceManager.widgetUpdater?.onPlayerChanged(this)
|
||||
|
||||
playerNotificationManager.setPlayer(null)
|
||||
mPlayer.release()
|
||||
castPlayer?.release()
|
||||
mediaSession.release()
|
||||
mediaProgressSyncer.reset()
|
||||
isStarted = false
|
||||
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
@@ -184,6 +194,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
Log.d(tag, "onTaskRemoved")
|
||||
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
@@ -258,7 +269,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||
playerNotificationManager.setUseFastForwardActionInCompactView(true)
|
||||
playerNotificationManager.setUseRewindActionInCompactView(true)
|
||||
playerNotificationManager.setSmallIcon(R.drawable.exo_icon_localaudio)
|
||||
playerNotificationManager.setSmallIcon(R.drawable.icon_monochrome)
|
||||
|
||||
// Unknown action
|
||||
playerNotificationManager.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE)
|
||||
@@ -409,9 +420,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
DeviceManager.setLastPlaybackSession(playbackSession) // Save playback session to use when app is closed
|
||||
|
||||
Log.d(tag, "Set CurrentPlaybackSession MediaPlayer ${currentPlaybackSession?.mediaPlayer}")
|
||||
|
||||
// Notify client
|
||||
clientEventEmitter?.onPlaybackSession(playbackSession)
|
||||
|
||||
// Update widget
|
||||
DeviceManager.widgetUpdater?.onPlayerChanged(this)
|
||||
|
||||
if (mediaItems.isEmpty()) {
|
||||
Log.e(tag, "Invalid playback session no media items to play")
|
||||
currentPlaybackSession = null
|
||||
@@ -881,6 +895,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
return if(currentPlayer == castPlayer) PLAYER_CAST else PLAYER_EXO
|
||||
}
|
||||
|
||||
@SuppressLint("HardwareIds")
|
||||
private fun getDeviceInfo(): DeviceInfo {
|
||||
/* EXAMPLE
|
||||
manufacturer: Google
|
||||
@@ -889,7 +904,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
sdkVersion: 32
|
||||
appVersion: 0.9.46-beta
|
||||
*/
|
||||
return DeviceInfo(Build.MANUFACTURER, Build.MODEL, Build.BRAND, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
|
||||
val deviceId = Settings.Secure.getString(ctx.contentResolver, Settings.Secure.ANDROID_ID)
|
||||
return DeviceInfo(deviceId, Build.MANUFACTURER, Build.MODEL, Build.BRAND, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
|
||||
}
|
||||
|
||||
private val deviceSettings get() = DeviceManager.deviceData.deviceSettings ?: DeviceSettings.default()
|
||||
|
||||
@@ -37,7 +37,11 @@ class AbsAudioPlayer : Plugin() {
|
||||
mainActivity = (activity as MainActivity)
|
||||
apiHandler = ApiHandler(mainActivity)
|
||||
|
||||
initCastManager()
|
||||
try {
|
||||
initCastManager()
|
||||
} catch(e:Exception) {
|
||||
Log.e(tag, "initCastManager exception ${e.printStackTrace()}")
|
||||
}
|
||||
|
||||
val foregroundServiceReady : () -> Unit = {
|
||||
playerNotificationService = mainActivity.foregroundService
|
||||
@@ -176,7 +180,7 @@ class AbsAudioPlayer : Plugin() {
|
||||
val playWhenReady = call.getBoolean("playWhenReady") == true
|
||||
val playbackRate = call.getFloat("playbackRate",1f) ?: 1f
|
||||
val startTimeOverride = call.getDouble("startTime")
|
||||
Log.d(tag, "prepareLibraryItem lid=$libraryItemId, startTimeOverride=$startTimeOverride")
|
||||
Log.d(tag, "prepareLibraryItem lid=$libraryItemId, startTimeOverride=$startTimeOverride, playbackRate=$playbackRate")
|
||||
|
||||
if (libraryItemId.isEmpty()) {
|
||||
Log.e(tag, "Invalid call to play library item no library item id")
|
||||
@@ -337,7 +341,8 @@ class AbsAudioPlayer : Plugin() {
|
||||
val isChapterTime:Boolean = call.getBoolean("isChapterTime", false) == true
|
||||
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
val success:Boolean = playerNotificationService.sleepTimerManager.setManualSleepTimer(time, isChapterTime)
|
||||
val playbackSession: PlaybackSession? = playerNotificationService.mediaProgressSyncer.currentPlaybackSession ?: playerNotificationService.currentPlaybackSession
|
||||
val success:Boolean = playerNotificationService.sleepTimerManager.setManualSleepTimer(playbackSession?.id ?: "", time, isChapterTime)
|
||||
val ret = JSObject()
|
||||
ret.put("success", success)
|
||||
call.resolve(ret)
|
||||
@@ -378,7 +383,9 @@ class AbsAudioPlayer : Plugin() {
|
||||
|
||||
@PluginMethod
|
||||
fun cancelSleepTimer(call: PluginCall) {
|
||||
playerNotificationService.sleepTimerManager.cancelSleepTimer()
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
playerNotificationService.sleepTimerManager.cancelSleepTimer()
|
||||
}
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
|
||||
@@ -265,6 +265,8 @@ class AbsDatabase : Plugin() {
|
||||
progress = mediaProgress.progress,
|
||||
currentTime = mediaProgress.currentTime,
|
||||
isFinished = mediaProgress.isFinished,
|
||||
ebookLocation = mediaProgress.ebookLocation,
|
||||
ebookProgress = mediaProgress.ebookProgress,
|
||||
lastUpdate = mediaProgress.lastUpdate,
|
||||
startedAt = mediaProgress.startedAt,
|
||||
finishedAt = mediaProgress.finishedAt,
|
||||
@@ -345,6 +347,8 @@ class AbsDatabase : Plugin() {
|
||||
progress = if (isFinished) 1.0 else 0.0,
|
||||
currentTime = 0.0,
|
||||
isFinished = isFinished,
|
||||
ebookLocation = null,
|
||||
ebookProgress = null,
|
||||
lastUpdate = currentTime,
|
||||
startedAt = if (isFinished) currentTime else 0L,
|
||||
finishedAt = if (isFinished) currentTime else null,
|
||||
@@ -389,6 +393,55 @@ class AbsDatabase : Plugin() {
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun updateLocalEbookProgress(call:PluginCall) {
|
||||
val localLibraryItemId = call.getString("localLibraryItemId", "").toString()
|
||||
val ebookLocation = call.getString("ebookLocation", "").toString()
|
||||
val ebookProgress = call.getDouble("ebookProgress") ?: 0.0
|
||||
|
||||
val localMediaProgressId = localLibraryItemId
|
||||
var localMediaProgress = DeviceManager.dbManager.getLocalMediaProgress(localMediaProgressId)
|
||||
|
||||
if (localMediaProgress == null) {
|
||||
Log.d(tag, "updateLocalEbookProgress Local Media Progress not found $localMediaProgressId - Creating new")
|
||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||
?: return call.resolve(JSObject("{\"error\":\"Library Item not found\"}"))
|
||||
|
||||
val book = localLibraryItem.media as Book
|
||||
|
||||
localMediaProgress = LocalMediaProgress(
|
||||
id = localMediaProgressId,
|
||||
localLibraryItemId = localLibraryItemId,
|
||||
localEpisodeId = null,
|
||||
duration = book.duration ?: 0.0,
|
||||
progress = 0.0,
|
||||
currentTime = 0.0,
|
||||
isFinished = false,
|
||||
ebookLocation = ebookLocation,
|
||||
ebookProgress = ebookProgress,
|
||||
lastUpdate = System.currentTimeMillis(),
|
||||
startedAt = 0L,
|
||||
finishedAt = null,
|
||||
serverConnectionConfigId = localLibraryItem.serverConnectionConfigId,
|
||||
serverAddress = localLibraryItem.serverAddress,
|
||||
serverUserId = localLibraryItem.serverUserId,
|
||||
libraryItemId = localLibraryItem.libraryItemId,
|
||||
episodeId = null)
|
||||
} else {
|
||||
localMediaProgress.updateEbookProgress(ebookLocation, ebookProgress)
|
||||
}
|
||||
|
||||
// Save local media progress locally
|
||||
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
|
||||
|
||||
val lmpstring = jacksonMapper.writeValueAsString(localMediaProgress)
|
||||
Log.d(tag, "updateLocalEbookProgress: Local Media Progress String $lmpstring")
|
||||
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("localMediaProgress", JSObject(lmpstring))
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun updateLocalTrackOrder(call:PluginCall) {
|
||||
val localLibraryItemId = call.getString("localLibraryItemId", "") ?: ""
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.audiobookshelf.app.plugins
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.MainActivity
|
||||
@@ -142,6 +141,28 @@ class AbsDownloader : Plugin() {
|
||||
val itemFolderPath = "${localFolder.absolutePath}/$itemSubfolder"
|
||||
val downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null, libraryItem.userMediaProgress,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, itemSubfolder, libraryItem.media, mutableListOf())
|
||||
|
||||
val book = libraryItem.media as Book
|
||||
book.ebookFile?.let { ebookFile ->
|
||||
val fileSize = ebookFile.metadata?.size ?: 0
|
||||
val serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(ebookFile.metadata?.relPath ?: "")}"
|
||||
val destinationFilename = getFilenameFromRelPath(ebookFile.metadata?.relPath ?: "")
|
||||
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||
val destinationFile = File("$tempFolderPath/$destinationFilename")
|
||||
|
||||
if (destinationFile.exists()) {
|
||||
Log.d(tag, "TEMP ebook file already exists, removing it from ${destinationFile.absolutePath}")
|
||||
destinationFile.delete()
|
||||
}
|
||||
|
||||
if (finalDestinationFile.exists()) {
|
||||
Log.d(tag, "ebook file already exists, removing it from ${finalDestinationFile.absolutePath}")
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
|
||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,ebookFile,null,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
|
||||
// Create download item part for each audio track
|
||||
tracks.forEach { audioTrack ->
|
||||
val fileSize = audioTrack.metadata?.size ?: 0
|
||||
@@ -162,7 +183,7 @@ class AbsDownloader : Plugin() {
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
|
||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,audioTrack,null)
|
||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,audioTrack,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
|
||||
@@ -187,7 +208,7 @@ class AbsDownloader : Plugin() {
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
|
||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, coverFileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,null)
|
||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, coverFileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,null,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
|
||||
@@ -216,7 +237,7 @@ class AbsDownloader : Plugin() {
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
|
||||
var downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,fileSize, destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,audioTrack,episode)
|
||||
var downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,fileSize, destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,audioTrack,episode)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
|
||||
if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) {
|
||||
@@ -232,7 +253,7 @@ class AbsDownloader : Plugin() {
|
||||
if (finalDestinationFile.exists()) {
|
||||
Log.d(tag, "Podcast cover already exists - not downloading cover again")
|
||||
} else {
|
||||
downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null)
|
||||
downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import com.getcapacitor.annotation.CapacitorPlugin
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
@CapacitorPlugin(name = "AbsFileSystem")
|
||||
class AbsFileSystem : Plugin() {
|
||||
@@ -226,8 +227,37 @@ class AbsFileSystem : Plugin() {
|
||||
val contentUrl = call.data.getString("contentUrl", "").toString()
|
||||
Log.d(tag, "deleteItem $absolutePath | $contentUrl")
|
||||
|
||||
var subfolderPathToDelete = ""
|
||||
|
||||
// Check if should delete subfolder
|
||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||
localLibraryItem?.folderId?.let { folderId ->
|
||||
val folder = DeviceManager.dbManager.getLocalFolder(folderId)
|
||||
folder?.absolutePath?.let { folderPath ->
|
||||
val splitAbsolutePath = absolutePath.split("/")
|
||||
val fullSubDir = splitAbsolutePath.subList(0, splitAbsolutePath.size - 1).joinToString("/")
|
||||
if (fullSubDir != folderPath) {
|
||||
val subdirHasAnItem = DeviceManager.dbManager.getLocalLibraryItems().any { _localLibraryItem ->
|
||||
if (_localLibraryItem.id == localLibraryItemId) {
|
||||
false
|
||||
} else {
|
||||
_localLibraryItem.absolutePath.startsWith(fullSubDir)
|
||||
}
|
||||
}
|
||||
subfolderPathToDelete = if (subdirHasAnItem) "" else fullSubDir
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
|
||||
val success = docfile?.delete() == true
|
||||
|
||||
if (subfolderPathToDelete != "") {
|
||||
Log.d(tag, "Deleting empty subfolder at $subfolderPathToDelete")
|
||||
val docfilesub = DocumentFileCompat.fromFullPath(mainActivity, subfolderPathToDelete)
|
||||
docfilesub?.delete()
|
||||
}
|
||||
|
||||
if (success) {
|
||||
DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId)
|
||||
}
|
||||
|
||||
@@ -356,6 +356,16 @@ class ApiHandler(var ctx:Context) {
|
||||
Log.d(tag, "Server progress for media item id=\"${mediaProgress.mediaItemId}\" is more recent then local. Updating local current time ${localMediaProgress.currentTime} to ${mediaProgress.currentTime}")
|
||||
localMediaProgress.updateFromServerMediaProgress(mediaProgress)
|
||||
MediaEventManager.syncEvent(mediaProgress, "Sync on server connection")
|
||||
} else if (localMediaProgress.lastUpdate > mediaProgress.lastUpdate && localMediaProgress.ebookLocation != null && localMediaProgress.ebookLocation != mediaProgress.ebookLocation) {
|
||||
// Patch ebook progress to server
|
||||
val endpoint = "/api/me/progress/${localMediaProgress.libraryItemId}"
|
||||
val updatePayload = JSObject()
|
||||
updatePayload.put("ebookLocation", localMediaProgress.ebookLocation)
|
||||
updatePayload.put("ebookProgress", localMediaProgress.ebookProgress)
|
||||
updatePayload.put("lastUpdate", localMediaProgress.lastUpdate)
|
||||
patchRequest(endpoint,updatePayload) {
|
||||
Log.d(tag, "syncLocalMediaProgressForUser patched ebook progress")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="862dp"
|
||||
android:height="1024dp"
|
||||
android:viewportWidth="862"
|
||||
android:viewportHeight="1024">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M855,504.4c-5.3,-4.4 -13.5,-10.9 -24.7,-18.6v-86.5c0,-220.5 -178.8,-399.3 -399.3,-399.3v0c-220.5,0 -399.3,178.8 -399.3,399.3v86.5c-11.1,7.7 -19.4,14.2 -24.7,18.6 -4.4,3.7 -7.1,9.1 -7.1,15.2 0,0 0,0 0,0v-0,103.6c0,0 0,0 0,0 0,6.1 2.8,11.6 7.1,15.2l0,0c12.3,10.3 40.6,31.8 84.7,53.8v10.1c0,27.2 17.5,49.2 39.1,49.2v0c21.6,0 39.1,-22 39.1,-49.2v-248.5c0,-27.2 -17.5,-49.2 -39.1,-49.2v0c-20.7,0 -37.7,20.2 -39,45.8l-0.1,0v-51.2c0,-187.4 151.9,-339.3 339.3,-339.3v0c187.4,0 339.3,151.9 339.3,339.3v51.2l-0.1,-0c-1.4,-25.6 -18.3,-45.8 -39,-45.8v0c-21.6,0 -39.1,22 -39.1,49.2v248.5c0,27.2 17.5,49.2 39.1,49.2v0c21.6,0 39.1,-22 39.1,-49.2v-10.1c44,-22.1 72.3,-43.6 84.7,-53.8 4.4,-3.7 7.1,-9.1 7.1,-15.2 0,-0 0,-0 0,-0v0,-103.6c0,-0 0,-0 0,-0 0,-6.1 -2.8,-11.6 -7.1,-15.2l-0,-0zM291.8,936.4c26.1,0 47.3,-21.2 47.3,-47.3v0,-529.4c0,-0 0,-0 0,-0 0,-26.1 -21.2,-47.3 -47.3,-47.3h-48.8c-26.1,0 -47.3,21.2 -47.3,47.3v0,529.4c0,26.1 21.2,47.3 47.3,47.3 0,0 0,0 0,0v0zM213.5,439.4h107.8v11.3h-107.8zM455.5,936.4c26.1,0 47.3,-21.2 47.3,-47.3v0,-529.4c0,-0 0,-0 0,-0 0,-26.1 -21.2,-47.3 -47.3,-47.3h-48.8c-26.1,0 -47.3,21.2 -47.3,47.3v0,529.4c0,26.1 21.2,47.3 47.3,47.3 0,0 0,0 0,0v0zM377.2,439.4h107.8v11.3h-107.8zM619.1,936.4c26.1,0 47.3,-21.2 47.3,-47.3v0,-529.4c0,-0 0,-0 0,-0 0,-26.1 -21.2,-47.3 -47.3,-47.3h-48.8c-26.1,0 -47.3,21.2 -47.3,47.3v0,529.4c0,26.1 21.2,47.3 47.3,47.3 0,0 0,0 0,0v0zM540.8,439.4h107.8v11.3h-107.8zM114.1,952.7h633.9c19.7,0 35.6,15.9 35.6,35.6v0c0,19.7 -15.9,35.6 -35.6,35.6h-633.9c-19.7,0 -35.6,-15.9 -35.6,-35.6v-0c0,-19.7 15.9,-35.6 35.6,-35.6z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,108 @@
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/widgetBackground"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="fill_parent"
|
||||
android:orientation="horizontal"
|
||||
android:theme="@style/AppTheme.AppWidgetContainer"
|
||||
style="@style/Widget.Android.AppWidget.Container">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="3"
|
||||
android:background="?android:attr/colorAccent"
|
||||
android:theme="@style/MediaPlayerWidgetTheme"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/widgetAlbumArt"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="2"
|
||||
android:scaleType="centerCrop"
|
||||
android:src="@drawable/icon" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="3"
|
||||
android:layout_marginVertical="8dp"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widgetArtistText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="2"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:gravity="bottom"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:text="Artist"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widgetMediaTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="2"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:gravity="top"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:text="Title"/>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/widgetButtonContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="3"
|
||||
android:orientation="horizontal"
|
||||
android:background="?android:attr/colorAccent"
|
||||
android:theme="@style/MediaPlayerWidgetTheme">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/widgetRewindButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:layout_margin="8dp"
|
||||
android:src="@drawable/exo_icon_rewind"
|
||||
style="@style/Widget.Android.AppWidget.InnerView"/>
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/widgetPlayPauseButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:layout_margin="6dp"
|
||||
android:src="@drawable/ic_media_play_dark" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/widgetFastForwardButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:layout_margin="8dp"
|
||||
android:src="@drawable/exo_icon_fastforward" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="36dp"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/tinyCornerIcon"
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:layout_margin="6dp"
|
||||
android:adjustViewBounds ="true"
|
||||
android:src="@drawable/icon_monochrome" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -1,26 +0,0 @@
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
style="@style/Widget.Android.AppWidget.Container"
|
||||
android:id="@+id/appWidget"
|
||||
android:theme="@style/AppTheme.AppWidgetContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imageView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_centerInParent="true"
|
||||
android:adjustViewBounds="true"
|
||||
android:alpha="0.75"
|
||||
android:contentDescription="Cover image"
|
||||
android:visibility="visible" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/playPauseIcon"
|
||||
android:layout_width="71dp"
|
||||
android:layout_height="55dp"
|
||||
android:layout_centerInParent="true"
|
||||
app:srcCompat="@drawable/cast_ic_expanded_controller_play" />
|
||||
|
||||
</RelativeLayout>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!--
|
||||
Having themes.xml for night-v31 because of the priority order of the resource qualifiers.
|
||||
-->
|
||||
<style name="AppTheme.AppWidgetContainerParent" parent="@android:style/Theme.DeviceDefault.DayNight">
|
||||
<item name="appWidgetRadius">@android:dimen/system_app_widget_background_radius</item>
|
||||
<item name="appWidgetInnerRadius">@android:dimen/system_app_widget_inner_radius</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,16 @@
|
||||
<resources>
|
||||
|
||||
<style name="Widget.Android.AppWidget.Container" parent="android:Widget">
|
||||
<item name="android:id">@android:id/background</item>
|
||||
<item name="android:padding">?attr/appWidgetPadding</item>
|
||||
<item name="android:background">@drawable/app_widget_background</item>
|
||||
<item name="android:clipToOutline">true</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.Android.AppWidget.InnerView" parent="android:Widget">
|
||||
<item name="android:padding">?attr/appWidgetPadding</item>
|
||||
<!-- <item name="android:background">@drawable/app_widget_inner_view_background</item>-->
|
||||
<!-- <item name="android:textColor">?android:attr/textColorPrimary</item>-->
|
||||
<item name="android:clipToOutline">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!--
|
||||
Having themes.xml for v31 variant because @android:dimen/system_app_widget_background_radius
|
||||
and @android:dimen/system_app_widget_internal_padding requires API level 31
|
||||
-->
|
||||
<style name="AppTheme.AppWidgetContainerParent" parent="@android:style/Theme.DeviceDefault.DayNight">
|
||||
<item name="appWidgetRadius">@android:dimen/system_app_widget_background_radius</item>
|
||||
<item name="appWidgetInnerRadius">@android:dimen/system_app_widget_inner_radius</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -1,7 +1,9 @@
|
||||
<resources>
|
||||
<declare-styleable name="AppWidgetAttrs">
|
||||
<attr name="appWidgetPadding" format="dimension" />
|
||||
<attr name="appWidgetInnerRadius" format="dimension" />
|
||||
<attr name="appWidgetRadius" format="dimension" />
|
||||
</declare-styleable>
|
||||
</resources>
|
||||
<declare-styleable name="AppWidgetAttrs">
|
||||
<attr name="appWidgetPadding" format="dimension" />
|
||||
<attr name="appWidgetInnerRadius" format="dimension" />
|
||||
<attr name="appWidgetRadius" format="dimension" />
|
||||
<attr name="backgroundRadius" format="dimension" />
|
||||
<attr name="appWidgetViewPadding" format="dimension" />
|
||||
</declare-styleable>
|
||||
</resources>
|
||||
|
||||
@@ -1,33 +1,42 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<!-- Base application theme. -->
|
||||
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
<item name="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||
<item name="colorAccent">@color/colorAccent</item>
|
||||
</style>
|
||||
<!-- Base application theme. -->
|
||||
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
<item name="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||
<item name="colorAccent">@color/colorAccent</item>
|
||||
</style>
|
||||
|
||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:background">@null</item>
|
||||
<item name="android:statusBarColor">@color/background_dark</item>
|
||||
<item name="android:navigationBarColor">@color/background_dark</item>
|
||||
</style>
|
||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:background">@null</item>
|
||||
<item name="android:statusBarColor">@color/background_dark</item>
|
||||
<item name="android:navigationBarColor">@color/background_dark</item>
|
||||
</style>
|
||||
|
||||
|
||||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||
<!-- <item name="android:background">@drawable/screen</item>-->
|
||||
</style>
|
||||
<style name="Widget.Android.AppWidget.Container" parent="android:Widget">
|
||||
<item name="android:id">@android:id/background</item>
|
||||
<item name="android:background">?android:attr/colorBackground</item>
|
||||
</style>
|
||||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||
<!-- <item name="android:background">@drawable/screen</item>-->
|
||||
</style>
|
||||
|
||||
<style name="Widget.Android.AppWidget.InnerView" parent="android:Widget">
|
||||
<item name="android:background">?android:attr/colorBackground</item>
|
||||
<item name="android:textColor">?android:attr/textColorPrimary</item>
|
||||
</style>
|
||||
<style name="MediaPlayerWidgetTheme" parent="Theme.AppCompat.DayNight">
|
||||
<!-- These colors match the vue pages -->
|
||||
<item name="android:colorBackground">#232323</item>
|
||||
<item name="android:colorAccent">#373838</item>
|
||||
<item name="android:textColor">#ffffff</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.Android.AppWidget.Container" parent="android:Widget">
|
||||
<item name="android:id">@android:id/background</item>
|
||||
<item name="android:background">?android:attr/colorBackground</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.Android.AppWidget.InnerView" parent="android:Widget">
|
||||
<!-- <item name="android:background">?android:attr/colorBackground</item>-->
|
||||
<!-- <item name="android:textColor">?android:attr/textColorPrimary</item>-->
|
||||
<item name="android:padding">0dp</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<resources>
|
||||
<style name="AppTheme.AppWidgetContainerParent" parent="@android:style/Theme.DeviceDefault">
|
||||
<!-- Radius of the outer bound of widgets to make the rounded corners -->
|
||||
<item name="appWidgetRadius">16dp</item>
|
||||
<!--
|
||||
|
||||
<style name="AppTheme.AppWidgetContainerParent" parent="@android:style/Theme.DeviceDefault">
|
||||
<!-- Radius of the outer bound of widgets to make the rounded corners -->
|
||||
<item name="appWidgetRadius">0dp</item>
|
||||
<!--
|
||||
Radius of the inner view's bound of widgets to make the rounded corners.
|
||||
It needs to be 8dp or less than the value of appWidgetRadius
|
||||
-->
|
||||
<item name="appWidgetInnerRadius">8dp</item>
|
||||
</style>
|
||||
<item name="appWidgetInnerRadius">0dp</item>
|
||||
</style>
|
||||
|
||||
<style name="AppTheme.AppWidgetContainer"
|
||||
parent="AppTheme.AppWidgetContainerParent">
|
||||
<!-- Apply padding to avoid the content of the widget colliding with the rounded corners -->
|
||||
<item name="appWidgetPadding">16dp</item>
|
||||
</style>
|
||||
<style name="AppTheme.AppWidgetContainer" parent="AppTheme.AppWidgetContainerParent">
|
||||
<!-- Apply padding to avoid the content of the widget colliding with the rounded corners -->
|
||||
<item name="appWidgetPadding">0dp</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:description="@string/app_widget_description"
|
||||
android:initialKeyguardLayout="@layout/media_player_widget"
|
||||
android:initialLayout="@layout/media_player_widget"
|
||||
android:minWidth="275dp"
|
||||
android:minHeight="50dp"
|
||||
android:previewImage="@drawable/media_player_widget_preview"
|
||||
android:previewLayout="@layout/media_player_widget"
|
||||
android:resizeMode="horizontal"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="1"
|
||||
android:updatePeriodMillis="86400000"
|
||||
android:widgetCategory="home_screen" />
|
||||
@@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="110dp"
|
||||
android:minHeight="40dp"
|
||||
android:updatePeriodMillis="86400000"
|
||||
android:previewImage="@drawable/example_appwidget_preview"
|
||||
android:initialLayout="@layout/new_app_widget"
|
||||
android:description="@string/app_widget_description"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:widgetCategory="home_screen"
|
||||
android:initialKeyguardLayout="@layout/new_app_widget"
|
||||
/>
|
||||
@@ -2,6 +2,19 @@
|
||||
@import './defaultStyles.css';
|
||||
@import './absicons.css';
|
||||
|
||||
* {
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
-webkit-touch-callout: auto;
|
||||
-webkit-user-select: auto;
|
||||
user-select: auto;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #262626;
|
||||
}
|
||||
@@ -102,4 +115,32 @@ Bookshelf Label
|
||||
background-position: center;
|
||||
opacity: 1;
|
||||
filter: blur(20px);
|
||||
}
|
||||
|
||||
.episode-subtitle {
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
line-height: 16px;
|
||||
/* fallback */
|
||||
max-height: 32px;
|
||||
/* fallback */
|
||||
-webkit-line-clamp: 2;
|
||||
/* number of lines to show */
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.line-clamp-4 {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 4;
|
||||
}
|
||||
+86
-114
@@ -12,27 +12,27 @@
|
||||
<div class="top-6 right-4 absolute cursor-pointer">
|
||||
<span class="material-icons text-3xl" :class="{ 'text-black text-opacity-75': coverBgIsLight }" @click="showMoreMenuDialog = true">more_vert</span>
|
||||
</div>
|
||||
<p class="top-4 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" style="font-size: 10px" :class="{ 'text-success': isLocalPlayMethod, 'text-accent': !isLocalPlayMethod }">{{ isDirectPlayMethod ? 'Direct' : isLocalPlayMethod ? 'Local' : 'Transcode' }}</p>
|
||||
<p class="top-4 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" :class="{ 'text-black text-opacity-75': coverBgIsLight }" style="font-size: 10px">{{ isDirectPlayMethod ? 'Direct' : isLocalPlayMethod ? 'Local' : 'Transcode' }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="useChapterTrack && useTotalTrack && showFullscreen" class="absolute total-track w-full px-3 z-30">
|
||||
<div v-if="useChapterTrack && useTotalTrack && showFullscreen" class="absolute total-track w-full z-30 px-6">
|
||||
<div class="flex">
|
||||
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem">{{ currentTimePretty }}</p>
|
||||
<div class="flex-grow" />
|
||||
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem">{{ totalTimeRemainingPretty }}</p>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div class="h-1 w-full bg-gray-500 bg-opacity-50 relative">
|
||||
<div ref="totalReadyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="totalBufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="totalPlayedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" />
|
||||
<div class="h-1 w-full bg-gray-500 bg-opacity-50 relative rounded-full">
|
||||
<div ref="totalReadyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none rounded-full" />
|
||||
<div ref="totalBufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 pointer-events-none rounded-full" />
|
||||
<div ref="totalPlayedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cover-wrapper absolute z-30 pointer-events-auto" @click="clickContainer">
|
||||
<div class="w-full h-full flex justify-center">
|
||||
<covers-book-cover v-if="libraryItem || localLibraryItemCoverSrc" ref="cover" :library-item="libraryItem" :download-cover="localLibraryItemCoverSrc" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" @imageLoaded="coverImageLoaded" />
|
||||
<covers-book-cover v-if="libraryItem || localLibraryItemCoverSrc" ref="cover" :library-item="libraryItem" :download-cover="localLibraryItemCoverSrc" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" raw @imageLoaded="coverImageLoaded" />
|
||||
</div>
|
||||
|
||||
<div v-if="syncStatus === $constants.SyncStatus.FAILED" class="absolute top-0 left-0 w-full h-full flex items-center justify-center z-30">
|
||||
@@ -46,7 +46,7 @@
|
||||
</div>
|
||||
|
||||
<div id="playerContent" class="playerContainer w-full z-20 absolute bottom-0 left-0 right-0 p-2 pointer-events-auto transition-all" :style="{ backgroundColor: showFullscreen ? '' : coverRgb }" @click="clickContainer">
|
||||
<div v-if="showFullscreen" class="absolute bottom-4 left-0 right-0 w-full py-3 mx-auto px-3" style="max-width: 380px">
|
||||
<div v-if="showFullscreen" class="absolute bottom-4 left-0 right-0 w-full pb-4 pt-2 mx-auto px-6" style="max-width: 414px">
|
||||
<div class="flex items-center justify-between pointer-events-auto">
|
||||
<span v-if="!isPodcast && isServerItem && networkConnected" class="material-icons text-3xl text-white text-opacity-75 cursor-pointer" @click="$emit('showBookmarks')">{{ bookmarks.length ? 'bookmark' : 'bookmark_border' }}</span>
|
||||
<!-- hidden for podcasts but still using this as a placeholder -->
|
||||
@@ -60,13 +60,13 @@
|
||||
<p class="text-xl font-mono text-success">{{ sleepTimeRemainingPretty }}</p>
|
||||
</div>
|
||||
|
||||
<span class="material-icons text-3xl text-white cursor-pointer" :class="chapters.length ? 'text-opacity-75' : 'text-opacity-10'" @click="showChapterModal = true">format_list_bulleted</span>
|
||||
<span class="material-icons text-3xl text-white cursor-pointer" :class="chapters.length ? 'text-opacity-75' : 'text-opacity-10'" @click="clickChaptersBtn">format_list_bulleted</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: linear-gradient(145deg, rgba(38, 38, 38, 0.5) 0%, rgba(38, 38, 38, 0.9) 20%, rgb(38, 38, 38) 60%)" />
|
||||
|
||||
<div id="playerControls" class="absolute right-0 bottom-0 py-2">
|
||||
<div class="flex items-center justify-center">
|
||||
<div id="playerControls" class="absolute right-0 bottom-0 mx-auto" style="max-width: 414px">
|
||||
<div class="flex items-center max-w-full" :class="lockUi ? 'justify-center' : 'justify-between'">
|
||||
<span v-show="showFullscreen && !lockUi" class="material-icons next-icon text-white text-opacity-75 cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
|
||||
<span v-show="!lockUi" class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span>
|
||||
<div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4 relative overflow-hidden" :style="{ backgroundColor: coverRgb }" :class="{ 'animate-spin': seekLoading }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
|
||||
@@ -80,20 +80,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="playerTrack" class="absolute left-0 w-full px-3">
|
||||
<div class="flex pb-0.5">
|
||||
<div id="playerTrack" class="absolute left-0 w-full px-6">
|
||||
<div class="flex pointer-events-none">
|
||||
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem" ref="currentTimestamp">0:00</p>
|
||||
<div class="flex-grow" />
|
||||
<p v-show="showFullscreen" class="text-sm truncate text-white text-opacity-75" style="max-width: 65%">{{ currentChapterTitle }}</p>
|
||||
<div class="flex-grow" />
|
||||
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem">{{ timeRemainingPretty }}</p>
|
||||
</div>
|
||||
<div ref="track" class="h-1.5 w-full bg-gray-500 bg-opacity-50 relative" :class="{ 'animate-pulse': isLoading }" @touchstart="touchstartTrack" @click="clickTrack">
|
||||
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="bufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="draggingTrack" class="h-full bg-warning bg-opacity-25 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="trackCursor" class="h-3.5 w-3.5 rounded-full bg-gray-200 absolute -top-1 pointer-events-none" :class="{ 'opacity-0': lockUi || !showFullscreen }" />
|
||||
<div ref="track" class="h-1.5 w-full bg-gray-500 bg-opacity-50 relative rounded-full" :class="{ 'animate-pulse': isLoading }" @click.stop>
|
||||
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||
<div ref="bufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||
<div ref="trackCursor" class="h-3.5 w-3.5 -top-1 rounded-full bg-gray-200 absolute pointer-events-auto" :class="{ 'opacity-0': lockUi || !showFullscreen }" @touchstart="touchstartCursor" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -151,8 +148,10 @@ export default {
|
||||
useTotalTrack: true,
|
||||
lockUi: false,
|
||||
isLoading: false,
|
||||
touchTrackStart: false,
|
||||
dragPercent: 0,
|
||||
isDraggingCursor: false,
|
||||
draggingTouchStartX: 0,
|
||||
draggingTouchStartTime: 0,
|
||||
draggingCurrentTime: 0,
|
||||
syncStatus: 0,
|
||||
showMoreMenuDialog: false,
|
||||
coverRgb: 'rgb(55, 56, 56)',
|
||||
@@ -287,6 +286,7 @@ export default {
|
||||
return this.playMethod == this.$constants.PlayMethod.DIRECTPLAY
|
||||
},
|
||||
title() {
|
||||
if (this.currentChapterTitle && this.showFullscreen) return this.currentChapterTitle
|
||||
if (this.playbackSession) return this.playbackSession.displayTitle
|
||||
return this.mediaMetadata ? this.mediaMetadata.title : 'Title'
|
||||
},
|
||||
@@ -318,17 +318,20 @@ export default {
|
||||
return this.$secondsToTimestamp(this.totalDuration)
|
||||
},
|
||||
currentTimePretty() {
|
||||
return this.$secondsToTimestamp(this.currentTime / this.currentPlaybackRate)
|
||||
let currentTimeToUse = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
|
||||
return this.$secondsToTimestamp(currentTimeToUse / this.currentPlaybackRate)
|
||||
},
|
||||
timeRemaining() {
|
||||
let currentTimeToUse = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
var currChapTime = this.currentTime - this.currentChapter.start
|
||||
var currChapTime = currentTimeToUse - this.currentChapter.start
|
||||
return (this.currentChapterDuration - currChapTime) / this.currentPlaybackRate
|
||||
}
|
||||
return this.totalTimeRemaining
|
||||
},
|
||||
totalTimeRemaining() {
|
||||
return (this.totalDuration - this.currentTime) / this.currentPlaybackRate
|
||||
let currentTimeToUse = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
|
||||
return (this.totalDuration - currentTimeToUse) / this.currentPlaybackRate
|
||||
},
|
||||
totalTimeRemainingPretty() {
|
||||
if (this.totalTimeRemaining < 0) {
|
||||
@@ -342,10 +345,6 @@ export default {
|
||||
}
|
||||
return '-' + this.$secondsToTimestamp(this.timeRemaining)
|
||||
},
|
||||
timeLeftInChapter() {
|
||||
if (!this.currentChapter) return 0
|
||||
return this.currentChapter.end - this.currentTime
|
||||
},
|
||||
sleepTimeRemainingPretty() {
|
||||
if (!this.sleepTimeRemaining) return '0s'
|
||||
var secondsRemaining = Math.round(this.sleepTimeRemaining)
|
||||
@@ -370,6 +369,10 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickChaptersBtn() {
|
||||
if (!this.chapters.length) return
|
||||
this.showChapterModal = true
|
||||
},
|
||||
async coverImageLoaded(fullCoverUrl) {
|
||||
if (!fullCoverUrl) return
|
||||
|
||||
@@ -392,11 +395,6 @@ export default {
|
||||
this.showFullscreen = false
|
||||
}
|
||||
},
|
||||
async touchstartTrack(e) {
|
||||
await this.$hapticsImpact()
|
||||
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen || this.lockUi) return
|
||||
this.touchTrackStart = true
|
||||
},
|
||||
async selectChapter(chapter) {
|
||||
await this.$hapticsImpact()
|
||||
this.seek(chapter.start)
|
||||
@@ -450,7 +448,6 @@ export default {
|
||||
this.$emit('showSleepTimer')
|
||||
},
|
||||
async setPlaybackSpeed(speed) {
|
||||
await this.$hapticsImpact()
|
||||
console.log(`[AudioPlayer] Set Playback Rate: ${speed}`)
|
||||
this.currentPlaybackRate = speed
|
||||
this.updateTimestamp()
|
||||
@@ -509,12 +506,13 @@ export default {
|
||||
console.error('No timestamp el')
|
||||
return
|
||||
}
|
||||
let currentTime = this.currentTime / this.currentPlaybackRate
|
||||
|
||||
let currentTime = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
const currChapTime = Math.max(0, this.currentTime - this.currentChapter.start)
|
||||
currentTime = currChapTime / this.currentPlaybackRate
|
||||
currentTime = Math.max(0, currentTime - this.currentChapter.start)
|
||||
}
|
||||
ts.innerText = this.$secondsToTimestamp(currentTime)
|
||||
|
||||
ts.innerText = this.$secondsToTimestamp(currentTime / this.currentPlaybackRate)
|
||||
},
|
||||
timeupdate() {
|
||||
if (!this.$refs.playedTrack) {
|
||||
@@ -536,22 +534,24 @@ export default {
|
||||
},
|
||||
updateTrack() {
|
||||
// Update progress track UI
|
||||
let percentDone = this.currentTime / this.totalDuration
|
||||
let currentTimeToUse = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
|
||||
let percentDone = currentTimeToUse / this.totalDuration
|
||||
const totalPercentDone = percentDone
|
||||
let bufferedPercent = this.bufferedTime / this.totalDuration
|
||||
const totalBufferedPercent = bufferedPercent
|
||||
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
const currChapTime = this.currentTime - this.currentChapter.start
|
||||
const currChapTime = currentTimeToUse - this.currentChapter.start
|
||||
percentDone = currChapTime / this.currentChapterDuration
|
||||
bufferedPercent = Math.max(0, Math.min(1, (this.bufferedTime - this.currentChapter.start) / this.currentChapterDuration))
|
||||
}
|
||||
|
||||
const ptWidth = Math.round(percentDone * this.trackWidth)
|
||||
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
||||
this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px'
|
||||
|
||||
if (this.$refs.trackCursor) {
|
||||
this.$refs.trackCursor.style.left = ptWidth - 8 + 'px'
|
||||
this.$refs.trackCursor.style.left = ptWidth - 7 + 'px'
|
||||
}
|
||||
|
||||
if (this.useChapterTrack) {
|
||||
@@ -580,27 +580,15 @@ export default {
|
||||
this.$refs.playedTrack.classList.add('bg-yellow-300')
|
||||
}
|
||||
},
|
||||
clickTrack(e) {
|
||||
if (this.isLoading || this.lockUi) return
|
||||
if (!this.showFullscreen) {
|
||||
// Track not clickable on mini-player
|
||||
return
|
||||
}
|
||||
if (e) e.stopPropagation()
|
||||
async touchstartCursor(e) {
|
||||
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen || this.lockUi) return
|
||||
|
||||
var offsetX = e.offsetX
|
||||
var perc = offsetX / this.trackWidth
|
||||
var time = 0
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
time = perc * this.currentChapterDuration + this.currentChapter.start
|
||||
} else {
|
||||
time = perc * this.totalDuration
|
||||
}
|
||||
if (isNaN(time) || time === null) {
|
||||
console.error('Invalid time', perc, time)
|
||||
return
|
||||
}
|
||||
this.seek(time)
|
||||
await this.$hapticsImpact()
|
||||
this.isDraggingCursor = true
|
||||
this.draggingTouchStartX = e.touches[0].pageX
|
||||
this.draggingTouchStartTime = this.currentTime
|
||||
this.draggingCurrentTime = this.currentTime
|
||||
this.updateTrack()
|
||||
},
|
||||
async playPauseClick() {
|
||||
await this.$hapticsImpact()
|
||||
@@ -653,24 +641,11 @@ export default {
|
||||
touchend(e) {
|
||||
if (!e.changedTouches) return
|
||||
|
||||
if (this.touchTrackStart) {
|
||||
var touch = e.changedTouches[0]
|
||||
const touchOnTrackPos = touch.pageX - 12
|
||||
const dragPercent = Math.max(0, Math.min(1, touchOnTrackPos / this.trackWidth))
|
||||
|
||||
var seekToTime = 0
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
const currChapTime = dragPercent * this.currentChapterDuration
|
||||
seekToTime = this.currentChapter.start + currChapTime
|
||||
} else {
|
||||
seekToTime = dragPercent * this.totalDuration
|
||||
if (this.isDraggingCursor) {
|
||||
if (this.draggingCurrentTime !== this.currentTime) {
|
||||
this.seek(this.draggingCurrentTime)
|
||||
}
|
||||
this.seek(seekToTime)
|
||||
|
||||
if (this.$refs.draggingTrack) {
|
||||
this.$refs.draggingTrack.style.width = '0px'
|
||||
}
|
||||
this.touchTrackStart = false
|
||||
this.isDraggingCursor = false
|
||||
} else if (this.showFullscreen) {
|
||||
this.touchEndY = e.changedTouches[0].screenY
|
||||
var touchDuration = Date.now() - this.touchStartTime
|
||||
@@ -682,29 +657,24 @@ export default {
|
||||
}
|
||||
},
|
||||
touchmove(e) {
|
||||
if (!this.touchTrackStart) return
|
||||
if (!this.isDraggingCursor || !e.touches) return
|
||||
|
||||
var touch = e.touches[0]
|
||||
const touchOnTrackPos = touch.pageX - 12
|
||||
const dragPercent = Math.max(0, Math.min(1, touchOnTrackPos / this.trackWidth))
|
||||
this.dragPercent = dragPercent
|
||||
|
||||
if (this.$refs.draggingTrack) {
|
||||
this.$refs.draggingTrack.style.width = this.dragPercent * this.trackWidth + 'px'
|
||||
const distanceMoved = e.touches[0].pageX - this.draggingTouchStartX
|
||||
let duration = this.totalDuration
|
||||
let minTime = 0
|
||||
let maxTime = duration
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
duration = this.currentChapterDuration
|
||||
minTime = this.currentChapter.start
|
||||
maxTime = minTime + duration
|
||||
}
|
||||
|
||||
var ts = this.$refs.currentTimestamp
|
||||
if (ts) {
|
||||
var currTimeStr = ''
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
const currChapTime = dragPercent * this.currentChapterDuration
|
||||
currTimeStr = this.$secondsToTimestamp(currChapTime)
|
||||
} else {
|
||||
const dragTime = dragPercent * this.totalDuration
|
||||
currTimeStr = this.$secondsToTimestamp(dragTime)
|
||||
}
|
||||
ts.innerText = currTimeStr
|
||||
}
|
||||
const timePerPixel = duration / this.trackWidth
|
||||
const newTime = this.draggingTouchStartTime + timePerPixel * distanceMoved
|
||||
this.draggingCurrentTime = Math.min(maxTime, Math.max(minTime, newTime))
|
||||
|
||||
this.updateTimestamp()
|
||||
this.updateTrack()
|
||||
},
|
||||
async clickMenuAction(action) {
|
||||
await this.$hapticsImpact()
|
||||
@@ -850,7 +820,7 @@ export default {
|
||||
document.documentElement.style.setProperty('--cover-image-height', coverHeight + 'px')
|
||||
document.documentElement.style.setProperty('--cover-image-width-collapsed', coverImageWidthCollapsed + 'px')
|
||||
document.documentElement.style.setProperty('--cover-image-height-collapsed', 46 + 'px')
|
||||
document.documentElement.style.setProperty('--title-author-left-offset-collapsed', 24 + coverImageWidthCollapsed + 'px')
|
||||
document.documentElement.style.setProperty('--title-author-left-offset-collapsed', 30 + coverImageWidthCollapsed + 'px')
|
||||
},
|
||||
minimizePlayerEvt() {
|
||||
this.collapseFullscreen()
|
||||
@@ -917,7 +887,7 @@ export default {
|
||||
--cover-image-height: 0px;
|
||||
--cover-image-width-collapsed: 46px;
|
||||
--cover-image-height-collapsed: 46px;
|
||||
--title-author-left-offset-collapsed: 70px;
|
||||
--title-author-left-offset-collapsed: 80px;
|
||||
}
|
||||
|
||||
.playerContainer {
|
||||
@@ -944,12 +914,14 @@ export default {
|
||||
|
||||
.cover-wrapper {
|
||||
bottom: 68px;
|
||||
left: 12px;
|
||||
left: 24px;
|
||||
height: var(--cover-image-height-collapsed);
|
||||
width: var(--cover-image-width-collapsed);
|
||||
transition: all 0.25s cubic-bezier(0.39, 0.575, 0.565, 1);
|
||||
transition-property: left, bottom, width, height;
|
||||
transform-origin: left bottom;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.total-track {
|
||||
@@ -990,19 +962,17 @@ export default {
|
||||
pointer-events: auto;
|
||||
}
|
||||
.fullscreen .title-author-texts .title-text {
|
||||
font-size: clamp(0.8rem, calc(var(--cover-image-height) / 260 * 20), 1.5rem);
|
||||
font-size: clamp(0.8rem, calc(var(--cover-image-height) / 260 * 20), 1.3rem);
|
||||
}
|
||||
.fullscreen .title-author-texts .author-text {
|
||||
font-size: clamp(0.6rem, calc(var(--cover-image-height) / 260 * 16), 1.1rem);
|
||||
font-size: clamp(0.6rem, calc(var(--cover-image-height) / 260 * 16), 1rem);
|
||||
}
|
||||
|
||||
#playerControls {
|
||||
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
||||
transition-property: width, bottom;
|
||||
height: 48px;
|
||||
width: 140px;
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
width: 128px;
|
||||
padding-right: 24px;
|
||||
bottom: 70px;
|
||||
}
|
||||
#playerControls .jump-icon {
|
||||
@@ -1020,7 +990,7 @@ export default {
|
||||
width: 40px;
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
margin: 0px 14px;
|
||||
margin: 0px 7px;
|
||||
}
|
||||
#playerControls .play-btn .material-icons {
|
||||
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
||||
@@ -1035,18 +1005,21 @@ export default {
|
||||
width: var(--cover-image-width);
|
||||
left: calc(50% - (calc(var(--cover-image-width)) / 2));
|
||||
bottom: calc(50% + 120px - (calc(var(--cover-image-height)) / 2));
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fullscreen #playerControls {
|
||||
width: 100%;
|
||||
bottom: 94px;
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
bottom: 78px;
|
||||
left: 0;
|
||||
}
|
||||
.fullscreen #playerControls .jump-icon {
|
||||
margin: 0px 18px;
|
||||
font-size: 2.4rem;
|
||||
}
|
||||
.fullscreen #playerControls .next-icon {
|
||||
margin: 0px 20px;
|
||||
font-size: 2rem;
|
||||
}
|
||||
.fullscreen #playerControls .play-btn {
|
||||
@@ -1054,7 +1027,6 @@ export default {
|
||||
width: 65px;
|
||||
min-width: 65px;
|
||||
min-height: 65px;
|
||||
margin: 0px 26px;
|
||||
}
|
||||
.fullscreen #playerControls .play-btn .material-icons {
|
||||
font-size: 2.1rem;
|
||||
|
||||
@@ -214,6 +214,8 @@ export default {
|
||||
if (startTime !== undefined && startTime !== null) {
|
||||
// seek to start time
|
||||
AbsAudioPlayer.seek({ value: Math.floor(startTime) })
|
||||
} else if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.play()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -50,6 +50,9 @@ export default {
|
||||
watch: {
|
||||
showBookshelfListView(newVal) {
|
||||
this.resetEntities()
|
||||
},
|
||||
seriesId() {
|
||||
this.resetEntities()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -85,6 +88,12 @@ export default {
|
||||
filterBy() {
|
||||
return this.$store.getters['user/getUserSetting']('mobileFilterBy')
|
||||
},
|
||||
collapseSeries() {
|
||||
return this.$store.getters['user/getUserSetting']('collapseSeries')
|
||||
},
|
||||
collapseBookSeries() {
|
||||
return this.$store.getters['user/getUserSetting']('collapseBookSeries')
|
||||
},
|
||||
isCoverSquareAspectRatio() {
|
||||
return this.bookCoverAspectRatio === 1
|
||||
},
|
||||
@@ -356,6 +365,9 @@ export default {
|
||||
let searchParams = new URLSearchParams()
|
||||
if (this.page === 'series-books') {
|
||||
searchParams.set('filter', `series.${this.$encode(this.seriesId)}`)
|
||||
if (this.collapseBookSeries) {
|
||||
searchParams.set('collapseseries', 1)
|
||||
}
|
||||
} else {
|
||||
if (this.filterBy && this.filterBy !== 'all') {
|
||||
searchParams.set('filter', this.filterBy)
|
||||
@@ -452,6 +464,12 @@ export default {
|
||||
this.libraryItemUpdated(ab)
|
||||
})
|
||||
},
|
||||
screenOrientationChange() {
|
||||
setTimeout(() => {
|
||||
console.log('LazyBookshelf Screen orientation change')
|
||||
this.resetEntities()
|
||||
}, 50)
|
||||
},
|
||||
initListeners() {
|
||||
const bookshelf = document.getElementById('bookshelf-wrapper')
|
||||
if (bookshelf) {
|
||||
@@ -466,6 +484,13 @@ export default {
|
||||
this.$socket.$on('item_removed', this.libraryItemRemoved)
|
||||
this.$socket.$on('items_updated', this.libraryItemsUpdated)
|
||||
this.$socket.$on('items_added', this.libraryItemsAdded)
|
||||
|
||||
if (screen.orientation) {
|
||||
// Not available on ios
|
||||
screen.orientation.addEventListener('change', this.screenOrientationChange)
|
||||
} else {
|
||||
document.addEventListener('orientationchange', this.screenOrientationChange)
|
||||
}
|
||||
},
|
||||
removeListeners() {
|
||||
const bookshelf = document.getElementById('bookshelf-wrapper')
|
||||
@@ -481,6 +506,13 @@ export default {
|
||||
this.$socket.$off('item_removed', this.libraryItemRemoved)
|
||||
this.$socket.$off('items_updated', this.libraryItemsUpdated)
|
||||
this.$socket.$off('items_added', this.libraryItemsAdded)
|
||||
|
||||
if (screen.orientation) {
|
||||
// Not available on ios
|
||||
screen.orientation.removeEventListener('change', this.screenOrientationChange)
|
||||
} else {
|
||||
document.removeEventListener('orientationchange', this.screenOrientationChange)
|
||||
}
|
||||
}
|
||||
},
|
||||
updated() {
|
||||
|
||||
@@ -7,26 +7,6 @@
|
||||
<p class="truncate text-sm">{{ name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div class="flex h-full px-1 overflow-hidden shadow-sm">
|
||||
<div style="max-height: 48px; max-width: 48px" class="w-12 h-12 bg-primary overflow-hidden rounded">
|
||||
<svg width="140%" height="140%" style="margin-left: -20%; margin-top: -20%; opacity: 0.6" viewBox="0 0 177 266" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="white" d="M40.7156 165.47C10.2694 150.865 -31.5407 148.629 -38.0532 155.529L63.3191 204.159L76.9443 190.899C66.828 181.394 54.006 171.846 40.7156 165.47Z" stroke="white" stroke-width="4" transform="translate(-2 -1)" />
|
||||
<path d="M-38.0532 155.529C-31.5407 148.629 10.2694 150.865 40.7156 165.47C54.006 171.846 66.828 181.394 76.9443 190.899L95.0391 173.37C80.6681 159.403 64.7526 149.155 51.5747 142.834C21.3549 128.337 -46.2471 114.563 -60.6897 144.67L-71.5489 167.307L44.5864 223.019L63.3191 204.159L-38.0532 155.529Z" fill="white" />
|
||||
<path
|
||||
d="M105.87 29.6508C80.857 17.6515 50.8784 28.1923 38.879 53.2056C26.8797 78.219 37.4205 108.198 62.4338 120.197C87.4472 132.196 117.426 121.656 129.425 96.6422C141.425 71.6288 130.884 41.6502 105.87 29.6508ZM106.789 85.783C112.761 73.3329 107.461 58.2599 95.0112 52.2874C82.5611 46.3148 67.4881 51.6147 61.5156 64.0648C55.543 76.5149 60.8429 91.5879 73.293 97.5604C85.7431 103.533 100.816 98.2331 106.789 85.783Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01ZM181.725 108.497C179.624 108.491 177.436 109.326 175.835 110.918L160.415 126.257L191.848 157.856L207.268 142.517C210.554 139.248 210.568 133.954 207.299 130.667L187.685 110.95C186.009 109.264 183.91 108.502 181.725 108.497ZM151.399 135.226L58.2034 227.931L58.1203 259.447L89.6359 259.53L182.831 166.825L151.399 135.226Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01Z" fill="white" stroke="white" stroke-width="10px" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-grow px-2 authorSearchCardContent h-full">
|
||||
<p class="truncate text-sm">{{ author }}</p>
|
||||
</div>
|
||||
</div> -->
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<p v-if="matchKey !== 'authors'" class="text-xs text-gray-200 truncate">by {{ authorName }}</p>
|
||||
<p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
|
||||
|
||||
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
|
||||
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin' || matchKey === 'episode' || matchKey === 'narrators'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -78,11 +78,14 @@ export default {
|
||||
var lastPart = this.matchText.substr(totalLenSoFar)
|
||||
html += lastPart
|
||||
|
||||
if (this.matchKey === 'episode') return `<p class="truncate">Episode: ${html}</p>`
|
||||
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
|
||||
if (this.matchKey === 'subtitle') return `<p class="truncate">${html}</p>`
|
||||
if (this.matchKey === 'authors') return `by ${html}`
|
||||
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`
|
||||
if (this.matchKey === 'asin') return `<p class="truncate">ASIN: ${html}</p>`
|
||||
if (this.matchKey === 'series') return `<p class="truncate">Series: ${html}</p>`
|
||||
if (this.matchKey === 'narrators') return `<p class="truncate">Narrator: ${html}</p>`
|
||||
return `${html}`
|
||||
}
|
||||
},
|
||||
|
||||
@@ -10,11 +10,16 @@
|
||||
<p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
|
||||
{{ displayTitle }}
|
||||
</p>
|
||||
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayAuthor || ' ' }}</p>
|
||||
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayLineTwo || ' ' }}</p>
|
||||
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="booksInSeries" class="absolute z-20 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ booksInSeries }}</div>
|
||||
<div v-if="seriesSequenceList" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20 text-right" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }" style="background-color: #78350f">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequenceList }}</p>
|
||||
</div>
|
||||
<div v-else-if="booksInSeries" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }" style="background-color: #cd9d49dd">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ booksInSeries }}</p>
|
||||
</div>
|
||||
|
||||
<div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10">
|
||||
<div v-show="libraryItem && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: sizeMultiplier * 0.5 + 'rem' }">
|
||||
@@ -35,7 +40,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Play/pause button for podcast episode -->
|
||||
<div v-if="recentEpisode" class="absolute z-10 top-0 left-0 bottom-0 right-0 m-auto flex items-center justify-center w-12 h-12 rounded-full bg-white bg-opacity-70">
|
||||
<div v-if="recentEpisode" class="absolute z-10 top-0 left-0 bottom-0 right-0 m-auto flex items-center justify-center w-12 h-12 rounded-full bg-white bg-opacity-70" @click.stop="playEpisode">
|
||||
<span class="material-icons text-6xl text-black text-opacity-80">{{ streamIsPlaying ? 'pause_circle' : 'play_circle_filled' }}</span>
|
||||
</div>
|
||||
|
||||
@@ -57,8 +62,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Podcast Episode # -->
|
||||
<div v-if="recentEpisodeNumber && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">Episode #{{ recentEpisodeNumber }}</p>
|
||||
<div v-if="recentEpisodeNumber !== null && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">
|
||||
Episode<span v-if="recentEpisodeNumber"> #{{ recentEpisodeNumber }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Podcast Num Episodes -->
|
||||
@@ -216,7 +223,7 @@ export default {
|
||||
if (this.recentEpisode.episode) {
|
||||
return this.recentEpisode.episode.replace(/^#/, '')
|
||||
}
|
||||
return this.recentEpisode.index
|
||||
return ''
|
||||
},
|
||||
collapsedSeries() {
|
||||
// Only added to item object when collapseSeries is enabled
|
||||
@@ -226,22 +233,36 @@ export default {
|
||||
// Only added to item object when collapseSeries is enabled
|
||||
return this.collapsedSeries ? this.collapsedSeries.numBooks : 0
|
||||
},
|
||||
displayTitle() {
|
||||
if (this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix && this.title.toLowerCase().startsWith('the ')) {
|
||||
return this.title.substr(4) + ', The'
|
||||
}
|
||||
return this.title
|
||||
seriesSequenceList() {
|
||||
return this.collapsedSeries ? this.collapsedSeries.seriesSequenceList : null
|
||||
},
|
||||
displayAuthor() {
|
||||
libraryItemIdsInSeries() {
|
||||
// Only added to item object when collapseSeries is enabled
|
||||
return this.collapsedSeries ? this.collapsedSeries.libraryItemIds || [] : []
|
||||
},
|
||||
displayTitle() {
|
||||
if (this.recentEpisode) return this.recentEpisode.title
|
||||
|
||||
const ignorePrefix = this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix
|
||||
if (this.collapsedSeries) return ignorePrefix ? this.collapsedSeries.nameIgnorePrefix : this.collapsedSeries.name
|
||||
return ignorePrefix ? this.mediaMetadata.titleIgnorePrefix : this.title
|
||||
},
|
||||
displayLineTwo() {
|
||||
if (this.recentEpisode) return this.title
|
||||
if (this.collapsedSeries) return ''
|
||||
if (this.isPodcast) return this.author
|
||||
|
||||
if (this.orderBy === 'media.metadata.authorNameLF') return this.authorLF
|
||||
return this.author
|
||||
},
|
||||
displaySortLine() {
|
||||
if (this.collapsedSeries) return null
|
||||
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs)
|
||||
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs)
|
||||
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt)
|
||||
if (this.orderBy === 'media.duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
|
||||
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
|
||||
if (this.orderBy === 'media.numTracks') return `${this.numEpisodes} Episodes`
|
||||
return null
|
||||
},
|
||||
episodeProgress() {
|
||||
@@ -255,8 +276,13 @@ export default {
|
||||
if (this.isLocal) return this.store.getters['globals/getLocalMediaProgressById'](this.libraryItemId)
|
||||
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
||||
},
|
||||
useEBookProgress() {
|
||||
if (!this.userProgress || this.userProgress.progress) return false
|
||||
return this.userProgress.ebookProgress > 0
|
||||
},
|
||||
userProgressPercent() {
|
||||
return this.userProgress ? this.userProgress.progress || 0 : 0
|
||||
if (this.useEBookProgress) return Math.max(Math.min(1, this.userProgress.ebookProgress), 0)
|
||||
return this.userProgress ? Math.max(Math.min(1, this.userProgress.progress), 0) || 0 : 0
|
||||
},
|
||||
itemIsFinished() {
|
||||
return this.userProgress ? !!this.userProgress.isFinished : false
|
||||
@@ -401,38 +427,40 @@ export default {
|
||||
// Server books may have a local library item
|
||||
this.localLibraryItem = localLibraryItem
|
||||
},
|
||||
async playEpisode() {
|
||||
await this.$hapticsImpact()
|
||||
const eventBus = this.$eventBus || this.$nuxt.$eventBus
|
||||
if (this.streamIsPlaying) {
|
||||
eventBus.$emit('pause-item')
|
||||
return
|
||||
}
|
||||
|
||||
if (this.localLibraryItem) {
|
||||
const localEpisode = this.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId === this.recentEpisode.id)
|
||||
if (localEpisode) {
|
||||
// Play episode locally
|
||||
eventBus.$emit('play-item', {
|
||||
libraryItemId: this.localLibraryItemId,
|
||||
episodeId: localEpisode.id,
|
||||
serverLibraryItemId: this.libraryItemId,
|
||||
serverEpisodeId: this.recentEpisode.id
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
eventBus.$emit('play-item', { libraryItemId: this.libraryItemId, episodeId: this.recentEpisode.id })
|
||||
},
|
||||
async clickCard(e) {
|
||||
if (this.isSelectionMode) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
this.selectBtnClick()
|
||||
} else if (this.recentEpisode) {
|
||||
await this.$hapticsImpact()
|
||||
var eventBus = this.$eventBus || this.$nuxt.$eventBus
|
||||
if (this.streamIsPlaying) {
|
||||
eventBus.$emit('pause-item')
|
||||
return
|
||||
}
|
||||
|
||||
if (this.localLibraryItem) {
|
||||
const localEpisode = this.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId === this.recentEpisode.id)
|
||||
if (localEpisode) {
|
||||
// Play episode locally
|
||||
eventBus.$emit('play-item', {
|
||||
libraryItemId: this.localLibraryItemId,
|
||||
episodeId: localEpisode.id,
|
||||
serverLibraryItemId: this.libraryItemId,
|
||||
serverEpisodeId: this.recentEpisode.id
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
eventBus.$emit('play-item', { libraryItemId: this.libraryItemId, episodeId: this.recentEpisode.id })
|
||||
} else {
|
||||
var router = this.$router || this.$nuxt.$router
|
||||
const router = this.$router || this.$nuxt.$router
|
||||
if (router) {
|
||||
if (this.collapsedSeries) router.push(`/library/${this.libraryId}/series/${this.collapsedSeries.id}`)
|
||||
if (this.recentEpisode) router.push(`/item/${this.libraryItemId}/${this.recentEpisode.id}`)
|
||||
else if (this.collapsedSeries) router.push(`/bookshelf/series/${this.collapsedSeries.id}`)
|
||||
else router.push(`/item/${this.libraryItemId}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="title" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
|
||||
<div v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b w-full" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" />
|
||||
|
||||
<div v-if="isAltViewEnabled && isCategorized" class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||
</div>
|
||||
@@ -53,6 +55,27 @@ export default {
|
||||
books() {
|
||||
return this.series ? this.series.books || [] : []
|
||||
},
|
||||
seriesBookProgress() {
|
||||
return this.books
|
||||
.map((libraryItem) => {
|
||||
return this.store.getters['user/getUserMediaProgress'](libraryItem.id)
|
||||
})
|
||||
.filter((p) => !!p)
|
||||
},
|
||||
seriesBooksFinished() {
|
||||
return this.seriesBookProgress.filter((p) => p.isFinished)
|
||||
},
|
||||
hasSeriesBookInProgress() {
|
||||
return this.seriesBookProgress.some((p) => !p.isFinished && p.progress > 0)
|
||||
},
|
||||
seriesPercentInProgress() {
|
||||
let totalFinishedAndInProgress = this.seriesBooksFinished.length
|
||||
if (this.hasSeriesBookInProgress) totalFinishedAndInProgress += 1
|
||||
return Math.min(1, Math.max(0, totalFinishedAndInProgress / this.books.length))
|
||||
},
|
||||
isSeriesFinished() {
|
||||
return this.books.length === this.seriesBooksFinished.length
|
||||
},
|
||||
store() {
|
||||
return this.$store || this.$nuxt.$store
|
||||
},
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="flex h-full px-1 overflow-hidden">
|
||||
<div class="w-10 h-10 flex items-center justify-center">
|
||||
<span class="material-icons text-2xl text-gray-200">record_voice_over</span>
|
||||
</div>
|
||||
<div class="flex-grow px-2 narratorSearchCardContent h-full">
|
||||
<p class="truncate text-sm">{{ narrator }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
narrator: String
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.narratorSearchCardContent {
|
||||
width: calc(100% - 40px);
|
||||
height: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -101,15 +101,15 @@ export default {
|
||||
icon: 'collections_bookmark',
|
||||
iconClass: 'text-xl',
|
||||
text: 'Collections'
|
||||
},
|
||||
{
|
||||
to: '/bookshelf/authors',
|
||||
routeName: 'bookshelf-authors',
|
||||
iconPack: 'abs-icons',
|
||||
icon: 'authors',
|
||||
iconClass: 'text-2xl',
|
||||
text: 'Authors'
|
||||
}
|
||||
// {
|
||||
// to: '/bookshelf/authors',
|
||||
// routeName: 'bookshelf-authors',
|
||||
// iconPack: 'abs-icons',
|
||||
// icon: 'authors',
|
||||
// iconClass: 'text-2xl pb-px',
|
||||
// text: 'Authors'
|
||||
// }
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,8 @@
|
||||
<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="pt-1">{{ totalEntities }} {{ entityTitle }}</p>
|
||||
<p v-show="selectedSeriesName" class="ml-2pt-1">{{ selectedSeriesName }} ({{ totalEntities }})</p>
|
||||
<p v-show="selectedSeriesName" class="ml-2 pt-1">{{ selectedSeriesName }} ({{ totalEntities }})</p>
|
||||
<div class="flex-grow" />
|
||||
<span v-if="page == 'library' || seriesBookPage" class="material-icons px-2" @click="changeView">{{ !bookshelfListView ? 'view_list' : 'grid_view' }}</span>
|
||||
<template v-if="page === 'library'">
|
||||
@@ -16,11 +13,13 @@
|
||||
</div>
|
||||
<span class="material-icons px-2" @click="showSortModal = true">sort</span>
|
||||
</template>
|
||||
<span v-if="(page == 'library' && isBookLibrary) || seriesBookPage" class="material-icons px-2" @click="showMoreMenuDialog = true">more_vert</span>
|
||||
</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" />
|
||||
<modals-dialog v-model="showMoreMenuDialog" :items="menuItems" @action="clickMenuAction" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -31,7 +30,8 @@ export default {
|
||||
showSortModal: false,
|
||||
showFilterModal: false,
|
||||
settings: {},
|
||||
totalEntities: 0
|
||||
totalEntities: 0,
|
||||
showMoreMenuDialog: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -44,6 +44,12 @@ export default {
|
||||
this.$store.commit('globals/setBookshelfListView', val)
|
||||
}
|
||||
},
|
||||
currentLibraryMediaType() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||
},
|
||||
isBookLibrary() {
|
||||
return this.currentLibraryMediaType === 'book'
|
||||
},
|
||||
hasFilters() {
|
||||
return this.$store.getters['user/getUserSetting']('mobileFilterBy') !== 'all'
|
||||
},
|
||||
@@ -66,6 +72,8 @@ export default {
|
||||
return 'Collections'
|
||||
} else if (this.page === 'playlists') {
|
||||
return 'Playlists'
|
||||
} else if (this.page === 'authors') {
|
||||
return 'Authors'
|
||||
}
|
||||
return ''
|
||||
},
|
||||
@@ -77,9 +85,40 @@ export default {
|
||||
},
|
||||
isPodcast() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType'] === 'podcast'
|
||||
},
|
||||
menuItems() {
|
||||
if (!this.isBookLibrary) return []
|
||||
|
||||
if (this.seriesBookPage) {
|
||||
return [
|
||||
{
|
||||
text: 'Collapse Sub-Series',
|
||||
value: 'collapse_subseries',
|
||||
icon: this.settings.collapseBookSeries ? 'check_box' : 'check_box_outline_blank'
|
||||
}
|
||||
]
|
||||
} else {
|
||||
return [
|
||||
{
|
||||
text: 'Collapse Series',
|
||||
value: 'collapse_series',
|
||||
icon: this.settings.collapseSeries ? 'check_box' : 'check_box_outline_blank'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickMenuAction(action) {
|
||||
this.showMoreMenuDialog = false
|
||||
if (action === 'collapse_series') {
|
||||
this.settings.collapseSeries = !this.settings.collapseSeries
|
||||
this.saveSettings()
|
||||
} else if (action === 'collapse_subseries') {
|
||||
this.settings.collapseBookSeries = !this.settings.collapseBookSeries
|
||||
this.saveSettings()
|
||||
}
|
||||
},
|
||||
updateOrder() {
|
||||
this.saveSettings()
|
||||
},
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="200" height="100%">
|
||||
<template #outer>
|
||||
<div class="absolute top-8 left-4 z-40">
|
||||
<p class="text-white text-2xl truncate">Auto Rewind Time</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center"
|
||||
@click="
|
||||
show = false
|
||||
manualTimerModal = false
|
||||
"
|
||||
>
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||
<div v-if="manualTimerModal" class="p-4">
|
||||
<div class="flex mb-4" @click="manualTimerModal = false">
|
||||
<span class="material-icons text-3xl">arrow_back</span>
|
||||
</div>
|
||||
<div class="flex my-2 justify-between">
|
||||
<ui-btn @click="decreaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">remove</span></ui-btn>
|
||||
<p class="text-2xl font-mono text-center">{{ manualTimeoutMin }} min</p>
|
||||
<ui-btn @click="increaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">add</span></ui-btn>
|
||||
</div>
|
||||
<ui-btn @click="clickedOption(manualTimeoutMin)" class="w-full">Set Timer</ui-btn>
|
||||
</div>
|
||||
<ul v-else class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="timeout in timeouts">
|
||||
<li :key="timeout" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption(timeout)">
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="font-normal block truncate text-lg">{{ timeout }} min</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="manualTimerModal = true">
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="font-normal block truncate text-lg text-center">Custom time</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
manualTimerModal: null,
|
||||
manualTimeoutMin: 1
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
timeouts() {
|
||||
return [5, 10, 15, 30, 45, 60, 90]
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async clickedChapterOption() {
|
||||
await this.$hapticsImpact()
|
||||
this.show = false
|
||||
this.$nextTick(() => this.$emit('change', 0))
|
||||
},
|
||||
async clickedOption(timeoutMin) {
|
||||
await this.$hapticsImpact()
|
||||
const timeout = timeoutMin * 1000 * 60
|
||||
this.show = false
|
||||
this.manualTimerModal = false
|
||||
this.$nextTick(() => this.$emit('change', timeout))
|
||||
},
|
||||
async increaseManualTimeout() {
|
||||
await this.$hapticsImpact()
|
||||
this.manualTimeoutMin++
|
||||
},
|
||||
async decreaseManualTimeout() {
|
||||
await this.$hapticsImpact()
|
||||
if (this.manualTimeoutMin > 1) this.manualTimeoutMin--
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="300" height="100%">
|
||||
<template #outer>
|
||||
<div class="absolute top-5 left-4 z-40">
|
||||
<div class="absolute top-8 left-4 z-40">
|
||||
<p class="text-white text-2xl truncate">Bookmarks</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="350" height="100%">
|
||||
<template #outer>
|
||||
<div v-if="currentChapter" class="absolute top-7 left-4 z-40" style="max-width: 80%">
|
||||
<div v-if="currentChapter" class="absolute top-8 left-4 z-40 pt-0.5" style="max-width: 80%">
|
||||
<p class="text-white text-lg truncate">Current: {{ currentChapterTitle }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="300" height="100%">
|
||||
<template #outer>
|
||||
<div v-if="title" class="absolute top-7 left-4 z-40" style="max-width: 80%">
|
||||
<p class="text-white text-lg truncate">{{ title }}</p>
|
||||
<div v-if="title" class="absolute top-8 left-4 z-40" style="max-width: 80%">
|
||||
<p class="text-white text-xl truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" width="90%" height="100%">
|
||||
<template #outer>
|
||||
<div v-show="selected !== 'all'" class="absolute top-4 left-4 z-40">
|
||||
<div v-show="selected !== 'all'" class="absolute top-10 left-4 z-40">
|
||||
<ui-btn class="text-xl border-yellow-400 border-opacity-40" @click="clearSelected">Clear</ui-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="400" height="100%">
|
||||
<template #outer>
|
||||
<div class="absolute top-5 left-4 z-40">
|
||||
<div class="absolute top-8 left-4 z-40">
|
||||
<p class="text-white text-2xl truncate">Details</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20 p-2" style="max-height: 75%" @click.stop>
|
||||
<p class="mb-1">{{ mediaMetadata.title }}</p>
|
||||
<p class="mb-2">{{ mediaMetadata.title }}</p>
|
||||
|
||||
<div v-if="size" class="text-sm mb-2">Size: {{ $bytesPretty(size) }}</div>
|
||||
|
||||
<p class="mb-1 text-xs text-gray-200">ID: {{ _libraryItem.id }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,6 +47,9 @@ export default {
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
size() {
|
||||
return this.media.size
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="300" :processing="processing" height="100%">
|
||||
<template #outer>
|
||||
<div class="absolute top-4 left-4 z-40" style="max-width: 80%">
|
||||
<div class="absolute top-8 left-4 z-40" style="max-width: 80%">
|
||||
<p class="text-white text-2xl truncate">Libraries</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div ref="wrapper" class="modal modal-bg w-full h-full max-h-screen fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-50 opacity-0">
|
||||
<div class="absolute top-0 left-0 w-full h-40 bg-gradient-to-b from-black to-transparent opacity-90 pointer-events-none" />
|
||||
|
||||
<div class="absolute z-40 top-4 right-4 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="show = false">
|
||||
<div class="absolute z-40 top-10 right-4 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="show = false">
|
||||
<span class="material-icons text-4xl">close</span>
|
||||
</div>
|
||||
<slot name="outer" />
|
||||
@@ -70,9 +70,9 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickBg(vm, ev) {
|
||||
clickBg(ev) {
|
||||
if (this.processing && this.persistent) return
|
||||
if (vm.srcElement.classList.contains('modal-bg')) {
|
||||
if (ev && ev.srcElement && ev.srcElement.classList && ev.srcElement.classList.contains('modal-bg')) {
|
||||
this.show = false
|
||||
}
|
||||
},
|
||||
|
||||
@@ -22,7 +22,8 @@ export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
orderBy: String,
|
||||
descending: Boolean
|
||||
descending: Boolean,
|
||||
episodes: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -89,6 +90,24 @@ export default {
|
||||
text: 'File Modified',
|
||||
value: 'mtimeMs'
|
||||
}
|
||||
],
|
||||
episodeItems: [
|
||||
{
|
||||
text: 'Pub Date',
|
||||
value: 'publishedAt'
|
||||
},
|
||||
{
|
||||
text: 'Title',
|
||||
value: 'title'
|
||||
},
|
||||
{
|
||||
text: 'Season',
|
||||
value: 'season'
|
||||
},
|
||||
{
|
||||
text: 'Episode',
|
||||
value: 'episode'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -121,6 +140,7 @@ export default {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType'] === 'podcast'
|
||||
},
|
||||
items() {
|
||||
if (this.episodes) return this.episodeItems
|
||||
if (this.isPodcast) return this.podcastItems
|
||||
return this.bookItems
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" @input="modalInput" :width="200" height="100%">
|
||||
<template #outer>
|
||||
<div class="absolute top-5 left-4 z-40">
|
||||
<div class="absolute top-8 left-4 z-40">
|
||||
<p class="text-white text-2xl truncate">Playback Speed</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" width="100%" height="100%" max-width="100%">
|
||||
<template #outer>
|
||||
<div class="absolute top-6 left-4 z-40">
|
||||
<div class="absolute top-8 left-4 z-40">
|
||||
<p class="text-white text-2xl truncate">Feed Episodes</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -22,7 +22,7 @@
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 w-full flex items-center" style="height: 50px">
|
||||
<div class="absolute bottom-6 left-0 w-full flex items-center" style="height: 50px">
|
||||
<ui-btn class="w-full" :disabled="!episodesSelected.length" color="success" @click.stop="downloadEpisodes">{{ episodesSelected.length ? `Add ${episodesSelected.length} Episode(s) to Server` : 'No Episodes Selected' }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
@@ -130,8 +130,8 @@ export default {
|
||||
|
||||
<style>
|
||||
.feed-content {
|
||||
height: calc(100vh - 125px);
|
||||
max-height: calc(100vh - 125px);
|
||||
margin-top: 20px;
|
||||
height: calc(100vh - 150px);
|
||||
max-height: calc(100vh - 150px);
|
||||
margin-top: 5px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="300" height="100%">
|
||||
<template #outer>
|
||||
<div class="absolute top-7 left-4 z-40" style="max-width: 80%">
|
||||
<div class="absolute top-8 left-4 z-40" style="max-width: 80%">
|
||||
<p class="text-white text-lg truncate">Select Local Folder</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="200" height="100%">
|
||||
<template #outer>
|
||||
<div class="absolute top-5 left-4 z-40">
|
||||
<div class="absolute top-8 left-4 z-40">
|
||||
<p class="text-white text-2xl truncate">Sleep Timer</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="200" height="100%">
|
||||
<template #outer>
|
||||
<div class="absolute top-5 left-4 z-40">
|
||||
<div class="absolute top-8 left-4 z-40">
|
||||
<p class="text-white text-2xl truncate">Sleep Timer</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="360" height="100%" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-5 left-4 z-40">
|
||||
<div class="absolute top-8 left-4 z-40">
|
||||
<p class="text-white text-2xl truncate">Add to Playlist</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div id="comic-reader" class="w-full h-full">
|
||||
<div v-show="showPageMenu" v-click-outside="clickOutside" class="pagemenu absolute right-20 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-52" style="top: 72px">
|
||||
<div v-for="(file, index) in pages" :key="file" class="w-full cursor-pointer hover:bg-black-200 px-2 py-1" :class="page === index ? 'bg-black-200' : ''" @click="setPage(index)">
|
||||
<div id="comic-reader" class="w-full h-full relative">
|
||||
<div v-show="showPageMenu" v-click-outside="clickOutsideObj" class="pagemenu absolute top-12 right-16 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-52">
|
||||
<div v-for="(file, index) in pages" :key="file" class="w-full cursor-pointer hover:bg-black-200 px-2 py-1" :class="page === index ? 'bg-black-200' : ''" @click.stop="setPage(index)">
|
||||
<p class="text-sm truncate">{{ file }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="showInfoMenu" v-click-outside="clickOutside" class="pagemenu absolute top-20 right-0 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-full" style="top: 72px">
|
||||
<div v-show="showInfoMenu" v-click-outside="clickedOutsideInfoMenu" class="pagemenu absolute top-12 right-0 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-full" @click.stop.prevent>
|
||||
<div v-for="key in comicMetadataKeys" :key="key" class="w-full px-2 py-1">
|
||||
<p class="text-xs">
|
||||
<strong>{{ key }}</strong>
|
||||
@@ -14,13 +14,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="comicMetadata" class="absolute top-8 right-36 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="showInfoMenu = !showInfoMenu">
|
||||
<div v-if="comicMetadata" class="absolute top-0 right-3 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="clickShowInfoMenu">
|
||||
<span class="material-icons text-lg">more</span>
|
||||
</div>
|
||||
<div class="absolute top-8 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" style="right: 92px" @mousedown.prevent @click.stop.prevent="showPageMenu = !showPageMenu">
|
||||
<div class="absolute top-0 right-16 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @mouseup.prevent @click.stop.prevent="clickShowPageMenu">
|
||||
<span class="material-icons text-lg">menu</span>
|
||||
</div>
|
||||
<div class="absolute top-8 right-4 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20">
|
||||
<div class="absolute top-0 left-3 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20" @click.stop>
|
||||
<p class="font-mono">{{ page + 1 }} / {{ numPages }}</p>
|
||||
</div>
|
||||
|
||||
@@ -61,7 +61,12 @@ export default {
|
||||
showInfoMenu: false,
|
||||
loadTimeout: null,
|
||||
loadedFirstPage: false,
|
||||
comicMetadata: null
|
||||
comicMetadata: null,
|
||||
clickOutsideObj: {
|
||||
handler: this.clickedOutside,
|
||||
events: ['mousedown'],
|
||||
isActive: true
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -84,9 +89,19 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickOutside() {
|
||||
if (this.showPageMenu) this.showPageMenu = false
|
||||
if (this.showInfoMenu) this.showInfoMenu = false
|
||||
clickShowInfoMenu() {
|
||||
this.showInfoMenu = !this.showInfoMenu
|
||||
this.showPageMenu = false
|
||||
},
|
||||
clickShowPageMenu() {
|
||||
this.showPageMenu = !this.showPageMenu
|
||||
this.showInfoMenu = false
|
||||
},
|
||||
clickedOutside() {
|
||||
this.showPageMenu = false
|
||||
},
|
||||
clickedOutsideInfoMenu() {
|
||||
this.showInfoMenu = false
|
||||
},
|
||||
next() {
|
||||
if (!this.canGoNext) return
|
||||
@@ -100,7 +115,8 @@ export default {
|
||||
if (index < 0 || index > this.numPages - 1) {
|
||||
return
|
||||
}
|
||||
var filename = this.pages[index]
|
||||
this.showPageMenu = false
|
||||
const filename = this.pages[index]
|
||||
this.page = index
|
||||
return this.extractFile(filename)
|
||||
},
|
||||
@@ -235,8 +251,16 @@ export default {
|
||||
|
||||
<style scoped>
|
||||
#comic-reader {
|
||||
height: calc(100% - 32px);
|
||||
height: calc(100% - 36px);
|
||||
max-height: calc(100% - 36px);
|
||||
padding-top: 36px;
|
||||
}
|
||||
.reader-player-open #comic-reader {
|
||||
height: calc(100% - 156px);
|
||||
max-height: calc(100% - 156px);
|
||||
padding-top: 36px;
|
||||
}
|
||||
|
||||
.pagemenu {
|
||||
max-height: calc(100% - 80px);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div id="epub-frame" class="w-full">
|
||||
<div id="viewer" class="border border-gray-100 bg-white shadow-md h-full w-full"></div>
|
||||
<div id="viewer" class="h-full w-full"></div>
|
||||
|
||||
<div class="fixed left-0 h-8 w-full bg-bg px-2 flex items-center" :style="{ bottom: playerLibraryItemId ? '120px' : '0px' }">
|
||||
<p class="text-xs">epub</p>
|
||||
<div class="flex-grow" />
|
||||
@@ -15,18 +16,20 @@ import ePub from 'epubjs'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
url: String
|
||||
url: String,
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
isLocal: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
/** @type {ePub.Book} */
|
||||
book: null,
|
||||
/** @type {ePub.Rendition} */
|
||||
rendition: null,
|
||||
chapters: [],
|
||||
title: '',
|
||||
author: '',
|
||||
progress: 0,
|
||||
hasNext: true,
|
||||
hasPrev: false
|
||||
progress: 0
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -35,11 +38,48 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
/** @returns {string} */
|
||||
libraryItemId() {
|
||||
return this.libraryItem?.id
|
||||
},
|
||||
localLibraryItem() {
|
||||
if (this.isLocal) return this.libraryItem
|
||||
return this.libraryItem.localLibraryItem || null
|
||||
},
|
||||
localLibraryItemId() {
|
||||
return this.localLibraryItem?.id
|
||||
},
|
||||
serverLibraryItemId() {
|
||||
if (!this.isLocal) return this.libraryItem.id
|
||||
// Check if local library item is connected to the current server
|
||||
if (!this.libraryItem.serverAddress || !this.libraryItem.libraryItemId) return null
|
||||
if (this.$store.getters['user/getServerAddress'] === this.libraryItem.serverAddress) {
|
||||
return this.libraryItem.libraryItemId
|
||||
}
|
||||
return null
|
||||
},
|
||||
playerLibraryItemId() {
|
||||
return this.$store.state.playerLibraryItemId
|
||||
},
|
||||
readerHeightOffset() {
|
||||
return this.playerLibraryItemId ? 164 : 64
|
||||
return this.playerLibraryItemId ? 196 : 96
|
||||
},
|
||||
/** @returns {Array<ePub.NavItem>} */
|
||||
chapters() {
|
||||
return this.book ? this.book.navigation.toc : []
|
||||
},
|
||||
userItemProgress() {
|
||||
if (this.isLocal) return this.localItemProgress
|
||||
return this.serverItemProgress
|
||||
},
|
||||
localItemProgress() {
|
||||
return this.$store.getters['globals/getLocalMediaProgressById'](this.localLibraryItemId)
|
||||
},
|
||||
serverItemProgress() {
|
||||
return this.$store.getters['user/getUserMediaProgress'](this.serverLibraryItemId)
|
||||
},
|
||||
localStorageLocationsKey() {
|
||||
return `ebookLocations-${this.libraryItemId}`
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -58,80 +98,180 @@ export default {
|
||||
this.rendition.next()
|
||||
}
|
||||
},
|
||||
keyUp() {
|
||||
if ((e.keyCode || e.which) == 37) {
|
||||
this.prev()
|
||||
} else if ((e.keyCode || e.which) == 39) {
|
||||
this.next()
|
||||
/**
|
||||
* @param {object} payload
|
||||
* @param {string} payload.ebookLocation - CFI of the current location
|
||||
* @param {string} payload.ebookProgress - eBook Progress Percentage
|
||||
*/
|
||||
async updateProgress(payload) {
|
||||
// Update local item
|
||||
if (this.localLibraryItemId) {
|
||||
const localPayload = {
|
||||
localLibraryItemId: this.localLibraryItemId,
|
||||
...payload
|
||||
}
|
||||
const localResponse = await this.$db.updateLocalEbookProgress(localPayload)
|
||||
if (localResponse.localMediaProgress) {
|
||||
this.$store.commit('globals/updateLocalMediaProgress', localResponse.localMediaProgress)
|
||||
}
|
||||
}
|
||||
|
||||
// Update server item
|
||||
if (this.serverLibraryItemId) {
|
||||
this.$axios.$patch(`/api/me/progress/${this.serverLibraryItemId}`, payload).catch((error) => {
|
||||
console.error('EpubReader.updateProgress failed:', error)
|
||||
})
|
||||
}
|
||||
},
|
||||
getAllEbookLocationData() {
|
||||
const locations = []
|
||||
let totalSize = 0 // Total in bytes
|
||||
|
||||
for (const key in localStorage) {
|
||||
if (!localStorage.hasOwnProperty(key) || !key.startsWith('ebookLocations-')) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const ebookLocations = JSON.parse(localStorage[key])
|
||||
if (!ebookLocations.locations) throw new Error('Invalid locations object')
|
||||
|
||||
ebookLocations.key = key
|
||||
ebookLocations.size = (localStorage[key].length + key.length) * 2
|
||||
locations.push(ebookLocations)
|
||||
totalSize += ebookLocations.size
|
||||
} catch (error) {
|
||||
console.error('Failed to parse ebook locations', key, error)
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by oldest lastAccessed first
|
||||
locations.sort((a, b) => a.lastAccessed - b.lastAccessed)
|
||||
|
||||
return {
|
||||
locations,
|
||||
totalSize
|
||||
}
|
||||
},
|
||||
/** @param {string} locationString */
|
||||
checkSaveLocations(locationString) {
|
||||
const maxSizeInBytes = 3000000 // Allow epub locations to take up to 3MB of space
|
||||
const newLocationsSize = JSON.stringify({ lastAccessed: Date.now(), locations: locationString }).length * 2
|
||||
|
||||
// Too large overall
|
||||
if (newLocationsSize > maxSizeInBytes) {
|
||||
console.error('Epub locations are too large to store. Size =', newLocationsSize)
|
||||
return
|
||||
}
|
||||
|
||||
const ebookLocationsData = this.getAllEbookLocationData()
|
||||
|
||||
let availableSpace = maxSizeInBytes - ebookLocationsData.totalSize
|
||||
|
||||
// Remove epub locations until there is room for locations
|
||||
while (availableSpace < newLocationsSize && ebookLocationsData.locations.length) {
|
||||
const oldestLocation = ebookLocationsData.locations.shift()
|
||||
console.log(`Removing cached locations for epub "${oldestLocation.key}" taking up ${oldestLocation.size} bytes`)
|
||||
availableSpace += oldestLocation.size
|
||||
localStorage.removeItem(oldestLocation.key)
|
||||
}
|
||||
|
||||
console.log(`Cacheing epub locations with key "${this.localStorageLocationsKey}" taking up ${newLocationsSize} bytes`)
|
||||
this.saveLocations(locationString)
|
||||
},
|
||||
/** @param {string} locationString */
|
||||
saveLocations(locationString) {
|
||||
localStorage.setItem(
|
||||
this.localStorageLocationsKey,
|
||||
JSON.stringify({
|
||||
lastAccessed: Date.now(),
|
||||
locations: locationString
|
||||
})
|
||||
)
|
||||
},
|
||||
loadLocations() {
|
||||
const locationsObjString = localStorage.getItem(this.localStorageLocationsKey)
|
||||
if (!locationsObjString) return null
|
||||
|
||||
const locationsObject = JSON.parse(locationsObjString)
|
||||
|
||||
// Remove invalid location objects
|
||||
if (!locationsObject.locations) {
|
||||
console.error('Invalid epub locations stored', this.localStorageLocationsKey)
|
||||
localStorage.removeItem(this.localStorageLocationsKey)
|
||||
return null
|
||||
}
|
||||
|
||||
// Update lastAccessed
|
||||
this.saveLocations(locationsObject.locations)
|
||||
|
||||
return locationsObject.locations
|
||||
},
|
||||
/** @param {string} location - CFI of the new location */
|
||||
relocated(location) {
|
||||
if (this.userItemProgress?.ebookLocation === location.start.cfi) {
|
||||
return
|
||||
}
|
||||
|
||||
if (location.end.percentage) {
|
||||
this.updateProgress({
|
||||
ebookLocation: location.start.cfi,
|
||||
ebookProgress: location.end.percentage
|
||||
})
|
||||
this.progress = Math.round(location.end.percentage * 100)
|
||||
} else {
|
||||
this.updateProgress({
|
||||
ebookLocation: location.start.cfi
|
||||
})
|
||||
}
|
||||
},
|
||||
initEpub() {
|
||||
var book = ePub(this.url)
|
||||
this.book = book
|
||||
this.progress = Math.round((this.userItemProgress?.ebookProgress || 0) * 100)
|
||||
|
||||
this.rendition = book.renderTo('viewer', {
|
||||
/** @type {EpubReader} */
|
||||
const reader = this
|
||||
|
||||
/** @type {ePub.Book} */
|
||||
reader.book = new ePub(reader.url, {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight - this.readerHeightOffset
|
||||
})
|
||||
|
||||
/** @type {ePub.Rendition} */
|
||||
reader.rendition = reader.book.renderTo('viewer', {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight - this.readerHeightOffset,
|
||||
snap: true,
|
||||
manager: 'continuous',
|
||||
flow: 'paginated'
|
||||
})
|
||||
const displayed = this.rendition.display()
|
||||
|
||||
book.ready
|
||||
.then(() => {
|
||||
console.log('Book ready')
|
||||
return book.locations.generate(1600)
|
||||
})
|
||||
.then((locations) => {
|
||||
// console.log('Loaded locations', locations)
|
||||
// Wait for book to be rendered to get current page
|
||||
displayed.then(() => {
|
||||
// Get the current CFI
|
||||
var currentLocation = this.rendition.currentLocation()
|
||||
if (!currentLocation.start) {
|
||||
console.error('No Start', currentLocation)
|
||||
} else {
|
||||
var currentPage = book.locations.percentageFromCfi(currentLocation.start.cfi)
|
||||
// console.log('current page', currentPage)
|
||||
}
|
||||
// load saved progress
|
||||
reader.rendition.display(this.userItemProgress?.ebookLocation || reader.book.locations.start)
|
||||
|
||||
// load style
|
||||
reader.rendition.themes.default({ '*': { color: '#fff!important' } })
|
||||
|
||||
reader.book.ready.then(() => {
|
||||
// set up event listeners
|
||||
reader.rendition.on('relocated', reader.relocated)
|
||||
|
||||
// load ebook cfi locations
|
||||
const savedLocations = this.loadLocations()
|
||||
if (savedLocations) {
|
||||
reader.book.locations.load(savedLocations)
|
||||
} else {
|
||||
reader.book.locations.generate().then(() => {
|
||||
this.checkSaveLocations(reader.book.locations.save())
|
||||
})
|
||||
})
|
||||
|
||||
book.loaded.navigation.then((toc) => {
|
||||
var _chapters = []
|
||||
toc.forEach((chapter) => {
|
||||
_chapters.push(chapter)
|
||||
})
|
||||
this.chapters = _chapters
|
||||
})
|
||||
book.loaded.metadata.then((metadata) => {
|
||||
// this.author = metadata.creator
|
||||
// this.title = metadata.title
|
||||
})
|
||||
|
||||
// const spine_get = book.spine.get.bind(book.spine)
|
||||
// book.spine.get = function (target) {
|
||||
// var t = spine_get(target)
|
||||
// console.log(t, target)
|
||||
// // while (t == null && target.includes('#')) {
|
||||
// // target = target.split('#')[0]
|
||||
// // t = spine_get(target)
|
||||
// // }
|
||||
// return t
|
||||
// }
|
||||
|
||||
this.rendition.on('keyup', this.keyUp)
|
||||
|
||||
this.rendition.on('relocated', (location) => {
|
||||
var percent = book.locations.percentageFromCfi(location.start.cfi)
|
||||
this.progress = Math.floor(percent * 100)
|
||||
|
||||
this.hasNext = !location.atEnd
|
||||
this.hasPrev = !location.atStart
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.book?.destroy()
|
||||
},
|
||||
mounted() {
|
||||
this.initEpub()
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="mobi-ebook-viewer w-full">
|
||||
<div class="mobi-ebook-viewer w-full relative">
|
||||
<div class="absolute overflow-hidden left-0 right-0 w-full max-w-full m-auto z-10 border border-black border-opacity-20 shadow-md bg-white">
|
||||
<iframe title="html-viewer" class="w-full overflow-hidden"> Loading </iframe>
|
||||
</div>
|
||||
<div class="fixed bottom-0 left-0 h-8 w-full bg-bg px-2 flex items-center z-20" :style="{ bottom: playerLibraryItemId ? '100px' : '0px' }">
|
||||
<div class="fixed bottom-0 left-0 h-8 w-full bg-bg px-2 flex items-center z-20" :style="{ bottom: playerLibraryItemId ? '120px' : '0px' }">
|
||||
<p class="text-xs">mobi</p>
|
||||
<div class="flex-grow" />
|
||||
</div>
|
||||
@@ -25,9 +25,6 @@ export default {
|
||||
computed: {
|
||||
playerLibraryItemId() {
|
||||
return this.$store.state.playerLibraryItemId
|
||||
},
|
||||
readerHeightOffset() {
|
||||
return this.playerLibraryItemId ? 164 : 64
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -122,13 +119,15 @@ export default {
|
||||
|
||||
<style>
|
||||
.mobi-ebook-viewer {
|
||||
height: calc(100% - 32px);
|
||||
max-height: calc(100% - 32px);
|
||||
overflow: hidden;
|
||||
height: calc(100% - 52px);
|
||||
max-height: calc(100% - 52px);
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.reader-player-open .mobi-ebook-viewer {
|
||||
height: calc(100% - 132px);
|
||||
max-height: calc(100% - 132px);
|
||||
overflow: hidden;
|
||||
height: calc(100% - 152px);
|
||||
max-height: calc(100% - 152px);
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="w-full h-full pt-20 relative">
|
||||
<div class="w-full h-full pt-6 relative">
|
||||
<div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent>
|
||||
<div class="flex items-center justify-center h-full w-1/2">
|
||||
<span class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
<template>
|
||||
<div v-if="show" class="absolute top-0 left-0 w-full h-full bg-bg z-40 pt-8" :class="{ 'reader-player-open': !!playerLibraryItemId }">
|
||||
<div class="h-8 w-full bg-primary flex items-center px-2 fixed top-0 left-0 z-30 box-shadow-sm">
|
||||
<div v-if="show" class="absolute top-0 left-0 w-full h-full bg-bg z-40 pt-16" :class="{ 'reader-player-open': !!playerLibraryItemId }">
|
||||
<div class="h-16 pt-8 w-full bg-primary flex items-center px-2 fixed top-0 left-0 z-30 box-shadow-sm">
|
||||
<p class="w-5/6 truncate">{{ title }}</p>
|
||||
<div class="flex-grow" />
|
||||
<span class="material-icons text-xl text-white" @click.stop="show = false">close</span>
|
||||
</div>
|
||||
<component v-if="readerComponentName" ref="readerComponent" :is="readerComponentName" :url="ebookUrl" />
|
||||
<component v-if="readerComponentName" ref="readerComponent" :is="readerComponentName" :url="ebookUrl" :library-item="selectedLibraryItem" :is-local="isLocal" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
@@ -66,11 +68,10 @@ export default {
|
||||
return this.selectedLibraryItem ? this.selectedLibraryItem.libraryId : null
|
||||
},
|
||||
ebookFile() {
|
||||
return this.media ? this.media.ebookFile : null
|
||||
return this.media?.ebookFile || null
|
||||
},
|
||||
ebookFormat() {
|
||||
if (!this.ebookFile) return null
|
||||
return this.ebookFile.ebookFormat
|
||||
return this.ebookFile?.ebookFormat || null
|
||||
},
|
||||
ebookType() {
|
||||
if (this.isMobi) return 'mobi'
|
||||
@@ -91,8 +92,17 @@ export default {
|
||||
isComic() {
|
||||
return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr'
|
||||
},
|
||||
isLocal() {
|
||||
return !!this.ebookFile?.isLocal
|
||||
},
|
||||
localContentUrl() {
|
||||
return this.ebookFile?.contentUrl
|
||||
},
|
||||
ebookUrl() {
|
||||
if (!this.ebookFile) return null
|
||||
if (this.localContentUrl) {
|
||||
return Capacitor.convertFileSrc(this.localContentUrl)
|
||||
}
|
||||
let filepath = ''
|
||||
if (this.selectedLibraryItem.isFile) {
|
||||
filepath = this.$encodeUriPath(this.ebookFile.metadata.filename)
|
||||
@@ -132,7 +142,9 @@ export default {
|
||||
|
||||
const touchDistanceX = Math.abs(this.touchendX - this.touchstartX)
|
||||
const touchDistanceY = Math.abs(this.touchendY - this.touchstartY)
|
||||
if (touchDistanceX < 100 || touchDistanceY > touchDistanceX) return
|
||||
if (touchDistanceX < 60 || touchDistanceY > touchDistanceX) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.touchendX < this.touchstartX) {
|
||||
console.log('swiped left')
|
||||
@@ -144,8 +156,8 @@ export default {
|
||||
}
|
||||
},
|
||||
touchstart(e) {
|
||||
this.touchstartX = e.changedTouches[0].screenX
|
||||
this.touchstartY = e.changedTouches[0].screenY
|
||||
this.touchstartX = e.touches[0].screenX
|
||||
this.touchstartY = e.touches[0].screenY
|
||||
this.touchstartTime = Date.now()
|
||||
},
|
||||
touchend(e) {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div class="w-full my-4">
|
||||
<div class="w-full bg-primary px-4 py-2 flex items-center" :class="expanded ? 'rounded-t-md' : 'rounded-md'" @click.stop="clickBar">
|
||||
<p class="pr-2">Chapters</p>
|
||||
<div class="h-6 w-6 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
|
||||
<span class="text-xs font-mono">{{ chapters.length }}</span>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div class="h-10 w-10 rounded-full flex justify-center items-center duration-500" :class="expanded ? 'transform rotate-180' : ''">
|
||||
<span class="material-icons text-3xl">expand_more</span>
|
||||
</div>
|
||||
</div>
|
||||
<transition name="slide">
|
||||
<table class="text-xs tracksTable" v-show="expanded">
|
||||
<tr>
|
||||
<th class="text-left">Title</th>
|
||||
<th class="text-center w-16">Start</th>
|
||||
</tr>
|
||||
<tr v-for="chapter in chapters" :key="chapter.id">
|
||||
<td>
|
||||
{{ chapter.title }}
|
||||
</td>
|
||||
<td class="font-mono text-center underline w-16" @click.stop="goToTimestamp(chapter.start)">
|
||||
{{ $secondsToTimestamp(chapter.start) }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
expanded: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
libraryItemId() {
|
||||
return this.libraryItem.id
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
},
|
||||
metadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
chapters() {
|
||||
return this.media.chapters || []
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickBar() {
|
||||
this.expanded = !this.expanded
|
||||
},
|
||||
goToTimestamp(time) {
|
||||
this.$emit('playAtTimestamp', time)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tracksTable {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
border: 1px solid #474747;
|
||||
}
|
||||
|
||||
.tracksTable tr:nth-child(even) {
|
||||
background-color: #2e2e2e;
|
||||
}
|
||||
|
||||
.tracksTable tr {
|
||||
background-color: #373838;
|
||||
}
|
||||
|
||||
.tracksTable td {
|
||||
padding: 8px 8px;
|
||||
}
|
||||
|
||||
.tracksTable th {
|
||||
padding: 4px 8px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div class="w-full my-4">
|
||||
<div class="w-full bg-primary px-4 py-2 flex items-center" :class="showTracks ? 'rounded-t-md' : 'rounded-md'" @click.stop="clickBar">
|
||||
<p class="pr-2">{{ title }}</p>
|
||||
<div class="h-6 w-6 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
|
||||
<span class="text-xs font-mono">{{ tracks.length }}</span>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div class="h-10 w-10 rounded-full flex justify-center items-center duration-500" :class="showTracks ? 'transform rotate-180' : ''">
|
||||
<span class="material-icons text-3xl">expand_more</span>
|
||||
</div>
|
||||
</div>
|
||||
<transition name="slide">
|
||||
<div class="w-full" v-show="showTracks">
|
||||
<table class="text-xs tracksTable">
|
||||
<tr>
|
||||
<th class="text-left">Filename</th>
|
||||
<th class="text-center w-16">Duration</th>
|
||||
</tr>
|
||||
<template v-for="track in tracks">
|
||||
<tr :key="track.index">
|
||||
<td>{{ (track.metadata && track.metadata.filename) || track.title || 'Unknown' }}</td>
|
||||
<td class="font-mono text-center w-16">
|
||||
{{ $secondsToTimestamp(track.duration) }}
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Audio Tracks'
|
||||
},
|
||||
tracks: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
libraryItemId: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showTracks: false
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
clickBar() {
|
||||
this.showTracks = !this.showTracks
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tracksTable {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
border: 1px solid #474747;
|
||||
}
|
||||
|
||||
.tracksTable tr:nth-child(even) {
|
||||
background-color: #2e2e2e;
|
||||
}
|
||||
|
||||
.tracksTable tr {
|
||||
background-color: #373838;
|
||||
}
|
||||
|
||||
.tracksTable td {
|
||||
padding: 8px 8px;
|
||||
}
|
||||
|
||||
.tracksTable th {
|
||||
padding: 4px 8px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,16 +1,21 @@
|
||||
<template>
|
||||
<div class="w-full px-2 py-2 overflow-hidden relative">
|
||||
<nuxt-link v-if="book" :to="`/item/${book.id}`" class="flex w-full">
|
||||
<nuxt-link v-if="book" :to="`/item/${book.id}`" class="flex items-center w-full">
|
||||
<div class="h-full relative" :style="{ width: bookWidth + 'px' }">
|
||||
<covers-book-cover :library-item="book" :width="bookWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
<div class="flex-grow book-table-content h-full px-2 flex items-center">
|
||||
<div class="book-table-content h-full px-2 flex items-center">
|
||||
<div class="max-w-full">
|
||||
<p class="truncate block text-sm">{{ bookTitle }}</p>
|
||||
<p class="truncate block text-gray-400 text-xs">{{ bookAuthor }}</p>
|
||||
<p class="text-xxs text-gray-500">{{ bookDuration }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-8 min-w-8 flex justify-center">
|
||||
<button v-if="showPlayBtn" class="w-8 h-8 rounded-full border border-white border-opacity-20 flex items-center justify-center" @click.stop.prevent="playClick">
|
||||
<span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</template>
|
||||
@@ -31,6 +36,9 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
libraryItemId() {
|
||||
return this.book.id
|
||||
},
|
||||
media() {
|
||||
return this.book.media || {}
|
||||
},
|
||||
@@ -59,22 +67,29 @@ export default {
|
||||
isMissing() {
|
||||
return this.book.isMissing
|
||||
},
|
||||
isIncomplete() {
|
||||
return this.book.isIncomplete
|
||||
},
|
||||
numTracks() {
|
||||
return this.book.numTracks
|
||||
},
|
||||
isStreaming() {
|
||||
return this.$store.getters['getIsItemStreaming'](this.book.id)
|
||||
isInvalid() {
|
||||
return this.book.isInvalid
|
||||
},
|
||||
showPlayBtn() {
|
||||
return !this.isMissing && !this.isIncomplete && !this.isStreaming && this.numTracks
|
||||
return !this.isMissing && !this.isInvalid && this.tracks.length
|
||||
},
|
||||
isStreaming() {
|
||||
return this.$store.getters['getIsEpisodeStreaming'](this.libraryItemId)
|
||||
},
|
||||
streamIsPlaying() {
|
||||
return this.$store.state.playerIsPlaying && this.isStreaming
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickEdit() {
|
||||
this.$emit('edit', this.book)
|
||||
async playClick() {
|
||||
await this.$hapticsImpact()
|
||||
if (this.streamIsPlaying) {
|
||||
this.$eventBus.$emit('pause-item')
|
||||
} else {
|
||||
this.$eventBus.$emit('play-item', {
|
||||
libraryItemId: this.libraryItemId
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
@@ -83,7 +98,7 @@ export default {
|
||||
|
||||
<style>
|
||||
.book-table-content {
|
||||
width: calc(100% - 50px);
|
||||
max-width: calc(100% - 50px);
|
||||
width: calc(100% - 82px);
|
||||
max-width: calc(100% - 82px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,16 +1,21 @@
|
||||
<template>
|
||||
<div class="w-full px-2 py-2 overflow-hidden relative">
|
||||
<nuxt-link v-if="libraryItem" :to="`/item/${libraryItem.id}`" class="flex w-full">
|
||||
<nuxt-link v-if="libraryItem" :to="`/item/${libraryItem.id}`" class="flex items-center w-full">
|
||||
<div class="h-full relative" :style="{ width: '50px' }">
|
||||
<covers-book-cover :library-item="libraryItem" :width="50" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
<div class="flex-grow item-table-content h-full px-2 flex items-center">
|
||||
<div class="item-table-content h-full px-2 flex items-center">
|
||||
<div class="max-w-full">
|
||||
<p class="truncate block text-sm">{{ itemTitle }}</p>
|
||||
<p class="truncate block text-gray-400 text-xs">{{ bookAuthorName }}</p>
|
||||
<p class="text-xxs text-gray-500">{{ itemDuration }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-8 min-w-8 flex justify-center">
|
||||
<button v-if="showPlayBtn" class="w-8 h-8 rounded-full border border-white border-opacity-20 flex items-center justify-center" @click.stop.prevent="playClick">
|
||||
<span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</template>
|
||||
@@ -83,21 +88,36 @@ export default {
|
||||
isInvalid() {
|
||||
return this.libraryItem.isInvalid
|
||||
},
|
||||
isStreaming() {
|
||||
return this.$store.getters['getIsItemStreaming'](this.item.id)
|
||||
},
|
||||
showPlayBtn() {
|
||||
return !this.isMissing && !this.isInvalid && !this.isStreaming && (this.tracks.length || this.episode)
|
||||
return !this.isMissing && !this.isInvalid && (this.tracks.length || this.episode)
|
||||
},
|
||||
isStreaming() {
|
||||
return this.$store.getters['getIsEpisodeStreaming'](this.libraryItem.id, this.episodeId)
|
||||
},
|
||||
streamIsPlaying() {
|
||||
return this.$store.state.playerIsPlaying && this.isStreaming
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async playClick() {
|
||||
await this.$hapticsImpact()
|
||||
if (this.streamIsPlaying) {
|
||||
this.$eventBus.$emit('pause-item')
|
||||
} else {
|
||||
this.$eventBus.$emit('play-item', {
|
||||
libraryItemId: this.libraryItem.id,
|
||||
episodeId: this.episodeId
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.item-table-content {
|
||||
width: calc(100% - 50px);
|
||||
max-width: calc(100% - 50px);
|
||||
width: calc(100% - 82px);
|
||||
max-width: calc(100% - 82px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="w-full py-4 overflow-hidden relative border-b border-white border-opacity-10">
|
||||
<div class="w-full py-4 overflow-hidden relative border-b border-white border-opacity-10" @click.stop="goToEpisodePage">
|
||||
<div v-if="episode" class="w-full px-1">
|
||||
<!-- Help debug for testing -->
|
||||
<!-- <template>
|
||||
@@ -16,31 +16,36 @@
|
||||
<p class="text-sm font-semibold">
|
||||
{{ title }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5 default-style" v-html="description" />
|
||||
|
||||
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5" v-html="subtitle" />
|
||||
|
||||
<div v-if="episodeNumber || season || episodeType" class="flex py-2 items-center -mx-0.5">
|
||||
<div v-if="episodeNumber" class="px-2 pt-px pb-0.5 mx-0.5 bg-primary bg-opacity-50 rounded-full text-xs font-light text-gray-200">Episode #{{ episodeNumber }}</div>
|
||||
<div v-if="season" class="px-2 pt-px pb-0.5 mx-0.5 bg-primary bg-opacity-50 rounded-full text-xs font-light text-gray-200">Season #{{ season }}</div>
|
||||
<div v-if="episodeType" class="px-2 pt-px pb-0.5 mx-0.5 bg-primary bg-opacity-50 rounded-full text-xs font-light text-gray-200 capitalize">
|
||||
{{ episodeType }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center pt-2">
|
||||
<div class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click="playClick">
|
||||
<div class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick">
|
||||
<span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
|
||||
<p class="pl-2 pr-1 text-sm font-semibold">{{ timeRemaining }}</p>
|
||||
</div>
|
||||
|
||||
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
|
||||
|
||||
<button v-if="!isLocal" class="mx-1.5 mt-1.5" @click="addToPlaylist">
|
||||
<button v-if="!isLocal" class="mx-1.5 mt-1.5" @click.stop="addToPlaylist">
|
||||
<span class="material-icons text-2xl">playlist_add</span>
|
||||
</button>
|
||||
|
||||
<div v-if="userCanDownload">
|
||||
<span v-if="isLocal" class="material-icons-outlined px-2 text-success text-lg">audio_file</span>
|
||||
<span v-else-if="!localEpisode" class="material-icons mx-1.5 mt-2 text-xl" :class="downloadItem ? 'animate-bounce text-warning text-opacity-75' : ''" @click="downloadClick">{{ downloadItem ? 'downloading' : 'download' }}</span>
|
||||
<span v-else-if="!localEpisode" class="material-icons mx-1.5 mt-2 text-xl" :class="downloadItem ? 'animate-bounce text-warning text-opacity-75' : ''" @click.stop="downloadClick">{{ downloadItem ? 'downloading' : 'download' }}</span>
|
||||
<span v-else class="material-icons px-2 text-success text-xl">download_done</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow" />
|
||||
|
||||
<button v-if="!isLocal && isAdminOrUp" class="mx-1.5 mt-1.5" @click="deleteEpisodeFromServerClick">
|
||||
<span class="material-icons text-xl">delete_forever</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -77,9 +82,6 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
isIos() {
|
||||
return this.$platform === 'ios'
|
||||
},
|
||||
@@ -95,10 +97,18 @@ export default {
|
||||
title() {
|
||||
return this.episode.title || ''
|
||||
},
|
||||
description() {
|
||||
if (this.episode.subtitle) return this.episode.subtitle
|
||||
var desc = this.episode.description || ''
|
||||
return desc
|
||||
subtitle() {
|
||||
return this.episode.subtitle || this.episode.description || ''
|
||||
},
|
||||
episodeNumber() {
|
||||
return this.episode.episode
|
||||
},
|
||||
season() {
|
||||
return this.episode.season
|
||||
},
|
||||
episodeType() {
|
||||
if (this.episode.episodeType === 'full') return null // only show Trailer/Bonus
|
||||
return this.episode.episodeType
|
||||
},
|
||||
duration() {
|
||||
return this.$secondsToTimestamp(this.episode.duration)
|
||||
@@ -152,28 +162,8 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async deleteEpisodeFromServerClick() {
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: `Are you sure you want to delete episode "${this.title}" from the server?\nWarning: This will delete the audio file.`
|
||||
})
|
||||
|
||||
if (value) {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$delete(`/api/podcasts/${this.libraryItemId}/episode/${this.episode.id}?hard=1`)
|
||||
.then(() => {
|
||||
this.$toast.success('Episode deleted from server')
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMsg = error.response && error.response.data ? error.response.data : 'Failed to delete episode'
|
||||
console.error('Failed to delete episode', error)
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
}
|
||||
goToEpisodePage() {
|
||||
this.$router.push(`/item/${this.libraryItemId}/${this.episode.id}`)
|
||||
},
|
||||
addToPlaylist() {
|
||||
this.$emit('addToPlaylist', this.episode)
|
||||
@@ -288,11 +278,7 @@ export default {
|
||||
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
|
||||
}
|
||||
|
||||
var lmp = this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId, this.episode.id)
|
||||
console.log('toggleFinished Check LMP', this.libraryItemId, this.episode.id, JSON.stringify(lmp))
|
||||
|
||||
var serverUpdated = payload.server
|
||||
if (serverUpdated) {
|
||||
if (payload.server) {
|
||||
this.$toast.success(`Local & Server Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
} else {
|
||||
this.$toast.success(`Local Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
|
||||
@@ -49,6 +49,8 @@
|
||||
<modals-dialog v-model="showFiltersModal" title="Episode Filter" :items="filterItems" :selected="filterKey" @action="setFilter" />
|
||||
|
||||
<modals-podcast-episodes-feed-modal v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" />
|
||||
|
||||
<modals-order-modal v-model="showSortModal" :order-by.sync="sortKey" :descending.sync="sortDesc" episodes />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -76,6 +78,7 @@ export default {
|
||||
return {
|
||||
episodesCopy: [],
|
||||
showFiltersModal: false,
|
||||
showSortModal: false,
|
||||
sortKey: 'publishedAt',
|
||||
sortDesc: true,
|
||||
filterKey: 'incomplete',
|
||||
@@ -167,10 +170,19 @@ export default {
|
||||
},
|
||||
episodesSorted() {
|
||||
return this.episodesFiltered.sort((a, b) => {
|
||||
if (this.sortDesc) {
|
||||
return String(b[this.sortKey]).localeCompare(String(a[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
|
||||
let aValue = a[this.sortKey]
|
||||
let bValue = b[this.sortKey]
|
||||
|
||||
// Sort episodes with no pub date as the oldest
|
||||
if (this.sortKey === 'publishedAt') {
|
||||
if (!aValue) aValue = Number.MAX_VALUE
|
||||
if (!bValue) bValue = Number.MAX_VALUE
|
||||
}
|
||||
return String(a[this.sortKey]).localeCompare(String(b[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
|
||||
|
||||
if (this.sortDesc) {
|
||||
return String(bValue).localeCompare(String(aValue), undefined, { numeric: true, sensitivity: 'base' })
|
||||
}
|
||||
return String(aValue).localeCompare(String(bValue), undefined, { numeric: true, sensitivity: 'base' })
|
||||
})
|
||||
},
|
||||
// Map of local episodes where server episode id is key
|
||||
@@ -185,9 +197,8 @@ export default {
|
||||
},
|
||||
sortText() {
|
||||
if (!this.sortKey) return ''
|
||||
var _sel = this.episodeSortItems.find((i) => i.value === this.sortKey)
|
||||
if (!_sel) return ''
|
||||
return _sel.text
|
||||
const _sel = this.episodeSortItems.find((i) => i.value === this.sortKey)
|
||||
return _sel?.text || ''
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -249,7 +260,7 @@ export default {
|
||||
this.showFiltersModal = true
|
||||
},
|
||||
clickSort() {
|
||||
this.sortDesc = !this.sortDesc
|
||||
this.showSortModal = true
|
||||
},
|
||||
getEpisodeProgress(episode) {
|
||||
if (this.isLocal) return this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId, episode.id)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div class="w-full py-4 overflow-hidden relative border-b border-white border-opacity-10">
|
||||
<div class="w-full py-4 overflow-hidden relative border-b border-white border-opacity-10" @click.stop="goToEpisodePage">
|
||||
<div v-if="episode" class="w-full px-1">
|
||||
<div class="flex mb-2">
|
||||
<div class="w-10 min-w-10">
|
||||
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId)" :width="40" :book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false" class="md:hidden" />
|
||||
</div>
|
||||
<div class="flex-grow px-2">
|
||||
<div class="-mt-0.5 mb-0.5">
|
||||
<div class="-mt-0.5 mb-0.5" @click.stop>
|
||||
<nuxt-link :to="`/item/${libraryItemId}`" class="text-sm text-gray-200 underline">{{ podcast.metadata.title }}</nuxt-link>
|
||||
</div>
|
||||
<p v-if="publishedAt" class="text-xs text-gray-300">{{ $dateDistanceFromNow(publishedAt) }}</p>
|
||||
@@ -16,31 +16,36 @@
|
||||
<p class="text-sm font-semibold">
|
||||
{{ title }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5 default-style" v-html="description" />
|
||||
|
||||
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5" v-html="subtitle" />
|
||||
|
||||
<div v-if="episodeNumber || season || episodeType" class="flex pt-2 items-center -mx-0.5">
|
||||
<div v-if="episodeNumber" class="px-2 pt-px pb-0.5 mx-0.5 bg-primary bg-opacity-50 rounded-full text-xs font-light text-gray-200">Episode #{{ episodeNumber }}</div>
|
||||
<div v-if="season" class="px-2 pt-px pb-0.5 mx-0.5 bg-primary bg-opacity-50 rounded-full text-xs font-light text-gray-200">Season #{{ season }}</div>
|
||||
<div v-if="episodeType" class="px-2 pt-px pb-0.5 mx-0.5 bg-primary bg-opacity-50 rounded-full text-xs font-light text-gray-200 capitalize">
|
||||
{{ episodeType }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center pt-2">
|
||||
<div class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click="playClick">
|
||||
<div class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick">
|
||||
<span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
|
||||
<p class="pl-2 pr-1 text-sm font-semibold">{{ timeRemaining }}</p>
|
||||
</div>
|
||||
|
||||
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
|
||||
|
||||
<button v-if="!isLocal" class="mx-1.5 mt-1.5" @click="addToPlaylist">
|
||||
<button v-if="!isLocal" class="mx-1.5 mt-1.5" @click.stop="addToPlaylist">
|
||||
<span class="material-icons text-2xl">playlist_add</span>
|
||||
</button>
|
||||
|
||||
<div v-if="userCanDownload">
|
||||
<span v-if="isLocal" class="material-icons-outlined px-2 text-success text-lg">audio_file</span>
|
||||
<span v-else-if="!localEpisode" class="material-icons mx-1.5 mt-2 text-xl" :class="downloadItem ? 'animate-bounce text-warning text-opacity-75' : ''" @click="downloadClick">{{ downloadItem ? 'downloading' : 'download' }}</span>
|
||||
<span v-else-if="!localEpisode" class="material-icons mx-1.5 mt-2 text-xl" :class="downloadItem ? 'animate-bounce text-warning text-opacity-75' : ''" @click.stop="downloadClick">{{ downloadItem ? 'downloading' : 'download' }}</span>
|
||||
<span v-else class="material-icons px-2 text-success text-xl">download_done</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow" />
|
||||
|
||||
<button v-if="!isLocal && isAdminOrUp" class="mx-1.5 mt-1.5" @click="deleteEpisodeFromServerClick">
|
||||
<span class="material-icons text-xl">delete_forever</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -80,9 +85,6 @@ export default {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
isAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
isIos() {
|
||||
return this.$platform === 'ios'
|
||||
},
|
||||
@@ -98,10 +100,18 @@ export default {
|
||||
title() {
|
||||
return this.episode.title || ''
|
||||
},
|
||||
description() {
|
||||
if (this.episode.subtitle) return this.episode.subtitle
|
||||
var desc = this.episode.description || ''
|
||||
return desc
|
||||
subtitle() {
|
||||
return this.episode.subtitle || this.episode.description || ''
|
||||
},
|
||||
episodeNumber() {
|
||||
return this.episode.episode
|
||||
},
|
||||
season() {
|
||||
return this.episode.season
|
||||
},
|
||||
episodeType() {
|
||||
if (this.episode.episodeType === 'full') return null // only show Trailer/Bonus
|
||||
return this.episode.episodeType
|
||||
},
|
||||
duration() {
|
||||
return this.$secondsToTimestamp(this.episode.duration)
|
||||
@@ -158,28 +168,8 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async deleteEpisodeFromServerClick() {
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: `Are you sure you want to delete episode "${this.title}" from the server?\nWarning: This will delete the audio file.`
|
||||
})
|
||||
|
||||
if (value) {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$delete(`/api/podcasts/${this.libraryItemId}/episode/${this.episode.id}?hard=1`)
|
||||
.then(() => {
|
||||
this.$toast.success('Episode deleted from server')
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMsg = error.response && error.response.data ? error.response.data : 'Failed to delete episode'
|
||||
console.error('Failed to delete episode', error)
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
}
|
||||
goToEpisodePage() {
|
||||
this.$router.push(`/item/${this.libraryItemId}/${this.episode.id}`)
|
||||
},
|
||||
addToPlaylist() {
|
||||
this.$emit('addToPlaylist', this.episode)
|
||||
@@ -294,11 +284,7 @@ export default {
|
||||
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
|
||||
}
|
||||
|
||||
var lmp = this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId, this.episode.id)
|
||||
console.log('toggleFinished Check LMP', this.libraryItemId, this.episode.id, JSON.stringify(lmp))
|
||||
|
||||
var serverUpdated = payload.server
|
||||
if (serverUpdated) {
|
||||
if (payload.server) {
|
||||
this.$toast.success(`Local & Server Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
} else {
|
||||
this.$toast.success(`Local Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
|
||||
@@ -28,7 +28,7 @@ export default {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
this.$emit('click')
|
||||
this.$emit('click', e)
|
||||
e.stopPropagation()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -173,7 +173,6 @@
|
||||
BE96D57E131924D520D57057 /* Pods-Audiobookshelf.debug.xcconfig */,
|
||||
D2F7F575384A63F1C47DE984 /* Pods-Audiobookshelf.release.xcconfig */,
|
||||
);
|
||||
name = Pods;
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -721,12 +720,12 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 19;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 0.9.62;
|
||||
MARKETING_VERSION = 0.9.64;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -745,12 +744,12 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 19;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 0.9.62;
|
||||
MARKETING_VERSION = 0.9.64;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
||||
@@ -14,7 +14,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
// Override point for customization after application launch.
|
||||
|
||||
let configuration = Realm.Configuration(
|
||||
schemaVersion: 7,
|
||||
schemaVersion: 8,
|
||||
migrationBlock: { [weak self] migration, oldSchemaVersion in
|
||||
if (oldSchemaVersion < 1) {
|
||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)")
|
||||
|
||||
@@ -102,12 +102,6 @@
|
||||
"scale" : "2x",
|
||||
"size" : "83.5x83.5"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-1024.png",
|
||||
"idiom" : "ios-marketing",
|
||||
"scale" : "1x",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-120.png",
|
||||
"idiom" : "car",
|
||||
@@ -120,6 +114,72 @@
|
||||
"scale" : "3x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-1024.png",
|
||||
"idiom" : "ios-marketing",
|
||||
"scale" : "1x",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-16.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-32.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-32.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-64.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-128.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-256.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-256.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-512.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-512.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-1024.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-48.png",
|
||||
"idiom" : "watch",
|
||||
@@ -195,6 +255,13 @@
|
||||
"size" : "51x51",
|
||||
"subtype" : "45mm"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"role" : "appLauncher",
|
||||
"scale" : "2x",
|
||||
"size" : "54x54",
|
||||
"subtype" : "49mm"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-172.png",
|
||||
"idiom" : "watch",
|
||||
@@ -226,71 +293,18 @@
|
||||
"size" : "117x117",
|
||||
"subtype" : "45mm"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"role" : "quickLook",
|
||||
"scale" : "2x",
|
||||
"size" : "129x129",
|
||||
"subtype" : "49mm"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-1024.png",
|
||||
"idiom" : "watch-marketing",
|
||||
"scale" : "1x",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-16.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-32.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-32.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-64.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-128.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-256.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-256.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-512.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-512.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-1024.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSUserActivityTypes</key>
|
||||
<array>
|
||||
<string>INPlayMediaIntent</string>
|
||||
@@ -53,11 +58,6 @@
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
</dict>
|
||||
|
||||
@@ -66,6 +66,7 @@ public class AbsAudioPlayer: CAPPlugin {
|
||||
let episodeId = call.getString("episodeId")
|
||||
let playWhenReady = call.getBool("playWhenReady", true)
|
||||
let playbackRate = call.getFloat("playbackRate", 1)
|
||||
let startTimeOverride = call.getDouble("startTime")
|
||||
|
||||
if libraryItemId == nil {
|
||||
logger.error("provide library item id")
|
||||
@@ -84,6 +85,9 @@ public class AbsAudioPlayer: CAPPlugin {
|
||||
}
|
||||
|
||||
do {
|
||||
if (startTimeOverride != nil) {
|
||||
playbackSession.currentTime = startTimeOverride!
|
||||
}
|
||||
try playbackSession.save()
|
||||
try self.startPlaybackSession(playbackSession, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
||||
call.resolve(try playbackSession.asDictionary())
|
||||
@@ -95,6 +99,9 @@ public class AbsAudioPlayer: CAPPlugin {
|
||||
} else { // Playing from the server
|
||||
ApiClient.startPlaybackSession(libraryItemId: libraryItemId!, episodeId: episodeId, forceTranscode: false) { [weak self] session in
|
||||
do {
|
||||
if (startTimeOverride != nil) {
|
||||
session.currentTime = startTimeOverride!
|
||||
}
|
||||
try session.save()
|
||||
try self?.startPlaybackSession(session, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
||||
call.resolve(try session.asDictionary())
|
||||
|
||||
@@ -348,8 +348,9 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
|
||||
|
||||
private func urlForTrack(item: LibraryItem, track: AudioTrack) -> URL {
|
||||
// filename needs to be encoded otherwise would just use contentUrl
|
||||
let filenameEncoded = track.metadata?.filename.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed)
|
||||
let urlstr = "\(Store.serverConfig!.address)/s/item/\(item.id)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)"
|
||||
let relPath = track.metadata?.relPath ?? ""
|
||||
let filepathEncoded = relPath.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed)
|
||||
let urlstr = "\(Store.serverConfig!.address)/s/item/\(item.id)/\(filepathEncoded ?? "")?token=\(Store.serverConfig!.token)"
|
||||
return URL(string: urlstr)!
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ class PlaybackSession: Object, Codable, Deletable {
|
||||
}
|
||||
}
|
||||
return [
|
||||
"deviceId": UIDevice.current.identifierForVendor?.uuidString,
|
||||
"manufacturer": "Apple",
|
||||
"model": modelCode,
|
||||
"clientVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
||||
|
||||
@@ -169,9 +169,14 @@ extension LocalLibraryItem {
|
||||
self.media?.chapters.forEach { chapter in chapters.append(Chapter.detachCopy(of: chapter)!) }
|
||||
let authorName = mediaMetadata?.authorDisplayName
|
||||
|
||||
var duration = getDuration()
|
||||
var displayTitle = mediaMetadata?.title
|
||||
|
||||
let audioTracks = List<AudioTrack>()
|
||||
if let episode = episode, let track = episode.audioTrack {
|
||||
audioTracks.append(AudioTrack.detachCopy(of: track)!)
|
||||
duration = track.duration
|
||||
displayTitle = episode.title
|
||||
} else if let tracks = self.media?.tracks {
|
||||
tracks.forEach { t in audioTracks.append(AudioTrack.detachCopy(of: t)!) }
|
||||
}
|
||||
@@ -185,10 +190,10 @@ extension LocalLibraryItem {
|
||||
mediaType: self.mediaType,
|
||||
mediaMetadata: mediaMetadata,
|
||||
chapters: chapters,
|
||||
displayTitle: mediaMetadata?.title,
|
||||
displayTitle: displayTitle,
|
||||
displayAuthor: authorName,
|
||||
coverPath: self.coverContentUrl,
|
||||
duration: self.getDuration(),
|
||||
duration: duration,
|
||||
playMethod: PlayMethod.local.rawValue,
|
||||
startedAt: dateNow,
|
||||
updatedAt: dateNow,
|
||||
|
||||
@@ -12,6 +12,7 @@ class Metadata: EmbeddedObject, Codable {
|
||||
@Persisted var title: String = "Unknown"
|
||||
@Persisted var subtitle: String?
|
||||
@Persisted var authors = List<Author>()
|
||||
@Persisted var author: String? // Podcast author
|
||||
@Persisted var narrators = List<String>()
|
||||
@Persisted var genres = List<String>()
|
||||
@Persisted var publishedYear: String?
|
||||
@@ -28,12 +29,13 @@ class Metadata: EmbeddedObject, Codable {
|
||||
@Persisted var seriesName: String?
|
||||
@Persisted var feedUrl: String?
|
||||
|
||||
var authorDisplayName: String { self.authorName ?? "Unknown" }
|
||||
var authorDisplayName: String { self.authorName ?? self.author ?? "Unknown" }
|
||||
|
||||
private enum CodingKeys : String, CodingKey {
|
||||
case title,
|
||||
subtitle,
|
||||
authors,
|
||||
author, // Podcast author
|
||||
narrators,
|
||||
genres,
|
||||
publishedYear,
|
||||
@@ -60,6 +62,7 @@ class Metadata: EmbeddedObject, Codable {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
title = try values.decode(String.self, forKey: .title)
|
||||
subtitle = try? values.decode(String.self, forKey: .subtitle)
|
||||
author = try? values.decode(String.self, forKey: .author) // Podcast author
|
||||
if let authorList = try? values.decode([Author].self, forKey: .authors) {
|
||||
authors.append(objectsIn: authorList)
|
||||
}
|
||||
@@ -88,6 +91,7 @@ class Metadata: EmbeddedObject, Codable {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(title, forKey: .title)
|
||||
try container.encode(subtitle, forKey: .subtitle)
|
||||
try container.encode(author, forKey: .author) // Podcast author
|
||||
try container.encode(Array(authors), forKey: .authors)
|
||||
try container.encode(Array(narrators), forKey: .narrators)
|
||||
try container.encode(Array(genres), forKey: .genres)
|
||||
|
||||
@@ -88,6 +88,9 @@ class AudioPlayer: NSObject {
|
||||
for track in playbackSession.audioTracks {
|
||||
if let playerAsset = createAsset(itemId: playbackSession.libraryItemId!, track: track) {
|
||||
let playerItem = AVPlayerItem(asset: playerAsset)
|
||||
if (playbackSession.playMethod == PlayMethod.transcode.rawValue) {
|
||||
playerItem.preferredForwardBufferDuration = 50
|
||||
}
|
||||
self.allPlayerItems.append(playerItem)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +165,7 @@ class ApiClient {
|
||||
"forceTranscode": forceTranscode ? "1" : "",
|
||||
"mediaPlayer": "AVPlayer",
|
||||
"deviceInfo": [
|
||||
"deviceId": UIDevice.current.identifierForVendor?.uuidString,
|
||||
"manufacturer": "Apple",
|
||||
"model": modelCode,
|
||||
"clientVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="select-none w-full layout-wrapper bg-bg text-white">
|
||||
<div class="w-full layout-wrapper bg-bg text-white">
|
||||
<app-appbar />
|
||||
<div id="content" class="overflow-hidden relative" :class="playerIsOpen ? 'playerOpen' : ''">
|
||||
<Nuxt />
|
||||
@@ -101,7 +101,7 @@ export default {
|
||||
const deviceData = await this.$db.getDeviceData()
|
||||
let serverConfig = null
|
||||
if (deviceData) {
|
||||
this.$store.commit('globals/setHapticFeedback', deviceData.hapticFeedback)
|
||||
this.$store.commit('globals/setHapticFeedback', deviceData.deviceSettings?.hapticFeedback)
|
||||
|
||||
if (deviceData.lastServerConnectionConfigId && deviceData.serverConnectionConfigs.length) {
|
||||
serverConfig = deviceData.serverConnectionConfigs.find((scc) => scc.id == deviceData.lastServerConnectionConfigId)
|
||||
|
||||
Generated
+16
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf-app",
|
||||
"version": "0.9.62-beta",
|
||||
"version": "0.9.64-beta",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf-app",
|
||||
"version": "0.9.62-beta",
|
||||
"version": "0.9.64-beta",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "^4.0.0",
|
||||
"@capacitor/app": "^4.0.0",
|
||||
@@ -26,6 +26,7 @@
|
||||
"libarchive.js": "^1.3.0",
|
||||
"nuxt": "^2.15.7",
|
||||
"socket.io-client": "^4.1.3",
|
||||
"v-click-outside": "^3.2.0",
|
||||
"vue-pdf": "^4.2.0",
|
||||
"vue-toastification": "^1.7.11",
|
||||
"vuedraggable": "^2.24.3"
|
||||
@@ -17192,6 +17193,14 @@
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/v-click-outside": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/v-click-outside/-/v-click-outside-3.2.0.tgz",
|
||||
"integrity": "sha512-QD0bDy38SHJXQBjgnllmkI/rbdiwmq9RC+/+pvrFjYJKTn8dtp7Penf9q1lLBta280fYG2q53mgLhQ+3l3z74w==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
@@ -31992,6 +32001,11 @@
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
"integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM="
|
||||
},
|
||||
"v-click-outside": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/v-click-outside/-/v-click-outside-3.2.0.tgz",
|
||||
"integrity": "sha512-QD0bDy38SHJXQBjgnllmkI/rbdiwmq9RC+/+pvrFjYJKTn8dtp7Penf9q1lLBta280fYG2q53mgLhQ+3l3z74w=="
|
||||
},
|
||||
"vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-app",
|
||||
"version": "0.9.62-beta",
|
||||
"version": "0.9.64-beta",
|
||||
"author": "advplyr",
|
||||
"scripts": {
|
||||
"dev": "nuxt --hostname 0.0.0.0 --port 1337",
|
||||
@@ -30,6 +30,7 @@
|
||||
"libarchive.js": "^1.3.0",
|
||||
"nuxt": "^2.15.7",
|
||||
"socket.io-client": "^4.1.3",
|
||||
"v-click-outside": "^3.2.0",
|
||||
"vue-pdf": "^4.2.0",
|
||||
"vue-toastification": "^1.7.11",
|
||||
"vuedraggable": "^2.24.3"
|
||||
|
||||
@@ -1,14 +1,83 @@
|
||||
<template>
|
||||
<div>Authors</div>
|
||||
<div>
|
||||
<div id="bookshelf" class="w-full h-full p-4 overflow-y-auto">
|
||||
<div class="flex flex-wrap justify-center">
|
||||
<template v-for="author in authors">
|
||||
<cards-author-card :key="author.id" :author="author" :width="96" :height="120" class="p-2" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
return {
|
||||
loading: true,
|
||||
authors: [],
|
||||
loadedLibraryId: null
|
||||
}
|
||||
},
|
||||
watch: {},
|
||||
computed: {},
|
||||
methods: {}
|
||||
computed: {
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async init() {
|
||||
this.loadedLibraryId = this.currentLibraryId
|
||||
this.authors = await this.$axios
|
||||
.$get(`/api/libraries/${this.currentLibraryId}/authors`)
|
||||
.then((response) => response.authors)
|
||||
.catch((error) => {
|
||||
console.error('Failed to load authors', error)
|
||||
return []
|
||||
})
|
||||
console.log('Loaded authors', this.authors)
|
||||
this.$eventBus.$emit('bookshelf-total-entities', this.authors.length)
|
||||
this.loading = false
|
||||
},
|
||||
authorAdded(author) {
|
||||
if (!this.authors.some((au) => au.id === author.id)) {
|
||||
this.authors.push(author)
|
||||
this.$eventBus.$emit('bookshelf-total-entities', this.authors.length)
|
||||
}
|
||||
},
|
||||
authorUpdated(author) {
|
||||
this.authors = this.authors.map((au) => {
|
||||
if (au.id === author.id) {
|
||||
return author
|
||||
}
|
||||
return au
|
||||
})
|
||||
},
|
||||
authorRemoved(author) {
|
||||
this.authors = this.authors.filter((au) => au.id !== author.id)
|
||||
this.$eventBus.$emit('bookshelf-total-entities', this.authors.length)
|
||||
},
|
||||
libraryChanged(libraryId) {
|
||||
if (libraryId !== this.loadedLibraryId) {
|
||||
if (this.$store.getters['libraries/getCurrentLibraryMediaType'] === 'book') {
|
||||
this.init()
|
||||
} else {
|
||||
this.$router.replace('/bookshelf')
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
this.$socket.$on('author_added', this.authorAdded)
|
||||
this.$socket.$on('author_updated', this.authorUpdated)
|
||||
this.$socket.$on('author_removed', this.authorRemoved)
|
||||
this.$eventBus.$on('library-changed', this.libraryChanged)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$socket.$off('author_added', this.authorAdded)
|
||||
this.$socket.$off('author_updated', this.authorUpdated)
|
||||
this.$socket.$off('author_removed', this.authorRemoved)
|
||||
this.$eventBus.$off('library-changed', this.libraryChanged)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -257,11 +257,6 @@ export default {
|
||||
return cat
|
||||
})
|
||||
|
||||
// TODO: iOS has its own implementation of this. Android & iOS should be consistent here.
|
||||
if (!this.isIos) {
|
||||
this.openMediaPlayerWithMostRecentListening()
|
||||
}
|
||||
|
||||
// Only add the local shelf with the same media type
|
||||
const localShelves = localCategories.filter((cat) => cat.type === this.currentLibraryMediaType && !cat.localOnly)
|
||||
this.shelves.push(...localShelves)
|
||||
@@ -276,40 +271,6 @@ export default {
|
||||
|
||||
this.isLoading = false
|
||||
},
|
||||
openMediaPlayerWithMostRecentListening() {
|
||||
// If we don't already have a player open
|
||||
// Try opening the first book from continue-listening without playing it
|
||||
if (this.$store.state.playerLibraryItemId || !this.$store.state.isFirstAudioLoad) return
|
||||
this.$store.commit('setIsFirstAudioLoad', false) // Only run this once on app launch
|
||||
|
||||
const continueListeningShelf = this.shelves.find((cat) => cat.id === 'continue-listening')
|
||||
const mostRecentEntity = continueListeningShelf?.entities?.[0]
|
||||
if (mostRecentEntity) {
|
||||
const playObject = {
|
||||
libraryItemId: mostRecentEntity.id,
|
||||
episodeId: mostRecentEntity.recentEpisode?.id || null,
|
||||
paused: true
|
||||
}
|
||||
|
||||
// Check if there is a local copy
|
||||
if (mostRecentEntity.localLibraryItem) {
|
||||
if (mostRecentEntity.recentEpisode) {
|
||||
// Check if the podcast episode has a local copy
|
||||
const localEpisode = mostRecentEntity.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId === mostRecentEntity.recentEpisode.id)
|
||||
if (localEpisode) {
|
||||
playObject.libraryItemId = mostRecentEntity.localLibraryItem.id
|
||||
playObject.episodeId = localEpisode.id
|
||||
playObject.serverLibraryItemId = mostRecentEntity.id
|
||||
playObject.serverEpisodeId = mostRecentEntity.recentEpisode.id
|
||||
}
|
||||
} else {
|
||||
playObject.libraryItemId = mostRecentEntity.localLibraryItem.id
|
||||
playObject.serverLibraryItemId = mostRecentEntity.id
|
||||
}
|
||||
}
|
||||
this.$eventBus.$emit('play-item', playObject)
|
||||
}
|
||||
},
|
||||
libraryChanged() {
|
||||
if (this.currentLibraryId) {
|
||||
console.log(`[categories] libraryChanged so fetching categories`)
|
||||
|
||||
@@ -17,7 +17,8 @@ export default {
|
||||
totalEpisodes: 0,
|
||||
currentPage: 0,
|
||||
localEpisodeMap: {},
|
||||
isLocal: false
|
||||
isLocal: false,
|
||||
loadedLibraryId: null
|
||||
}
|
||||
},
|
||||
watch: {},
|
||||
@@ -39,6 +40,7 @@ export default {
|
||||
this.$store.commit('globals/setShowPlaylistsAddCreateModal', true)
|
||||
},
|
||||
async loadRecentEpisodes(page = 0) {
|
||||
this.loadedLibraryId = this.currentLibraryId
|
||||
this.processing = true
|
||||
const episodePayload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/recent-episodes?limit=25&page=${page}`).catch((error) => {
|
||||
console.error('Failed to get recent episodes', error)
|
||||
@@ -50,10 +52,23 @@ export default {
|
||||
this.recentEpisodes = episodePayload.episodes || []
|
||||
this.totalEpisodes = episodePayload.total
|
||||
this.currentPage = page
|
||||
},
|
||||
libraryChanged(libraryId) {
|
||||
if (libraryId !== this.loadedLibraryId) {
|
||||
if (this.$store.getters['libraries/getCurrentLibraryMediaType'] === 'podcast') {
|
||||
this.loadRecentEpisodes()
|
||||
} else {
|
||||
this.$router.replace('/bookshelf')
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadRecentEpisodes()
|
||||
this.$eventBus.$on('library-changed', this.libraryChanged)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off('library-changed', this.libraryChanged)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -7,7 +7,6 @@ export default {
|
||||
async asyncData({ store, params, query }) {
|
||||
// Set filter by
|
||||
if (query.filter) {
|
||||
store.commit('user/setSettings', { mobileFilterBy: query.filter })
|
||||
await store.dispatch('user/updateUserSettings', { mobileFilterBy: query.filter })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,529 @@
|
||||
<template>
|
||||
<div class="w-full h-full px-3 py-4 overflow-y-auto overflow-x-hidden relative bg-bg">
|
||||
<div class="flex mb-2">
|
||||
<div class="w-10 min-w-10">
|
||||
<covers-preview-cover :src="coverUrl" :width="40" :book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false" class="md:hidden" />
|
||||
</div>
|
||||
<div class="flex-grow px-2">
|
||||
<div class="-mt-0.5 mb-0.5">
|
||||
<nuxt-link :to="`/item/${libraryItemId}`" class="text-sm text-gray-200 underline">{{ podcast.metadata.title }}</nuxt-link>
|
||||
</div>
|
||||
<p v-if="publishedAt" class="text-xs text-gray-300">{{ $dateDistanceFromNow(publishedAt) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-lg font-semibold">
|
||||
{{ title }}
|
||||
</p>
|
||||
|
||||
<div v-if="episodeNumber || season || episodeType" class="flex py-2 items-center -mx-0.5">
|
||||
<div v-if="episodeNumber" class="px-2 pt-px pb-0.5 mx-0.5 bg-primary bg-opacity-60 rounded-full text-xs font-light text-gray-200">Episode #{{ episodeNumber }}</div>
|
||||
<div v-if="season" class="px-2 pt-px pb-0.5 mx-0.5 bg-primary bg-opacity-60 rounded-full text-xs font-light text-gray-200">Season #{{ season }}</div>
|
||||
<div v-if="episodeType" class="px-2 pt-px pb-0.5 mx-0.5 bg-primary bg-opacity-60 rounded-full text-xs font-light text-gray-200 capitalize">
|
||||
{{ episodeType }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- user progress card -->
|
||||
<div v-if="progressPercent > 0" class="px-4 py-2 bg-primary text-sm font-semibold rounded-md text-gray-200 mt-4 relative" :class="resettingProgress ? 'opacity-25' : ''">
|
||||
<p class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
|
||||
<p v-if="progressPercent < 1" class="text-gray-400 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
|
||||
<p v-else class="text-gray-400 text-xs">Finished {{ $formatDate(userProgressFinishedAt) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- action buttons -->
|
||||
<div class="flex mt-4 -mx-1">
|
||||
<ui-btn color="success" class="flex items-center justify-center flex-grow mx-1" :padding-x="4" @click="playClick">
|
||||
<span class="material-icons">{{ playerIsPlaying ? 'pause' : 'play_arrow' }}</span>
|
||||
<span class="px-1 text-sm">{{ playerIsPlaying ? 'Pause' : localEpisodeId ? 'Play' : 'Stream' }}</span>
|
||||
</ui-btn>
|
||||
<ui-btn v-if="showDownload" :color="downloadItem ? 'warning' : 'primary'" class="flex items-center justify-center mx-1" :padding-x="2" @click="downloadClick">
|
||||
<span class="material-icons" :class="downloadItem ? 'animate-pulse' : ''">{{ downloadItem ? 'downloading' : 'download' }}</span>
|
||||
</ui-btn>
|
||||
<ui-btn color="primary" class="flex items-center justify-center mx-1" :padding-x="2" @click="showMoreMenu = true">
|
||||
<span class="material-icons">more_vert</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-200 mt-1.5 mb-0.5 default-style" v-html="description" />
|
||||
|
||||
<!-- loading overlay -->
|
||||
<div v-if="processing" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-30 flex items-center justify-center">
|
||||
<widgets-loading-spinner size="la-lg" />
|
||||
</div>
|
||||
|
||||
<modals-dialog v-model="showMoreMenu" :items="moreMenuItems" @action="moreMenuAction" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { Dialog } from '@capacitor/dialog'
|
||||
import { AbsFileSystem, AbsDownloader } from '@/plugins/capacitor'
|
||||
|
||||
export default {
|
||||
async asyncData({ store, params, redirect, app }) {
|
||||
const libraryItemId = params.id
|
||||
const episodeId = params.episode
|
||||
|
||||
let libraryItem = null
|
||||
let episode = null
|
||||
let localEpisode = null
|
||||
|
||||
if (libraryItemId.startsWith('local')) {
|
||||
libraryItem = await app.$db.getLocalLibraryItem(libraryItemId)
|
||||
console.log('Got lli', libraryItemId)
|
||||
} else if (store.state.user.serverConnectionConfig) {
|
||||
libraryItem = await app.$axios.$get(`/api/items/${libraryItemId}?expanded=1`).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return false
|
||||
})
|
||||
|
||||
if (libraryItem) {
|
||||
const localLibraryItem = await app.$db.getLocalLibraryItemByLId(libraryItemId)
|
||||
if (localLibraryItem) {
|
||||
console.log('Library item has local library item also', localLibraryItem.id)
|
||||
libraryItem.localLibraryItem = localLibraryItem
|
||||
|
||||
localEpisode = localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId === episodeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!libraryItem) {
|
||||
console.error('No item...', params.id)
|
||||
return redirect('/')
|
||||
}
|
||||
|
||||
episode = libraryItem.media.episodes.find((ep) => ep.id === episodeId)
|
||||
if (localEpisode) {
|
||||
// For server episodes that have a matching local episode
|
||||
episode.localEpisode = localEpisode
|
||||
}
|
||||
|
||||
if (!episode) {
|
||||
console.error('No episode...', params.episode)
|
||||
return redirect(`/item/${libraryItemId}`)
|
||||
}
|
||||
|
||||
return {
|
||||
libraryItem,
|
||||
episode
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showMoreMenu: false,
|
||||
processing: false,
|
||||
resettingProgress: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
isAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
isIos() {
|
||||
return this.$platform === 'ios'
|
||||
},
|
||||
mediaType() {
|
||||
return 'podcast'
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
isLocal() {
|
||||
return this.libraryItem.isLocal
|
||||
},
|
||||
localLibraryItem() {
|
||||
if (this.isLocal) return this.libraryItem
|
||||
return this.libraryItem.localLibraryItem
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.libraryItem.id
|
||||
},
|
||||
isConnectedToServer() {
|
||||
if (!this.isLocal) return true
|
||||
if (!this.libraryItem.serverAddress) return false
|
||||
return this.$store.getters['user/getServerAddress'] === this.libraryItem.serverAddress
|
||||
},
|
||||
serverLibraryItemId() {
|
||||
if (!this.isLocal) return this.libraryItemId
|
||||
// Check if local library item is connected to the current server
|
||||
if (!this.libraryItem.libraryItemId) return null
|
||||
if (this.isConnectedToServer) {
|
||||
return this.libraryItem.libraryItemId
|
||||
}
|
||||
return null
|
||||
},
|
||||
localLibraryItemId() {
|
||||
return this.localLibraryItem?.id || null
|
||||
},
|
||||
localEpisode() {
|
||||
if (this.isLocal) return this.episode
|
||||
return this.episode.localEpisode
|
||||
},
|
||||
localEpisodeId() {
|
||||
if (this.localEpisode) return this.localEpisode.id
|
||||
return null
|
||||
},
|
||||
serverEpisodeId() {
|
||||
if (!this.isLocal) return this.episode.id
|
||||
if (this.isConnectedToServer) {
|
||||
return this.episode.serverEpisodeId
|
||||
}
|
||||
return null
|
||||
},
|
||||
podcast() {
|
||||
return this.libraryItem.media
|
||||
},
|
||||
audioFile() {
|
||||
return this.episode.audioFile
|
||||
},
|
||||
title() {
|
||||
return this.episode.title || ''
|
||||
},
|
||||
description() {
|
||||
return this.episode.description || ''
|
||||
},
|
||||
episodeNumber() {
|
||||
return this.episode.episode
|
||||
},
|
||||
season() {
|
||||
return this.episode.season
|
||||
},
|
||||
episodeType() {
|
||||
if (this.episode.episodeType === 'full') return null // only show Trailer/Bonus
|
||||
return this.episode.episodeType
|
||||
},
|
||||
duration() {
|
||||
return this.$secondsToTimestamp(this.episode.duration)
|
||||
},
|
||||
coverUrl() {
|
||||
if (this.isLocal) {
|
||||
if (!this.libraryItem.coverContentUrl) return '/book_placeholder.jpg'
|
||||
return Capacitor.convertFileSrc(this.libraryItem.coverContentUrl)
|
||||
}
|
||||
return this.$store.getters['globals/getLibraryItemCoverSrcById'](this.libraryItemId)
|
||||
},
|
||||
isPlaying() {
|
||||
if (this.playerIsLocal && this.localLibraryItemId && this.localEpisodeId) {
|
||||
// Check is streaming local version of this episode
|
||||
return this.$store.getters['getIsEpisodeStreaming'](this.localLibraryItemId, this.localEpisodeId)
|
||||
}
|
||||
return this.$store.getters['getIsEpisodeStreaming'](this.libraryItemId, this.episode.id)
|
||||
},
|
||||
playerIsLocal() {
|
||||
return !!this.$store.state.playerIsLocal
|
||||
},
|
||||
playerIsPlaying() {
|
||||
return this.$store.state.playerIsPlaying && this.isPlaying
|
||||
},
|
||||
userItemProgress() {
|
||||
if (this.isLocal) return this.localItemProgress
|
||||
return this.serverItemProgress
|
||||
},
|
||||
localItemProgress() {
|
||||
if (!this.localLibraryItemId || !this.localEpisodeId) return null
|
||||
return this.$store.getters['globals/getLocalMediaProgressById'](this.localLibraryItemId, this.localEpisodeId)
|
||||
},
|
||||
serverItemProgress() {
|
||||
if (!this.serverLibraryItemId || !this.serverEpisodeId) return null
|
||||
return this.$store.getters['user/getUserMediaProgress'](this.serverLibraryItemId, this.serverEpisodeId)
|
||||
},
|
||||
progressPercent() {
|
||||
return this.userItemProgress ? this.userItemProgress.progress : 0
|
||||
},
|
||||
userIsFinished() {
|
||||
return this.userItemProgress ? !!this.userItemProgress.isFinished : false
|
||||
},
|
||||
userProgressFinishedAt() {
|
||||
return this.userItemProgress ? this.userItemProgress.finishedAt : 0
|
||||
},
|
||||
userTimeRemaining() {
|
||||
if (!this.userItemProgress) return 0
|
||||
const duration = this.userItemProgress.duration || this.episode.duration
|
||||
return duration - this.userItemProgress.currentTime
|
||||
},
|
||||
timeRemaining() {
|
||||
if (this.playerIsPlaying) return 'Playing'
|
||||
if (!this.progressPercent) return this.$elapsedPretty(this.episode.duration)
|
||||
if (this.userIsFinished) return 'Finished'
|
||||
const remaining = Math.floor(this.userItemProgress.duration - this.userItemProgress.currentTime)
|
||||
return `${this.$elapsedPretty(remaining)} left`
|
||||
},
|
||||
publishedAt() {
|
||||
return this.episode.publishedAt
|
||||
},
|
||||
downloadItem() {
|
||||
return this.$store.getters['globals/getDownloadItem'](this.libraryItemId, this.episode.id)
|
||||
},
|
||||
showDownload() {
|
||||
return this.userCanDownload && !this.localEpisode
|
||||
},
|
||||
moreMenuItems() {
|
||||
const items = []
|
||||
|
||||
if (!this.userIsFinished) {
|
||||
items.push({
|
||||
text: 'Mark as Finished',
|
||||
value: 'markFinished',
|
||||
icon: 'beenhere'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.progressPercent > 0) {
|
||||
items.push({
|
||||
text: 'Discard Progress',
|
||||
value: 'discardProgress',
|
||||
icon: 'backspace'
|
||||
})
|
||||
}
|
||||
|
||||
if (!this.isLocal) {
|
||||
items.push({
|
||||
text: 'Add to Playlist',
|
||||
value: 'playlist',
|
||||
icon: 'playlist_add'
|
||||
})
|
||||
|
||||
if (this.isAdminOrUp) {
|
||||
items.push({
|
||||
text: 'Remove from Server',
|
||||
value: 'remove_from_server',
|
||||
icon: 'delete_forever'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async playClick() {
|
||||
await this.$hapticsImpact()
|
||||
if (this.playerIsPlaying) {
|
||||
this.$eventBus.$emit('pause-item')
|
||||
} else {
|
||||
if (this.localEpisodeId && this.localLibraryItemId && !this.isLocal) {
|
||||
console.log('Play local episode', this.localEpisodeId, this.localLibraryItemId)
|
||||
|
||||
this.$eventBus.$emit('play-item', {
|
||||
libraryItemId: this.localLibraryItemId,
|
||||
episodeId: this.localEpisodeId,
|
||||
serverLibraryItemId: this.serverLibraryItemId,
|
||||
serverEpisodeId: this.serverEpisodeId
|
||||
})
|
||||
} else {
|
||||
this.$eventBus.$emit('play-item', {
|
||||
libraryItemId: this.libraryItemId,
|
||||
episodeId: this.episode.id,
|
||||
serverLibraryItemId: this.serverLibraryItemId,
|
||||
serverEpisodeId: this.serverEpisodeId
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
async downloadClick() {
|
||||
if (this.downloadItem) return
|
||||
await this.$hapticsImpact()
|
||||
if (this.isIos) {
|
||||
// no local folders on iOS
|
||||
this.startDownload()
|
||||
} else {
|
||||
this.download()
|
||||
}
|
||||
},
|
||||
async download(selectedLocalFolder = null) {
|
||||
let localFolder = selectedLocalFolder
|
||||
if (!localFolder) {
|
||||
const localFolders = (await this.$db.getLocalFolders()) || []
|
||||
console.log('Local folders loaded', localFolders.length)
|
||||
const foldersWithMediaType = localFolders.filter((lf) => {
|
||||
console.log('Checking local folder', lf.mediaType)
|
||||
return lf.mediaType == this.mediaType
|
||||
})
|
||||
console.log('Folders with media type', this.mediaType, foldersWithMediaType.length)
|
||||
if (!foldersWithMediaType.length) {
|
||||
// No local folders or no local folders with this media type
|
||||
localFolder = await this.selectFolder()
|
||||
} else if (foldersWithMediaType.length == 1) {
|
||||
console.log('Only 1 local folder with this media type - auto select it')
|
||||
localFolder = foldersWithMediaType[0]
|
||||
} else {
|
||||
console.log('Multiple folders with media type')
|
||||
// this.showSelectLocalFolder = true
|
||||
return
|
||||
}
|
||||
if (!localFolder) {
|
||||
return this.$toast.error('Invalid download folder')
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Local folder', JSON.stringify(localFolder))
|
||||
|
||||
const startDownloadMessage = `Start download for "${this.title}" to folder ${localFolder.name}?`
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: startDownloadMessage
|
||||
})
|
||||
if (value) {
|
||||
this.startDownload(localFolder)
|
||||
}
|
||||
},
|
||||
async selectFolder() {
|
||||
const folderObj = await AbsFileSystem.selectFolder({ mediaType: this.mediaType })
|
||||
if (folderObj.error) {
|
||||
return this.$toast.error(`Error: ${folderObj.error || 'Unknown Error'}`)
|
||||
}
|
||||
return folderObj
|
||||
},
|
||||
async startDownload(localFolder) {
|
||||
const payload = {
|
||||
libraryItemId: this.libraryItemId,
|
||||
episodeId: this.episode.id
|
||||
}
|
||||
if (localFolder) {
|
||||
payload.localFolderId = localFolder.id
|
||||
}
|
||||
const downloadRes = await AbsDownloader.downloadLibraryItem(payload)
|
||||
if (downloadRes && downloadRes.error) {
|
||||
const errorMsg = downloadRes.error || 'Unknown error'
|
||||
console.error('Download error', errorMsg)
|
||||
this.$toast.error(errorMsg)
|
||||
}
|
||||
},
|
||||
moreMenuAction(action) {
|
||||
this.showMoreMenu = false
|
||||
if (action === 'markFinished') {
|
||||
this.toggleFinished()
|
||||
} else if (action === 'discardProgress') {
|
||||
this.discardProgress()
|
||||
} else if (action === 'playlist' && !this.isLocal) {
|
||||
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: this.episode }])
|
||||
this.$store.commit('globals/setShowPlaylistsAddCreateModal', true)
|
||||
} else if (action === 'remove_from_server' && !this.isLocal && this.isAdminOrUp) {
|
||||
this.deleteEpisodeFromServerClick()
|
||||
}
|
||||
},
|
||||
async discardProgress() {
|
||||
await this.$hapticsImpact()
|
||||
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: 'Are you sure you want to reset your progress?'
|
||||
})
|
||||
if (value) {
|
||||
this.resettingProgress = true
|
||||
|
||||
const serverItemProgressId = this.serverItemProgress?.id
|
||||
if (this.localItemProgress) {
|
||||
await this.$db.removeLocalMediaProgress(this.localItemProgress.id)
|
||||
this.$store.commit('globals/removeLocalMediaProgress', this.localItemProgress.id)
|
||||
}
|
||||
|
||||
if (serverItemProgressId) {
|
||||
await this.$axios
|
||||
.$delete(`/api/me/progress/${serverItemProgressId}`)
|
||||
.then(() => {
|
||||
console.log('Progress reset complete')
|
||||
this.$toast.success(`Your progress was reset`)
|
||||
this.$store.commit('user/removeMediaProgress', serverItemProgressId)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Progress reset failed', error)
|
||||
})
|
||||
}
|
||||
|
||||
this.resettingProgress = false
|
||||
}
|
||||
},
|
||||
async toggleFinished() {
|
||||
await this.$hapticsImpact()
|
||||
|
||||
if (this.isLocal || this.localEpisode) {
|
||||
const isFinished = !this.userIsFinished
|
||||
const localLibraryItemId = this.localLibraryItemId
|
||||
const localEpisodeId = this.localEpisodeId
|
||||
const payload = await this.$db.updateLocalMediaProgressFinished({ localLibraryItemId, localEpisodeId, isFinished })
|
||||
console.log('toggleFinished payload', JSON.stringify(payload))
|
||||
|
||||
if (!payload || payload.error) {
|
||||
var errorMsg = payload ? payload.error : 'Unknown error'
|
||||
this.$toast.error(errorMsg)
|
||||
} else {
|
||||
const localMediaProgress = payload.localMediaProgress
|
||||
console.log('toggleFinished localMediaProgress', JSON.stringify(localMediaProgress))
|
||||
if (localMediaProgress) {
|
||||
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
|
||||
}
|
||||
|
||||
if (payload.server) {
|
||||
this.$toast.success(`Local & Server Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
} else {
|
||||
this.$toast.success(`Local Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const updatePayload = {
|
||||
isFinished: !this.userIsFinished
|
||||
}
|
||||
this.$axios
|
||||
.$patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload)
|
||||
.then(() => {
|
||||
this.$toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
})
|
||||
}
|
||||
},
|
||||
async deleteEpisodeFromServerClick() {
|
||||
await this.$hapticsImpact()
|
||||
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: `Are you sure you want to delete episode "${this.title}" from the server?\nWarning: This will delete the audio file.`
|
||||
})
|
||||
|
||||
if (value) {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$delete(`/api/podcasts/${this.libraryItemId}/episode/${this.episode.id}?hard=1`)
|
||||
.then(() => {
|
||||
this.$toast.success('Episode deleted from server')
|
||||
this.$router.replace(`/item/${this.libraryItemId}`)
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMsg = error.response && error.response.data ? error.response.data : 'Failed to delete episode'
|
||||
console.error('Failed to delete episode', error)
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
newLocalLibraryItem(item) {
|
||||
if (item.libraryItemId == this.libraryItemId) {
|
||||
console.log('New local library item', item.id)
|
||||
this.$set(this.libraryItem, 'localLibraryItem', item)
|
||||
|
||||
const episode = item.media.episodes.find((ep) => ep.serverEpisodeId === this.episode.id)
|
||||
if (episode) {
|
||||
this.$set(this.episode, 'localEpisode', episode)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$eventBus.$on('new-local-library-item', this.newLocalLibraryItem)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off('new-local-library-item', this.newLocalLibraryItem)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
+181
-130
@@ -1,11 +1,12 @@
|
||||
<template>
|
||||
<div class="w-full h-full px-3 py-4 overflow-y-auto overflow-x-hidden relative bg-bg">
|
||||
<div class="w-full h-full px-3 pb-4 overflow-y-auto overflow-x-hidden relative bg-bg">
|
||||
<div class="fixed top-0 left-0 w-full h-full pointer-events-none p-px z-10">
|
||||
<div class="w-full h-full" :style="{ backgroundColor: coverRgb }" />
|
||||
<div class="w-full h-full absolute top-0 left-0" style="background: linear-gradient(169deg, rgba(0, 0, 0, 0.4) 0%, rgba(55, 56, 56, 1) 80%)" />
|
||||
</div>
|
||||
|
||||
<div class="z-10 relative">
|
||||
<!-- cover -->
|
||||
<div class="w-full flex justify-center relative mb-4">
|
||||
<div style="width: 0; transform: translateX(-50vw); overflow: visible">
|
||||
<div style="width: 150vw; overflow: hidden">
|
||||
@@ -15,65 +16,32 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative" @click="showFullscreenCover = true">
|
||||
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" no-bg @imageLoaded="coverImageLoaded" />
|
||||
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" no-bg raw @imageLoaded="coverImageLoaded" />
|
||||
<div v-if="!isPodcast" class="absolute bottom-0 left-0 h-1 shadow-sm z-10" :class="userIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: coverWidth * progressPercent + 'px' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="text-xl font-semibold">{{ title }}</h1>
|
||||
|
||||
<p v-if="subtitle" class="text-gray-100 text-base py-0.5 mb-0.5">{{ subtitle }}</p>
|
||||
|
||||
<p v-if="seriesList && seriesList.length" class="text-sm text-gray-300 py-0.5">
|
||||
<template v-for="(series, index) in seriesList">
|
||||
<nuxt-link :key="series.id" :to="`/bookshelf/series/${series.id}`" class="underline">{{ series.text }}</nuxt-link
|
||||
><span :key="`${series.id}-comma`" v-if="index < seriesList.length - 1">, </span>
|
||||
</template>
|
||||
</p>
|
||||
|
||||
<p v-if="podcastAuthor" class="text-sm text-gray-300 py-0.5">by {{ podcastAuthor }}</p>
|
||||
<p v-else-if="bookAuthors && bookAuthors.length" class="text-sm text-gray-300 py-0.5">
|
||||
by
|
||||
<template v-for="(author, index) in bookAuthors">
|
||||
<nuxt-link :key="author.id" :to="`/bookshelf/library?filter=authors.${$encode(author.id)}`" class="underline">{{ author.name }}</nuxt-link
|
||||
><span :key="`${author.id}-comma`" v-if="index < bookAuthors.length - 1">, </span>
|
||||
</template>
|
||||
</p>
|
||||
<!-- title -->
|
||||
<div class="text-center mb-2">
|
||||
<h1 class="text-xl font-semibold">{{ title }}</h1>
|
||||
<p v-if="subtitle" class="text-gray-100 text-base">{{ subtitle }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Show an indicator for local library items whether they are linked to a server item and if that server item is connected -->
|
||||
<p v-if="isLocal && serverLibraryItemId" style="font-size: 10px" class="text-success py-1 uppercase tracking-widest">connected</p>
|
||||
<p v-else-if="isLocal && libraryItem.serverAddress" style="font-size: 10px" class="text-gray-400 py-1">{{ libraryItem.serverAddress }}</p>
|
||||
<p v-if="isLocal && serverLibraryItemId" style="font-size: 10px" class="text-success text-center py-1 uppercase tracking-widest">connected</p>
|
||||
<p v-else-if="isLocal && libraryItem.serverAddress" style="font-size: 10px" class="text-gray-400 text-center py-1">{{ libraryItem.serverAddress }}</p>
|
||||
|
||||
<!-- action buttons -->
|
||||
<div>
|
||||
<div v-if="!isPodcast && progressPercent > 0" class="px-4 py-2 bg-primary text-sm font-semibold rounded-md text-gray-200 mt-4 relative" :class="resettingProgress ? 'opacity-25' : ''">
|
||||
<p class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
|
||||
<p v-if="progressPercent < 1" class="text-gray-400 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
|
||||
<p v-else class="text-gray-400 text-xs">Finished {{ $formatDate(userProgressFinishedAt) }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="isLocal" class="flex mt-4 -mx-1">
|
||||
<ui-btn v-if="showPlay" color="success" :disabled="isPlaying" class="flex items-center justify-center flex-grow mx-1" :padding-x="4" @click="playClick">
|
||||
<span v-show="!isPlaying" class="material-icons">play_arrow</span>
|
||||
<span class="px-1 text-sm">{{ isPlaying ? 'Playing' : 'Play' }}</span>
|
||||
<div class="col-span-full">
|
||||
<div v-if="showPlay || showRead" class="flex mt-4 -mx-1">
|
||||
<ui-btn v-if="showPlay" color="success" class="flex items-center justify-center flex-grow mx-1" :padding-x="4" @click="playClick">
|
||||
<span class="material-icons">{{ playerIsPlaying ? 'pause' : 'play_arrow' }}</span>
|
||||
<span class="px-1 text-sm">{{ playerIsPlaying ? 'Pause' : isPodcast ? 'Next Episode' : hasLocal ? 'Play' : 'Stream' }}</span>
|
||||
</ui-btn>
|
||||
<ui-btn v-if="showRead" color="info" class="flex items-center justify-center mx-1" :class="showPlay ? '' : 'flex-grow'" :padding-x="2" @click="readBook">
|
||||
<span class="material-icons">auto_stories</span>
|
||||
<span v-if="!showPlay" class="px-2 text-base">Read {{ ebookFormat }}</span>
|
||||
</ui-btn>
|
||||
<ui-btn color="primary" class="flex items-center justify-center mx-1" :padding-x="2" @click="moreButtonPress">
|
||||
<span class="material-icons">more_vert</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
<div v-else-if="(user && (showPlay || showRead)) || hasLocal" class="flex mt-4 -mx-1">
|
||||
<ui-btn v-if="showPlay" color="success" :disabled="isPlaying" class="flex items-center justify-center flex-grow mx-1" :padding-x="4" @click="playClick">
|
||||
<span v-show="!isPlaying" class="material-icons">play_arrow</span>
|
||||
<span class="px-1 text-sm">{{ isPlaying ? (isStreaming ? 'Streaming' : 'Playing') : isPodcast ? 'Next Episode' : hasLocal ? 'Play' : 'Stream' }}</span>
|
||||
</ui-btn>
|
||||
<ui-btn v-if="showRead && user" color="info" class="flex items-center justify-center mx-1" :class="showPlay ? '' : 'flex-grow'" :padding-x="2" @click="readBook">
|
||||
<span class="material-icons">auto_stories</span>
|
||||
<span v-if="!showPlay" class="px-2 text-base">Read {{ ebookFormat }}</span>
|
||||
</ui-btn>
|
||||
<ui-btn v-if="showDownload" :color="downloadItem ? 'warning' : 'primary'" class="flex items-center justify-center mx-1" :padding-x="2" @click="downloadClick">
|
||||
<span class="material-icons" :class="downloadItem ? 'animate-pulse' : ''">{{ downloadItem ? 'downloading' : 'download' }}</span>
|
||||
</ui-btn>
|
||||
@@ -81,6 +49,12 @@
|
||||
<span class="material-icons">more_vert</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
|
||||
<div v-if="!isPodcast && progressPercent > 0" class="px-4 py-2 bg-primary text-sm font-semibold rounded-md text-gray-200 mt-4 text-center" :class="resettingProgress ? 'opacity-25' : ''">
|
||||
<p>Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
|
||||
<p v-if="!useEBookProgress && !userIsFinished" class="text-gray-400 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
|
||||
<p v-else-if="userIsFinished" class="text-gray-400 text-xs">Finished {{ $formatDate(userProgressFinishedAt) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="downloadItem" class="py-3">
|
||||
@@ -89,8 +63,31 @@
|
||||
</div>
|
||||
|
||||
<!-- metadata -->
|
||||
<div class="grid gap-2 my-4" style="grid-template-columns: max-content auto">
|
||||
<div v-if="narrators && narrators.length" class="text-white text-opacity-60 uppercase text-sm">Narrators</div>
|
||||
<div id="metadata" class="grid gap-2 my-2" style="">
|
||||
<div v-if="podcastAuthor || (bookAuthors && bookAuthors.length)" class="text-white text-opacity-60 uppercase text-sm">Author</div>
|
||||
<div v-if="podcastAuthor" class="text-sm">{{ podcastAuthor }}</div>
|
||||
<div v-else-if="bookAuthors && bookAuthors.length" class="text-sm">
|
||||
<template v-for="(author, index) in bookAuthors">
|
||||
<nuxt-link :key="author.id" :to="`/bookshelf/library?filter=authors.${$encode(author.id)}`" class="underline">{{ author.name }}</nuxt-link
|
||||
><span :key="`${author.id}-comma`" v-if="index < bookAuthors.length - 1">, </span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="podcastType" class="text-white text-opacity-60 uppercase text-sm">Type</div>
|
||||
<div v-if="podcastType" class="text-sm capitalize">{{ podcastType }}</div>
|
||||
|
||||
<div v-if="series && series.length" class="text-white text-opacity-60 uppercase text-sm">Series</div>
|
||||
<div v-if="series && series.length" class="truncate text-sm">
|
||||
<template v-for="(series, index) in seriesList">
|
||||
<nuxt-link :key="series.id" :to="`/bookshelf/series/${series.id}`" class="underline">{{ series.text }}</nuxt-link
|
||||
><span :key="`${series.id}-comma`" v-if="index < seriesList.length - 1">, </span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="numTracks" class="text-white text-opacity-60 uppercase text-sm">Duration</div>
|
||||
<div v-if="numTracks" class="text-sm">{{ $elapsedPretty(duration) }}</div>
|
||||
|
||||
<div v-if="narrators && narrators.length" class="text-white text-opacity-60 uppercase text-sm">{{ narrators.length === 1 ? 'Narrator' : 'Narrators' }}</div>
|
||||
<div v-if="narrators && narrators.length" class="truncate text-sm">
|
||||
<template v-for="(narrator, index) in narrators">
|
||||
<nuxt-link :key="narrator" :to="`/bookshelf/library?filter=narrators.${$encode(narrator)}`" class="underline">{{ narrator }}</nuxt-link
|
||||
@@ -98,40 +95,35 @@
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="publishedYear" class="text-white text-opacity-60 uppercase text-sm">Published</div>
|
||||
<div v-if="publishedYear" class="text-sm">{{ publishedYear }}</div>
|
||||
|
||||
<div v-if="genres.length" class="text-white text-opacity-60 uppercase text-sm">Genres</div>
|
||||
<div v-if="genres.length" class="text-white text-opacity-60 uppercase text-sm">{{ genres.length === 1 ? 'Genre' : 'Genres' }}</div>
|
||||
<div v-if="genres.length" class="truncate text-sm">
|
||||
<template v-for="(genre, index) in genres">
|
||||
<nuxt-link :key="genre" :to="`/bookshelf/library?filter=genres.${$encode(genre)}`" class="underline">{{ genre }}</nuxt-link
|
||||
><span :key="index" v-if="index < genres.length - 1">, </span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="publishedYear" class="text-white text-opacity-60 uppercase text-sm">Published</div>
|
||||
<div v-if="publishedYear" class="text-sm">{{ publishedYear }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="numTracks" class="flex text-gray-100 text-xs my-2 -mx-0.5">
|
||||
<div class="bg-primary bg-opacity-80 px-3 py-0.5 rounded-full mx-0.5">
|
||||
<p>{{ $elapsedPretty(duration) }}</p>
|
||||
</div>
|
||||
<!-- TODO: Local books dont save the size -->
|
||||
<div v-if="size" class="bg-primary bg-opacity-80 px-3 py-0.5 rounded-full mx-0.5">
|
||||
<p>{{ $bytesPretty(size) }}</p>
|
||||
</div>
|
||||
<div class="bg-primary bg-opacity-80 px-3 py-0.5 rounded-full mx-0.5">
|
||||
<p>{{ numTracks }} Track{{ numTracks > 1 ? 's' : '' }}</p>
|
||||
</div>
|
||||
<div v-if="numChapters" class="bg-primary bg-opacity-80 px-3 py-0.5 rounded-full mx-0.5">
|
||||
<p>{{ numChapters }} Chapter{{ numChapters > 1 ? 's' : '' }}</p>
|
||||
<div v-if="description" class="w-full py-2">
|
||||
<p ref="description" class="text-sm text-justify whitespace-pre-line font-light" :class="{ 'line-clamp-4': !showFullDescription }" style="hyphens: auto">{{ description }}</p>
|
||||
|
||||
<div v-if="descriptionClamped" class="text-white text-sm py-2" @click="showFullDescription = !showFullDescription">
|
||||
{{ showFullDescription ? 'Read less' : 'Read more' }}
|
||||
<span class="material-icons align-middle text-base -mt-px">{{ showFullDescription ? 'expand_less' : 'expand_more' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full py-4">
|
||||
<p class="text-sm text-justify" style="hyphens: auto;">{{ description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- tables -->
|
||||
<tables-podcast-episodes-table v-if="isPodcast" :library-item="libraryItem" :local-library-item-id="localLibraryItemId" :episodes="episodes" :local-episodes="localLibraryItemEpisodes" :is-local="isLocal" />
|
||||
|
||||
<tables-chapters-table v-if="numChapters" :library-item="libraryItem" @playAtTimestamp="playAtTimestamp" />
|
||||
|
||||
<tables-tracks-table v-if="numTracks" :tracks="tracks" :library-item-id="libraryItemId" />
|
||||
|
||||
<!-- modals -->
|
||||
<modals-select-local-folder-modal v-model="showSelectLocalFolder" :media-type="mediaType" @select="selectedLocalFolder" />
|
||||
|
||||
<modals-dialog v-model="showMoreMenu" :items="moreMenuItems" @action="moreMenuAction" />
|
||||
@@ -150,9 +142,8 @@ import { FastAverageColor } from 'fast-average-color'
|
||||
|
||||
export default {
|
||||
async asyncData({ store, params, redirect, app }) {
|
||||
var libraryItemId = params.id
|
||||
var libraryItem = null
|
||||
console.log(libraryItemId)
|
||||
const libraryItemId = params.id
|
||||
let libraryItem = null
|
||||
if (libraryItemId.startsWith('local')) {
|
||||
libraryItem = await app.$db.getLocalLibraryItem(libraryItemId)
|
||||
console.log('Got lli', libraryItemId)
|
||||
@@ -161,9 +152,9 @@ export default {
|
||||
console.error('Failed', error)
|
||||
return false
|
||||
})
|
||||
// Check if
|
||||
|
||||
if (libraryItem) {
|
||||
var localLibraryItem = await app.$db.getLocalLibraryItemByLId(libraryItemId)
|
||||
const localLibraryItem = await app.$db.getLocalLibraryItemByLId(libraryItemId)
|
||||
if (localLibraryItem) {
|
||||
console.log('Library item has local library item also', localLibraryItem.id)
|
||||
libraryItem.localLibraryItem = localLibraryItem
|
||||
@@ -189,7 +180,9 @@ export default {
|
||||
showFullscreenCover: false,
|
||||
coverRgb: 'rgb(55, 56, 56)',
|
||||
coverBgIsLight: false,
|
||||
windowWidth: 0
|
||||
windowWidth: 0,
|
||||
descriptionClamped: false,
|
||||
showFullDescription: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -211,12 +204,12 @@ export default {
|
||||
return this.libraryItem.localLibraryItem || null
|
||||
},
|
||||
localLibraryItemId() {
|
||||
return this.localLibraryItem ? this.localLibraryItem.id : null
|
||||
return this.localLibraryItem?.id || null
|
||||
},
|
||||
localLibraryItemEpisodes() {
|
||||
if (!this.isPodcast || !this.localLibraryItem) return []
|
||||
var podcastMedia = this.localLibraryItem.media
|
||||
return podcastMedia ? podcastMedia.episodes || [] : []
|
||||
return podcastMedia?.episodes || []
|
||||
},
|
||||
serverLibraryItemId() {
|
||||
if (!this.isLocal) return this.libraryItem.id
|
||||
@@ -257,6 +250,9 @@ export default {
|
||||
publishedYear() {
|
||||
return this.mediaMetadata.publishedYear
|
||||
},
|
||||
podcastType() {
|
||||
return this.mediaMetadata.type
|
||||
},
|
||||
podcastAuthor() {
|
||||
if (!this.isPodcast) return null
|
||||
return this.mediaMetadata.author || ''
|
||||
@@ -289,9 +285,6 @@ export default {
|
||||
duration() {
|
||||
return this.media.duration
|
||||
},
|
||||
size() {
|
||||
return this.media.size
|
||||
},
|
||||
user() {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
@@ -300,8 +293,16 @@ export default {
|
||||
},
|
||||
userItemProgress() {
|
||||
if (this.isPodcast) return null
|
||||
if (this.isLocal) return this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId)
|
||||
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
||||
if (this.isLocal) return this.localItemProgress
|
||||
return this.serverItemProgress
|
||||
},
|
||||
localItemProgress() {
|
||||
if (this.isPodcast) return null
|
||||
return this.$store.getters['globals/getLocalMediaProgressById'](this.localLibraryItemId)
|
||||
},
|
||||
serverItemProgress() {
|
||||
if (this.isPodcast) return null
|
||||
return this.$store.getters['user/getUserMediaProgress'](this.serverLibraryItemId)
|
||||
},
|
||||
userIsFinished() {
|
||||
return this.userItemProgress ? !!this.userItemProgress.isFinished : false
|
||||
@@ -311,11 +312,13 @@ export default {
|
||||
const duration = this.userItemProgress.duration || this.duration
|
||||
return duration - this.userItemProgress.currentTime
|
||||
},
|
||||
progressPercent() {
|
||||
return this.userItemProgress ? Math.max(Math.min(1, this.userItemProgress.progress), 0) : 0
|
||||
useEBookProgress() {
|
||||
if (!this.userItemProgress || this.userItemProgress.progress) return false
|
||||
return this.userItemProgress.ebookProgress > 0
|
||||
},
|
||||
userProgressStartedAt() {
|
||||
return this.userItemProgress ? this.userItemProgress.startedAt : 0
|
||||
progressPercent() {
|
||||
if (this.useEBookProgress) return Math.max(Math.min(1, this.userItemProgress.ebookProgress), 0)
|
||||
return this.userItemProgress ? Math.max(Math.min(1, this.userItemProgress.progress), 0) : 0
|
||||
},
|
||||
userProgressFinishedAt() {
|
||||
return this.userItemProgress ? this.userItemProgress.finishedAt : 0
|
||||
@@ -327,9 +330,14 @@ export default {
|
||||
if (this.localLibraryItemId && this.$store.getters['getIsItemStreaming'](this.localLibraryItemId)) return true
|
||||
return this.$store.getters['getIsItemStreaming'](this.libraryItemId)
|
||||
},
|
||||
playerIsPlaying() {
|
||||
return this.$store.state.playerIsPlaying && (this.isStreaming || this.isPlaying)
|
||||
},
|
||||
tracks() {
|
||||
return this.media.tracks || []
|
||||
},
|
||||
numTracks() {
|
||||
if (!this.media.tracks) return 0
|
||||
return this.media.tracks.length || 0
|
||||
return this.tracks.length || 0
|
||||
},
|
||||
numChapters() {
|
||||
if (!this.media.chapters) return 0
|
||||
@@ -338,18 +346,18 @@ export default {
|
||||
isMissing() {
|
||||
return this.libraryItem.isMissing
|
||||
},
|
||||
isIncomplete() {
|
||||
return this.libraryItem.isIncomplete
|
||||
isInvalid() {
|
||||
return this.libraryItem.isInvalid
|
||||
},
|
||||
showPlay() {
|
||||
return !this.isMissing && !this.isIncomplete && (this.numTracks || this.episodes.length)
|
||||
return !this.isMissing && !this.isInvalid && (this.numTracks || this.episodes.length)
|
||||
},
|
||||
showRead() {
|
||||
return this.ebookFile
|
||||
},
|
||||
showDownload() {
|
||||
if (this.isPodcast) return false
|
||||
return this.user && this.userCanDownload && this.showPlay && !this.hasLocal
|
||||
if (this.isPodcast || this.hasLocal) return false
|
||||
return this.user && this.userCanDownload && (this.showPlay || (this.showRead && !this.isIos))
|
||||
},
|
||||
ebookFile() {
|
||||
return this.media.ebookFile
|
||||
@@ -473,12 +481,25 @@ export default {
|
||||
this.showMoreMenu = true
|
||||
},
|
||||
readBook() {
|
||||
this.$store.commit('openReader', this.libraryItem)
|
||||
if (this.localLibraryItem?.media?.ebookFile) {
|
||||
// Has local ebook file
|
||||
this.$store.commit('openReader', this.localLibraryItem)
|
||||
} else {
|
||||
this.$store.commit('openReader', this.libraryItem)
|
||||
}
|
||||
},
|
||||
playAtTimestamp(seconds) {
|
||||
this.play(seconds)
|
||||
},
|
||||
async playClick() {
|
||||
let episodeId = null
|
||||
await this.$hapticsImpact()
|
||||
|
||||
if (this.playerIsPlaying) {
|
||||
this.$eventBus.$emit('pause-item')
|
||||
} else {
|
||||
this.play()
|
||||
}
|
||||
},
|
||||
async play(startTime = null) {
|
||||
if (this.isPodcast) {
|
||||
this.episodes.sort((a, b) => {
|
||||
return String(b.publishedAt).localeCompare(String(a.publishedAt), undefined, { numeric: true, sensitivity: 'base' })
|
||||
@@ -496,7 +517,7 @@ export default {
|
||||
|
||||
if (!episode) episode = this.episodes[0]
|
||||
|
||||
episodeId = episode.id
|
||||
const episodeId = episode.id
|
||||
|
||||
let localEpisode = null
|
||||
if (this.hasLocal && !this.isLocal) {
|
||||
@@ -509,26 +530,33 @@ export default {
|
||||
if (serverEpisodeId && this.serverLibraryItemId && this.isCasting) {
|
||||
// If casting and connected to server for local library item then send server library item id
|
||||
this.$eventBus.$emit('play-item', { libraryItemId: this.serverLibraryItemId, episodeId: serverEpisodeId })
|
||||
return
|
||||
}
|
||||
if (localEpisode) {
|
||||
} else if (localEpisode) {
|
||||
this.$eventBus.$emit('play-item', { libraryItemId: this.localLibraryItem.id, episodeId: localEpisode.id, serverLibraryItemId: this.serverLibraryItemId, serverEpisodeId })
|
||||
return
|
||||
} else {
|
||||
this.$eventBus.$emit('play-item', { libraryItemId: this.libraryItemId, episodeId })
|
||||
}
|
||||
} else {
|
||||
// Audiobook
|
||||
if (this.hasLocal && this.serverLibraryItemId && this.isCasting) {
|
||||
// If casting and connected to server for local library item then send server library item id
|
||||
this.$eventBus.$emit('play-item', { libraryItemId: this.serverLibraryItemId })
|
||||
return
|
||||
}
|
||||
if (this.hasLocal) {
|
||||
this.$eventBus.$emit('play-item', { libraryItemId: this.localLibraryItem.id, serverLibraryItemId: this.serverLibraryItemId })
|
||||
return
|
||||
}
|
||||
}
|
||||
let libraryItemId = this.libraryItemId
|
||||
|
||||
this.$eventBus.$emit('play-item', { libraryItemId: this.libraryItemId, episodeId })
|
||||
// When casting use server library item
|
||||
if (this.hasLocal && this.serverLibraryItemId && this.isCasting) {
|
||||
libraryItemId = this.serverLibraryItemId
|
||||
} else if (this.hasLocal) {
|
||||
libraryItemId = this.localLibraryItem.id
|
||||
}
|
||||
|
||||
// If start time and is not already streaming then ask for confirmation
|
||||
if (startTime !== null && startTime !== undefined && !this.$store.getters['getIsMediaStreaming'](libraryItemId, null)) {
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: `Start playback for "${this.title}" at ${this.$secondsToTimestamp(startTime)}?`
|
||||
})
|
||||
if (!value) return
|
||||
}
|
||||
|
||||
this.$eventBus.$emit('play-item', { libraryItemId, serverLibraryItemId: this.serverLibraryItemId, startTime })
|
||||
}
|
||||
},
|
||||
async clearProgressClick() {
|
||||
await this.$hapticsImpact()
|
||||
@@ -539,18 +567,19 @@ export default {
|
||||
})
|
||||
if (value) {
|
||||
this.resettingProgress = true
|
||||
if (this.isLocal) {
|
||||
// TODO: If connected to server also sync with server
|
||||
await this.$db.removeLocalMediaProgress(this.libraryItemId)
|
||||
this.$store.commit('globals/removeLocalMediaProgressForItem', this.libraryItemId)
|
||||
} else {
|
||||
var progressId = this.userItemProgress.id
|
||||
const serverMediaProgressId = this.serverItemProgress?.id
|
||||
if (this.localLibraryItemId) {
|
||||
await this.$db.removeLocalMediaProgress(this.localLibraryItemId)
|
||||
this.$store.commit('globals/removeLocalMediaProgressForItem', this.localLibraryItemId)
|
||||
}
|
||||
|
||||
if (this.serverLibraryItemId && serverMediaProgressId) {
|
||||
await this.$axios
|
||||
.$delete(`/api/me/progress/${this.libraryItemId}`)
|
||||
.$delete(`/api/me/progress/${serverMediaProgressId}`)
|
||||
.then(() => {
|
||||
console.log('Progress reset complete')
|
||||
this.$toast.success(`Your progress was reset`)
|
||||
this.$store.commit('user/removeMediaProgress', progressId)
|
||||
this.$store.commit('user/removeMediaProgress', serverMediaProgressId)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Progress reset failed', error)
|
||||
@@ -564,6 +593,7 @@ export default {
|
||||
if (libraryItem.id === this.libraryItemId) {
|
||||
console.log('Item Updated')
|
||||
this.libraryItem = libraryItem
|
||||
this.checkDescriptionClamped()
|
||||
}
|
||||
},
|
||||
async selectFolder() {
|
||||
@@ -582,9 +612,6 @@ export default {
|
||||
if (this.downloadItem) {
|
||||
return
|
||||
}
|
||||
if (!this.numTracks) {
|
||||
return
|
||||
}
|
||||
await this.$hapticsImpact()
|
||||
if (this.isIos) {
|
||||
// no local folders on iOS
|
||||
@@ -622,7 +649,14 @@ export default {
|
||||
|
||||
console.log('Local folder', JSON.stringify(localFolder))
|
||||
|
||||
var startDownloadMessage = `Start download for "${this.title}" with ${this.numTracks} audio track${this.numTracks == 1 ? '' : 's'} to folder ${localFolder.name}?`
|
||||
let startDownloadMessage = `Start download for "${this.title}" with ${this.numTracks} audio track${this.numTracks == 1 ? '' : 's'} to folder ${localFolder.name}?`
|
||||
if (!this.isIos && this.showRead) {
|
||||
if (this.numTracks > 0) {
|
||||
startDownloadMessage = `Start download for "${this.title}" with ${this.numTracks} audio track${this.numTracks == 1 ? '' : 's'} and ebook file to folder ${localFolder.name}?`
|
||||
} else {
|
||||
startDownloadMessage = `Start download for "${this.title}" with ebook file to folder ${localFolder.name}?`
|
||||
}
|
||||
}
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: startDownloadMessage
|
||||
@@ -679,11 +713,7 @@ export default {
|
||||
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
|
||||
}
|
||||
|
||||
var lmp = this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId)
|
||||
console.log('toggleFinished Check LMP', this.libraryItemId, JSON.stringify(lmp))
|
||||
|
||||
var serverUpdated = payload.server
|
||||
if (serverUpdated) {
|
||||
if (payload.server) {
|
||||
this.$toast.success(`Local & Server Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
} else {
|
||||
this.$toast.success(`Local Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
@@ -712,8 +742,17 @@ export default {
|
||||
this.$router.replace('/bookshelf')
|
||||
}
|
||||
},
|
||||
checkDescriptionClamped() {
|
||||
if (this.showFullDescription) return
|
||||
if (!this.$refs.description) {
|
||||
this.descriptionClamped = false
|
||||
} else {
|
||||
this.descriptionClamped = this.$refs.description.scrollHeight > this.$refs.description.clientHeight
|
||||
}
|
||||
},
|
||||
windowResized() {
|
||||
this.windowWidth = window.innerWidth
|
||||
this.checkDescriptionClamped()
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@@ -722,6 +761,7 @@ export default {
|
||||
this.$eventBus.$on('library-changed', this.libraryChanged)
|
||||
this.$eventBus.$on('new-local-library-item', this.newLocalLibraryItem)
|
||||
this.$socket.$on('item_updated', this.itemUpdated)
|
||||
this.checkDescriptionClamped()
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.windowResized)
|
||||
@@ -741,4 +781,15 @@ export default {
|
||||
width: 150vw !important;
|
||||
max-width: 150vw !important;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 500px) {
|
||||
#metadata {
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
}
|
||||
@media only screen and (min-width: 500px) {
|
||||
#metadata {
|
||||
grid-template-columns: auto 1fr auto 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -189,7 +189,11 @@ export default {
|
||||
|
||||
<style scoped>
|
||||
.media-item-container {
|
||||
height: calc(100vh - 200px);
|
||||
max-height: calc(100vh - 200px);
|
||||
height: calc(100vh - 210px);
|
||||
max-height: calc(100vh - 210px);
|
||||
}
|
||||
.playerOpen .media-item-container {
|
||||
height: calc(100vh - 310px);
|
||||
max-height: calc(100vh - 310px);
|
||||
}
|
||||
</style>
|
||||
@@ -390,7 +390,7 @@ export default {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
<style scoped>
|
||||
.media-item-container {
|
||||
height: calc(100vh - 200px);
|
||||
max-height: calc(100vh - 200px);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user