Compare commits

...
Author SHA1 Message Date
advplyr 7031c895b5 iOS version bump 0.9.64 2023-05-21 18:41:18 -05:00
advplyr 7c739bf3bf Version bump 0.9.64-beta 2023-05-21 18:01:28 -05:00
advplyr facf56930e iOS add deviceId 2023-05-21 17:19:21 -05:00
advplyr 2c3dff3544 Add:EBook download and offline reading #187 #243 2023-05-21 15:02:49 -05:00
advplyr b1bf68b8cd Add:Series progress to series cards #410 2023-05-21 10:25:10 -05:00
advplyr 78aade7694 Fix:Android delete empty author folder when removing item #701 2023-05-20 18:13:19 -05:00
advplyr 83a4474375 Add:Podcast episode table sort options #376 2023-05-20 17:12:36 -05:00
advplyr 1b47c38efa Fix:Android sleep timer getting set to time left in media #659 2023-05-20 16:51:16 -05:00
advplyr 922d9f22ce Fix chapters button clickable when no chapters 2023-05-20 16:31:51 -05:00
advplyr d7feb52dd8 Fix:Discard progress for local and server media progress #646 2023-05-20 16:29:54 -05:00
advplyr fafaad8b5f Update:Play buttons on item pages show Play/Pause instead of disabled and showing Playing/Streaming #697 2023-05-20 14:49:55 -05:00
advplyr 928f33005a Update:Add play button to collections and playlist table #666 2023-05-20 14:22:53 -05:00
advplyr 0448d20583 Update:Android disable check for updated progress when playing 2023-05-20 13:22:04 -05:00
advplyrandGitHub f9d408b33f Merge pull request #692 from HStep20/master
Added Auto Rewind on Sleep Timer setting to Android
2023-05-20 12:18:17 -05:00
advplyr 9386ae95a3 Fix set sleepTimerSessionId 2023-05-20 12:17:41 -05:00
advplyr c530e0f7e3 Remove test log 2023-05-20 12:00:19 -05:00
advplyr e39a5e1ba3 Fix saveSettings function call 2023-05-20 11:58:27 -05:00
advplyr cb9e317e74 Initialize auto rewind time and update description 2023-05-20 11:56:36 -05:00
advplyr 8061ac0ced Fix auto rewind when check resetting sleep timer 2023-05-20 11:41:39 -05:00
advplyr daff8355d7 Merge branch 'master' into HStep20/master 2023-05-20 10:18:16 -05:00
advplyr 231289e9ac Update:Add podcast type, episode type, episode number, season and support html episode description #677 2023-05-19 17:57:36 -05:00
advplyr 87df022af0 Add:Narrators in search results 2023-05-19 17:18:31 -05:00
advplyr 07fdac9746 Update:Podcast episodes on home page dont show an Episode number if none is set 2023-05-19 17:03:54 -05:00
advplyr 0d482aaa42 Update:Android remove auto-open most recent listened item 2023-05-19 16:59:10 -05:00
advplyr d48d1fdde3 Update:Audio player play method color for visibility #706 2023-05-17 17:59:47 -05:00
hstephens 65b69cc020 fixed if statement syntax 2023-05-12 20:19:18 -06:00
hstephens a3794a6a6a added check before setting state 2023-04-29 17:20:04 -06:00
hstephens 695ab84940 changed behavior for first timer set 2023-04-29 16:48:43 -06:00
hstephens 5399ec67d7 Added Auto Rewind on Sleep Timer setting 2023-04-29 15:38:51 -06:00
advplyr d9a6ec65df Add deviceId to DeviceInfo for Android 2023-04-22 16:48:22 -05:00
advplyr e842de78dd Fix:Audiobook tracks table for local only books 2023-03-28 17:30:54 -05:00
advplyr 260363bf4f Fix:Android media widget initialize DB 2023-03-28 17:30:20 -05:00
advplyrandGitHub 6d7dffd4de Merge pull request #649 from maltejur/largeHeap
Add `android:largeHeap="true"` to `AndroidManifest.xml`
2023-03-27 17:02:09 -05:00
advplyr ef1d8a8eb4 Update:iOS HLS stream buffer set to 50s 2023-03-27 16:54:19 -05:00
Malte Jürgens cdd7a1a636 add android:largeHeap to AndroidManifest.xml 2023-03-27 21:00:52 +02:00
advplyr 6584cd1522 Fix:Android auto sleep timer window #648 2023-03-26 16:43:24 -05:00
advplyr d01b602306 Update epub reader media progress update 2023-03-26 13:50:21 -05:00
advplyr c90f2c136a Add:Support epub progress saving 2023-03-25 17:40:46 -05:00
advplyr 15ccc7192e Fix:Android sleep timer reset volume when shake #626 2023-03-25 16:51:24 -05:00
advplyr e4345a4438 Update:Raw cover on library item page, Android download raw cover #634 2023-03-19 17:13:33 -05:00
advplyr 3f4d564bad Fix:Add try/catch to initCastManager #480 2023-03-10 16:51:45 -06:00
advplyr 3311f66da8 Update readme screenshot 2023-03-10 16:13:04 -06:00
advplyr e711ada0b3 Fix:Center play button when locking ui #624 2023-03-10 15:37:42 -06:00
60 changed files with 1188 additions and 433 deletions
+2 -2
View File
@@ -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.
+2 -1
View File
@@ -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")
@@ -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")
}
} }
} }
} }
+14
View File
@@ -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;
+7 -3
View File
@@ -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
+2
View File
@@ -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
} }
-20
View File
@@ -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>
+4 -1
View File
@@ -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}`
} }
}, },
+11 -4
View File
@@ -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
+23
View File
@@ -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
}, },
+34
View File
@@ -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 -1
View File
@@ -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>
+1 -1
View File
@@ -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" />
+21 -1
View File
@@ -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
} }
+210 -61
View File
@@ -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()
} }
+14 -4
View File
@@ -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)
+1 -1
View File
@@ -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>
+30 -15
View File
@@ -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>
+29 -9
View File
@@ -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>
+21 -7
View File
@@ -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'}`)
+18 -7
View File
@@ -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)
+22 -7
View File
@@ -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'}`)
+4 -4
View File
@@ -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" : {
+5 -5
View File
@@ -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
+3
View File
@@ -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)
} }
} }
+1
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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",
-58
View File
@@ -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() {
+68 -24
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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() {
+16 -3
View File
@@ -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
View File
@@ -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)
} }
+1 -1
View File
@@ -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