mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-08-09 13:28:43 +02:00
Compare commits
43
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 |
@@ -35,8 +35,8 @@ android {
|
|||||||
applicationId "com.audiobookshelf.app"
|
applicationId "com.audiobookshelf.app"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 94
|
versionCode 95
|
||||||
versionName "0.9.63-beta"
|
versionName "0.9.64-beta"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
@@ -21,7 +21,8 @@
|
|||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/AppTheme"
|
android:theme="@style/AppTheme"
|
||||||
android:usesCleartextTraffic="true" >
|
android:usesCleartextTraffic="true"
|
||||||
|
android:largeHeap="true" >
|
||||||
|
|
||||||
<!-- Android app widget -->
|
<!-- Android app widget -->
|
||||||
<receiver
|
<receiver
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import androidx.media.session.MediaButtonReceiver
|
|||||||
import com.audiobookshelf.app.data.PlaybackSession
|
import com.audiobookshelf.app.data.PlaybackSession
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
import com.audiobookshelf.app.device.WidgetEventEmitter
|
import com.audiobookshelf.app.device.WidgetEventEmitter
|
||||||
|
import com.audiobookshelf.app.managers.DbManager
|
||||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.request.RequestOptions
|
import com.bumptech.glide.request.RequestOptions
|
||||||
@@ -37,6 +38,8 @@ class MediaPlayerWidget : AppWidgetProvider() {
|
|||||||
override fun onEnabled(context: Context) {
|
override fun onEnabled(context: Context) {
|
||||||
Log.i(tag, "onEnabled check context ${context.packageName}")
|
Log.i(tag, "onEnabled check context ${context.packageName}")
|
||||||
|
|
||||||
|
DbManager.initialize(context)
|
||||||
|
|
||||||
DeviceManager.deviceData.lastPlaybackSession?.let {
|
DeviceManager.deviceData.lastPlaybackSession?.let {
|
||||||
val appWidgetManager = AppWidgetManager.getInstance(context)
|
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||||
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
|
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ class Book(
|
|||||||
var audioFiles:List<AudioFile>?,
|
var audioFiles:List<AudioFile>?,
|
||||||
var chapters:List<BookChapter>?,
|
var chapters:List<BookChapter>?,
|
||||||
var tracks:MutableList<AudioTrack>?,
|
var tracks:MutableList<AudioTrack>?,
|
||||||
|
var ebookFile: EBookFile?,
|
||||||
var size:Long?,
|
var size:Long?,
|
||||||
var duration:Double?,
|
var duration:Double?,
|
||||||
var numTracks:Int?
|
var numTracks:Int?
|
||||||
@@ -179,7 +180,7 @@ class Book(
|
|||||||
}
|
}
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
override fun getLocalCopy(): Book {
|
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
|
if (mimeType == "video/mp4") return true
|
||||||
return mimeType?.startsWith("audio") == 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)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
@@ -74,6 +88,7 @@ open class LibraryItemWrapper(var id:String) {
|
|||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class DeviceInfo(
|
data class DeviceInfo(
|
||||||
|
var deviceId:String,
|
||||||
var manufacturer:String,
|
var manufacturer:String,
|
||||||
var model:String,
|
var model:String,
|
||||||
var brand:String,
|
var brand:String,
|
||||||
@@ -102,6 +117,8 @@ data class DeviceSettings(
|
|||||||
var autoSleepTimer: Boolean,
|
var autoSleepTimer: Boolean,
|
||||||
var autoSleepTimerStartTime: String,
|
var autoSleepTimerStartTime: String,
|
||||||
var autoSleepTimerEndTime: String,
|
var autoSleepTimerEndTime: String,
|
||||||
|
var autoSleepTimerAutoRewind: Boolean,
|
||||||
|
var autoSleepTimerAutoRewindTime: Long, //Time in milliseconds
|
||||||
var sleepTimerLength: Long, // Time in milliseconds
|
var sleepTimerLength: Long, // Time in milliseconds
|
||||||
var disableSleepTimerFadeOut: Boolean,
|
var disableSleepTimerFadeOut: Boolean,
|
||||||
var disableSleepTimerResetFeedback: Boolean
|
var disableSleepTimerResetFeedback: Boolean
|
||||||
@@ -122,6 +139,8 @@ data class DeviceSettings(
|
|||||||
autoSleepTimerStartTime = "22:00",
|
autoSleepTimerStartTime = "22:00",
|
||||||
autoSleepTimerEndTime = "06:00",
|
autoSleepTimerEndTime = "06:00",
|
||||||
sleepTimerLength = 900000L, // 15 minutes
|
sleepTimerLength = 900000L, // 15 minutes
|
||||||
|
autoSleepTimerAutoRewind = false,
|
||||||
|
autoSleepTimerAutoRewindTime = 300000L, // 5 minutes
|
||||||
disableSleepTimerFadeOut = false,
|
disableSleepTimerFadeOut = false,
|
||||||
disableSleepTimerResetFeedback = false
|
disableSleepTimerResetFeedback = false
|
||||||
)
|
)
|
||||||
@@ -141,6 +160,7 @@ data class DeviceSettings(
|
|||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val autoSleepTimerEndMinute get() = autoSleepTimerEndTime.split(":")[1].toInt()
|
val autoSleepTimerEndMinute get() = autoSleepTimerEndTime.split(":")[1].toInt()
|
||||||
|
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getShakeThresholdGravity() : Float { // Used in ShakeDetector
|
fun getShakeThresholdGravity() : Float { // Used in ShakeDetector
|
||||||
return if (shakeSensitivity == ShakeSensitivitySetting.VERY_HIGH) 1.2f
|
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?
|
||||||
|
)
|
||||||
@@ -18,6 +18,7 @@ data class LocalMediaItem(
|
|||||||
var basePath:String,
|
var basePath:String,
|
||||||
var absolutePath:String,
|
var absolutePath:String,
|
||||||
var audioTracks:MutableList<AudioTrack>,
|
var audioTracks:MutableList<AudioTrack>,
|
||||||
|
var ebookFile:EBookFile?,
|
||||||
var localFiles:MutableList<LocalFile>,
|
var localFiles:MutableList<LocalFile>,
|
||||||
var coverContentUrl:String?,
|
var coverContentUrl:String?,
|
||||||
var coverAbsolutePath:String?
|
var coverAbsolutePath:String?
|
||||||
@@ -61,7 +62,7 @@ data class LocalMediaItem(
|
|||||||
val mediaMetadata = getMediaMetadata()
|
val mediaMetadata = getMediaMetadata()
|
||||||
if (mediaType == "book") {
|
if (mediaType == "book") {
|
||||||
val chapters = getAudiobookChapters()
|
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)
|
return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false,mediaType, book, localFiles, coverContentUrl, coverAbsolutePath,true,null,null,null,null)
|
||||||
} else {
|
} else {
|
||||||
val podcast = Podcast(mediaMetadata as PodcastMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), false, 0)
|
val podcast = Podcast(mediaMetadata as PodcastMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), false, 0)
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ class LocalMediaProgress(
|
|||||||
progress:Double, // 0 to 1
|
progress:Double, // 0 to 1
|
||||||
currentTime:Double,
|
currentTime:Double,
|
||||||
isFinished:Boolean,
|
isFinished:Boolean,
|
||||||
|
var ebookLocation:String?, // cfi tag
|
||||||
|
var ebookProgress:Double?, // 0 to 1
|
||||||
var lastUpdate:Long,
|
var lastUpdate:Long,
|
||||||
var startedAt:Long,
|
var startedAt:Long,
|
||||||
var finishedAt:Long?,
|
var finishedAt:Long?,
|
||||||
@@ -58,11 +60,20 @@ class LocalMediaProgress(
|
|||||||
finishedAt = if (isFinished) lastUpdate else null
|
finishedAt = if (isFinished) lastUpdate else null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun updateEbookProgress(ebookLocation:String, ebookProgress:Double) {
|
||||||
|
lastUpdate = System.currentTimeMillis()
|
||||||
|
this.ebookProgress = ebookProgress
|
||||||
|
this.ebookLocation = ebookLocation
|
||||||
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun updateFromServerMediaProgress(serverMediaProgress:MediaProgress) {
|
fun updateFromServerMediaProgress(serverMediaProgress:MediaProgress) {
|
||||||
isFinished = serverMediaProgress.isFinished
|
isFinished = serverMediaProgress.isFinished
|
||||||
progress = serverMediaProgress.progress
|
progress = serverMediaProgress.progress
|
||||||
currentTime = serverMediaProgress.currentTime
|
currentTime = serverMediaProgress.currentTime
|
||||||
|
ebookProgress = serverMediaProgress.ebookProgress
|
||||||
|
ebookLocation = serverMediaProgress.ebookLocation
|
||||||
duration = serverMediaProgress.duration
|
duration = serverMediaProgress.duration
|
||||||
lastUpdate = serverMediaProgress.lastUpdate
|
lastUpdate = serverMediaProgress.lastUpdate
|
||||||
finishedAt = serverMediaProgress.finishedAt
|
finishedAt = serverMediaProgress.finishedAt
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ class MediaProgress(
|
|||||||
progress:Double, // 0 to 1
|
progress:Double, // 0 to 1
|
||||||
currentTime:Double,
|
currentTime:Double,
|
||||||
isFinished:Boolean,
|
isFinished:Boolean,
|
||||||
|
var ebookLocation:String?, // cfi tag
|
||||||
|
var ebookProgress:Double?, // 0 to 1
|
||||||
var lastUpdate:Long,
|
var lastUpdate:Long,
|
||||||
var startedAt:Long,
|
var startedAt:Long,
|
||||||
var finishedAt:Long?
|
var finishedAt:Long?
|
||||||
|
|||||||
@@ -264,6 +264,6 @@ class PlaybackSession(
|
|||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getNewLocalMediaProgress():LocalMediaProgress {
|
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) {
|
if (deviceData.deviceSettings?.shakeSensitivity == null) {
|
||||||
deviceData.deviceSettings?.shakeSensitivity = ShakeSensitivitySetting.MEDIUM
|
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 {
|
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}")
|
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 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
|
// Do not scan folders that have no media items and not an existing item already
|
||||||
if (existingItem != null || filesInFolder.isNotEmpty()) {
|
if (existingItem != null || filesInFolder.isNotEmpty()) {
|
||||||
@@ -110,6 +110,8 @@ class FolderScanner(var ctx: Context) {
|
|||||||
var startOffset = 0.0
|
var startOffset = 0.0
|
||||||
var coverContentUrl:String? = null
|
var coverContentUrl:String? = null
|
||||||
var coverAbsolutePath:String? = null
|
var coverAbsolutePath:String? = null
|
||||||
|
var hasEBookFile = false
|
||||||
|
var newEBookFile:EBookFile? = null
|
||||||
|
|
||||||
val existingLocalFilesRemoved = existingLocalFiles.filter { elf ->
|
val existingLocalFilesRemoved = existingLocalFiles.filter { elf ->
|
||||||
filesInFolder.find { fif -> DeviceManager.getBase64Id(fif.id) == elf.id } == null // File was not found in media item folder
|
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 ->
|
filesInFolder.forEach { file ->
|
||||||
val mimeType = file.mimeType ?: ""
|
val mimeType = file.mimeType ?: ""
|
||||||
val filename = file.name ?: ""
|
val filename = file.name ?: ""
|
||||||
val isAudio = mimeType.startsWith("audio") || mimeType == "video/mp4"
|
|
||||||
Log.d(tag, "Found $mimeType file $filename in folder $itemFolderName")
|
Log.d(tag, "Found $mimeType file $filename in folder $itemFolderName")
|
||||||
|
|
||||||
val localFileId = DeviceManager.getBase64Id(file.id)
|
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}")
|
Log.d(tag, "File attributes Id:${localFileId}|ContentUrl:${localFile.contentUrl}|isDownloadsDocument:${file.isDownloadsDocument}")
|
||||||
|
|
||||||
if (isAudio) {
|
if (localFile.isAudioFile()) {
|
||||||
val audioTrackToAdd:AudioTrack?
|
val audioTrackToAdd:AudioTrack?
|
||||||
|
|
||||||
val existingAudioTrack = existingAudioTracks.find { eat -> eat.localFileId == localFileId }
|
val existingAudioTrack = existingAudioTracks.find { eat -> eat.localFileId == localFileId }
|
||||||
@@ -174,6 +175,15 @@ class FolderScanner(var ctx: Context) {
|
|||||||
startOffset += audioTrackToAdd.duration
|
startOffset += audioTrackToAdd.duration
|
||||||
index++
|
index++
|
||||||
audioTracks.add(audioTrackToAdd)
|
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 {
|
} else {
|
||||||
val existingLocalFile = existingLocalFiles.find { elf -> elf.id == localFileId }
|
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")
|
Log.d(tag, "Local library item ${existingItem.media.metadata.title} no longer has audio tracks - removing item")
|
||||||
DeviceManager.dbManager.removeLocalLibraryItem(existingItem.id)
|
DeviceManager.dbManager.removeLocalLibraryItem(existingItem.id)
|
||||||
return ItemScanResult.REMOVED
|
return ItemScanResult.REMOVED
|
||||||
@@ -210,9 +220,9 @@ class FolderScanner(var ctx: Context) {
|
|||||||
existingItem.updateFromScan(audioTracks,localFiles)
|
existingItem.updateFromScan(audioTracks,localFiles)
|
||||||
DeviceManager.dbManager.saveLocalLibraryItem(existingItem)
|
DeviceManager.dbManager.saveLocalLibraryItem(existingItem)
|
||||||
return ItemScanResult.UPDATED
|
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")
|
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()
|
val localLibraryItem = localMediaItem.getLocalLibraryItem()
|
||||||
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
||||||
return ItemScanResult.ADDED
|
return ItemScanResult.ADDED
|
||||||
@@ -257,7 +267,7 @@ class FolderScanner(var ctx: Context) {
|
|||||||
|
|
||||||
// Search for files in media item folder
|
// Search for files in media item folder
|
||||||
// m4b files showing as mimeType application/octet-stream on Android 10 and earlier see #154
|
// 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}")
|
Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}")
|
||||||
|
|
||||||
var localEpisodeId:String? = null
|
var localEpisodeId:String? = null
|
||||||
@@ -274,6 +284,7 @@ class FolderScanner(var ctx: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val audioTracks:MutableList<AudioTrack> = mutableListOf()
|
val audioTracks:MutableList<AudioTrack> = mutableListOf()
|
||||||
|
var foundEBookFile = false
|
||||||
|
|
||||||
filesFound.forEach { docFile ->
|
filesFound.forEach { docFile ->
|
||||||
val itemPart = downloadItem.downloadItemParts.find { itemPart ->
|
val itemPart = downloadItem.downloadItemParts.find { itemPart ->
|
||||||
@@ -304,6 +315,16 @@ class FolderScanner(var ctx: Context) {
|
|||||||
localEpisodeId = newEpisode.id
|
localEpisodeId = newEpisode.id
|
||||||
Log.d(tag, "scanDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}")
|
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
|
} else { // Cover image
|
||||||
val localFileId = DeviceManager.getBase64Id(docFile.id)
|
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())
|
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()) {
|
if (audioTracks.isEmpty() && !foundEBookFile) {
|
||||||
Log.d(tag, "scanDownloadItem did not find any audio tracks in folder for ${downloadItem.itemFolderPath}")
|
Log.d(tag, "scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}")
|
||||||
return cb(null)
|
return cb(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,6 +371,8 @@ class FolderScanner(var ctx: Context) {
|
|||||||
progress = mediaProgress.progress,
|
progress = mediaProgress.progress,
|
||||||
currentTime = mediaProgress.currentTime,
|
currentTime = mediaProgress.currentTime,
|
||||||
isFinished = false,
|
isFinished = false,
|
||||||
|
ebookLocation = mediaProgress.ebookLocation,
|
||||||
|
ebookProgress = mediaProgress.ebookProgress,
|
||||||
lastUpdate = mediaProgress.lastUpdate,
|
lastUpdate = mediaProgress.lastUpdate,
|
||||||
startedAt = mediaProgress.startedAt,
|
startedAt = mediaProgress.startedAt,
|
||||||
finishedAt = mediaProgress.finishedAt,
|
finishedAt = mediaProgress.finishedAt,
|
||||||
@@ -381,7 +404,7 @@ class FolderScanner(var ctx: Context) {
|
|||||||
var wasUpdated = false
|
var wasUpdated = false
|
||||||
|
|
||||||
// Search for files in media item folder
|
// 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}")
|
Log.d(tag, "scanLocalLibraryItem ${filesFound.size} files found in ${localLibraryItem.absolutePath}")
|
||||||
|
|
||||||
filesFound.forEach {
|
filesFound.forEach {
|
||||||
@@ -427,7 +450,7 @@ class FolderScanner(var ctx: Context) {
|
|||||||
val audioProbeResult = probeAudioFile(localFile.absolutePath)
|
val audioProbeResult = probeAudioFile(localFile.absolutePath)
|
||||||
|
|
||||||
val existingTrack = existingAudioTracks.find { audioTrack ->
|
val existingTrack = existingAudioTracks.find { audioTrack ->
|
||||||
audioTrack.localFileId == localFile.id
|
audioTrack.localFileId == localFileId
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existingTrack == null) {
|
if (existingTrack == null) {
|
||||||
@@ -446,6 +469,16 @@ class FolderScanner(var ctx: Context) {
|
|||||||
|
|
||||||
wasUpdated = true
|
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
|
} else { // Check if cover is empty
|
||||||
if (localLibraryItem.coverContentUrl == null) {
|
if (localLibraryItem.coverContentUrl == null) {
|
||||||
Log.d(tag, "scanLocalLibraryItem setting cover for ${localLibraryItem.media.metadata.title}")
|
Log.d(tag, "scanLocalLibraryItem setting cover for ${localLibraryItem.media.metadata.title}")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.audiobookshelf.app.managers
|
package com.audiobookshelf.app.managers
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.media.metrics.PlaybackSession
|
||||||
import android.os.*
|
import android.os.*
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
@@ -21,6 +22,8 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
|||||||
private var sleepTimerElapsed:Long = 0L
|
private var sleepTimerElapsed:Long = 0L
|
||||||
private var sleepTimerFinishedAt:Long = 0L
|
private var sleepTimerFinishedAt:Long = 0L
|
||||||
private var isAutoSleepTimer:Boolean = false // When timer was auto-set
|
private var isAutoSleepTimer:Boolean = false // When timer was auto-set
|
||||||
|
private var isFirstAutoSleepTimer: Boolean = true
|
||||||
|
private var sleepTimerSessionId:String = ""
|
||||||
|
|
||||||
private fun getCurrentTime():Long {
|
private fun getCurrentTime():Long {
|
||||||
return playerNotificationService.getCurrentTime()
|
return playerNotificationService.getCurrentTime()
|
||||||
@@ -61,6 +64,7 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
|||||||
sleepTimerRunning = true
|
sleepTimerRunning = true
|
||||||
sleepTimerFinishedAt = 0L
|
sleepTimerFinishedAt = 0L
|
||||||
sleepTimerElapsed = 0L
|
sleepTimerElapsed = 0L
|
||||||
|
setVolume(1f)
|
||||||
|
|
||||||
// Register shake sensor
|
// Register shake sensor
|
||||||
playerNotificationService.registerSensor()
|
playerNotificationService.registerSensor()
|
||||||
@@ -80,10 +84,6 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
|||||||
} else {
|
} else {
|
||||||
sleepTimerLength = time
|
sleepTimerLength = time
|
||||||
sleepTimerEndTime = 0L
|
sleepTimerEndTime = 0L
|
||||||
|
|
||||||
if (sleepTimerLength + getCurrentTime() > getDuration()) {
|
|
||||||
sleepTimerLength = getDuration() - getCurrentTime()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds(), isAutoSleepTimer)
|
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds(), isAutoSleepTimer)
|
||||||
@@ -107,17 +107,14 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
|||||||
playerNotificationService.clientEventEmitter?.onSleepTimerEnded(getCurrentTime())
|
playerNotificationService.clientEventEmitter?.onSleepTimerEnded(getCurrentTime())
|
||||||
clearSleepTimer()
|
clearSleepTimer()
|
||||||
sleepTimerFinishedAt = System.currentTimeMillis()
|
sleepTimerFinishedAt = System.currentTimeMillis()
|
||||||
} else if (sleepTimeSecondsRemaining <= 60) {
|
} else if (sleepTimeSecondsRemaining <= 60 && DeviceManager.deviceData.deviceSettings?.disableSleepTimerFadeOut != true) {
|
||||||
if (DeviceManager.deviceData.deviceSettings?.disableSleepTimerFadeOut == true) {
|
// Start fading out audio down to 10% volume
|
||||||
// Set volume to 1 in case setting was enabled while fading
|
val percentToReduce = 1 - (sleepTimeSecondsRemaining / 60F)
|
||||||
setVolume(1f)
|
val volume = 1f - (percentToReduce * 0.9f)
|
||||||
} else {
|
Log.d(tag, "SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining")
|
||||||
// Start fading out audio down to 10% volume
|
setVolume(volume)
|
||||||
val percentToReduce = 1 - (sleepTimeSecondsRemaining / 60F)
|
} else {
|
||||||
val volume = 1f - (percentToReduce * 0.9f)
|
setVolume(1f)
|
||||||
Log.d(tag, "SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining")
|
|
||||||
setVolume(volume)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,7 +122,8 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setManualSleepTimer(time: Long, isChapterTime:Boolean):Boolean {
|
fun setManualSleepTimer(playbackSessionId:String, time: Long, isChapterTime:Boolean):Boolean {
|
||||||
|
sleepTimerSessionId = playbackSessionId
|
||||||
isAutoSleepTimer = false
|
isAutoSleepTimer = false
|
||||||
return setSleepTimer(time, isChapterTime)
|
return setSleepTimer(time, isChapterTime)
|
||||||
}
|
}
|
||||||
@@ -135,7 +133,6 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
|||||||
sleepTimerTask = null
|
sleepTimerTask = null
|
||||||
sleepTimerEndTime = 0
|
sleepTimerEndTime = 0
|
||||||
sleepTimerRunning = false
|
sleepTimerRunning = false
|
||||||
isAutoSleepTimer = false
|
|
||||||
playerNotificationService.unregisterSensor()
|
playerNotificationService.unregisterSensor()
|
||||||
|
|
||||||
setVolume(1f)
|
setVolume(1f)
|
||||||
@@ -229,6 +226,17 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
|||||||
return
|
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
|
// Set sleep timer
|
||||||
// When sleepTimerLength is 0 then use end of chapter/track time
|
// When sleepTimerLength is 0 then use end of chapter/track time
|
||||||
if (sleepTimerLength == 0L) {
|
if (sleepTimerLength == 0L) {
|
||||||
@@ -327,18 +335,31 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
|||||||
endCalendar.set(Calendar.HOUR_OF_DAY, deviceSettings.autoSleepTimerEndHour)
|
endCalendar.set(Calendar.HOUR_OF_DAY, deviceSettings.autoSleepTimerEndHour)
|
||||||
endCalendar.set(Calendar.MINUTE, deviceSettings.autoSleepTimerEndMinute)
|
endCalendar.set(Calendar.MINUTE, deviceSettings.autoSleepTimerEndMinute)
|
||||||
|
|
||||||
// In cases where end time is earlier then start time then we add a day to end time
|
val currentCalendar = Calendar.getInstance()
|
||||||
// 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.
|
// 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)) {
|
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)
|
val currentHour = SimpleDateFormat("HH:mm", Locale.getDefault()).format(currentCalendar.time)
|
||||||
if (currentCalendar.after(startCalendar) && currentCalendar.before(endCalendar)) {
|
if (currentCalendar.after(startCalendar) && currentCalendar.before(endCalendar)) {
|
||||||
Log.i(tag, "Current hour $currentHour is between ${deviceSettings.autoSleepTimerStartTime} and ${deviceSettings.autoSleepTimerEndTime} - starting sleep timer")
|
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
|
// Set sleep timer
|
||||||
// When sleepTimerLength is 0 then use end of chapter/track time
|
// When sleepTimerLength is 0 then use end of chapter/track time
|
||||||
if (deviceSettings.sleepTimerLength == 0L) {
|
if (deviceSettings.sleepTimerLength == 0L) {
|
||||||
@@ -354,13 +375,23 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
|||||||
setSleepTimer(deviceSettings.sleepTimerLength, false)
|
setSleepTimer(deviceSettings.sleepTimerLength, false)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
isFirstAutoSleepTimer = true
|
||||||
Log.d(tag, "Current hour $currentHour is NOT between ${deviceSettings.autoSleepTimerStartTime} and ${deviceSettings.autoSleepTimerEndTime}")
|
Log.d(tag, "Current hour $currentHour is NOT between ${deviceSettings.autoSleepTimerStartTime} and ${deviceSettings.autoSleepTimerEndTime}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleMediaPlayEvent() {
|
fun handleMediaPlayEvent(playbackSessionId:String) {
|
||||||
checkShouldResetSleepTimer()
|
// 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()
|
checkAutoSleepTimer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.app.DownloadManager
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.audiobookshelf.app.data.AudioTrack
|
import com.audiobookshelf.app.data.AudioTrack
|
||||||
|
import com.audiobookshelf.app.data.EBookFile
|
||||||
import com.audiobookshelf.app.data.LocalFolder
|
import com.audiobookshelf.app.data.LocalFolder
|
||||||
import com.audiobookshelf.app.data.PodcastEpisode
|
import com.audiobookshelf.app.data.PodcastEpisode
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
@@ -20,6 +21,7 @@ data class DownloadItemPart(
|
|||||||
val localFolderName: String,
|
val localFolderName: String,
|
||||||
val localFolderUrl: String,
|
val localFolderUrl: String,
|
||||||
val localFolderId: String,
|
val localFolderId: String,
|
||||||
|
val ebookFile: EBookFile?,
|
||||||
val audioTrack: AudioTrack?,
|
val audioTrack: AudioTrack?,
|
||||||
val episode: PodcastEpisode?,
|
val episode: PodcastEpisode?,
|
||||||
var completed:Boolean,
|
var completed:Boolean,
|
||||||
@@ -35,12 +37,12 @@ data class DownloadItemPart(
|
|||||||
var bytesDownloaded: Long
|
var bytesDownloaded: Long
|
||||||
) {
|
) {
|
||||||
companion object {
|
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 destinationUri = Uri.fromFile(destinationFile)
|
||||||
val finalDestinationUri = Uri.fromFile(finalDestinationFile)
|
val finalDestinationUri = Uri.fromFile(finalDestinationFile)
|
||||||
|
|
||||||
var downloadUrl = "${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}"
|
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)
|
val downloadUri = Uri.parse(downloadUrl)
|
||||||
Log.d("DownloadItemPart", "Audio File Destination Uri: $destinationUri | Final Destination Uri: $finalDestinationUri | Download URI $downloadUri")
|
Log.d("DownloadItemPart", "Audio File Destination Uri: $destinationUri | Final Destination Uri: $finalDestinationUri | Download URI $downloadUri")
|
||||||
return DownloadItemPart(
|
return DownloadItemPart(
|
||||||
@@ -53,6 +55,7 @@ data class DownloadItemPart(
|
|||||||
localFolderName = localFolder.name,
|
localFolderName = localFolder.name,
|
||||||
localFolderUrl = localFolder.contentUrl,
|
localFolderUrl = localFolder.contentUrl,
|
||||||
localFolderId = localFolder.id,
|
localFolderId = localFolder.id,
|
||||||
|
ebookFile = ebookFile,
|
||||||
audioTrack = audioTrack,
|
audioTrack = audioTrack,
|
||||||
episode = episode,
|
episode = episode,
|
||||||
completed = false,
|
completed = false,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import com.audiobookshelf.app.device.DeviceManager
|
|||||||
import com.google.android.exoplayer2.PlaybackException
|
import com.google.android.exoplayer2.PlaybackException
|
||||||
import com.google.android.exoplayer2.Player
|
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 {
|
class PlayerListener(var playerNotificationService:PlayerNotificationService) : Player.Listener {
|
||||||
var tag = "PlayerListener"
|
var tag = "PlayerListener"
|
||||||
@@ -74,12 +74,13 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
|
|||||||
Log.d(tag, "SeekBackTime $seekBackTime")
|
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
|
// Check if playback session still exists or sync media progress if updated
|
||||||
val pauseLength: Long = System.currentTimeMillis() - lastPauseTime
|
// val pauseLength: Long = System.currentTimeMillis() - lastPauseTime
|
||||||
if (pauseLength > PAUSE_LEN_BEFORE_RECHECK) {
|
// if (pauseLength > PAUSE_LEN_BEFORE_RECHECK) {
|
||||||
val shouldCarryOn = playerNotificationService.checkCurrentSessionProgress(seekBackTime)
|
// val shouldCarryOn = playerNotificationService.checkCurrentSessionProgress(seekBackTime)
|
||||||
if (!shouldCarryOn) return
|
// if (!shouldCarryOn) return
|
||||||
}
|
// }
|
||||||
|
|
||||||
if (seekBackTime > 0L) {
|
if (seekBackTime > 0L) {
|
||||||
playerNotificationService.seekBackward(seekBackTime)
|
playerNotificationService.seekBackward(seekBackTime)
|
||||||
@@ -92,12 +93,15 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
|
|||||||
|
|
||||||
// Start/stop progress sync interval
|
// Start/stop progress sync interval
|
||||||
if (isPlaying) {
|
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
|
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 {
|
} else {
|
||||||
playerNotificationService.mediaProgressSyncer.pause {
|
playerNotificationService.mediaProgressSyncer.pause {
|
||||||
Log.d(tag, "Media Progress Syncer paused and synced")
|
Log.d(tag, "Media Progress Syncer paused and synced")
|
||||||
|
|||||||
+5
-2
@@ -1,5 +1,6 @@
|
|||||||
package com.audiobookshelf.app.player
|
package com.audiobookshelf.app.player
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.app.*
|
import android.app.*
|
||||||
import android.appwidget.AppWidgetManager
|
import android.appwidget.AppWidgetManager
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
@@ -16,6 +17,7 @@ import android.net.NetworkCapabilities
|
|||||||
import android.net.NetworkRequest
|
import android.net.NetworkRequest
|
||||||
import android.os.*
|
import android.os.*
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
|
import android.provider.Settings
|
||||||
import android.support.v4.media.MediaBrowserCompat
|
import android.support.v4.media.MediaBrowserCompat
|
||||||
import android.support.v4.media.MediaDescriptionCompat
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
import android.support.v4.media.MediaMetadataCompat
|
import android.support.v4.media.MediaMetadataCompat
|
||||||
@@ -31,7 +33,6 @@ import androidx.core.content.ContextCompat
|
|||||||
import androidx.media.MediaBrowserServiceCompat
|
import androidx.media.MediaBrowserServiceCompat
|
||||||
import androidx.media.utils.MediaConstants
|
import androidx.media.utils.MediaConstants
|
||||||
import com.audiobookshelf.app.BuildConfig
|
import com.audiobookshelf.app.BuildConfig
|
||||||
import com.audiobookshelf.app.MediaPlayerWidget
|
|
||||||
import com.audiobookshelf.app.R
|
import com.audiobookshelf.app.R
|
||||||
import com.audiobookshelf.app.data.*
|
import com.audiobookshelf.app.data.*
|
||||||
import com.audiobookshelf.app.data.DeviceInfo
|
import com.audiobookshelf.app.data.DeviceInfo
|
||||||
@@ -894,6 +895,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
return if(currentPlayer == castPlayer) PLAYER_CAST else PLAYER_EXO
|
return if(currentPlayer == castPlayer) PLAYER_CAST else PLAYER_EXO
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressLint("HardwareIds")
|
||||||
private fun getDeviceInfo(): DeviceInfo {
|
private fun getDeviceInfo(): DeviceInfo {
|
||||||
/* EXAMPLE
|
/* EXAMPLE
|
||||||
manufacturer: Google
|
manufacturer: Google
|
||||||
@@ -902,7 +904,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
sdkVersion: 32
|
sdkVersion: 32
|
||||||
appVersion: 0.9.46-beta
|
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()
|
private val deviceSettings get() = DeviceManager.deviceData.deviceSettings ?: DeviceSettings.default()
|
||||||
|
|||||||
@@ -37,7 +37,11 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
mainActivity = (activity as MainActivity)
|
mainActivity = (activity as MainActivity)
|
||||||
apiHandler = ApiHandler(mainActivity)
|
apiHandler = ApiHandler(mainActivity)
|
||||||
|
|
||||||
initCastManager()
|
try {
|
||||||
|
initCastManager()
|
||||||
|
} catch(e:Exception) {
|
||||||
|
Log.e(tag, "initCastManager exception ${e.printStackTrace()}")
|
||||||
|
}
|
||||||
|
|
||||||
val foregroundServiceReady : () -> Unit = {
|
val foregroundServiceReady : () -> Unit = {
|
||||||
playerNotificationService = mainActivity.foregroundService
|
playerNotificationService = mainActivity.foregroundService
|
||||||
@@ -176,7 +180,7 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
val playWhenReady = call.getBoolean("playWhenReady") == true
|
val playWhenReady = call.getBoolean("playWhenReady") == true
|
||||||
val playbackRate = call.getFloat("playbackRate",1f) ?: 1f
|
val playbackRate = call.getFloat("playbackRate",1f) ?: 1f
|
||||||
val startTimeOverride = call.getDouble("startTime")
|
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()) {
|
if (libraryItemId.isEmpty()) {
|
||||||
Log.e(tag, "Invalid call to play library item no library item id")
|
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
|
val isChapterTime:Boolean = call.getBoolean("isChapterTime", false) == true
|
||||||
|
|
||||||
Handler(Looper.getMainLooper()).post {
|
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()
|
val ret = JSObject()
|
||||||
ret.put("success", success)
|
ret.put("success", success)
|
||||||
call.resolve(ret)
|
call.resolve(ret)
|
||||||
|
|||||||
@@ -265,6 +265,8 @@ class AbsDatabase : Plugin() {
|
|||||||
progress = mediaProgress.progress,
|
progress = mediaProgress.progress,
|
||||||
currentTime = mediaProgress.currentTime,
|
currentTime = mediaProgress.currentTime,
|
||||||
isFinished = mediaProgress.isFinished,
|
isFinished = mediaProgress.isFinished,
|
||||||
|
ebookLocation = mediaProgress.ebookLocation,
|
||||||
|
ebookProgress = mediaProgress.ebookProgress,
|
||||||
lastUpdate = mediaProgress.lastUpdate,
|
lastUpdate = mediaProgress.lastUpdate,
|
||||||
startedAt = mediaProgress.startedAt,
|
startedAt = mediaProgress.startedAt,
|
||||||
finishedAt = mediaProgress.finishedAt,
|
finishedAt = mediaProgress.finishedAt,
|
||||||
@@ -345,6 +347,8 @@ class AbsDatabase : Plugin() {
|
|||||||
progress = if (isFinished) 1.0 else 0.0,
|
progress = if (isFinished) 1.0 else 0.0,
|
||||||
currentTime = 0.0,
|
currentTime = 0.0,
|
||||||
isFinished = isFinished,
|
isFinished = isFinished,
|
||||||
|
ebookLocation = null,
|
||||||
|
ebookProgress = null,
|
||||||
lastUpdate = currentTime,
|
lastUpdate = currentTime,
|
||||||
startedAt = if (isFinished) currentTime else 0L,
|
startedAt = if (isFinished) currentTime else 0L,
|
||||||
finishedAt = if (isFinished) currentTime else null,
|
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
|
@PluginMethod
|
||||||
fun updateLocalTrackOrder(call:PluginCall) {
|
fun updateLocalTrackOrder(call:PluginCall) {
|
||||||
val localLibraryItemId = call.getString("localLibraryItemId", "") ?: ""
|
val localLibraryItemId = call.getString("localLibraryItemId", "") ?: ""
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package com.audiobookshelf.app.plugins
|
|||||||
|
|
||||||
import android.app.DownloadManager
|
import android.app.DownloadManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.audiobookshelf.app.MainActivity
|
import com.audiobookshelf.app.MainActivity
|
||||||
@@ -142,6 +141,28 @@ class AbsDownloader : Plugin() {
|
|||||||
val itemFolderPath = "${localFolder.absolutePath}/$itemSubfolder"
|
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 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
|
// Create download item part for each audio track
|
||||||
tracks.forEach { audioTrack ->
|
tracks.forEach { audioTrack ->
|
||||||
val fileSize = audioTrack.metadata?.size ?: 0
|
val fileSize = audioTrack.metadata?.size ?: 0
|
||||||
@@ -162,7 +183,7 @@ class AbsDownloader : Plugin() {
|
|||||||
finalDestinationFile.delete()
|
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)
|
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,7 +208,7 @@ class AbsDownloader : Plugin() {
|
|||||||
finalDestinationFile.delete()
|
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)
|
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +237,7 @@ class AbsDownloader : Plugin() {
|
|||||||
finalDestinationFile.delete()
|
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)
|
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||||
|
|
||||||
if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) {
|
if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) {
|
||||||
@@ -232,7 +253,7 @@ class AbsDownloader : Plugin() {
|
|||||||
if (finalDestinationFile.exists()) {
|
if (finalDestinationFile.exists()) {
|
||||||
Log.d(tag, "Podcast cover already exists - not downloading cover again")
|
Log.d(tag, "Podcast cover already exists - not downloading cover again")
|
||||||
} else {
|
} 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)
|
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import com.getcapacitor.annotation.CapacitorPlugin
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
@CapacitorPlugin(name = "AbsFileSystem")
|
@CapacitorPlugin(name = "AbsFileSystem")
|
||||||
class AbsFileSystem : Plugin() {
|
class AbsFileSystem : Plugin() {
|
||||||
@@ -226,8 +227,37 @@ class AbsFileSystem : Plugin() {
|
|||||||
val contentUrl = call.data.getString("contentUrl", "").toString()
|
val contentUrl = call.data.getString("contentUrl", "").toString()
|
||||||
Log.d(tag, "deleteItem $absolutePath | $contentUrl")
|
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 docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
|
||||||
val success = docfile?.delete() == true
|
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) {
|
if (success) {
|
||||||
DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId)
|
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}")
|
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)
|
localMediaProgress.updateFromServerMediaProgress(mediaProgress)
|
||||||
MediaEventManager.syncEvent(mediaProgress, "Sync on server connection")
|
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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,20 @@ Bookshelf Label
|
|||||||
filter: blur(20px);
|
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 {
|
.line-clamp-2 {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<div class="top-6 right-4 absolute cursor-pointer">
|
<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>
|
<span class="material-icons text-3xl" :class="{ 'text-black text-opacity-75': coverBgIsLight }" @click="showMoreMenuDialog = true">more_vert</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div v-if="useChapterTrack && useTotalTrack && showFullscreen" class="absolute total-track w-full z-30 px-6">
|
<div v-if="useChapterTrack && useTotalTrack && showFullscreen" class="absolute total-track w-full z-30 px-6">
|
||||||
@@ -60,13 +60,13 @@
|
|||||||
<p class="text-xl font-mono text-success">{{ sleepTimeRemainingPretty }}</p>
|
<p class="text-xl font-mono text-success">{{ sleepTimeRemainingPretty }}</p>
|
||||||
</div>
|
</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>
|
</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 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 mx-auto" style="max-width: 414px">
|
<div id="playerControls" class="absolute right-0 bottom-0 mx-auto" style="max-width: 414px">
|
||||||
<div class="flex items-center justify-between max-w-full">
|
<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="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>
|
<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">
|
<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">
|
||||||
@@ -369,6 +369,10 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
clickChaptersBtn() {
|
||||||
|
if (!this.chapters.length) return
|
||||||
|
this.showChapterModal = true
|
||||||
|
},
|
||||||
async coverImageLoaded(fullCoverUrl) {
|
async coverImageLoaded(fullCoverUrl) {
|
||||||
if (!fullCoverUrl) return
|
if (!fullCoverUrl) return
|
||||||
|
|
||||||
|
|||||||
@@ -214,6 +214,8 @@ export default {
|
|||||||
if (startTime !== undefined && startTime !== null) {
|
if (startTime !== undefined && startTime !== null) {
|
||||||
// seek to start time
|
// seek to start time
|
||||||
AbsAudioPlayer.seek({ value: Math.floor(startTime) })
|
AbsAudioPlayer.seek({ value: Math.floor(startTime) })
|
||||||
|
} else if (this.$refs.audioPlayer) {
|
||||||
|
this.$refs.audioPlayer.play()
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,26 +7,6 @@
|
|||||||
<p class="truncate text-sm">{{ name }}</p>
|
<p class="truncate text-sm">{{ name }}</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<p v-if="matchKey !== 'authors'" class="text-xs text-gray-200 truncate">by {{ authorName }}</p>
|
<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" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -78,11 +78,14 @@ export default {
|
|||||||
var lastPart = this.matchText.substr(totalLenSoFar)
|
var lastPart = this.matchText.substr(totalLenSoFar)
|
||||||
html += lastPart
|
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 === '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 === 'authors') return `by ${html}`
|
||||||
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`
|
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 === 'asin') return `<p class="truncate">ASIN: ${html}</p>`
|
||||||
if (this.matchKey === 'series') return `<p class="truncate">Series: ${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}`
|
return `${html}`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -62,8 +62,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Podcast Episode # -->
|
<!-- 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` }">
|
<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 #{{ recentEpisodeNumber }}</p>
|
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">
|
||||||
|
Episode<span v-if="recentEpisodeNumber"> #{{ recentEpisodeNumber }}</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Podcast Num Episodes -->
|
<!-- Podcast Num Episodes -->
|
||||||
@@ -221,7 +223,7 @@ export default {
|
|||||||
if (this.recentEpisode.episode) {
|
if (this.recentEpisode.episode) {
|
||||||
return this.recentEpisode.episode.replace(/^#/, '')
|
return this.recentEpisode.episode.replace(/^#/, '')
|
||||||
}
|
}
|
||||||
return this.recentEpisode.index
|
return ''
|
||||||
},
|
},
|
||||||
collapsedSeries() {
|
collapsedSeries() {
|
||||||
// Only added to item object when collapseSeries is enabled
|
// Only added to item object when collapseSeries is enabled
|
||||||
@@ -274,8 +276,13 @@ export default {
|
|||||||
if (this.isLocal) return this.store.getters['globals/getLocalMediaProgressById'](this.libraryItemId)
|
if (this.isLocal) return this.store.getters['globals/getLocalMediaProgressById'](this.libraryItemId)
|
||||||
return this.store.getters['user/getUserMediaProgress'](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() {
|
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() {
|
itemIsFinished() {
|
||||||
return this.userProgress ? !!this.userProgress.isFinished : false
|
return this.userProgress ? !!this.userProgress.isFinished : false
|
||||||
|
|||||||
@@ -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" />
|
<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>
|
||||||
|
|
||||||
|
<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">
|
<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>
|
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -53,6 +55,27 @@ export default {
|
|||||||
books() {
|
books() {
|
||||||
return this.series ? this.series.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() {
|
store() {
|
||||||
return this.$store || this.$nuxt.$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>
|
||||||
@@ -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>
|
<template>
|
||||||
<modals-modal v-model="show" width="90%" height="100%">
|
<modals-modal v-model="show" width="90%" height="100%">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div v-show="selected !== 'all'" class="absolute top-6 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>
|
<ui-btn class="text-xl border-yellow-400 border-opacity-40" @click="clearSelected">Clear</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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 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 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-6 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>
|
<span class="material-icons text-4xl">close</span>
|
||||||
</div>
|
</div>
|
||||||
<slot name="outer" />
|
<slot name="outer" />
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
value: Boolean,
|
value: Boolean,
|
||||||
orderBy: String,
|
orderBy: String,
|
||||||
descending: Boolean
|
descending: Boolean,
|
||||||
|
episodes: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -89,6 +90,24 @@ export default {
|
|||||||
text: 'File Modified',
|
text: 'File Modified',
|
||||||
value: 'mtimeMs'
|
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'
|
return this.$store.getters['libraries/getCurrentLibraryMediaType'] === 'podcast'
|
||||||
},
|
},
|
||||||
items() {
|
items() {
|
||||||
|
if (this.episodes) return this.episodeItems
|
||||||
if (this.isPodcast) return this.podcastItems
|
if (this.isPodcast) return this.podcastItems
|
||||||
return this.bookItems
|
return this.bookItems
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="epub-frame" class="w-full">
|
<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' }">
|
<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>
|
<p class="text-xs">epub</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
@@ -15,18 +16,20 @@ import ePub from 'epubjs'
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
url: String
|
url: String,
|
||||||
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
isLocal: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
/** @type {ePub.Book} */
|
||||||
book: null,
|
book: null,
|
||||||
|
/** @type {ePub.Rendition} */
|
||||||
rendition: null,
|
rendition: null,
|
||||||
chapters: [],
|
progress: 0
|
||||||
title: '',
|
|
||||||
author: '',
|
|
||||||
progress: 0,
|
|
||||||
hasNext: true,
|
|
||||||
hasPrev: false
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -35,11 +38,48 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
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() {
|
playerLibraryItemId() {
|
||||||
return this.$store.state.playerLibraryItemId
|
return this.$store.state.playerLibraryItemId
|
||||||
},
|
},
|
||||||
readerHeightOffset() {
|
readerHeightOffset() {
|
||||||
return this.playerLibraryItemId ? 196 : 96
|
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: {
|
methods: {
|
||||||
@@ -58,71 +98,180 @@ export default {
|
|||||||
this.rendition.next()
|
this.rendition.next()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
initEpub() {
|
/**
|
||||||
var book = ePub(this.url)
|
* @param {object} payload
|
||||||
this.book = book
|
* @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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.rendition = book.renderTo('viewer', {
|
// 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() {
|
||||||
|
this.progress = Math.round((this.userItemProgress?.ebookProgress || 0) * 100)
|
||||||
|
|
||||||
|
/** @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,
|
width: window.innerWidth,
|
||||||
height: window.innerHeight - this.readerHeightOffset,
|
height: window.innerHeight - this.readerHeightOffset,
|
||||||
snap: true,
|
snap: true,
|
||||||
manager: 'continuous',
|
manager: 'continuous',
|
||||||
flow: 'paginated'
|
flow: 'paginated'
|
||||||
})
|
})
|
||||||
const displayed = this.rendition.display()
|
|
||||||
|
|
||||||
book.ready
|
// load saved progress
|
||||||
.then(() => {
|
reader.rendition.display(this.userItemProgress?.ebookLocation || reader.book.locations.start)
|
||||||
console.log('Book ready')
|
|
||||||
return book.locations.generate(1600)
|
// load style
|
||||||
})
|
reader.rendition.themes.default({ '*': { color: '#fff!important' } })
|
||||||
.then((locations) => {
|
|
||||||
// console.log('Loaded locations', locations)
|
reader.book.ready.then(() => {
|
||||||
// Wait for book to be rendered to get current page
|
// set up event listeners
|
||||||
displayed.then(() => {
|
reader.rendition.on('relocated', reader.relocated)
|
||||||
// Get the current CFI
|
|
||||||
var currentLocation = this.rendition.currentLocation()
|
// load ebook cfi locations
|
||||||
if (!currentLocation.start) {
|
const savedLocations = this.loadLocations()
|
||||||
console.error('No Start', currentLocation)
|
if (savedLocations) {
|
||||||
} else {
|
reader.book.locations.load(savedLocations)
|
||||||
var currentPage = book.locations.percentageFromCfi(currentLocation.start.cfi)
|
} else {
|
||||||
console.log('current page', currentPage)
|
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('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() {
|
mounted() {
|
||||||
this.initEpub()
|
this.initEpub()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,13 @@
|
|||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<span class="material-icons text-xl text-white" @click.stop="show = false">close</span>
|
<span class="material-icons text-xl text-white" @click.stop="show = false">close</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { Capacitor } from '@capacitor/core'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -66,11 +68,10 @@ export default {
|
|||||||
return this.selectedLibraryItem ? this.selectedLibraryItem.libraryId : null
|
return this.selectedLibraryItem ? this.selectedLibraryItem.libraryId : null
|
||||||
},
|
},
|
||||||
ebookFile() {
|
ebookFile() {
|
||||||
return this.media ? this.media.ebookFile : null
|
return this.media?.ebookFile || null
|
||||||
},
|
},
|
||||||
ebookFormat() {
|
ebookFormat() {
|
||||||
if (!this.ebookFile) return null
|
return this.ebookFile?.ebookFormat || null
|
||||||
return this.ebookFile.ebookFormat
|
|
||||||
},
|
},
|
||||||
ebookType() {
|
ebookType() {
|
||||||
if (this.isMobi) return 'mobi'
|
if (this.isMobi) return 'mobi'
|
||||||
@@ -91,8 +92,17 @@ export default {
|
|||||||
isComic() {
|
isComic() {
|
||||||
return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr'
|
return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr'
|
||||||
},
|
},
|
||||||
|
isLocal() {
|
||||||
|
return !!this.ebookFile?.isLocal
|
||||||
|
},
|
||||||
|
localContentUrl() {
|
||||||
|
return this.ebookFile?.contentUrl
|
||||||
|
},
|
||||||
ebookUrl() {
|
ebookUrl() {
|
||||||
if (!this.ebookFile) return null
|
if (!this.ebookFile) return null
|
||||||
|
if (this.localContentUrl) {
|
||||||
|
return Capacitor.convertFileSrc(this.localContentUrl)
|
||||||
|
}
|
||||||
let filepath = ''
|
let filepath = ''
|
||||||
if (this.selectedLibraryItem.isFile) {
|
if (this.selectedLibraryItem.isFile) {
|
||||||
filepath = this.$encodeUriPath(this.ebookFile.metadata.filename)
|
filepath = this.$encodeUriPath(this.ebookFile.metadata.filename)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<template v-for="track in tracks">
|
<template v-for="track in tracks">
|
||||||
<tr :key="track.index">
|
<tr :key="track.index">
|
||||||
<td>{{ track.metadata.filename }}</td>
|
<td>{{ (track.metadata && track.metadata.filename) || track.title || 'Unknown' }}</td>
|
||||||
<td class="font-mono text-center w-16">
|
<td class="font-mono text-center w-16">
|
||||||
{{ $secondsToTimestamp(track.duration) }}
|
{{ $secondsToTimestamp(track.duration) }}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full px-2 py-2 overflow-hidden relative">
|
<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' }">
|
<div class="h-full relative" :style="{ width: bookWidth + 'px' }">
|
||||||
<covers-book-cover :library-item="book" :width="bookWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-book-cover :library-item="book" :width="bookWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
</div>
|
</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">
|
<div class="max-w-full">
|
||||||
<p class="truncate block text-sm">{{ bookTitle }}</p>
|
<p class="truncate block text-sm">{{ bookTitle }}</p>
|
||||||
<p class="truncate block text-gray-400 text-xs">{{ bookAuthor }}</p>
|
<p class="truncate block text-gray-400 text-xs">{{ bookAuthor }}</p>
|
||||||
<p class="text-xxs text-gray-500">{{ bookDuration }}</p>
|
<p class="text-xxs text-gray-500">{{ bookDuration }}</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</nuxt-link>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -31,6 +36,9 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
libraryItemId() {
|
||||||
|
return this.book.id
|
||||||
|
},
|
||||||
media() {
|
media() {
|
||||||
return this.book.media || {}
|
return this.book.media || {}
|
||||||
},
|
},
|
||||||
@@ -59,22 +67,29 @@ export default {
|
|||||||
isMissing() {
|
isMissing() {
|
||||||
return this.book.isMissing
|
return this.book.isMissing
|
||||||
},
|
},
|
||||||
isIncomplete() {
|
isInvalid() {
|
||||||
return this.book.isIncomplete
|
return this.book.isInvalid
|
||||||
},
|
|
||||||
numTracks() {
|
|
||||||
return this.book.numTracks
|
|
||||||
},
|
|
||||||
isStreaming() {
|
|
||||||
return this.$store.getters['getIsItemStreaming'](this.book.id)
|
|
||||||
},
|
},
|
||||||
showPlayBtn() {
|
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: {
|
methods: {
|
||||||
clickEdit() {
|
async playClick() {
|
||||||
this.$emit('edit', this.book)
|
await this.$hapticsImpact()
|
||||||
|
if (this.streamIsPlaying) {
|
||||||
|
this.$eventBus.$emit('pause-item')
|
||||||
|
} else {
|
||||||
|
this.$eventBus.$emit('play-item', {
|
||||||
|
libraryItemId: this.libraryItemId
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
@@ -83,7 +98,7 @@ export default {
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.book-table-content {
|
.book-table-content {
|
||||||
width: calc(100% - 50px);
|
width: calc(100% - 82px);
|
||||||
max-width: calc(100% - 50px);
|
max-width: calc(100% - 82px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,16 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full px-2 py-2 overflow-hidden relative">
|
<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' }">
|
<div class="h-full relative" :style="{ width: '50px' }">
|
||||||
<covers-book-cover :library-item="libraryItem" :width="50" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-book-cover :library-item="libraryItem" :width="50" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
</div>
|
</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">
|
<div class="max-w-full">
|
||||||
<p class="truncate block text-sm">{{ itemTitle }}</p>
|
<p class="truncate block text-sm">{{ itemTitle }}</p>
|
||||||
<p class="truncate block text-gray-400 text-xs">{{ bookAuthorName }}</p>
|
<p class="truncate block text-gray-400 text-xs">{{ bookAuthorName }}</p>
|
||||||
<p class="text-xxs text-gray-500">{{ itemDuration }}</p>
|
<p class="text-xxs text-gray-500">{{ itemDuration }}</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</nuxt-link>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -83,21 +88,36 @@ export default {
|
|||||||
isInvalid() {
|
isInvalid() {
|
||||||
return this.libraryItem.isInvalid
|
return this.libraryItem.isInvalid
|
||||||
},
|
},
|
||||||
isStreaming() {
|
|
||||||
return this.$store.getters['getIsItemStreaming'](this.item.id)
|
|
||||||
},
|
|
||||||
showPlayBtn() {
|
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() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.item-table-content {
|
.item-table-content {
|
||||||
width: calc(100% - 50px);
|
width: calc(100% - 82px);
|
||||||
max-width: calc(100% - 50px);
|
max-width: calc(100% - 82px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -17,7 +17,15 @@
|
|||||||
{{ title }}
|
{{ title }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="text-sm text-gray-200 line-clamp-2 mt-1.5 mb-0.5">{{ subtitle }}</p>
|
<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="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.stop="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">
|
||||||
@@ -90,7 +98,17 @@ export default {
|
|||||||
return this.episode.title || ''
|
return this.episode.title || ''
|
||||||
},
|
},
|
||||||
subtitle() {
|
subtitle() {
|
||||||
return this.episode.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() {
|
duration() {
|
||||||
return this.$secondsToTimestamp(this.episode.duration)
|
return this.$secondsToTimestamp(this.episode.duration)
|
||||||
@@ -260,11 +278,7 @@ export default {
|
|||||||
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
|
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
|
||||||
}
|
}
|
||||||
|
|
||||||
var lmp = this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId, this.episode.id)
|
if (payload.server) {
|
||||||
console.log('toggleFinished Check LMP', this.libraryItemId, this.episode.id, JSON.stringify(lmp))
|
|
||||||
|
|
||||||
var serverUpdated = payload.server
|
|
||||||
if (serverUpdated) {
|
|
||||||
this.$toast.success(`Local & Server Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
|
this.$toast.success(`Local & Server Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
|
||||||
} else {
|
} else {
|
||||||
this.$toast.success(`Local Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
|
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-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-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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -76,6 +78,7 @@ export default {
|
|||||||
return {
|
return {
|
||||||
episodesCopy: [],
|
episodesCopy: [],
|
||||||
showFiltersModal: false,
|
showFiltersModal: false,
|
||||||
|
showSortModal: false,
|
||||||
sortKey: 'publishedAt',
|
sortKey: 'publishedAt',
|
||||||
sortDesc: true,
|
sortDesc: true,
|
||||||
filterKey: 'incomplete',
|
filterKey: 'incomplete',
|
||||||
@@ -167,10 +170,19 @@ export default {
|
|||||||
},
|
},
|
||||||
episodesSorted() {
|
episodesSorted() {
|
||||||
return this.episodesFiltered.sort((a, b) => {
|
return this.episodesFiltered.sort((a, b) => {
|
||||||
if (this.sortDesc) {
|
let aValue = a[this.sortKey]
|
||||||
return String(b[this.sortKey]).localeCompare(String(a[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
|
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
|
// Map of local episodes where server episode id is key
|
||||||
@@ -185,9 +197,8 @@ export default {
|
|||||||
},
|
},
|
||||||
sortText() {
|
sortText() {
|
||||||
if (!this.sortKey) return ''
|
if (!this.sortKey) return ''
|
||||||
var _sel = this.episodeSortItems.find((i) => i.value === this.sortKey)
|
const _sel = this.episodeSortItems.find((i) => i.value === this.sortKey)
|
||||||
if (!_sel) return ''
|
return _sel?.text || ''
|
||||||
return _sel.text
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -249,7 +260,7 @@ export default {
|
|||||||
this.showFiltersModal = true
|
this.showFiltersModal = true
|
||||||
},
|
},
|
||||||
clickSort() {
|
clickSort() {
|
||||||
this.sortDesc = !this.sortDesc
|
this.showSortModal = true
|
||||||
},
|
},
|
||||||
getEpisodeProgress(episode) {
|
getEpisodeProgress(episode) {
|
||||||
if (this.isLocal) return this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId, episode.id)
|
if (this.isLocal) return this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId, episode.id)
|
||||||
|
|||||||
@@ -16,7 +16,16 @@
|
|||||||
<p class="text-sm font-semibold">
|
<p class="text-sm font-semibold">
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-gray-200 line-clamp-2 mt-1.5 mb-0.5">{{ subtitle }}</p>
|
|
||||||
|
<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="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.stop="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">
|
||||||
@@ -92,7 +101,17 @@ export default {
|
|||||||
return this.episode.title || ''
|
return this.episode.title || ''
|
||||||
},
|
},
|
||||||
subtitle() {
|
subtitle() {
|
||||||
return this.episode.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() {
|
duration() {
|
||||||
return this.$secondsToTimestamp(this.episode.duration)
|
return this.$secondsToTimestamp(this.episode.duration)
|
||||||
@@ -265,11 +284,7 @@ export default {
|
|||||||
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
|
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
|
||||||
}
|
}
|
||||||
|
|
||||||
var lmp = this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId, this.episode.id)
|
if (payload.server) {
|
||||||
console.log('toggleFinished Check LMP', this.libraryItemId, this.episode.id, JSON.stringify(lmp))
|
|
||||||
|
|
||||||
var serverUpdated = payload.server
|
|
||||||
if (serverUpdated) {
|
|
||||||
this.$toast.success(`Local & Server Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
|
this.$toast.success(`Local & Server Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
|
||||||
} else {
|
} else {
|
||||||
this.$toast.success(`Local Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
|
this.$toast.success(`Local Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
|
||||||
|
|||||||
@@ -720,12 +720,12 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 20;
|
CURRENT_PROJECT_VERSION = 21;
|
||||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
MARKETING_VERSION = 0.9.63;
|
MARKETING_VERSION = 0.9.64;
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
|
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -744,12 +744,12 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 20;
|
CURRENT_PROJECT_VERSION = 21;
|
||||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
MARKETING_VERSION = 0.9.63;
|
MARKETING_VERSION = 0.9.64;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
|
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||||
|
|||||||
@@ -102,12 +102,6 @@
|
|||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "83.5x83.5"
|
"size" : "83.5x83.5"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"filename" : "icon-1024.png",
|
|
||||||
"idiom" : "ios-marketing",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "1024x1024"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"filename" : "icon-120.png",
|
"filename" : "icon-120.png",
|
||||||
"idiom" : "car",
|
"idiom" : "car",
|
||||||
@@ -120,6 +114,72 @@
|
|||||||
"scale" : "3x",
|
"scale" : "3x",
|
||||||
"size" : "60x60"
|
"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",
|
"filename" : "icon-48.png",
|
||||||
"idiom" : "watch",
|
"idiom" : "watch",
|
||||||
@@ -195,6 +255,13 @@
|
|||||||
"size" : "51x51",
|
"size" : "51x51",
|
||||||
"subtype" : "45mm"
|
"subtype" : "45mm"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "watch",
|
||||||
|
"role" : "appLauncher",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "54x54",
|
||||||
|
"subtype" : "49mm"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"filename" : "icon-172.png",
|
"filename" : "icon-172.png",
|
||||||
"idiom" : "watch",
|
"idiom" : "watch",
|
||||||
@@ -226,71 +293,18 @@
|
|||||||
"size" : "117x117",
|
"size" : "117x117",
|
||||||
"subtype" : "45mm"
|
"subtype" : "45mm"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "watch",
|
||||||
|
"role" : "quickLook",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "129x129",
|
||||||
|
"subtype" : "49mm"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"filename" : "icon-1024.png",
|
"filename" : "icon-1024.png",
|
||||||
"idiom" : "watch-marketing",
|
"idiom" : "watch-marketing",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "1024x1024"
|
"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" : {
|
"info" : {
|
||||||
|
|||||||
@@ -22,6 +22,11 @@
|
|||||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
<key>NSUserActivityTypes</key>
|
<key>NSUserActivityTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>INPlayMediaIntent</string>
|
<string>INPlayMediaIntent</string>
|
||||||
@@ -53,11 +58,6 @@
|
|||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
<key>NSAppTransportSecurity</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSAllowsArbitraryLoads</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
|
||||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ class PlaybackSession: Object, Codable, Deletable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
|
"deviceId": UIDevice.current.identifierForVendor?.uuidString,
|
||||||
"manufacturer": "Apple",
|
"manufacturer": "Apple",
|
||||||
"model": modelCode,
|
"model": modelCode,
|
||||||
"clientVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
"clientVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ class AudioPlayer: NSObject {
|
|||||||
for track in playbackSession.audioTracks {
|
for track in playbackSession.audioTracks {
|
||||||
if let playerAsset = createAsset(itemId: playbackSession.libraryItemId!, track: track) {
|
if let playerAsset = createAsset(itemId: playbackSession.libraryItemId!, track: track) {
|
||||||
let playerItem = AVPlayerItem(asset: playerAsset)
|
let playerItem = AVPlayerItem(asset: playerAsset)
|
||||||
|
if (playbackSession.playMethod == PlayMethod.transcode.rawValue) {
|
||||||
|
playerItem.preferredForwardBufferDuration = 50
|
||||||
|
}
|
||||||
self.allPlayerItems.append(playerItem)
|
self.allPlayerItems.append(playerItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ class ApiClient {
|
|||||||
"forceTranscode": forceTranscode ? "1" : "",
|
"forceTranscode": forceTranscode ? "1" : "",
|
||||||
"mediaPlayer": "AVPlayer",
|
"mediaPlayer": "AVPlayer",
|
||||||
"deviceInfo": [
|
"deviceInfo": [
|
||||||
|
"deviceId": UIDevice.current.identifierForVendor?.uuidString,
|
||||||
"manufacturer": "Apple",
|
"manufacturer": "Apple",
|
||||||
"model": modelCode,
|
"model": modelCode,
|
||||||
"clientVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
"clientVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-app",
|
"name": "audiobookshelf-app",
|
||||||
"version": "0.9.63-beta",
|
"version": "0.9.64-beta",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf-app",
|
"name": "audiobookshelf-app",
|
||||||
"version": "0.9.63-beta",
|
"version": "0.9.64-beta",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "^4.0.0",
|
"@capacitor/android": "^4.0.0",
|
||||||
"@capacitor/app": "^4.0.0",
|
"@capacitor/app": "^4.0.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-app",
|
"name": "audiobookshelf-app",
|
||||||
"version": "0.9.63-beta",
|
"version": "0.9.64-beta",
|
||||||
"author": "advplyr",
|
"author": "advplyr",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nuxt --hostname 0.0.0.0 --port 1337",
|
"dev": "nuxt --hostname 0.0.0.0 --port 1337",
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
shelves: [],
|
shelves: [],
|
||||||
isSettingsLoaded: false,
|
|
||||||
isFirstNetworkConnection: true,
|
isFirstNetworkConnection: true,
|
||||||
lastServerFetch: 0,
|
lastServerFetch: 0,
|
||||||
lastServerFetchLibraryId: null,
|
lastServerFetchLibraryId: null,
|
||||||
@@ -258,11 +257,6 @@ export default {
|
|||||||
return cat
|
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
|
// Only add the local shelf with the same media type
|
||||||
const localShelves = localCategories.filter((cat) => cat.type === this.currentLibraryMediaType && !cat.localOnly)
|
const localShelves = localCategories.filter((cat) => cat.type === this.currentLibraryMediaType && !cat.localOnly)
|
||||||
this.shelves.push(...localShelves)
|
this.shelves.push(...localShelves)
|
||||||
@@ -277,53 +271,6 @@ export default {
|
|||||||
|
|
||||||
this.isLoading = false
|
this.isLoading = false
|
||||||
},
|
},
|
||||||
async waitForSettings() {
|
|
||||||
// Wait up to 3 seconds
|
|
||||||
for (let i = 0; i < 6; i++) {
|
|
||||||
if (this.isSettingsLoaded) return true
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
async 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
|
|
||||||
|
|
||||||
// Wait for settings to load to prevent race condition when setting playback speed.
|
|
||||||
if (!this.isSettingsLoaded) {
|
|
||||||
await this.waitForSettings()
|
|
||||||
}
|
|
||||||
|
|
||||||
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() {
|
libraryChanged() {
|
||||||
if (this.currentLibraryId) {
|
if (this.currentLibraryId) {
|
||||||
console.log(`[categories] libraryChanged so fetching categories`)
|
console.log(`[categories] libraryChanged so fetching categories`)
|
||||||
@@ -370,16 +317,11 @@ export default {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
settingsUpdated() {
|
|
||||||
this.isSettingsLoaded = true
|
|
||||||
},
|
|
||||||
initListeners() {
|
initListeners() {
|
||||||
this.$eventBus.$on('library-changed', this.libraryChanged)
|
this.$eventBus.$on('library-changed', this.libraryChanged)
|
||||||
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
|
||||||
},
|
},
|
||||||
removeListeners() {
|
removeListeners() {
|
||||||
this.$eventBus.$off('library-changed', this.libraryChanged)
|
this.$eventBus.$off('library-changed', this.libraryChanged)
|
||||||
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|||||||
@@ -16,6 +16,14 @@
|
|||||||
{{ title }}
|
{{ title }}
|
||||||
</p>
|
</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 -->
|
<!-- 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' : ''">
|
<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 class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
|
||||||
@@ -25,9 +33,9 @@
|
|||||||
|
|
||||||
<!-- action buttons -->
|
<!-- action buttons -->
|
||||||
<div class="flex mt-4 -mx-1">
|
<div class="flex mt-4 -mx-1">
|
||||||
<ui-btn color="success" :disabled="isPlaying" class="flex items-center justify-center flex-grow mx-1" :padding-x="4" @click="playClick">
|
<ui-btn color="success" 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="material-icons">{{ playerIsPlaying ? 'pause' : 'play_arrow' }}</span>
|
||||||
<span class="px-1 text-sm">{{ isPlaying ? (playerIsLocal ? 'Playing' : 'Streaming') : localEpisodeId ? 'Play' : 'Stream' }}</span>
|
<span class="px-1 text-sm">{{ playerIsPlaying ? 'Pause' : localEpisodeId ? 'Play' : 'Stream' }}</span>
|
||||||
</ui-btn>
|
</ui-btn>
|
||||||
<ui-btn v-if="showDownload" :color="downloadItem ? 'warning' : 'primary'" class="flex items-center justify-center mx-1" :padding-x="2" @click="downloadClick">
|
<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>
|
<span class="material-icons" :class="downloadItem ? 'animate-pulse' : ''">{{ downloadItem ? 'downloading' : 'download' }}</span>
|
||||||
@@ -136,10 +144,23 @@ export default {
|
|||||||
libraryItemId() {
|
libraryItemId() {
|
||||||
return this.libraryItem.id
|
return this.libraryItem.id
|
||||||
},
|
},
|
||||||
localLibraryItemId() {
|
isConnectedToServer() {
|
||||||
if (this.localLibraryItem) return this.localLibraryItem.id
|
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
|
return null
|
||||||
},
|
},
|
||||||
|
localLibraryItemId() {
|
||||||
|
return this.localLibraryItem?.id || null
|
||||||
|
},
|
||||||
localEpisode() {
|
localEpisode() {
|
||||||
if (this.isLocal) return this.episode
|
if (this.isLocal) return this.episode
|
||||||
return this.episode.localEpisode
|
return this.episode.localEpisode
|
||||||
@@ -148,6 +169,13 @@ export default {
|
|||||||
if (this.localEpisode) return this.localEpisode.id
|
if (this.localEpisode) return this.localEpisode.id
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
|
serverEpisodeId() {
|
||||||
|
if (!this.isLocal) return this.episode.id
|
||||||
|
if (this.isConnectedToServer) {
|
||||||
|
return this.episode.serverEpisodeId
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
podcast() {
|
podcast() {
|
||||||
return this.libraryItem.media
|
return this.libraryItem.media
|
||||||
},
|
},
|
||||||
@@ -160,6 +188,16 @@ export default {
|
|||||||
description() {
|
description() {
|
||||||
return this.episode.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() {
|
duration() {
|
||||||
return this.$secondsToTimestamp(this.episode.duration)
|
return this.$secondsToTimestamp(this.episode.duration)
|
||||||
},
|
},
|
||||||
@@ -184,16 +222,16 @@ export default {
|
|||||||
return this.$store.state.playerIsPlaying && this.isPlaying
|
return this.$store.state.playerIsPlaying && this.isPlaying
|
||||||
},
|
},
|
||||||
userItemProgress() {
|
userItemProgress() {
|
||||||
if (this.isLocal) return this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId, this.episode.id)
|
if (this.isLocal) return this.localItemProgress
|
||||||
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, this.episode.id)
|
return this.serverItemProgress
|
||||||
},
|
},
|
||||||
localMediaProgress() {
|
localItemProgress() {
|
||||||
if (this.isLocal) return this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId, this.episode.id)
|
if (!this.localLibraryItemId || !this.localEpisodeId) return null
|
||||||
else if (this.localLibraryItemId && this.localEpisodeId) {
|
return this.$store.getters['globals/getLocalMediaProgressById'](this.localLibraryItemId, this.localEpisodeId)
|
||||||
return this.$store.getters['globals/getLocalMediaProgressById'](this.localLibraryItemId, this.localEpisodeId)
|
},
|
||||||
} else {
|
serverItemProgress() {
|
||||||
return null
|
if (!this.serverLibraryItemId || !this.serverEpisodeId) return null
|
||||||
}
|
return this.$store.getters['user/getUserMediaProgress'](this.serverLibraryItemId, this.serverEpisodeId)
|
||||||
},
|
},
|
||||||
progressPercent() {
|
progressPercent() {
|
||||||
return this.userItemProgress ? this.userItemProgress.progress : 0
|
return this.userItemProgress ? this.userItemProgress.progress : 0
|
||||||
@@ -275,13 +313,15 @@ export default {
|
|||||||
this.$eventBus.$emit('play-item', {
|
this.$eventBus.$emit('play-item', {
|
||||||
libraryItemId: this.localLibraryItemId,
|
libraryItemId: this.localLibraryItemId,
|
||||||
episodeId: this.localEpisodeId,
|
episodeId: this.localEpisodeId,
|
||||||
serverLibraryItemId: this.libraryItemId,
|
serverLibraryItemId: this.serverLibraryItemId,
|
||||||
serverEpisodeId: this.episode.id
|
serverEpisodeId: this.serverEpisodeId
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
this.$eventBus.$emit('play-item', {
|
this.$eventBus.$emit('play-item', {
|
||||||
libraryItemId: this.libraryItemId,
|
libraryItemId: this.libraryItemId,
|
||||||
episodeId: this.episode.id
|
episodeId: this.episode.id,
|
||||||
|
serverLibraryItemId: this.serverLibraryItemId,
|
||||||
|
serverEpisodeId: this.serverEpisodeId
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -377,22 +417,26 @@ export default {
|
|||||||
})
|
})
|
||||||
if (value) {
|
if (value) {
|
||||||
this.resettingProgress = true
|
this.resettingProgress = true
|
||||||
if (this.isLocal) {
|
|
||||||
// TODO: If connected to server also sync with server
|
const serverItemProgressId = this.serverItemProgress?.id
|
||||||
await this.$db.removeLocalMediaProgress(this.localMediaProgress.id)
|
if (this.localItemProgress) {
|
||||||
this.$store.commit('globals/removeLocalMediaProgressForItem', this.localMediaProgress.id)
|
await this.$db.removeLocalMediaProgress(this.localItemProgress.id)
|
||||||
} else {
|
this.$store.commit('globals/removeLocalMediaProgress', this.localItemProgress.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serverItemProgressId) {
|
||||||
await this.$axios
|
await this.$axios
|
||||||
.$delete(`/api/me/progress/${this.userItemProgress.id}`)
|
.$delete(`/api/me/progress/${serverItemProgressId}`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.log('Progress reset complete')
|
console.log('Progress reset complete')
|
||||||
this.$toast.success(`Your progress was reset`)
|
this.$toast.success(`Your progress was reset`)
|
||||||
this.$store.commit('user/removeMediaProgress', this.userItemProgress.id)
|
this.$store.commit('user/removeMediaProgress', serverItemProgressId)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Progress reset failed', error)
|
console.error('Progress reset failed', error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
this.resettingProgress = false
|
this.resettingProgress = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+69
-51
@@ -16,7 +16,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative" @click="showFullscreenCover = true">
|
<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 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>
|
||||||
</div>
|
</div>
|
||||||
@@ -33,28 +33,15 @@
|
|||||||
|
|
||||||
<!-- action buttons -->
|
<!-- action buttons -->
|
||||||
<div class="col-span-full">
|
<div class="col-span-full">
|
||||||
<div v-if="isLocal" class="flex mt-4 -mx-1">
|
<div v-if="showPlay || showRead" 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">
|
<ui-btn v-if="showPlay" color="success" 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="material-icons">{{ playerIsPlaying ? 'pause' : 'play_arrow' }}</span>
|
||||||
<span class="px-1 text-sm">{{ isPlaying ? 'Playing' : 'Play' }}</span>
|
<span class="px-1 text-sm">{{ playerIsPlaying ? 'Pause' : isPodcast ? 'Next Episode' : hasLocal ? 'Play' : 'Stream' }}</span>
|
||||||
</ui-btn>
|
</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">
|
<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 class="material-icons">auto_stories</span>
|
||||||
<span v-if="!showPlay" class="px-2 text-base">Read {{ ebookFormat }}</span>
|
<span v-if="!showPlay" class="px-2 text-base">Read {{ ebookFormat }}</span>
|
||||||
</ui-btn>
|
</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">
|
<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>
|
<span class="material-icons" :class="downloadItem ? 'animate-pulse' : ''">{{ downloadItem ? 'downloading' : 'download' }}</span>
|
||||||
</ui-btn>
|
</ui-btn>
|
||||||
@@ -65,8 +52,8 @@
|
|||||||
|
|
||||||
<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' : ''">
|
<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>Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
|
||||||
<p v-if="progressPercent < 1" class="text-gray-400 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
|
<p v-if="!useEBookProgress && !userIsFinished" class="text-gray-400 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
|
||||||
<p v-else class="text-gray-400 text-xs">Finished {{ $formatDate(userProgressFinishedAt) }}</p>
|
<p v-else-if="userIsFinished" class="text-gray-400 text-xs">Finished {{ $formatDate(userProgressFinishedAt) }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -86,6 +73,9 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</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="text-white text-opacity-60 uppercase text-sm">Series</div>
|
||||||
<div v-if="series && series.length" class="truncate text-sm">
|
<div v-if="series && series.length" class="truncate text-sm">
|
||||||
<template v-for="(series, index) in seriesList">
|
<template v-for="(series, index) in seriesList">
|
||||||
@@ -214,12 +204,12 @@ export default {
|
|||||||
return this.libraryItem.localLibraryItem || null
|
return this.libraryItem.localLibraryItem || null
|
||||||
},
|
},
|
||||||
localLibraryItemId() {
|
localLibraryItemId() {
|
||||||
return this.localLibraryItem ? this.localLibraryItem.id : null
|
return this.localLibraryItem?.id || null
|
||||||
},
|
},
|
||||||
localLibraryItemEpisodes() {
|
localLibraryItemEpisodes() {
|
||||||
if (!this.isPodcast || !this.localLibraryItem) return []
|
if (!this.isPodcast || !this.localLibraryItem) return []
|
||||||
var podcastMedia = this.localLibraryItem.media
|
var podcastMedia = this.localLibraryItem.media
|
||||||
return podcastMedia ? podcastMedia.episodes || [] : []
|
return podcastMedia?.episodes || []
|
||||||
},
|
},
|
||||||
serverLibraryItemId() {
|
serverLibraryItemId() {
|
||||||
if (!this.isLocal) return this.libraryItem.id
|
if (!this.isLocal) return this.libraryItem.id
|
||||||
@@ -260,6 +250,9 @@ export default {
|
|||||||
publishedYear() {
|
publishedYear() {
|
||||||
return this.mediaMetadata.publishedYear
|
return this.mediaMetadata.publishedYear
|
||||||
},
|
},
|
||||||
|
podcastType() {
|
||||||
|
return this.mediaMetadata.type
|
||||||
|
},
|
||||||
podcastAuthor() {
|
podcastAuthor() {
|
||||||
if (!this.isPodcast) return null
|
if (!this.isPodcast) return null
|
||||||
return this.mediaMetadata.author || ''
|
return this.mediaMetadata.author || ''
|
||||||
@@ -300,8 +293,16 @@ export default {
|
|||||||
},
|
},
|
||||||
userItemProgress() {
|
userItemProgress() {
|
||||||
if (this.isPodcast) return null
|
if (this.isPodcast) return null
|
||||||
if (this.isLocal) return this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId)
|
if (this.isLocal) return this.localItemProgress
|
||||||
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
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() {
|
userIsFinished() {
|
||||||
return this.userItemProgress ? !!this.userItemProgress.isFinished : false
|
return this.userItemProgress ? !!this.userItemProgress.isFinished : false
|
||||||
@@ -311,7 +312,12 @@ export default {
|
|||||||
const duration = this.userItemProgress.duration || this.duration
|
const duration = this.userItemProgress.duration || this.duration
|
||||||
return duration - this.userItemProgress.currentTime
|
return duration - this.userItemProgress.currentTime
|
||||||
},
|
},
|
||||||
|
useEBookProgress() {
|
||||||
|
if (!this.userItemProgress || this.userItemProgress.progress) return false
|
||||||
|
return this.userItemProgress.ebookProgress > 0
|
||||||
|
},
|
||||||
progressPercent() {
|
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
|
return this.userItemProgress ? Math.max(Math.min(1, this.userItemProgress.progress), 0) : 0
|
||||||
},
|
},
|
||||||
userProgressFinishedAt() {
|
userProgressFinishedAt() {
|
||||||
@@ -324,6 +330,9 @@ export default {
|
|||||||
if (this.localLibraryItemId && this.$store.getters['getIsItemStreaming'](this.localLibraryItemId)) return true
|
if (this.localLibraryItemId && this.$store.getters['getIsItemStreaming'](this.localLibraryItemId)) return true
|
||||||
return this.$store.getters['getIsItemStreaming'](this.libraryItemId)
|
return this.$store.getters['getIsItemStreaming'](this.libraryItemId)
|
||||||
},
|
},
|
||||||
|
playerIsPlaying() {
|
||||||
|
return this.$store.state.playerIsPlaying && (this.isStreaming || this.isPlaying)
|
||||||
|
},
|
||||||
tracks() {
|
tracks() {
|
||||||
return this.media.tracks || []
|
return this.media.tracks || []
|
||||||
},
|
},
|
||||||
@@ -337,18 +346,18 @@ export default {
|
|||||||
isMissing() {
|
isMissing() {
|
||||||
return this.libraryItem.isMissing
|
return this.libraryItem.isMissing
|
||||||
},
|
},
|
||||||
isIncomplete() {
|
isInvalid() {
|
||||||
return this.libraryItem.isIncomplete
|
return this.libraryItem.isInvalid
|
||||||
},
|
},
|
||||||
showPlay() {
|
showPlay() {
|
||||||
return !this.isMissing && !this.isIncomplete && (this.numTracks || this.episodes.length)
|
return !this.isMissing && !this.isInvalid && (this.numTracks || this.episodes.length)
|
||||||
},
|
},
|
||||||
showRead() {
|
showRead() {
|
||||||
return this.ebookFile
|
return this.ebookFile
|
||||||
},
|
},
|
||||||
showDownload() {
|
showDownload() {
|
||||||
if (this.isPodcast) return false
|
if (this.isPodcast || this.hasLocal) return false
|
||||||
return this.user && this.userCanDownload && this.showPlay && !this.hasLocal
|
return this.user && this.userCanDownload && (this.showPlay || (this.showRead && !this.isIos))
|
||||||
},
|
},
|
||||||
ebookFile() {
|
ebookFile() {
|
||||||
return this.media.ebookFile
|
return this.media.ebookFile
|
||||||
@@ -472,17 +481,25 @@ export default {
|
|||||||
this.showMoreMenu = true
|
this.showMoreMenu = true
|
||||||
},
|
},
|
||||||
readBook() {
|
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) {
|
playAtTimestamp(seconds) {
|
||||||
this.play(seconds)
|
this.play(seconds)
|
||||||
},
|
},
|
||||||
playClick() {
|
async playClick() {
|
||||||
this.play()
|
await this.$hapticsImpact()
|
||||||
|
if (this.playerIsPlaying) {
|
||||||
|
this.$eventBus.$emit('pause-item')
|
||||||
|
} else {
|
||||||
|
this.play()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async play(startTime = null) {
|
async play(startTime = null) {
|
||||||
await this.$hapticsImpact()
|
|
||||||
|
|
||||||
if (this.isPodcast) {
|
if (this.isPodcast) {
|
||||||
this.episodes.sort((a, b) => {
|
this.episodes.sort((a, b) => {
|
||||||
return String(b.publishedAt).localeCompare(String(a.publishedAt), undefined, { numeric: true, sensitivity: 'base' })
|
return String(b.publishedAt).localeCompare(String(a.publishedAt), undefined, { numeric: true, sensitivity: 'base' })
|
||||||
@@ -550,18 +567,19 @@ export default {
|
|||||||
})
|
})
|
||||||
if (value) {
|
if (value) {
|
||||||
this.resettingProgress = true
|
this.resettingProgress = true
|
||||||
if (this.isLocal) {
|
const serverMediaProgressId = this.serverItemProgress?.id
|
||||||
// TODO: If connected to server also sync with server
|
if (this.localLibraryItemId) {
|
||||||
await this.$db.removeLocalMediaProgress(this.libraryItemId)
|
await this.$db.removeLocalMediaProgress(this.localLibraryItemId)
|
||||||
this.$store.commit('globals/removeLocalMediaProgressForItem', this.libraryItemId)
|
this.$store.commit('globals/removeLocalMediaProgressForItem', this.localLibraryItemId)
|
||||||
} else {
|
}
|
||||||
var progressId = this.userItemProgress.id
|
|
||||||
|
if (this.serverLibraryItemId && serverMediaProgressId) {
|
||||||
await this.$axios
|
await this.$axios
|
||||||
.$delete(`/api/me/progress/${this.libraryItemId}`)
|
.$delete(`/api/me/progress/${serverMediaProgressId}`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.log('Progress reset complete')
|
console.log('Progress reset complete')
|
||||||
this.$toast.success(`Your progress was reset`)
|
this.$toast.success(`Your progress was reset`)
|
||||||
this.$store.commit('user/removeMediaProgress', progressId)
|
this.$store.commit('user/removeMediaProgress', serverMediaProgressId)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Progress reset failed', error)
|
console.error('Progress reset failed', error)
|
||||||
@@ -594,9 +612,6 @@ export default {
|
|||||||
if (this.downloadItem) {
|
if (this.downloadItem) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!this.numTracks) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
if (this.isIos) {
|
if (this.isIos) {
|
||||||
// no local folders on iOS
|
// no local folders on iOS
|
||||||
@@ -634,7 +649,14 @@ export default {
|
|||||||
|
|
||||||
console.log('Local folder', JSON.stringify(localFolder))
|
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({
|
const { value } = await Dialog.confirm({
|
||||||
title: 'Confirm',
|
title: 'Confirm',
|
||||||
message: startDownloadMessage
|
message: startDownloadMessage
|
||||||
@@ -691,11 +713,7 @@ export default {
|
|||||||
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
|
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
|
||||||
}
|
}
|
||||||
|
|
||||||
var lmp = this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId)
|
if (payload.server) {
|
||||||
console.log('toggleFinished Check LMP', this.libraryItemId, JSON.stringify(lmp))
|
|
||||||
|
|
||||||
var serverUpdated = payload.server
|
|
||||||
if (serverUpdated) {
|
|
||||||
this.$toast.success(`Local & Server Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
|
this.$toast.success(`Local & Server Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
|
||||||
} else {
|
} else {
|
||||||
this.$toast.success(`Local Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
|
this.$toast.success(`Local Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
|
||||||
|
|||||||
+19
-7
@@ -41,7 +41,16 @@
|
|||||||
<template v-for="authorResult in authorResults">
|
<template v-for="authorResult in authorResults">
|
||||||
<div :key="authorResult.id" class="w-full h-14 py-1">
|
<div :key="authorResult.id" class="w-full h-14 py-1">
|
||||||
<nuxt-link :to="`/bookshelf/library?filter=authors.${$encode(authorResult.id)}`">
|
<nuxt-link :to="`/bookshelf/library?filter=authors.${$encode(authorResult.id)}`">
|
||||||
<cards-author-search-card :key="authorResult.id" :author="authorResult" />
|
<cards-author-search-card :author="authorResult" />
|
||||||
|
</nuxt-link>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p v-if="narratorResults.length" class="font-semibold text-sm mb-1 mt-2">Narrators</p>
|
||||||
|
<template v-for="narrator in narratorResults">
|
||||||
|
<div :key="narrator.name" class="w-full h-14 py-1">
|
||||||
|
<nuxt-link :to="`/bookshelf/library?filter=narrators.${$encode(narrator.name)}`">
|
||||||
|
<cards-narrator-search-card :narrator="narrator.name" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -60,7 +69,8 @@ export default {
|
|||||||
bookResults: [],
|
bookResults: [],
|
||||||
podcastResults: [],
|
podcastResults: [],
|
||||||
seriesResults: [],
|
seriesResults: [],
|
||||||
authorResults: []
|
authorResults: [],
|
||||||
|
narratorResults: []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -71,7 +81,7 @@ export default {
|
|||||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||||
},
|
},
|
||||||
totalResults() {
|
totalResults() {
|
||||||
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.podcastResults.length
|
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.podcastResults.length + this.narratorResults.length
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -86,6 +96,7 @@ export default {
|
|||||||
this.podcastResults = []
|
this.podcastResults = []
|
||||||
this.seriesResults = []
|
this.seriesResults = []
|
||||||
this.authorResults = []
|
this.authorResults = []
|
||||||
|
this.narratorResults = []
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.isFetching = true
|
this.isFetching = true
|
||||||
@@ -101,10 +112,11 @@ export default {
|
|||||||
|
|
||||||
this.isFetching = false
|
this.isFetching = false
|
||||||
|
|
||||||
this.bookResults = results ? results.book || [] : []
|
this.bookResults = results?.book || []
|
||||||
this.podcastResults = results ? results.podcast || [] : []
|
this.podcastResults = results?.podcast || []
|
||||||
this.seriesResults = results ? results.series || [] : []
|
this.seriesResults = results?.series || []
|
||||||
this.authorResults = results ? results.authors || [] : []
|
this.authorResults = results?.authors || []
|
||||||
|
this.narratorResults = results?.narrators || []
|
||||||
},
|
},
|
||||||
updateSearch(val) {
|
updateSearch(val) {
|
||||||
clearTimeout(this.searchTimeout)
|
clearTimeout(this.searchTimeout)
|
||||||
|
|||||||
+40
-1
@@ -95,9 +95,23 @@
|
|||||||
<ui-text-input :value="sleepTimerLengthOption" readonly append-icon="expand_more" style="width: 145px; max-width: 145px" />
|
<ui-text-input :value="sleepTimerLengthOption" readonly append-icon="expand_more" style="width: 145px; max-width: 145px" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="settings.autoSleepTimer" class="flex items-center py-3" @click="toggleAutoSleepTimerAutoRewind">
|
||||||
|
<div class="w-10 flex justify-center">
|
||||||
|
<ui-toggle-switch v-model="settings.autoSleepTimerAutoRewind" @input="saveSettings" />
|
||||||
|
</div>
|
||||||
|
<p class="pl-4">Auto Sleep Timer Auto Rewind</p>
|
||||||
|
<span class="material-icons-outlined ml-2" @click.stop="showInfo('autoSleepTimerAutoRewind')">info</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="settings.autoSleepTimerAutoRewind" class="py-3 flex items-center">
|
||||||
|
<p class="pr-4 w-36">Auto Rewind Time</p>
|
||||||
|
<div @click.stop="showAutoSleepTimerRewindOptions">
|
||||||
|
<ui-text-input :value="autoSleepTimerRewindLengthOption" readonly append-icon="expand_more" style="width: 145px; max-width: 145px" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<modals-dialog v-model="showMoreMenuDialog" :items="moreMenuItems" @action="clickMenuAction" />
|
<modals-dialog v-model="showMoreMenuDialog" :items="moreMenuItems" @action="clickMenuAction" />
|
||||||
<modals-sleep-timer-length-modal v-model="showSleepTimerLengthModal" @change="sleepTimerLengthModalSelection" />
|
<modals-sleep-timer-length-modal v-model="showSleepTimerLengthModal" @change="sleepTimerLengthModalSelection" />
|
||||||
|
<modals-auto-sleep-timer-rewind-length-modal v-model="showAutoSleepTimerRewindLengthModal" @change="showAutoSleepTimerRewindLengthModalSelection" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -110,6 +124,7 @@ export default {
|
|||||||
deviceData: null,
|
deviceData: null,
|
||||||
showMoreMenuDialog: false,
|
showMoreMenuDialog: false,
|
||||||
showSleepTimerLengthModal: false,
|
showSleepTimerLengthModal: false,
|
||||||
|
showAutoSleepTimerRewindLengthModal: false,
|
||||||
moreMenuSetting: '',
|
moreMenuSetting: '',
|
||||||
settings: {
|
settings: {
|
||||||
disableAutoRewind: false,
|
disableAutoRewind: false,
|
||||||
@@ -125,7 +140,9 @@ export default {
|
|||||||
autoSleepTimerEndTime: '06:00',
|
autoSleepTimerEndTime: '06:00',
|
||||||
sleepTimerLength: 900000, // 15 minutes
|
sleepTimerLength: 900000, // 15 minutes
|
||||||
disableSleepTimerFadeOut: false,
|
disableSleepTimerFadeOut: false,
|
||||||
disableSleepTimerResetFeedback: false
|
disableSleepTimerResetFeedback: false,
|
||||||
|
autoSleepTimerAutoRewind: false,
|
||||||
|
autoSleepTimerAutoRewindTime: 300000 // 5 minutes
|
||||||
},
|
},
|
||||||
lockCurrentOrientation: false,
|
lockCurrentOrientation: false,
|
||||||
settingInfo: {
|
settingInfo: {
|
||||||
@@ -144,6 +161,10 @@ export default {
|
|||||||
disableSleepTimerResetFeedback: {
|
disableSleepTimerResetFeedback: {
|
||||||
name: 'Disable vibrate on reset',
|
name: 'Disable vibrate on reset',
|
||||||
message: 'When the sleep timer gets reset your device will vibrate. Enable this setting to not vibrate when the sleep timer resets.'
|
message: 'When the sleep timer gets reset your device will vibrate. Enable this setting to not vibrate when the sleep timer resets.'
|
||||||
|
},
|
||||||
|
autoSleepTimerAutoRewind: {
|
||||||
|
name: 'Enable sleep timer auto rewind',
|
||||||
|
message: 'When the auto sleep timer finishes, playing the item again will automatically rewind your position.'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
hapticFeedbackItems: [
|
hapticFeedbackItems: [
|
||||||
@@ -234,6 +255,10 @@ export default {
|
|||||||
const minutes = Number(this.settings.sleepTimerLength) / 1000 / 60
|
const minutes = Number(this.settings.sleepTimerLength) / 1000 / 60
|
||||||
return `${minutes} min`
|
return `${minutes} min`
|
||||||
},
|
},
|
||||||
|
autoSleepTimerRewindLengthOption() {
|
||||||
|
const minutes = Number(this.settings.autoSleepTimerAutoRewindTime) / 1000 / 60
|
||||||
|
return `${minutes} min`
|
||||||
|
},
|
||||||
moreMenuItems() {
|
moreMenuItems() {
|
||||||
if (this.moreMenuSetting === 'shakeSensitivity') return this.shakeSensitivityItems
|
if (this.moreMenuSetting === 'shakeSensitivity') return this.shakeSensitivityItems
|
||||||
else if (this.moreMenuSetting === 'hapticFeedback') return this.hapticFeedbackItems
|
else if (this.moreMenuSetting === 'hapticFeedback') return this.hapticFeedbackItems
|
||||||
@@ -245,9 +270,16 @@ export default {
|
|||||||
this.settings.sleepTimerLength = value
|
this.settings.sleepTimerLength = value
|
||||||
this.saveSettings()
|
this.saveSettings()
|
||||||
},
|
},
|
||||||
|
showAutoSleepTimerRewindLengthModalSelection(value) {
|
||||||
|
this.settings.autoSleepTimerAutoRewindTime = value
|
||||||
|
this.saveSettings()
|
||||||
|
},
|
||||||
showSleepTimerOptions() {
|
showSleepTimerOptions() {
|
||||||
this.showSleepTimerLengthModal = true
|
this.showSleepTimerLengthModal = true
|
||||||
},
|
},
|
||||||
|
showAutoSleepTimerRewindOptions() {
|
||||||
|
this.showAutoSleepTimerRewindLengthModal = true
|
||||||
|
},
|
||||||
showHapticFeedbackOptions() {
|
showHapticFeedbackOptions() {
|
||||||
this.moreMenuSetting = 'hapticFeedback'
|
this.moreMenuSetting = 'hapticFeedback'
|
||||||
this.showMoreMenuDialog = true
|
this.showMoreMenuDialog = true
|
||||||
@@ -287,6 +319,10 @@ export default {
|
|||||||
this.settings.autoSleepTimer = !this.settings.autoSleepTimer
|
this.settings.autoSleepTimer = !this.settings.autoSleepTimer
|
||||||
this.saveSettings()
|
this.saveSettings()
|
||||||
},
|
},
|
||||||
|
toggleAutoSleepTimerAutoRewind() {
|
||||||
|
this.settings.autoSleepTimerAutoRewind = !this.settings.autoSleepTimerAutoRewind
|
||||||
|
this.saveSettings()
|
||||||
|
},
|
||||||
toggleDisableSleepTimerFadeOut() {
|
toggleDisableSleepTimerFadeOut() {
|
||||||
this.settings.disableSleepTimerFadeOut = !this.settings.disableSleepTimerFadeOut
|
this.settings.disableSleepTimerFadeOut = !this.settings.disableSleepTimerFadeOut
|
||||||
this.saveSettings()
|
this.saveSettings()
|
||||||
@@ -364,6 +400,9 @@ export default {
|
|||||||
this.settings.sleepTimerLength = !isNaN(deviceSettings.sleepTimerLength) ? deviceSettings.sleepTimerLength : 900000 // 15 minutes
|
this.settings.sleepTimerLength = !isNaN(deviceSettings.sleepTimerLength) ? deviceSettings.sleepTimerLength : 900000 // 15 minutes
|
||||||
this.settings.disableSleepTimerFadeOut = !!deviceSettings.disableSleepTimerFadeOut
|
this.settings.disableSleepTimerFadeOut = !!deviceSettings.disableSleepTimerFadeOut
|
||||||
this.settings.disableSleepTimerResetFeedback = !!deviceSettings.disableSleepTimerResetFeedback
|
this.settings.disableSleepTimerResetFeedback = !!deviceSettings.disableSleepTimerResetFeedback
|
||||||
|
|
||||||
|
this.settings.autoSleepTimerAutoRewind = !!deviceSettings.autoSleepTimerAutoRewind
|
||||||
|
this.settings.autoSleepTimerAutoRewindTime = !isNaN(deviceSettings.autoSleepTimerAutoRewindTime) ? deviceSettings.autoSleepTimerAutoRewindTime : 300000 // 5 minutes
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { registerPlugin, WebPlugin } from '@capacitor/core';
|
import { registerPlugin, WebPlugin } from '@capacitor/core'
|
||||||
|
import { nanoid } from 'nanoid'
|
||||||
const { PlayerState } = require('../constants')
|
const { PlayerState } = require('../constants')
|
||||||
|
|
||||||
var $axios = null
|
var $axios = null
|
||||||
@@ -42,6 +43,15 @@ class AbsAudioPlayerWeb extends WebPlugin {
|
|||||||
return this.player && !this.player.paused
|
return this.player && !this.player.paused
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getDeviceId() {
|
||||||
|
let deviceId = localStorage.getItem('absDeviceId')
|
||||||
|
if (!deviceId) {
|
||||||
|
deviceId = nanoid()
|
||||||
|
localStorage.setItem('absDeviceId', deviceId)
|
||||||
|
}
|
||||||
|
return deviceId
|
||||||
|
}
|
||||||
|
|
||||||
// PluginMethod
|
// PluginMethod
|
||||||
async prepareLibraryItem({ libraryItemId, episodeId, playWhenReady, startTime }) {
|
async prepareLibraryItem({ libraryItemId, episodeId, playWhenReady, startTime }) {
|
||||||
console.log('[AbsAudioPlayer] Prepare library item', libraryItemId)
|
console.log('[AbsAudioPlayer] Prepare library item', libraryItemId)
|
||||||
@@ -49,8 +59,11 @@ class AbsAudioPlayerWeb extends WebPlugin {
|
|||||||
if (libraryItemId.startsWith('local_')) {
|
if (libraryItemId.startsWith('local_')) {
|
||||||
// Fetch Local - local not implemented on web
|
// Fetch Local - local not implemented on web
|
||||||
} else {
|
} else {
|
||||||
var route = !episodeId ? `/api/items/${libraryItemId}/play` : `/api/items/${libraryItemId}/play/${episodeId}`
|
const route = !episodeId ? `/api/items/${libraryItemId}/play` : `/api/items/${libraryItemId}/play/${episodeId}`
|
||||||
var playbackSession = await $axios.$post(route, { mediaPlayer: 'html5-mobile', forceDirectPlay: true })
|
const deviceInfo = {
|
||||||
|
deviceId: this.getDeviceId()
|
||||||
|
}
|
||||||
|
const playbackSession = await $axios.$post(route, { deviceInfo, mediaPlayer: 'html5-mobile', forceDirectPlay: true })
|
||||||
if (playbackSession) {
|
if (playbackSession) {
|
||||||
if (startTime !== undefined && startTime !== null) playbackSession.currentTime = startTime
|
if (startTime !== undefined && startTime !== null) playbackSession.currentTime = startTime
|
||||||
this.setAudioPlayer(playbackSession, playWhenReady)
|
this.setAudioPlayer(playbackSession, playWhenReady)
|
||||||
|
|||||||
+6
-1
@@ -86,11 +86,16 @@ class DbService {
|
|||||||
return AbsDatabase.updateLocalTrackOrder(payload)
|
return AbsDatabase.updateLocalTrackOrder(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
// input: { localMediaProgressId:String, isFinished:Boolean }
|
// input: { localLibraryItemId:String, localEpisodeId:String, isFinished:Boolean }
|
||||||
updateLocalMediaProgressFinished(payload) {
|
updateLocalMediaProgressFinished(payload) {
|
||||||
return AbsDatabase.updateLocalMediaProgressFinished(payload)
|
return AbsDatabase.updateLocalMediaProgressFinished(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// input: { localLibraryItemId:String, ebookLocation:String, ebookProgress:Double }
|
||||||
|
updateLocalEbookProgress(payload) {
|
||||||
|
return AbsDatabase.updateLocalEbookProgress(payload)
|
||||||
|
}
|
||||||
|
|
||||||
updateDeviceSettings(payload) {
|
updateDeviceSettings(payload) {
|
||||||
return AbsDatabase.updateDeviceSettings(payload)
|
return AbsDatabase.updateDeviceSettings(payload)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ Join us on [discord](https://discord.gg/pJsjuNCKRq)
|
|||||||
|
|
||||||
**Requires an Audiobookshelf server to connect with**
|
**Requires an Audiobookshelf server to connect with**
|
||||||
|
|
||||||
<img alt="Screenshot1" src="https://github.com/advplyr/audiobookshelf-app/raw/master/screenshots/BookshelfViews.png" />
|
<img alt="Screenshot" src="https://github.com/advplyr/audiobookshelf-app/raw/master/screenshots/DeviceDemoScreens.png" />
|
||||||
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.8 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 784 KiB |
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user