Compare commits

...
Author SHA1 Message Date
advplyr cdbbd56a58 Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2023-06-25 14:29:09 -05:00
advplyr d6a1eba657 Fix:Android file provider to use application id 2023-06-25 14:29:02 -05:00
advplyr da899bfddd iOS version bump 0.9.65 2023-06-24 16:16:56 -05:00
advplyr b7b746fb2a Version bump 0.9.65-beta 2023-06-24 15:38:56 -05:00
advplyr c8053355df Add:Open/close RSS feed for library items 2023-06-24 14:45:25 -05:00
advplyr 1ddb6eca00 Fix:iOS local podcast episode progress #719 2023-06-24 10:37:56 -05:00
advplyr f42c624cba Update:iOS saving progress for offline ebooks 2023-06-24 09:41:14 -05:00
advplyr f8fbeef9b7 Fix:Add podcast form cutoff by open player #750 2023-06-21 17:23:51 -05:00
advplyr 46b82a81ce Update:iOS deleting downloaded item redirects to downloads page 2023-06-20 16:34:44 -05:00
advplyr 48342e5bd3 Fix:iOS offline reading and initialization 2023-06-20 16:29:56 -05:00
advplyr 147f89f870 Add:iOS offline ebooks 2023-06-19 17:42:15 -05:00
advplyr 8b39ba9c92 Fix dependency issue 2023-06-19 15:28:39 -05:00
advplyr 13d32dc1f7 Fix:Android internal file paths using file provider 2023-06-19 15:09:12 -05:00
advplyr 0d1312e497 Fix:Android auto filter out ebook only from downloaded tab 2023-06-19 14:03:29 -05:00
advplyr ff4f8324e7 Update:Syncing playback time when media item is open in player 2023-06-19 12:37:44 -05:00
advplyr b4bf10d409 Set epubjs version to 0.3.88 to work on ios, fix epub reader height 2023-06-18 17:11:22 -05:00
advplyr ae37861f7d Add:iOS support offline episode chapters #675 2023-06-18 15:34:56 -05:00
advplyr 1da03cdd65 Add:Podcast episode chapters for local eps #675 2023-06-18 15:12:07 -05:00
advplyr a7443d0992 Fix:Persist Total Track setting in audio player #663 2023-06-18 14:53:01 -05:00
advplyr 5a953464ab Update:Remove toast for mark as finished #718 2023-06-18 14:26:04 -05:00
advplyr 953bde5a42 Update:Add epub ereader settings and table of contents 2023-06-18 13:31:38 -05:00
advplyr 481d0d0285 Update:Show status bar when ereader toolbar is shown 2023-06-18 08:59:35 -05:00
advplyr 7b3a005ad6 Update:Ereader fullscreen #731 2023-06-17 17:34:08 -05:00
advplyr 4f9d341f69 Update:Delete local item/episode options in context menu #690, android: remove scan options for local server items & remove option 2023-06-17 12:49:24 -05:00
advplyr 9e7d1d1617 Add:Android setting to use mp3 index seeking #638 2023-06-17 11:27:44 -05:00
advplyr 4f03c2a35d Update:Cleanup chapters modal & increase width 2023-06-13 17:15:28 -05:00
advplyr db3c5396e9 Update:UI/UX improvements for playlists 2023-06-11 17:03:03 -05:00
advplyr 455af239a1 Update:Increasing touch size for seek bar cursor #713 2023-06-11 14:20:33 -05:00
advplyr d8bc26f5f8 Add:Ebook files table and supplementary ereader 2023-06-11 13:36:19 -05:00
advplyr 543ac209e4 Add:Progress for comics #738 2023-06-11 11:12:52 -05:00
advplyr c8b5cefeb5 Fix:Playlist items playing from server and not using local copy #734 2023-06-09 17:05:29 -05:00
advplyr f1411011e6 Update:Android include DeviceInfo with local playback session 2023-06-08 17:19:31 -05:00
advplyr f3964dda17 Update:Home screen server loading to not block local item access #708 2023-06-04 17:14:26 -05:00
advplyr d207e88e18 Update:Save last scroll position for item pages #717 2023-06-04 15:52:36 -05:00
advplyr 2643e98fc0 Fix:Back button closes ebook #709 2023-06-04 15:38:26 -05:00
advplyr 410648e316 Fix:Android auto filter out ebook only items 2023-06-04 15:12:56 -05:00
advplyr 373221703d Update:Android download to internal storage option #635 2023-06-04 14:59:55 -05:00
advplyr fbcb8620f9 Update:iOS stream and download URLs to use new API endpoints 2023-06-04 12:21:40 -05:00
advplyr a8ac4970cc Update:Android downloads to use new API endpoint 2023-06-04 11:34:34 -05:00
advplyr 195e33ae32 Update:EReaders to use new ebook API routes 2023-06-04 11:13:05 -05:00
advplyr 9acf695802 Remove file copy 2023-06-03 17:28:37 -05:00
advplyr e6aaccfc74 Update:Android support storing downloads to internal app storage #635 2023-06-03 17:24:32 -05:00
advplyr 87c74fe78b Fix:Playback speed not persisting #532 2023-06-02 16:57:17 -05:00
advplyr 7dc319c1bd Fix:Android app widget updating #658 2023-06-02 16:29:52 -05:00
advplyr c093548b71 Update:Scale chapter times with playback rate #726 2023-06-01 17:36:52 -05:00
advplyr b067a2d3d5 Fix:Libraries modal background colors #723 2023-06-01 17:22:28 -05:00
advplyr a9ae7bfdb8 Update:Fix epub3 background color, increase padding on top of ereader 2023-06-01 17:19:16 -05:00
advplyr 9dcf22614c Update:Android remove stop action when notification cancelled 2023-06-01 16:28:25 -05:00
advplyr c7564d439e Update:Android exoplayer version, target SDK to 33, remove stop button from notification 2023-05-31 17:43:54 -05:00
advplyr 157dc01673 Fix:Android close playback session on server when streaming 2023-05-27 17:20:01 -05:00
advplyr cce6e1d0ab Update:Android local media page to show ebook file 2023-05-25 17:59:24 -05:00
advplyr b935cb5cf1 Fix:Android remove local sessions after sync 2023-05-25 17:23:35 -05:00
advplyr 9ab37ac525 Add:Saving progress for PDF ebooks #712 2023-05-25 17:09:28 -05:00
112 changed files with 16281 additions and 13158 deletions
+2 -7
View File
@@ -21,8 +21,6 @@ kotlin {
} }
android { android {
namespace 'com.audiobookshelf.app' namespace 'com.audiobookshelf.app'
buildFeatures { buildFeatures {
viewBinding true viewBinding true
@@ -35,8 +33,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 95 versionCode 96
versionName "0.9.64-beta" versionName "0.9.65-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.
@@ -86,9 +84,6 @@ dependencies {
implementation 'androidx.constraintlayout:constraintlayout:2.1.3' implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
// testImplementation "junit:junit:$junitVersion"
// androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
// androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins') implementation project(':capacitor-cordova-android-plugins')
implementation "androidx.core:core-ktx:$androidx_core_ktx_version" implementation "androidx.core:core-ktx:$androidx_core_ktx_version"
+1
View File
@@ -10,6 +10,7 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies { dependencies {
implementation project(':capacitor-app') implementation project(':capacitor-app')
implementation project(':capacitor-clipboard')
implementation project(':capacitor-dialog') implementation project(':capacitor-dialog')
implementation project(':capacitor-haptics') implementation project(':capacitor-haptics')
implementation project(':capacitor-network') implementation project(':capacitor-network')
+10
View File
@@ -92,6 +92,16 @@
<action android:name="android.media.browse.MediaBrowserService" /> <action android:name="android.media.browse.MediaBrowserService" />
</intent-filter> </intent-filter>
</service> </service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:grantUriPermissions="true"
android:exported="false">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application> </application>
</manifest> </manifest>
@@ -3,6 +3,10 @@
"pkg": "@capacitor/app", "pkg": "@capacitor/app",
"classpath": "com.capacitorjs.plugins.app.AppPlugin" "classpath": "com.capacitorjs.plugins.app.AppPlugin"
}, },
{
"pkg": "@capacitor/clipboard",
"classpath": "com.capacitorjs.plugins.clipboard.ClipboardPlugin"
},
{ {
"pkg": "@capacitor/dialog", "pkg": "@capacitor/dialog",
"classpath": "com.capacitorjs.plugins.dialog.DialogPlugin" "classpath": "com.capacitorjs.plugins.dialog.DialogPlugin"
@@ -12,10 +12,10 @@ import android.util.Log
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import com.anggrayudi.storage.SimpleStorage import com.anggrayudi.storage.SimpleStorage
import com.anggrayudi.storage.SimpleStorageHelper import com.anggrayudi.storage.SimpleStorageHelper
import com.audiobookshelf.app.plugins.AbsDatabase
import com.audiobookshelf.app.managers.DbManager import com.audiobookshelf.app.managers.DbManager
import com.audiobookshelf.app.player.PlayerNotificationService import com.audiobookshelf.app.player.PlayerNotificationService
import com.audiobookshelf.app.plugins.AbsAudioPlayer import com.audiobookshelf.app.plugins.AbsAudioPlayer
import com.audiobookshelf.app.plugins.AbsDatabase
import com.audiobookshelf.app.plugins.AbsDownloader import com.audiobookshelf.app.plugins.AbsDownloader
import com.audiobookshelf.app.plugins.AbsFileSystem import com.audiobookshelf.app.plugins.AbsFileSystem
import com.getcapacitor.BridgeActivity import com.getcapacitor.BridgeActivity
@@ -15,9 +15,7 @@ import android.widget.RemoteViews
import androidx.media.session.MediaButtonReceiver 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.managers.DbManager import com.audiobookshelf.app.managers.DbManager
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
import com.bumptech.glide.request.target.AppWidgetTarget import com.bumptech.glide.request.target.AppWidgetTarget
@@ -28,12 +26,9 @@ import com.bumptech.glide.request.transition.Transition
*/ */
class MediaPlayerWidget : AppWidgetProvider() { class MediaPlayerWidget : AppWidgetProvider() {
val tag = "MediaPlayerWidget" val tag = "MediaPlayerWidget"
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
// There may be multiple widgets active, so update all of them Log.d(tag, "onUpdate $appWidgetIds")
for (appWidgetId in appWidgetIds) { }
updateAppWidget(context, appWidgetManager, appWidgetId, null, false, PlayerNotificationService.isClosed)
}
}
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}")
@@ -51,32 +46,14 @@ class MediaPlayerWidget : AppWidgetProvider() {
} }
// Enter relevant functionality for when the first widget is created // Enter relevant functionality for when the first widget is created
DeviceManager.widgetUpdater = (object : WidgetEventEmitter { DeviceManager.initializeWidgetUpdater(context)
override fun onPlayerChanged(pns: PlayerNotificationService) {
val isPlaying = pns.currentPlayer.isPlaying
val appWidgetManager = AppWidgetManager.getInstance(context)
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
val ids = appWidgetManager.getAppWidgetIds(componentName)
val playbackSession = pns.getCurrentPlaybackSessionCopy()
for (widgetId in ids) {
updateAppWidget(context, appWidgetManager, widgetId, playbackSession, isPlaying, PlayerNotificationService.isClosed)
}
}
})
} }
override fun onDisabled(context: Context) {
// Enter relevant functionality for when the last widget is disabled
}
} }
internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, playbackSession: PlaybackSession?, isPlaying:Boolean, isAppClosed:Boolean) { internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, playbackSession: PlaybackSession?, isPlaying:Boolean, isAppClosed:Boolean) {
val tag = "MediaPlayerWidget" val tag = "MediaPlayerWidget"
val views = RemoteViews(context.packageName, R.layout.media_player_widget) val views = RemoteViews(context.packageName, R.layout.media_player_widget)
Log.i(tag, "updateAppWidget ${playbackSession?.displayTitle ?: "No Title"} isPlaying=$isPlaying isAppClosed=$isAppClosed")
val wholeWidgetClickI = Intent(context, MainActivity::class.java) val wholeWidgetClickI = Intent(context, MainActivity::class.java)
wholeWidgetClickI.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK wholeWidgetClickI.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
val wholeWidgetClickPI = PendingIntent.getActivity( val wholeWidgetClickPI = PendingIntent.getActivity(
@@ -100,7 +77,7 @@ internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManage
views.setOnClickPendingIntent(R.id.widgetBackground, wholeWidgetClickPI) views.setOnClickPendingIntent(R.id.widgetBackground, wholeWidgetClickPI)
val imageUri = playbackSession?.getCoverUri() ?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon) val imageUri = playbackSession?.getCoverUri(context) ?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
val awt: AppWidgetTarget = object : AppWidgetTarget(context.applicationContext, R.id.widgetAlbumArt, views, appWidgetId) { val awt: AppWidgetTarget = object : AppWidgetTarget(context.applicationContext, R.id.widgetAlbumArt, views, appWidgetId) {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) { override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
super.onResourceReady(resource, transition) super.onResourceReady(resource, transition)
@@ -53,7 +53,7 @@ class Podcast(
audioTracks.forEach { at -> audioTracks.forEach { at ->
if (episodes?.find{ it.audioTrack?.localFileId == at.localFileId } == null) { if (episodes?.find{ it.audioTrack?.localFileId == at.localFileId } == null) {
val localEpisodeId = "local_ep_" + at.localFileId val localEpisodeId = "local_ep_" + at.localFileId
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,null,null,at.title,null,null,null, null, null, at,at.duration,0, null, localEpisodeId) val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,null,null,at.title,null,null,null, null, null, at,null,at.duration,0, null, localEpisodeId)
episodes?.add(newEpisode) episodes?.add(newEpisode)
} }
} }
@@ -67,7 +67,7 @@ class Podcast(
@JsonIgnore @JsonIgnore
override fun addAudioTrack(audioTrack:AudioTrack) { override fun addAudioTrack(audioTrack:AudioTrack) {
val localEpisodeId = "local_ep_" + audioTrack.localFileId val localEpisodeId = "local_ep_" + audioTrack.localFileId
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,null,null,audioTrack.title,null,null,null, null, null,audioTrack,audioTrack.duration,0, null, localEpisodeId) val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,null,null,audioTrack.title,null,null,null, null, null,audioTrack,null,audioTrack.duration,0, null, localEpisodeId)
episodes?.add(newEpisode) episodes?.add(newEpisode)
var index = 1 var index = 1
@@ -96,7 +96,7 @@ class Podcast(
@JsonIgnore @JsonIgnore
fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode { fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode {
val localEpisodeId = "local_ep_" + episode.id val localEpisodeId = "local_ep_" + episode.id
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,null,null,audioTrack,audioTrack.duration,0, episode.id, localEpisodeId) val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,null,null,audioTrack,episode.chapters,audioTrack.duration,0, episode.id, localEpisodeId)
episodes?.add(newEpisode) episodes?.add(newEpisode)
var index = 1 var index = 1
@@ -251,15 +251,16 @@ data class PodcastEpisode(
var publishedAt:Long?, var publishedAt:Long?,
var audioFile:AudioFile?, var audioFile:AudioFile?,
var audioTrack:AudioTrack?, var audioTrack:AudioTrack?,
var chapters:List<BookChapter>?,
var duration:Double?, var duration:Double?,
var size:Long?, var size:Long?,
var serverEpisodeId:String?, // For local podcasts to match with server podcasts var serverEpisodeId:String?, // For local podcasts to match with server podcasts
var localEpisodeId:String? // For Android Auto server episodes with local copy var localEpisodeId:String? // For Android Auto server episodes with local copy
) { ) {
@JsonIgnore @JsonIgnore
fun getMediaDescription(libraryItem:LibraryItemWrapper, progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat { fun getMediaDescription(libraryItem:LibraryItemWrapper, progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat {
val coverUri = if (libraryItem is LocalLibraryItem) { val coverUri = if (libraryItem is LocalLibraryItem) {
libraryItem.getCoverUri() libraryItem.getCoverUri(ctx)
} else { } else {
(libraryItem as LibraryItem).getCoverUri() (libraryItem as LibraryItem).getCoverUri()
} }
@@ -83,7 +83,7 @@ data class LocalFolder(
) )
open class LibraryItemWrapper(var id:String) { open class LibraryItemWrapper(var id:String) {
@JsonIgnore @JsonIgnore
open fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat { return MediaDescriptionCompat.Builder().build() } open fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat { return MediaDescriptionCompat.Builder().build() }
} }
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
@@ -91,7 +91,6 @@ data class DeviceInfo(
var deviceId:String, var deviceId:String,
var manufacturer:String, var manufacturer:String,
var model:String, var model:String,
var brand:String,
var sdkVersion:Int, var sdkVersion:Int,
var clientVersion: String var clientVersion: String
) )
@@ -110,6 +109,7 @@ data class DeviceSettings(
var enableAltView:Boolean, var enableAltView:Boolean,
var jumpBackwardsTime:Int, var jumpBackwardsTime:Int,
var jumpForwardTime:Int, var jumpForwardTime:Int,
var enableMp3IndexSeeking:Boolean,
var disableShakeToResetSleepTimer:Boolean, var disableShakeToResetSleepTimer:Boolean,
var shakeSensitivity: ShakeSensitivitySetting, var shakeSensitivity: ShakeSensitivitySetting,
var lockOrientation: LockOrientationSetting, var lockOrientation: LockOrientationSetting,
@@ -131,6 +131,7 @@ data class DeviceSettings(
enableAltView = true, enableAltView = true,
jumpBackwardsTime = 10, jumpBackwardsTime = 10,
jumpForwardTime = 10, jumpForwardTime = 10,
enableMp3IndexSeeking = false,
disableShakeToResetSleepTimer = false, disableShakeToResetSleepTimer = false,
shakeSensitivity = ShakeSensitivitySetting.MEDIUM, shakeSensitivity = ShakeSensitivitySetting.MEDIUM,
lockOrientation = LockOrientationSetting.NONE, lockOrientation = LockOrientationSetting.NONE,
@@ -58,7 +58,7 @@ class LibraryItem(
} }
@JsonIgnore @JsonIgnore
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat { override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat {
val extras = Bundle() val extras = Bundle()
if (localLibraryItemId != null) { if (localLibraryItemId != null) {
@@ -9,6 +9,8 @@ import android.os.Bundle
import android.provider.MediaStore import android.provider.MediaStore
import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaDescriptionCompat
import android.util.Log import android.util.Log
import androidx.core.content.FileProvider
import androidx.core.net.toFile
import androidx.media.utils.MediaConstants import androidx.media.utils.MediaConstants
import com.audiobookshelf.app.BuildConfig import com.audiobookshelf.app.BuildConfig
import com.audiobookshelf.app.R import com.audiobookshelf.app.R
@@ -46,7 +48,10 @@ class LocalLibraryItem(
val isPodcast get() = mediaType == "podcast" val isPodcast get() = mediaType == "podcast"
@JsonIgnore @JsonIgnore
fun getCoverUri(): Uri { fun getCoverUri(ctx:Context): Uri {
if (coverContentUrl?.startsWith("file:") == true) {
return FileProvider.getUriForFile(ctx, "${BuildConfig.APPLICATION_ID}.fileprovider", Uri.parse(coverContentUrl).toFile())
}
return if (coverContentUrl != null) Uri.parse(coverContentUrl) else Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon) return if (coverContentUrl != null) Uri.parse(coverContentUrl) else Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
} }
@@ -74,7 +79,7 @@ class LocalLibraryItem(
} }
@JsonIgnore @JsonIgnore
fun getPlaybackSession(episode:PodcastEpisode?):PlaybackSession { fun getPlaybackSession(episode:PodcastEpisode?, deviceInfo:DeviceInfo):PlaybackSession {
val localEpisodeId = episode?.id val localEpisodeId = episode?.id
val sessionId = "play_local_${UUID.randomUUID()}" val sessionId = "play_local_${UUID.randomUUID()}"
@@ -85,19 +90,20 @@ class LocalLibraryItem(
val mediaMetadata = media.metadata val mediaMetadata = media.metadata
val chapters = if (mediaType == "book") (media as Book).chapters else mutableListOf() var chapters = if (mediaType == "book") (media as Book).chapters else mutableListOf()
var audioTracks = media.getAudioTracks() as MutableList<AudioTrack> var audioTracks = media.getAudioTracks() as MutableList<AudioTrack>
val authorName = mediaMetadata.getAuthorDisplayName() val authorName = mediaMetadata.getAuthorDisplayName()
val displayTitle = episode?.title ?: mediaMetadata.title val displayTitle = episode?.title ?: mediaMetadata.title
var duration = getDuration() var duration = getDuration()
if (episode != null) { // Get podcast episode audio track if (episode != null) { // Get podcast episode audio track
episode.audioTrack?.let { at -> mutableListOf(at) }?.let { tracks -> audioTracks = tracks } episode.audioTrack?.let { at -> mutableListOf(at) }?.let { tracks -> audioTracks = tracks }
chapters = episode.chapters
duration = episode.audioTrack?.duration ?: 0.0 duration = episode.audioTrack?.duration ?: 0.0
Log.d("LocalLibraryItem", "getPlaybackSession: Got podcast episode audio track ${audioTracks.size}") Log.d("LocalLibraryItem", "getPlaybackSession: Got podcast episode audio track ${audioTracks.size}")
} }
val dateNow = System.currentTimeMillis() val dateNow = System.currentTimeMillis()
return PlaybackSession(sessionId,serverUserId,libraryItemId,episode?.serverEpisodeId, mediaType, mediaMetadata, chapters ?: mutableListOf(), displayTitle, authorName,null,duration,PLAYMETHOD_LOCAL,dateNow,0L,0L, audioTracks,currentTime,null,this,localEpisodeId,serverConnectionConfigId, serverAddress, "exo-player") return PlaybackSession(sessionId,serverUserId,libraryItemId,episode?.serverEpisodeId, mediaType, mediaMetadata, deviceInfo,chapters ?: mutableListOf(), displayTitle, authorName,null,duration,PLAYMETHOD_LOCAL,dateNow,0L,0L, audioTracks,currentTime,null,this,localEpisodeId,serverConnectionConfigId, serverAddress, "exo-player")
} }
@JsonIgnore @JsonIgnore
@@ -106,18 +112,16 @@ class LocalLibraryItem(
} }
@JsonIgnore @JsonIgnore
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx:Context?): MediaDescriptionCompat { override fun getMediaDescription(progress:MediaProgressWrapper?, ctx:Context): MediaDescriptionCompat {
val coverUri = getCoverUri() val coverUri = getCoverUri(ctx)
var bitmap:Bitmap? = null var bitmap:Bitmap? = null
if (coverContentUrl != null) { if (coverContentUrl != null) {
ctx?.let { bitmap = if (Build.VERSION.SDK_INT < 28) {
bitmap = if (Build.VERSION.SDK_INT < 28) { MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
MediaStore.Images.Media.getBitmap(it.contentResolver, coverUri) } else {
} else { val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, coverUri)
val source: ImageDecoder.Source = ImageDecoder.createSource(it.contentResolver, coverUri) ImageDecoder.decodeBitmap(source)
ImageDecoder.decodeBitmap(source)
}
} }
} }
@@ -6,6 +6,8 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.MediaStore import android.provider.MediaStore
import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.MediaMetadataCompat
import androidx.core.content.FileProvider
import androidx.core.net.toFile
import com.audiobookshelf.app.BuildConfig import com.audiobookshelf.app.BuildConfig
import com.audiobookshelf.app.R import com.audiobookshelf.app.R
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
@@ -27,6 +29,7 @@ class PlaybackSession(
var episodeId:String?, var episodeId:String?,
var mediaType:String, var mediaType:String,
var mediaMetadata:MediaTypeMetadata, var mediaMetadata:MediaTypeMetadata,
var deviceInfo:DeviceInfo,
var chapters:List<BookChapter>, var chapters:List<BookChapter>,
var displayTitle: String?, var displayTitle: String?,
var displayAuthor: String?, var displayAuthor: String?,
@@ -137,8 +140,15 @@ class PlaybackSession(
} }
@JsonIgnore @JsonIgnore
fun getCoverUri(): Uri { fun getCoverUri(ctx:Context): Uri {
if (localLibraryItem?.coverContentUrl != null) return Uri.parse(localLibraryItem?.coverContentUrl) ?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon) if (localLibraryItem?.coverContentUrl != null) {
var coverUri = Uri.parse(localLibraryItem?.coverContentUrl.toString())
if (coverUri.toString().startsWith("file:")) {
coverUri = FileProvider.getUriForFile(ctx, "${BuildConfig.APPLICATION_ID}.fileprovider", coverUri.toFile())
}
return coverUri ?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
}
if (coverPath == null) return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon) if (coverPath == null) return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
return Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}") return Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")
@@ -152,6 +162,8 @@ class PlaybackSession(
@JsonIgnore @JsonIgnore
fun getMediaMetadataCompat(ctx: Context): MediaMetadataCompat { fun getMediaMetadataCompat(ctx: Context): MediaMetadataCompat {
val coverUri = getCoverUri(ctx)
val metadataBuilder = MediaMetadataCompat.Builder() val metadataBuilder = MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, displayTitle) .putString(MediaMetadataCompat.METADATA_KEY_TITLE, displayTitle)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle) .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle)
@@ -162,16 +174,16 @@ class PlaybackSession(
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, displayAuthor) .putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, displayAuthor)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, displayAuthor) .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, displayAuthor)
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id) .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getCoverUri().toString()) .putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString())
.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, getCoverUri().toString()) .putString(MediaMetadataCompat.METADATA_KEY_ART_URI, coverUri.toString())
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, getCoverUri().toString()) .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString())
// Local covers get bitmap // Local covers get bitmap
if (localLibraryItem?.coverContentUrl != null) { if (localLibraryItem?.coverContentUrl != null) {
val bitmap = if (Build.VERSION.SDK_INT < 28) { val bitmap = if (Build.VERSION.SDK_INT < 28) {
MediaStore.Images.Media.getBitmap(ctx.contentResolver, getCoverUri()) MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
} else { } else {
val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, getCoverUri()) val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, coverUri)
ImageDecoder.decodeBitmap(source) ImageDecoder.decodeBitmap(source)
} }
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap) metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
@@ -182,7 +194,9 @@ class PlaybackSession(
} }
@JsonIgnore @JsonIgnore
fun getExoMediaMetadata(audioTrack:AudioTrack): MediaMetadata { fun getExoMediaMetadata(ctx:Context): MediaMetadata {
val coverUri = getCoverUri(ctx)
val metadataBuilder = MediaMetadata.Builder() val metadataBuilder = MediaMetadata.Builder()
.setTitle(displayTitle) .setTitle(displayTitle)
.setDisplayTitle(displayTitle) .setDisplayTitle(displayTitle)
@@ -191,20 +205,18 @@ class PlaybackSession(
.setSubtitle(displayAuthor) .setSubtitle(displayAuthor)
.setAlbumTitle(displayAuthor) .setAlbumTitle(displayAuthor)
.setDescription(displayAuthor) .setDescription(displayAuthor)
.setArtworkUri(getCoverUri()) .setArtworkUri(coverUri)
.setMediaType(MediaMetadata.MEDIA_TYPE_AUDIO_BOOK)
val contentUri = this.getContentUri(audioTrack)
metadataBuilder.setMediaUri(contentUri)
return metadataBuilder.build() return metadataBuilder.build()
} }
@JsonIgnore @JsonIgnore
fun getMediaItems():List<MediaItem> { fun getMediaItems(ctx:Context):List<MediaItem> {
val mediaItems:MutableList<MediaItem> = mutableListOf() val mediaItems:MutableList<MediaItem> = mutableListOf()
for (audioTrack in audioTracks) { for (audioTrack in audioTracks) {
val mediaMetadata = this.getExoMediaMetadata(audioTrack) val mediaMetadata = this.getExoMediaMetadata(ctx)
val mediaUri = this.getContentUri(audioTrack) val mediaUri = this.getContentUri(audioTrack)
val mimeType = audioTrack.mimeType val mimeType = audioTrack.mimeType
@@ -252,7 +264,7 @@ class PlaybackSession(
@JsonIgnore @JsonIgnore
fun clone():PlaybackSession { fun clone():PlaybackSession {
return PlaybackSession(id,userId,libraryItemId,episodeId,mediaType,mediaMetadata,chapters,displayTitle,displayAuthor,coverPath,duration,playMethod,startedAt,updatedAt,timeListening,audioTracks,currentTime,libraryItem,localLibraryItem,localEpisodeId,serverConnectionConfigId,serverAddress, mediaPlayer) return PlaybackSession(id,userId,libraryItemId,episodeId,mediaType,mediaMetadata,deviceInfo,chapters,displayTitle,displayAuthor,coverPath,duration,playMethod,startedAt,updatedAt,timeListening,audioTracks,currentTime,libraryItem,localLibraryItem,localEpisodeId,serverConnectionConfigId,serverAddress, mediaPlayer)
} }
@JsonIgnore @JsonIgnore
@@ -1,15 +1,20 @@
package com.audiobookshelf.app.device package com.audiobookshelf.app.device
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context import android.content.Context
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.util.Log import android.util.Log
import com.audiobookshelf.app.MediaPlayerWidget
import com.audiobookshelf.app.data.* import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.managers.DbManager import com.audiobookshelf.app.managers.DbManager
import com.audiobookshelf.app.player.PlayerNotificationService import com.audiobookshelf.app.player.PlayerNotificationService
import com.audiobookshelf.app.updateAppWidget
interface WidgetEventEmitter { interface WidgetEventEmitter {
fun onPlayerChanged(pns:PlayerNotificationService) fun onPlayerChanged(pns:PlayerNotificationService)
fun onPlayerClosed()
} }
object DeviceManager { object DeviceManager {
@@ -76,4 +81,31 @@ object DeviceManager {
deviceData.lastPlaybackSession = playbackSession deviceData.lastPlaybackSession = playbackSession
dbManager.saveDeviceData(deviceData) dbManager.saveDeviceData(deviceData)
} }
fun initializeWidgetUpdater(context:Context) {
Log.d(tag, "Initializing widget updater")
widgetUpdater = (object : WidgetEventEmitter {
override fun onPlayerChanged(pns: PlayerNotificationService) {
val isPlaying = pns.currentPlayer.isPlaying
val appWidgetManager = AppWidgetManager.getInstance(context)
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
val ids = appWidgetManager.getAppWidgetIds(componentName)
val playbackSession = pns.getCurrentPlaybackSessionCopy()
for (widgetId in ids) {
updateAppWidget(context, appWidgetManager, widgetId, playbackSession, isPlaying, PlayerNotificationService.isClosed)
}
}
override fun onPlayerClosed() {
val appWidgetManager = AppWidgetManager.getInstance(context)
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
val ids = appWidgetManager.getAppWidgetIds(componentName)
for (widgetId in ids) {
updateAppWidget(context, appWidgetManager, widgetId, deviceData.lastPlaybackSession, false, PlayerNotificationService.isClosed)
}
}
})
}
} }
@@ -15,6 +15,7 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue import com.fasterxml.jackson.module.kotlin.readValue
import com.getcapacitor.JSObject import com.getcapacitor.JSObject
import org.json.JSONException import org.json.JSONException
import java.io.File
class FolderScanner(var ctx: Context) { class FolderScanner(var ctx: Context) {
private val tag = "FolderScanner" private val tag = "FolderScanner"
@@ -231,8 +232,189 @@ class FolderScanner(var ctx: Context) {
} }
} }
private fun scanInternalDownloadItem(downloadItem:DownloadItem, cb: (DownloadItemScanResult?) -> Unit) {
val localLibraryItemId = "local_${downloadItem.libraryItemId}"
var localEpisodeId:String? = null
var localLibraryItem:LocalLibraryItem?
if (downloadItem.mediaType == "book") {
localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, downloadItem.itemFolderPath, downloadItem.itemFolderPath, "", false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true, downloadItem.serverConnectionConfigId, downloadItem.serverAddress, downloadItem.serverUserId, downloadItem.libraryItemId)
} else {
// Lookup or create podcast local library item
localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
if (localLibraryItem == null) {
Log.d(tag, "[FolderScanner] Podcast local library item not created yet for ${downloadItem.media.metadata.title}")
localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, downloadItem.itemFolderPath, downloadItem.itemFolderPath, "", false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true,downloadItem.serverConnectionConfigId,downloadItem.serverAddress,downloadItem.serverUserId,downloadItem.libraryItemId)
}
}
val audioTracks:MutableList<AudioTrack> = mutableListOf()
var foundEBookFile = false
downloadItem.downloadItemParts.forEach { downloadItemPart ->
Log.d(tag, "Scan internal storage item with finalDestinationUri=${downloadItemPart.finalDestinationUri}")
val file = File(downloadItemPart.finalDestinationPath)
Log.d(tag, "Scan internal storage item created file ${file.name}")
if (file == null) {
Log.e(tag, "scanInternalDownloadItem: Null docFile for path ${downloadItemPart.finalDestinationPath}")
} else {
if (downloadItemPart.audioTrack != null) {
val audioTrackFromServer = downloadItemPart.audioTrack
Log.d(
tag,
"scanInternalDownloadItem: Audio Track from Server index = ${audioTrackFromServer.index}"
)
val localFileId = DeviceManager.getBase64Id(file.name)
Log.d(tag, "Scan internal file localFileId=$localFileId")
val localFile = LocalFile(
localFileId,
file.name,
downloadItemPart.finalDestinationUri.toString(),
file.getBasePath(ctx),
file.absolutePath,
file.getSimplePath(ctx),
file.mimeType,
file.length()
)
localLibraryItem.localFiles.add(localFile)
// Create new audio track
val track = AudioTrack(
audioTrackFromServer.index,
audioTrackFromServer.startOffset,
audioTrackFromServer.duration,
localFile.filename ?: "",
localFile.contentUrl,
localFile.mimeType ?: "",
null,
true,
localFileId,
null,
audioTrackFromServer.index
)
audioTracks.add(track)
Log.d(
tag,
"scanInternalDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}"
)
// Add podcast episodes to library
downloadItemPart.episode?.let { podcastEpisode ->
val podcast = localLibraryItem.media as Podcast
val newEpisode = podcast.addEpisode(track, podcastEpisode)
localEpisodeId = newEpisode.id
Log.d(
tag,
"scanInternalDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}"
)
}
} else if (downloadItemPart.ebookFile != null) {
foundEBookFile = true
Log.d(tag, "scanInternalDownloadItem: Ebook file found with mimetype=${file.mimeType}")
val localFileId = DeviceManager.getBase64Id(file.name)
val localFile = LocalFile(
localFileId,
file.name,
Uri.fromFile(file).toString(),
file.getBasePath(ctx),
file.absolutePath,
file.getSimplePath(ctx),
file.mimeType,
file.length()
)
localLibraryItem.localFiles.add(localFile)
val ebookFile = EBookFile(
downloadItemPart.ebookFile.ino,
downloadItemPart.ebookFile.metadata,
downloadItemPart.ebookFile.ebookFormat,
true,
localFileId,
localFile.contentUrl
)
(localLibraryItem.media as Book).ebookFile = ebookFile
Log.d(tag, "scanInternalDownloadItem: Ebook file added to lli ${localFile.contentUrl}")
} else {
val localFileId = DeviceManager.getBase64Id(file.name)
val localFile = LocalFile(localFileId,file.name,Uri.fromFile(file).toString(),file.getBasePath(ctx),file.absolutePath,file.getSimplePath(ctx),file.mimeType,file.length())
localLibraryItem.coverAbsolutePath = localFile.absolutePath
localLibraryItem.coverContentUrl = localFile.contentUrl
localLibraryItem.localFiles.add(localFile)
}
}
}
if (audioTracks.isEmpty() && !foundEBookFile) {
Log.d(tag, "scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}")
return cb(null)
}
// For books sort audio tracks then set
if (downloadItem.mediaType == "book") {
audioTracks.sortBy { it.index }
var indexCheck = 1
var startOffset = 0.0
audioTracks.forEach { audioTrack ->
if (audioTrack.index != indexCheck || audioTrack.startOffset != startOffset) {
audioTrack.index = indexCheck
audioTrack.startOffset = startOffset
}
indexCheck++
startOffset += audioTrack.duration
}
localLibraryItem.media.setAudioTracks(audioTracks)
}
val downloadItemScanResult = DownloadItemScanResult(localLibraryItem,null)
// If library item had media progress then make local media progress and save
downloadItem.userMediaProgress?.let { mediaProgress ->
val localMediaProgressId = if (downloadItem.episodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
val newLocalMediaProgress = LocalMediaProgress(
id = localMediaProgressId,
localLibraryItemId = localLibraryItemId,
localEpisodeId = localEpisodeId,
duration = mediaProgress.duration,
progress = mediaProgress.progress,
currentTime = mediaProgress.currentTime,
isFinished = false,
ebookLocation = mediaProgress.ebookLocation,
ebookProgress = mediaProgress.ebookProgress,
lastUpdate = mediaProgress.lastUpdate,
startedAt = mediaProgress.startedAt,
finishedAt = mediaProgress.finishedAt,
serverConnectionConfigId = downloadItem.serverConnectionConfigId,
serverAddress = downloadItem.serverAddress,
serverUserId = downloadItem.serverUserId,
libraryItemId = downloadItem.libraryItemId,
episodeId = downloadItem.episodeId)
Log.d(tag, "scanLibraryItemFolder: Saving local media progress ${newLocalMediaProgress.id} at progress ${newLocalMediaProgress.progress}")
DeviceManager.dbManager.saveLocalMediaProgress(newLocalMediaProgress)
downloadItemScanResult.localMediaProgress = newLocalMediaProgress
}
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
cb(downloadItemScanResult)
}
// Scan item after download and create local library item // Scan item after download and create local library item
fun scanDownloadItem(downloadItem: DownloadItem, cb: (DownloadItemScanResult?) -> Unit) { fun scanDownloadItem(downloadItem: DownloadItem, cb: (DownloadItemScanResult?) -> Unit) {
// If downloading to internal storage handle separately
if (downloadItem.isInternalStorage) {
scanInternalDownloadItem(downloadItem, cb)
return
}
val folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl)) val folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl))
val foldersFound = folderDf?.search(true, DocumentFileType.FOLDER) ?: mutableListOf() val foldersFound = folderDf?.search(true, DocumentFileType.FOLDER) ?: mutableListOf()
@@ -22,6 +22,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File
import java.io.FileOutputStream
import java.util.* import java.util.*
class DownloadItemManager(var downloadManager:DownloadManager, private var folderScanner: FolderScanner, var mainActivity: MainActivity, private var clientEventEmitter:DownloadEventEmitter) { class DownloadItemManager(var downloadManager:DownloadManager, private var folderScanner: FolderScanner, var mainActivity: MainActivity, private var clientEventEmitter:DownloadEventEmitter) {
@@ -44,6 +46,11 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
fun onDownloadItemComplete(jsobj:JSObject) fun onDownloadItemComplete(jsobj:JSObject)
} }
interface InternalProgressCallback {
fun onProgress(totalBytesWritten:Long, progress: Long)
fun onComplete(failed: Boolean)
}
companion object { companion object {
var isDownloading:Boolean = false var isDownloading:Boolean = false
} }
@@ -65,11 +72,32 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
if (nextDownloadItemParts.size > 0) { if (nextDownloadItemParts.size > 0) {
nextDownloadItemParts.forEach { nextDownloadItemParts.forEach {
val dlRequest = it.getDownloadRequest() if (it.isInternalStorage) {
val downloadId = downloadManager.enqueue(dlRequest) val file = File(it.finalDestinationPath)
it.downloadId = downloadId file.parentFile?.mkdirs()
Log.d(tag, "checkUpdateDownloadQueue: Starting download item part, downloadId=$downloadId")
currentDownloadItemParts.add(it) val fileOutputStream = FileOutputStream(it.finalDestinationPath)
val internalProgressCallback = (object : InternalProgressCallback {
override fun onProgress(totalBytesWritten:Long, progress: Long) {
it.bytesDownloaded = totalBytesWritten
it.progress = progress
}
override fun onComplete(failed:Boolean) {
it.failed = failed
it.completed = true
}
})
Log.d(tag, "Start internal download to destination path ${it.finalDestinationPath} from ${it.serverUrl}")
InternalDownloadManager(fileOutputStream, internalProgressCallback).download(it.serverUrl)
currentDownloadItemParts.add(it)
} else {
val dlRequest = it.getDownloadRequest()
val downloadId = downloadManager.enqueue(dlRequest)
it.downloadId = downloadId
Log.d(tag, "checkUpdateDownloadQueue: Starting download item part, downloadId=$downloadId")
currentDownloadItemParts.add(it)
}
} }
} }
@@ -89,14 +117,25 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
isDownloading = true isDownloading = true
while (currentDownloadItemParts.size > 0) { while (currentDownloadItemParts.size > 0) {
val itemParts = currentDownloadItemParts.filter { !it.isMoving }.map { it } val itemParts = currentDownloadItemParts.filter { !it.isMoving }.map { it }
for (downloadItemPart in itemParts) { for (downloadItemPart in itemParts) {
val downloadCheckStatus = checkDownloadItemPart(downloadItemPart) if (downloadItemPart.isInternalStorage) {
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart) clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
// Will move to final destination, remove current item parts, and check if download item is finished if (downloadItemPart.completed) {
handleDownloadItemPartCheck(downloadCheckStatus, downloadItemPart) val downloadItem = downloadItemQueue.find { it.id == downloadItemPart.downloadItemId }
downloadItem?.let {
checkDownloadItemFinished(it)
}
currentDownloadItemParts.remove(downloadItemPart)
}
} else {
val downloadCheckStatus = checkDownloadItemPart(downloadItemPart)
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
// Will move to final destination, remove current item parts, and check if download item is finished
handleDownloadItemPartCheck(downloadCheckStatus, downloadItemPart)
}
} }
delay(500) delay(500)
@@ -166,7 +205,6 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
Log.e(tag, "Download item part finished but download item not found ${downloadItemPart.filename}") Log.e(tag, "Download item part finished but download item not found ${downloadItemPart.filename}")
currentDownloadItemParts.remove(downloadItemPart) currentDownloadItemParts.remove(downloadItemPart)
} else if (downloadCheckStatus == DownloadCheckStatus.Successful) { } else if (downloadCheckStatus == DownloadCheckStatus.Successful) {
val file = DocumentFileCompat.fromUri(mainActivity, downloadItemPart.destinationUri) val file = DocumentFileCompat.fromUri(mainActivity, downloadItemPart.destinationUri)
Log.d(tag, "DOWNLOAD: DESTINATION URI ${downloadItemPart.destinationUri}") Log.d(tag, "DOWNLOAD: DESTINATION URI ${downloadItemPart.destinationUri}")
@@ -0,0 +1,74 @@
package com.audiobookshelf.app.managers
import android.util.Log
import com.google.common.net.HttpHeaders.CONTENT_LENGTH
import okhttp3.*
import java.io.*
import java.util.*
class InternalDownloadManager(outputStream:FileOutputStream, private val progressCallback: DownloadItemManager.InternalProgressCallback) : AutoCloseable {
private val tag = "InternalDownloadManager"
private val client: OkHttpClient = OkHttpClient()
private val writer = BinaryFileWriter(outputStream, progressCallback)
@Throws(IOException::class)
fun download(url:String) {
val request: Request = Request.Builder().url(url).build()
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
Log.e(tag, "download URL $url FAILED")
progressCallback.onComplete(true)
}
override fun onResponse(call: Call, response: Response) {
val responseBody: ResponseBody = response.body
?: throw IllegalStateException("Response doesn't contain a file")
val length: Long = (response.header(CONTENT_LENGTH, "1") ?: "0").toLong()
writer.write(responseBody.byteStream(), length)
}
})
}
@Throws(Exception::class)
override fun close() {
writer.close()
}
}
class BinaryFileWriter(outputStream: OutputStream, progressCallback: DownloadItemManager.InternalProgressCallback) :
AutoCloseable {
private val outputStream: OutputStream
private val progressCallback: DownloadItemManager.InternalProgressCallback
init {
this.outputStream = outputStream
this.progressCallback = progressCallback
}
@Throws(IOException::class)
fun write(inputStream: InputStream?, length: Long): Long {
BufferedInputStream(inputStream).use { input ->
val dataBuffer = ByteArray(CHUNK_SIZE)
var readBytes: Int
var totalBytes: Long = 0
while (input.read(dataBuffer).also { readBytes = it } != -1) {
totalBytes += readBytes.toLong()
outputStream.write(dataBuffer, 0, readBytes)
progressCallback.onProgress(totalBytes, (totalBytes * 100L) / length)
}
progressCallback.onComplete(false)
return totalBytes
}
}
@Throws(IOException::class)
override fun close() {
outputStream.close()
}
companion object {
private const val CHUNK_SIZE = 1024
}
}
@@ -111,7 +111,10 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
cb(serverItemsInProgress) cb(serverItemsInProgress)
} else { } else {
apiHandler.getAllItemsInProgress { itemsInProgress -> apiHandler.getAllItemsInProgress { itemsInProgress ->
serverItemsInProgress = itemsInProgress serverItemsInProgress = itemsInProgress.filter {
val libraryItem = it.libraryItemWrapper as LibraryItem
libraryItem.checkHasTracks()
}
cb(serverItemsInProgress) cb(serverItemsInProgress)
} }
} }
@@ -394,7 +397,7 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
fun play(libraryItemWrapper:LibraryItemWrapper, episode:PodcastEpisode?, playItemRequestPayload:PlayItemRequestPayload, cb: (PlaybackSession?) -> Unit) { fun play(libraryItemWrapper:LibraryItemWrapper, episode:PodcastEpisode?, playItemRequestPayload:PlayItemRequestPayload, cb: (PlaybackSession?) -> Unit) {
if (libraryItemWrapper is LocalLibraryItem) { if (libraryItemWrapper is LocalLibraryItem) {
cb(libraryItemWrapper.getPlaybackSession(episode)) cb(libraryItemWrapper.getPlaybackSession(episode, playItemRequestPayload.deviceInfo))
} else { } else {
val libraryItem = libraryItemWrapper as LibraryItem val libraryItem = libraryItemWrapper as LibraryItem
apiHandler.playLibraryItem(libraryItem.id,episode?.id ?: "", playItemRequestPayload) { apiHandler.playLibraryItem(libraryItem.id,episode?.id ?: "", playItemRequestPayload) {
@@ -38,8 +38,8 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
var currentLocalMediaProgress: LocalMediaProgress? = null var currentLocalMediaProgress: LocalMediaProgress? = null
private val currentDisplayTitle get() = currentPlaybackSession?.displayTitle ?: "Unset" private val currentDisplayTitle get() = currentPlaybackSession?.displayTitle ?: "Unset"
private val currentIsLocal get() = currentPlaybackSession?.isLocal == true val currentIsLocal get() = currentPlaybackSession?.isLocal == true
private val currentSessionId get() = currentPlaybackSession?.id ?: "" val currentSessionId get() = currentPlaybackSession?.id ?: ""
private val currentPlaybackDuration get() = currentPlaybackSession?.duration ?: 0.0 private val currentPlaybackDuration get() = currentPlaybackSession?.duration ?: 0.0
fun start(playbackSession:PlaybackSession) { fun start(playbackSession:PlaybackSession) {
@@ -215,7 +215,6 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
val listeningTimeToAdd = diffSinceLastSync / 1000L val listeningTimeToAdd = diffSinceLastSync / 1000L
val syncData = MediaProgressSyncData(listeningTimeToAdd,currentPlaybackDuration,currentTime) val syncData = MediaProgressSyncData(listeningTimeToAdd,currentPlaybackDuration,currentTime)
currentPlaybackSession?.syncData(syncData) currentPlaybackSession?.syncData(syncData)
if (currentPlaybackSession?.progress?.isNaN() == true) { if (currentPlaybackSession?.progress?.isNaN() == true) {
@@ -243,11 +242,6 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
// Send sync to server also if connected to this server and local item belongs to this server // Send sync to server also if connected to this server and local item belongs to this server
if (hasNetworkConnection && shouldSyncServer && !it.libraryItemId.isNullOrEmpty() && it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) { if (hasNetworkConnection && shouldSyncServer && !it.libraryItemId.isNullOrEmpty() && it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
apiHandler.sendLocalProgressSync(it) { syncSuccess, errorMsg -> apiHandler.sendLocalProgressSync(it) { syncSuccess, errorMsg ->
Log.d(
tag,
"Local progress sync data sent to server $currentDisplayTitle for time $currentTime"
)
if (syncSuccess) { if (syncSuccess) {
failedSyncs = 0 failedSyncs = 0
playerNotificationService.alertSyncSuccess() playerNotificationService.alertSyncSuccess()
@@ -21,6 +21,9 @@ data class DownloadItem(
val media: MediaType, val media: MediaType,
val downloadItemParts: MutableList<DownloadItemPart> val downloadItemParts: MutableList<DownloadItemPart>
) { ) {
@get:JsonIgnore
val isInternalStorage get() = localFolder.id.startsWith("internal-")
@get:JsonIgnore @get:JsonIgnore
val isDownloadFinished get() = !downloadItemParts.any { !it.completed || it.isMoving } val isDownloadFinished get() = !downloadItemParts.any { !it.completed || it.isMoving }
@@ -73,6 +73,12 @@ data class DownloadItemPart(
} }
} }
@get:JsonIgnore
val isInternalStorage get() = localFolderId.startsWith("internal-")
@get:JsonIgnore
val serverUrl get() = "${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}"
@JsonIgnore @JsonIgnore
fun getDownloadRequest(): DownloadManager.Request { fun getDownloadRequest(): DownloadManager.Request {
val dlRequest = DownloadManager.Request(uri) val dlRequest = DownloadManager.Request(uri)
@@ -123,7 +123,7 @@ class CastManager constructor(val mainActivity:Activity) {
} }
fun startRouteScan(connListener:ChromecastListener) { fun startRouteScan(connListener:ChromecastListener) {
var callback = object : ScanCallback() { val callback = object : ScanCallback() {
override fun onRouteUpdate(routes: List<MediaRouter.RouteInfo>?) { override fun onRouteUpdate(routes: List<MediaRouter.RouteInfo>?) {
Log.d(tag, "CAST On ROUTE UPDATED ${routes?.size} | ${getContext().castState}") Log.d(tag, "CAST On ROUTE UPDATED ${routes?.size} | ${getContext().castState}")
// if the routes have changed, we may have an available device // if the routes have changed, we may have an available device
@@ -136,7 +136,7 @@ class CastManager constructor(val mainActivity:Activity) {
// Let the client know a receiver is available // Let the client know a receiver is available
connListener.onReceiverAvailableUpdate(true) connListener.onReceiverAvailableUpdate(true)
// Since we have a receiver we may also have an active session // Since we have a receiver we may also have an active session
var session = getSessionManager()?.currentCastSession val session = getSessionManager()?.currentCastSession
// If we do have a session // If we do have a session
if (session != null) { if (session != null) {
// Let the client know // Let the client know
@@ -246,15 +246,15 @@ class CastManager constructor(val mainActivity:Activity) {
onRouteUpdate(outRoutes) onRouteUpdate(outRoutes)
} }
override fun onRouteAdded(router: MediaRouter?, route: MediaRouter.RouteInfo?) { override fun onRouteAdded(router: MediaRouter, route: MediaRouter.RouteInfo) {
onFilteredRouteUpdate() onFilteredRouteUpdate()
} }
override fun onRouteChanged(router: MediaRouter?, route: MediaRouter.RouteInfo?) { override fun onRouteChanged(router: MediaRouter, route: MediaRouter.RouteInfo) {
onFilteredRouteUpdate() onFilteredRouteUpdate()
} }
override fun onRouteRemoved(router: MediaRouter?, route: MediaRouter.RouteInfo?) { override fun onRouteRemoved(router: MediaRouter, route: MediaRouter.RouteInfo) {
onFilteredRouteUpdate() onFilteredRouteUpdate()
} }
} }
@@ -11,12 +11,12 @@ import com.google.android.exoplayer2.*
import com.google.android.exoplayer2.C.TrackType import com.google.android.exoplayer2.C.TrackType
import com.google.android.exoplayer2.MediaMetadata import com.google.android.exoplayer2.MediaMetadata
import com.google.android.exoplayer2.Player.* import com.google.android.exoplayer2.Player.*
import com.google.android.exoplayer2.TracksInfo.TrackGroupInfo import com.google.android.exoplayer2.Tracks.Group
import com.google.android.exoplayer2.audio.AudioAttributes import com.google.android.exoplayer2.audio.AudioAttributes
import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener
import com.google.android.exoplayer2.source.TrackGroup import com.google.android.exoplayer2.source.TrackGroup
import com.google.android.exoplayer2.source.TrackGroupArray import com.google.android.exoplayer2.source.TrackGroupArray
import com.google.android.exoplayer2.text.Cue import com.google.android.exoplayer2.text.CueGroup
import com.google.android.exoplayer2.trackselection.TrackSelection import com.google.android.exoplayer2.trackselection.TrackSelection
import com.google.android.exoplayer2.trackselection.TrackSelectionArray import com.google.android.exoplayer2.trackselection.TrackSelectionArray
import com.google.android.exoplayer2.trackselection.TrackSelectionParameters import com.google.android.exoplayer2.trackselection.TrackSelectionParameters
@@ -49,7 +49,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
private var myPlayWhenReady:Boolean = false private var myPlayWhenReady:Boolean = false
private var playbackParameters:PlaybackParameters = PlaybackParameters.DEFAULT private var playbackParameters:PlaybackParameters = PlaybackParameters.DEFAULT
private var myAvailableCommands: Commands private var myAvailableCommands: Commands
private var myCurrentTracksInfo: TracksInfo private var myCurrentTracksInfo: Tracks
private var myCurrentTrackGroups: TrackGroupArray private var myCurrentTrackGroups: TrackGroupArray
private var myCurrentTrackSelections: TrackSelectionArray private var myCurrentTrackSelections: TrackSelectionArray
@@ -76,7 +76,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
COMMAND_GET_MEDIA_ITEMS_METADATA, COMMAND_GET_MEDIA_ITEMS_METADATA,
COMMAND_SET_MEDIA_ITEMS_METADATA, COMMAND_SET_MEDIA_ITEMS_METADATA,
COMMAND_CHANGE_MEDIA_ITEMS, COMMAND_CHANGE_MEDIA_ITEMS,
COMMAND_GET_TRACK_INFOS) COMMAND_GET_TRACKS)
.build() .build()
var currentPlaybackState = Player.STATE_IDLE var currentPlaybackState = Player.STATE_IDLE
@@ -95,7 +95,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
Log.d(tag, "Init CastPlayer") Log.d(tag, "Init CastPlayer")
myCurrentTrackGroups = TrackGroupArray.EMPTY myCurrentTrackGroups = TrackGroupArray.EMPTY
myCurrentTrackSelections = EMPTY_TRACK_SELECTION_ARRAY myCurrentTrackSelections = EMPTY_TRACK_SELECTION_ARRAY
myCurrentTracksInfo = TracksInfo.EMPTY myCurrentTracksInfo = Tracks.EMPTY
statusListener = StatusListener() statusListener = StatusListener()
timelineTracker = CastTimelineTracker() timelineTracker = CastTimelineTracker()
myCurrentTimeline = CastTimeline.EMPTY_CAST_TIMELINE myCurrentTimeline = CastTimeline.EMPTY_CAST_TIMELINE
@@ -385,7 +385,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
} }
if (updateTracksAndSelectionsAndNotifyIfChanged()) { if (updateTracksAndSelectionsAndNotifyIfChanged()) {
listeners.queueEvent( listeners.queueEvent(
EVENT_TRACKS_CHANGED) { listener: Listener -> listener.onTracksInfoChanged(currentTracksInfo) } EVENT_TRACKS_CHANGED) { listener: Listener -> listener.onTracksChanged(currentTracks) }
} }
updateAvailableCommandsAndNotifyIfChanged() updateAvailableCommandsAndNotifyIfChanged()
listeners.flushEvents() listeners.flushEvents()
@@ -405,7 +405,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
val hasChanged = !myCurrentTrackGroups.isEmpty val hasChanged = !myCurrentTrackGroups.isEmpty
myCurrentTrackGroups = TrackGroupArray.EMPTY myCurrentTrackGroups = TrackGroupArray.EMPTY
myCurrentTrackSelections = EMPTY_TRACK_SELECTION_ARRAY myCurrentTrackSelections = EMPTY_TRACK_SELECTION_ARRAY
myCurrentTracksInfo = TracksInfo.EMPTY myCurrentTracksInfo = Tracks.EMPTY
return hasChanged return hasChanged
} }
var activeTrackIds = mediaStatus.activeTrackIds var activeTrackIds = mediaStatus.activeTrackIds
@@ -414,7 +414,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
} }
val trackGroups = arrayOfNulls<TrackGroup>(castMediaTracks.size) val trackGroups = arrayOfNulls<TrackGroup>(castMediaTracks.size)
val trackSelections = arrayOfNulls<TrackSelection>(RENDERER_COUNT) val trackSelections = arrayOfNulls<TrackSelection>(RENDERER_COUNT)
val trackGroupInfos = arrayOfNulls<TrackGroupInfo>(castMediaTracks.size) val trackGroupInfos = arrayOfNulls<Group>(castMediaTracks.size)
for (i in castMediaTracks.indices) { for (i in castMediaTracks.indices) {
val mediaTrack = castMediaTracks[i] val mediaTrack = castMediaTracks[i]
trackGroups[i] = TrackGroup( /* id= */i.toString(), mediaTrackToFormat(mediaTrack)) trackGroups[i] = TrackGroup( /* id= */i.toString(), mediaTrackToFormat(mediaTrack))
@@ -428,18 +428,16 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
} }
val trackSupport = intArrayOf(if (supported) C.FORMAT_HANDLED else C.FORMAT_UNSUPPORTED_TYPE) val trackSupport = intArrayOf(if (supported) C.FORMAT_HANDLED else C.FORMAT_UNSUPPORTED_TYPE)
val trackSelected = booleanArrayOf(selected) val trackSelected = booleanArrayOf(selected)
trackGroupInfos[i] = TrackGroupInfo(trackGroups[i]!!, trackSupport, trackType, trackSelected) trackGroupInfos[i] = Group(trackGroups[i]!!, false, trackSupport, trackSelected)
} }
val tg = trackGroups.filterNotNull().toTypedArray() val tg = trackGroups.filterNotNull().toTypedArray()
var ts = trackSelections.filterNotNull().toTypedArray() val ts = trackSelections.filterNotNull().toTypedArray()
var tgi = trackGroupInfos.filterNotNull().toMutableList() val tgi = trackGroupInfos.filterNotNull().toMutableList()
val newTrackGroups = TrackGroupArray(*tg) val newTrackGroups = TrackGroupArray(*tg)
val newTrackSelections = TrackSelectionArray(*ts) val newTrackSelections = TrackSelectionArray(*ts)
val newTracksInfo = TracksInfo(tgi) val newTracksInfo = Tracks(tgi)
if (newTrackGroups != currentTrackGroups if (newTracksInfo != currentTracks) {
|| newTrackSelections != currentTrackSelections
|| newTracksInfo != currentTracksInfo) {
myCurrentTrackSelections = newTrackSelections myCurrentTrackSelections = newTrackSelections
myCurrentTrackGroups = newTrackGroups myCurrentTrackGroups = newTrackGroups
myCurrentTracksInfo = newTracksInfo myCurrentTracksInfo = newTracksInfo
@@ -593,7 +591,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
listeners.flushEvents() listeners.flushEvents()
val pendingResult: PendingResult<RemoteMediaClient.MediaChannelResult> = if (playWhenReady) remoteMediaClient!!.play() else remoteMediaClient!!.pause() val pendingResult: PendingResult<RemoteMediaClient.MediaChannelResult> = if (playWhenReady) remoteMediaClient!!.play() else remoteMediaClient!!.pause()
var resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> { val resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
updatePlayerStateAndNotifyIfChanged() updatePlayerStateAndNotifyIfChanged()
} }
@@ -622,10 +620,10 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
return false return false
} }
override fun seekTo(mediaItemIndex: Int, positionMs: Long) { override fun seekTo(mediaItemIndex: Int, positionMs: Long, seekCommand: Int, isRepeatingCurrentItem: Boolean) {
Log.d(tag, "seekTo $mediaItemIndex position $positionMs") Log.d(tag, "seekTo $mediaItemIndex position $positionMs")
var resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> { val resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
val statusCode: Int = it.status.statusCode val statusCode: Int = it.status.statusCode
if (statusCode != CastStatusCodes.SUCCESS && statusCode != CastStatusCodes.REPLACED) { if (statusCode != CastStatusCodes.SUCCESS && statusCode != CastStatusCodes.REPLACED) {
Log.e(tag, "Seek failed. Error code $statusCode") Log.e(tag, "Seek failed. Error code $statusCode")
@@ -645,14 +643,14 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
// We assume the default position is 0. There is no support for seeking to the default position // We assume the default position is 0. There is no support for seeking to the default position
// in RemoteMediaClient. // in RemoteMediaClient.
var positionMsFinal = if (positionMs != C.TIME_UNSET) positionMs else 0 val positionMsFinal = if (positionMs != C.TIME_UNSET) positionMs else 0
if (mediaStatus != null) { if (mediaStatus != null) {
if (currentMediaItemIndex != mediaItemIndex) { if (currentMediaItemIndex != mediaItemIndex) {
Log.d(tag, "seekTo: Changing media item index from $currentMediaItemIndex to $mediaItemIndex") Log.d(tag, "seekTo: Changing media item index from $currentMediaItemIndex to $mediaItemIndex")
remoteMediaClient?.queueJumpToItem(myCurrentTimeline.getPeriod(mediaItemIndex, period).uid as Int, positionMsFinal, JSONObject())?.setResultCallback(resultCb) remoteMediaClient?.queueJumpToItem(myCurrentTimeline.getPeriod(mediaItemIndex, period).uid as Int, positionMsFinal, JSONObject())?.setResultCallback(resultCb)
} else { } else {
Log.d(tag, "seekTo: Same media index seek to position $positionMsFinal") Log.d(tag, "seekTo: Same media index seek to position $positionMsFinal")
var mediaSeekOptions = MediaSeekOptions.Builder().setPosition(positionMsFinal).build() val mediaSeekOptions = MediaSeekOptions.Builder().setPosition(positionMsFinal).build()
remoteMediaClient?.seek(mediaSeekOptions)?.setResultCallback(resultCb) remoteMediaClient?.seek(mediaSeekOptions)?.setResultCallback(resultCb)
} }
val oldPosition = getCurrentPositionInfo() val oldPosition = getCurrentPositionInfo()
@@ -709,7 +707,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
val pendingResult: PendingResult<RemoteMediaClient.MediaChannelResult>? = remoteMediaClient?.setPlaybackRate(actualPlaybackParameters.speed.toDouble(), /* customData= */null) val pendingResult: PendingResult<RemoteMediaClient.MediaChannelResult>? = remoteMediaClient?.setPlaybackRate(actualPlaybackParameters.speed.toDouble(), /* customData= */null)
var resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> { val resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
updatePlaybackRateAndNotifyIfChanged() updatePlaybackRateAndNotifyIfChanged()
listeners.flushEvents() listeners.flushEvents()
} }
@@ -736,15 +734,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
sessionManager.endCurrentSession(false) sessionManager.endCurrentSession(false)
} }
override fun getCurrentTrackGroups(): TrackGroupArray { override fun getCurrentTracks(): Tracks {
return this.myCurrentTrackGroups
}
override fun getCurrentTrackSelections(): TrackSelectionArray {
return this.myCurrentTrackSelections
}
override fun getCurrentTracksInfo(): TracksInfo {
return this.myCurrentTracksInfo return this.myCurrentTracksInfo
} }
@@ -858,8 +848,12 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
return VideoSize.UNKNOWN return VideoSize.UNKNOWN
} }
override fun getCurrentCues(): MutableList<Cue> { override fun getSurfaceSize(): Size {
return mutableListOf() return Size.UNKNOWN
}
override fun getCurrentCues(): CueGroup {
return CueGroup.EMPTY_TIME_ZERO
} }
override fun getDeviceInfo(): DeviceInfo { override fun getDeviceInfo(): DeviceInfo {
@@ -56,6 +56,9 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
lazyIsPlaying = isPlaying lazyIsPlaying = isPlaying
// Update widget
DeviceManager.widgetUpdater?.onPlayerChanged(playerNotificationService)
if (isPlaying) { if (isPlaying) {
Log.d(tag, "SeekBackTime: Player is playing") Log.d(tag, "SeekBackTime: Player is playing")
if (lastPauseTime > 0 && DeviceManager.deviceData.deviceSettings?.disableAutoRewind != true) { if (lastPauseTime > 0 && DeviceManager.deviceData.deviceSettings?.disableAutoRewind != true) {
@@ -109,8 +112,6 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
} }
playerNotificationService.clientEventEmitter?.onPlayingUpdate(isPlaying) playerNotificationService.clientEventEmitter?.onPlayingUpdate(isPlaying)
DeviceManager.widgetUpdater?.onPlayerChanged(playerNotificationService)
} }
override fun onEvents(player: Player, events: Player.Events) { override fun onEvents(player: Player, events: Player.Events) {
@@ -44,11 +44,7 @@ class PlayerNotificationListener(var playerNotificationService:PlayerNotificatio
} else { } else {
Log.d(tag, "onNotificationCancelled not dismissed by user") Log.d(tag, "onNotificationCancelled not dismissed by user")
// When stop button is pressed on the notification I guess it isn't considered "dismissedByUser" so we need to close playback ourselves if (PlayerNotificationService.isSwitchingPlayer) {
if (!PlayerNotificationService.isClosed && !PlayerNotificationService.isSwitchingPlayer) {
Log.d(tag, "PNS is not closed - closing it now")
playerNotificationService.closePlayback()
} else if (PlayerNotificationService.isSwitchingPlayer) {
// When switching from cast player to exo player and vice versa the notification is cancelled and posted again // When switching from cast player to exo player and vice versa the notification is cancelled and posted again
// so we don't want to cancel the playback during this switch // so we don't want to cancel the playback during this switch
Log.d(tag, "PNS is switching player") Log.d(tag, "PNS is switching player")
@@ -2,8 +2,6 @@ package com.audiobookshelf.app.player
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.* import android.app.*
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
@@ -11,10 +9,7 @@ import android.graphics.Color
import android.graphics.ImageDecoder import android.graphics.ImageDecoder
import android.hardware.Sensor import android.hardware.Sensor
import android.hardware.SensorManager import android.hardware.SensorManager
import android.net.ConnectivityManager import android.net.*
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.* import android.os.*
import android.provider.MediaStore import android.provider.MediaStore
import android.provider.Settings import android.provider.Settings
@@ -25,8 +20,6 @@ import android.support.v4.media.session.MediaControllerCompat
import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat import android.support.v4.media.session.PlaybackStateCompat
import android.util.Log import android.util.Log
import android.view.View
import android.widget.RemoteViews
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@@ -47,6 +40,8 @@ import com.google.android.exoplayer2.audio.AudioAttributes
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.CustomActionProvider import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.CustomActionProvider
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator
import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory
import com.google.android.exoplayer2.extractor.mp3.Mp3Extractor
import com.google.android.exoplayer2.source.MediaSource import com.google.android.exoplayer2.source.MediaSource
import com.google.android.exoplayer2.source.ProgressiveMediaSource import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.source.hls.HlsMediaSource import com.google.android.exoplayer2.source.hls.HlsMediaSource
@@ -203,6 +198,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
super.onCreate() super.onCreate()
ctx = this ctx = this
// Initialize Paper
DbManager.initialize(ctx)
// Initialize widget
DeviceManager.initializeWidgetUpdater(ctx)
// To listen for network change from metered to unmetered // To listen for network change from metered to unmetered
val networkRequest = NetworkRequest.Builder() val networkRequest = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
@@ -264,7 +265,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
playerNotificationManager.setUseNextAction(false) playerNotificationManager.setUseNextAction(false)
playerNotificationManager.setUsePreviousAction(false) playerNotificationManager.setUsePreviousAction(false)
playerNotificationManager.setUseChronometer(false) playerNotificationManager.setUseChronometer(false)
playerNotificationManager.setUseStopAction(true) playerNotificationManager.setUseStopAction(false)
playerNotificationManager.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) playerNotificationManager.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX) playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX)
playerNotificationManager.setUseFastForwardActionInCompactView(true) playerNotificationManager.setUseFastForwardActionInCompactView(true)
@@ -288,10 +289,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
return MediaDescriptionCompat.Builder().build() return MediaDescriptionCompat.Builder().build()
} }
val coverUri = currentPlaybackSession!!.getCoverUri() val coverUri = currentPlaybackSession!!.getCoverUri(ctx)
var bitmap:Bitmap? = null var bitmap: Bitmap? = null
// Local covers get bitmap // Local covers get bitmap
if (currentPlaybackSession!!.localLibraryItem?.coverContentUrl != null) { if (currentPlaybackSession!!.localLibraryItem?.coverContentUrl != null) {
bitmap = if (Build.VERSION.SDK_INT < 28) { bitmap = if (Build.VERSION.SDK_INT < 28) {
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri) MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
@@ -362,7 +363,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
.build() .build()
mPlayer.setHandleAudioBecomingNoisy(true) mPlayer.setHandleAudioBecomingNoisy(true)
mPlayer.addListener(PlayerListener(this)) mPlayer.addListener(PlayerListener(this))
val audioAttributes:AudioAttributes = AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.CONTENT_TYPE_SPEECH).build() val audioAttributes:AudioAttributes = AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.AUDIO_CONTENT_TYPE_SPEECH).build()
mPlayer.setAudioAttributes(audioAttributes, true) mPlayer.setAudioAttributes(audioAttributes, true)
//attach player to playerNotificationManager //attach player to playerNotificationManager
@@ -394,7 +395,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
val metadata = playbackSession.getMediaMetadataCompat(ctx) val metadata = playbackSession.getMediaMetadataCompat(ctx)
mediaSession.setMetadata(metadata) mediaSession.setMetadata(metadata)
val mediaItems = playbackSession.getMediaItems() val mediaItems = playbackSession.getMediaItems(ctx)
val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f
initialPlaybackRate = playbackRate initialPlaybackRate = playbackRate
@@ -438,12 +439,26 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
if (playbackSession.isLocal) { if (playbackSession.isLocal) {
Log.d(tag, "Playing Local Item") Log.d(tag, "Playing Local Item")
val dataSourceFactory = DefaultDataSource.Factory(ctx) val dataSourceFactory = DefaultDataSource.Factory(ctx)
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0])
val extractorsFactory = DefaultExtractorsFactory()
if (DeviceManager.deviceData.deviceSettings?.enableMp3IndexSeeking == true) {
// @see https://exoplayer.dev/troubleshooting.html#why-is-seeking-inaccurate-in-some-mp3-files
extractorsFactory.setMp3ExtractorFlags(Mp3Extractor.FLAG_ENABLE_INDEX_SEEKING)
}
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory).createMediaSource(mediaItems[0])
} else if (!playbackSession.isHLS) { } else if (!playbackSession.isHLS) {
Log.d(tag, "Direct Playing Item") Log.d(tag, "Direct Playing Item")
val dataSourceFactory = DefaultHttpDataSource.Factory() val dataSourceFactory = DefaultHttpDataSource.Factory()
val extractorsFactory = DefaultExtractorsFactory()
if (DeviceManager.deviceData.deviceSettings?.enableMp3IndexSeeking == true) {
// @see https://exoplayer.dev/troubleshooting.html#why-is-seeking-inaccurate-in-some-mp3-files
extractorsFactory.setMp3ExtractorFlags(Mp3Extractor.FLAG_ENABLE_INDEX_SEEKING)
}
dataSourceFactory.setUserAgent(channelId) dataSourceFactory.setUserAgent(channelId)
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0]) mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory).createMediaSource(mediaItems[0])
} else { } else {
Log.d(tag, "Playing HLS Item") Log.d(tag, "Playing HLS Item")
val dataSourceFactory = DefaultHttpDataSource.Factory() val dataSourceFactory = DefaultHttpDataSource.Factory()
@@ -483,7 +498,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
private fun setMediaSessionConnectorCustomActions(playbackSession:PlaybackSession) { private fun setMediaSessionConnectorCustomActions(playbackSession:PlaybackSession) {
val mediaItems = playbackSession.getMediaItems() val mediaItems = playbackSession.getMediaItems(ctx)
val customActionProviders = mutableListOf( val customActionProviders = mutableListOf(
JumpBackwardCustomActionProvider(), JumpBackwardCustomActionProvider(),
JumpForwardCustomActionProvider(), JumpForwardCustomActionProvider(),
@@ -863,10 +878,26 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
fun closePlayback(calledOnError:Boolean? = false) { fun closePlayback(calledOnError:Boolean? = false) {
Log.d(tag, "closePlayback") Log.d(tag, "closePlayback")
val isLocal = mediaProgressSyncer.currentIsLocal
val currentSessionId = mediaProgressSyncer.currentSessionId
if (mediaProgressSyncer.listeningTimerRunning) { if (mediaProgressSyncer.listeningTimerRunning) {
Log.i(tag, "About to close playback so stopping media progress syncer first") Log.i(tag, "About to close playback so stopping media progress syncer first")
mediaProgressSyncer.stop(calledOnError == false) { // If closing on error then do not sync progress (causes exception) mediaProgressSyncer.stop(calledOnError == false) { // If closing on error then do not sync progress (causes exception)
Log.d(tag, "Media Progress syncer stopped") Log.d(tag, "Media Progress syncer stopped")
// If not local session then close on server
if (!isLocal && currentSessionId != "") {
apiHandler.closePlaybackSession(currentSessionId) {
Log.d(tag, "Closed playback session $currentSessionId")
}
}
}
} else {
// If not local session then close on server
if (!isLocal && currentSessionId != "") {
apiHandler.closePlaybackSession(currentSessionId) {
Log.d(tag, "Closed playback session $currentSessionId")
}
} }
} }
@@ -880,8 +911,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
currentPlaybackSession = null currentPlaybackSession = null
mediaProgressSyncer.reset() mediaProgressSyncer.reset()
clientEventEmitter?.onPlaybackClosed() clientEventEmitter?.onPlaybackClosed()
PlayerListener.lastPauseTime = 0 PlayerListener.lastPauseTime = 0
isClosed = true isClosed = true
DeviceManager.widgetUpdater?.onPlayerClosed()
stopForeground(Service.STOP_FOREGROUND_REMOVE) stopForeground(Service.STOP_FOREGROUND_REMOVE)
stopSelf() stopSelf()
} }
@@ -896,7 +929,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
@SuppressLint("HardwareIds") @SuppressLint("HardwareIds")
private fun getDeviceInfo(): DeviceInfo { fun getDeviceInfo(): DeviceInfo {
/* EXAMPLE /* EXAMPLE
manufacturer: Google manufacturer: Google
model: Pixel 6 model: Pixel 6
@@ -905,7 +938,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
appVersion: 0.9.46-beta appVersion: 0.9.46-beta
*/ */
val deviceId = Settings.Secure.getString(ctx.contentResolver, Settings.Secure.ANDROID_ID) 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) return DeviceInfo(deviceId, Build.MANUFACTURER, Build.MODEL, 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()
@@ -1008,10 +1041,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
val localBrowseItems:MutableList<MediaBrowserCompat.MediaItem> = mutableListOf() val localBrowseItems:MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
localBooks.forEach { localLibraryItem -> localBooks.forEach { localLibraryItem ->
val progress = DeviceManager.dbManager.getLocalMediaProgress(localLibraryItem.id) if (localLibraryItem.media.getAudioTracks().isNotEmpty()) {
val description = localLibraryItem.getMediaDescription(progress, ctx) val progress = DeviceManager.dbManager.getLocalMediaProgress(localLibraryItem.id)
val description = localLibraryItem.getMediaDescription(progress, ctx)
localBrowseItems += MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) localBrowseItems += MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
}
} }
localPodcasts.forEach { localLibraryItem -> localPodcasts.forEach { localLibraryItem ->
@@ -201,7 +201,7 @@ class AbsAudioPlayer : Plugin() {
Handler(Looper.getMainLooper()).post { Handler(Looper.getMainLooper()).post {
Log.d(tag, "prepareLibraryItem: Preparing Local Media item ${jacksonMapper.writeValueAsString(it)}") Log.d(tag, "prepareLibraryItem: Preparing Local Media item ${jacksonMapper.writeValueAsString(it)}")
val playbackSession = it.getPlaybackSession(episode) val playbackSession = it.getPlaybackSession(episode, playerNotificationService.getDeviceInfo())
if (startTimeOverride != null) { if (startTimeOverride != null) {
Log.d(tag, "prepareLibraryItem: Using start time override $startTimeOverride") Log.d(tag, "prepareLibraryItem: Using start time override $startTimeOverride")
playbackSession.currentTime = startTimeOverride playbackSession.currentTime = startTimeOverride
@@ -234,6 +234,8 @@ class AbsDatabase : Plugin() {
call.resolve() call.resolve()
} }
} }
} else {
call.resolve()
} }
} }
} }
@@ -57,7 +57,7 @@ class AbsDownloader : Plugin() {
val libraryItemId = call.data.getString("libraryItemId").toString() val libraryItemId = call.data.getString("libraryItemId").toString()
var episodeId = call.data.getString("episodeId").toString() var episodeId = call.data.getString("episodeId").toString()
if (episodeId == "null") episodeId = "" if (episodeId == "null") episodeId = ""
val localFolderId = call.data.getString("localFolderId").toString() var localFolderId = call.data.getString("localFolderId", "").toString()
Log.d(tag, "Download library item $libraryItemId to folder $localFolderId / episode: $episodeId") Log.d(tag, "Download library item $libraryItemId to folder $localFolderId / episode: $episodeId")
val downloadId = if (episodeId.isEmpty()) libraryItemId else "$libraryItemId-$episodeId" val downloadId = if (episodeId.isEmpty()) libraryItemId else "$libraryItemId-$episodeId"
@@ -72,9 +72,18 @@ class AbsDownloader : Plugin() {
} else { } else {
Log.d(tag, "Got library item from server ${libraryItem.id}") Log.d(tag, "Got library item from server ${libraryItem.id}")
val localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId) if (localFolderId == "") {
if (localFolder != null) { localFolderId = "internal-${libraryItem.mediaType}"
}
var localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
if (localFolder == null && localFolderId.startsWith("internal-")) {
Log.d(tag, "Creating new App Storage internal LocalFolder $localFolderId")
localFolder = LocalFolder(localFolderId, "Internal App Storage", "", "", "", "", "internal", libraryItem.mediaType)
DeviceManager.dbManager.saveLocalFolder(localFolder)
}
if (localFolder != null) {
if (episodeId.isNotEmpty() && libraryItem.mediaType != "podcast") { if (episodeId.isNotEmpty() && libraryItem.mediaType != "podcast") {
Log.e(tag, "Library item is not a podcast but episode was requested") Log.e(tag, "Library item is not a podcast but episode was requested")
call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}")) call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
@@ -100,12 +109,6 @@ class AbsDownloader : Plugin() {
} }
} }
// Clean folder path so it can be used in URL
private fun cleanRelPath(relPath: String): String {
val cleanedRelPath = relPath.replace("\\", "/").replace("%", "%25").replace("#", "%23")
return if (cleanedRelPath.startsWith("/")) cleanedRelPath.substring(1) else cleanedRelPath
}
// Item filenames could be the same if they are in sub-folders, this will make them unique // Item filenames could be the same if they are in sub-folders, this will make them unique
private fun getFilenameFromRelPath(relPath: String): String { private fun getFilenameFromRelPath(relPath: String): String {
var cleanedRelPath = relPath.replace("\\", "_").replace("/", "_") var cleanedRelPath = relPath.replace("\\", "_").replace("/", "_")
@@ -127,7 +130,9 @@ class AbsDownloader : Plugin() {
} }
private fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) { private fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) {
val tempFolderPath = mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) val isInternal = localFolder.id.startsWith("internal-")
val tempFolderPath = if (isInternal) "${mainActivity.filesDir}/downloads/${libraryItem.id}" else mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
Log.d(tag, "downloadCacheDirectory=$tempFolderPath") Log.d(tag, "downloadCacheDirectory=$tempFolderPath")
@@ -138,13 +143,13 @@ class AbsDownloader : Plugin() {
val tracks = libraryItem.media.getAudioTracks() val tracks = libraryItem.media.getAudioTracks()
Log.d(tag, "Starting library item download with ${tracks.size} tracks") Log.d(tag, "Starting library item download with ${tracks.size} tracks")
val itemSubfolder = "$bookAuthor/$bookTitle" val itemSubfolder = "$bookAuthor/$bookTitle"
val itemFolderPath = "${localFolder.absolutePath}/$itemSubfolder" val itemFolderPath = if (isInternal) "$tempFolderPath" else "${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 val book = libraryItem.media as Book
book.ebookFile?.let { ebookFile -> book.ebookFile?.let { ebookFile ->
val fileSize = ebookFile.metadata?.size ?: 0 val fileSize = ebookFile.metadata?.size ?: 0
val serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(ebookFile.metadata?.relPath ?: "")}" val serverPath = "/api/items/${libraryItem.id}/file/${ebookFile.ino}/download"
val destinationFilename = getFilenameFromRelPath(ebookFile.metadata?.relPath ?: "") val destinationFilename = getFilenameFromRelPath(ebookFile.metadata?.relPath ?: "")
val finalDestinationFile = File("$itemFolderPath/$destinationFilename") val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
val destinationFile = File("$tempFolderPath/$destinationFilename") val destinationFile = File("$tempFolderPath/$destinationFilename")
@@ -164,9 +169,14 @@ class AbsDownloader : Plugin() {
} }
// Create download item part for each audio track // Create download item part for each audio track
val audioFiles = (libraryItem.media as Book).audioFiles ?: mutableListOf()
tracks.forEach { audioTrack -> tracks.forEach { audioTrack ->
val fileSize = audioTrack.metadata?.size ?: 0 val fileSize = audioTrack.metadata?.size ?: 0
val serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioTrack.relPath)}"
// TODO: Currently file ino is only stored on AudioFile. This should be updated server side to be in FileMetadata or on the AudioTrack
val audioFileIno = audioFiles.find { it.metadata.path == audioTrack.metadata?.path }?.ino
val serverPath = "/api/items/${libraryItem.id}/file/${audioFileIno}/download"
val destinationFilename = getFilenameFromRelPath(audioTrack.relPath) val destinationFilename = getFilenameFromRelPath(audioTrack.relPath)
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename") Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
@@ -219,14 +229,15 @@ class AbsDownloader : Plugin() {
val podcastTitle = cleanStringForFileSystem(libraryItem.media.metadata.title) val podcastTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
val audioTrack = episode?.audioTrack val audioTrack = episode?.audioTrack
val audioFileIno = episode?.audioFile?.ino
val fileSize = audioTrack?.metadata?.size ?: 0 val fileSize = audioTrack?.metadata?.size ?: 0
Log.d(tag, "Starting podcast episode download") Log.d(tag, "Starting podcast episode download")
val itemFolderPath = localFolder.absolutePath + "/" + podcastTitle val itemFolderPath = if (isInternal) "$tempFolderPath" else "${localFolder.absolutePath}/$podcastTitle"
val downloadItemId = "${libraryItem.id}-${episode?.id}" val downloadItemId = "${libraryItem.id}-${episode?.id}"
val downloadItem = DownloadItem(downloadItemId, libraryItem.id, episode?.id, libraryItem.userMediaProgress, DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, podcastTitle, podcastTitle, libraryItem.media, mutableListOf()) val downloadItem = DownloadItem(downloadItemId, libraryItem.id, episode?.id, libraryItem.userMediaProgress, DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, podcastTitle, podcastTitle, libraryItem.media, mutableListOf())
var serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioTrack?.relPath ?: "")}" var serverPath = "/api/items/${libraryItem.id}/file/${audioFileIno}/download"
var destinationFilename = getFilenameFromRelPath(audioTrack?.relPath ?: "") var destinationFilename = getFilenameFromRelPath(audioTrack?.relPath ?: "")
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack?.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename") Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack?.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
@@ -1,7 +1,6 @@
package com.audiobookshelf.app.plugins package com.audiobookshelf.app.plugins
import android.app.AlertDialog import android.app.AlertDialog
import android.database.Cursor
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
@@ -165,6 +164,13 @@ class AbsFileSystem : Plugin() {
call.resolve(jsobj) call.resolve(jsobj)
} }
@PluginMethod
fun getSDKVersion(call: PluginCall) {
val jsObject = JSObject()
jsObject.put("version", Build.VERSION.SDK_INT)
call.resolve(jsObject)
}
@PluginMethod @PluginMethod
fun scanFolder(call: PluginCall) { fun scanFolder(call: PluginCall) {
val folderId = call.data.getString("folderId", "").toString() val folderId = call.data.getString("folderId", "").toString()
@@ -227,35 +233,44 @@ 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 // Check if should delete subfolder
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId) val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
localLibraryItem?.folderId?.let { folderId ->
val folder = DeviceManager.dbManager.getLocalFolder(folderId) val success: Boolean
folder?.absolutePath?.let { folderPath ->
val splitAbsolutePath = absolutePath.split("/") // If internal library item use File to delete
val fullSubDir = splitAbsolutePath.subList(0, splitAbsolutePath.size - 1).joinToString("/") if (localLibraryItem?.folderId?.startsWith("internal-") == true) {
if (fullSubDir != folderPath) { Log.d(tag, "Deleting internal library item at absolutePath $absolutePath")
val subdirHasAnItem = DeviceManager.dbManager.getLocalLibraryItems().any { _localLibraryItem -> val file = File(absolutePath)
if (_localLibraryItem.id == localLibraryItemId) { success = file.deleteRecursively()
false } else {
} else { var subfolderPathToDelete = ""
_localLibraryItem.absolutePath.startsWith(fullSubDir) 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
} }
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 success = docfile?.delete() == true
if (subfolderPathToDelete != "") { if (subfolderPathToDelete != "") {
Log.d(tag, "Deleting empty subfolder at $subfolderPathToDelete") Log.d(tag, "Deleting empty subfolder at $subfolderPathToDelete")
val docfilesub = DocumentFileCompat.fromFullPath(mainActivity, subfolderPathToDelete) val docfilesub = DocumentFileCompat.fromFullPath(mainActivity, subfolderPathToDelete)
docfilesub?.delete() docfilesub?.delete()
}
} }
if (success) { if (success) {
@@ -17,6 +17,7 @@ import com.getcapacitor.JSObject
import okhttp3.* import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.internal.EMPTY_REQUEST
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONException import org.json.JSONException
import org.json.JSONObject import org.json.JSONObject
@@ -45,11 +46,11 @@ class ApiHandler(var ctx:Context) {
makeRequest(request, httpClient, cb) makeRequest(request, httpClient, cb)
} }
private fun postRequest(endpoint:String, payload: JSObject, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) { private fun postRequest(endpoint:String, payload: JSObject?, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
val address = config?.address ?: DeviceManager.serverAddress val address = config?.address ?: DeviceManager.serverAddress
val token = config?.token ?: DeviceManager.token val token = config?.token ?: DeviceManager.token
val mediaType = "application/json; charset=utf-8".toMediaType() val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = payload.toString().toRequestBody(mediaType) val requestBody = payload?.toString()?.toRequestBody(mediaType) ?: EMPTY_REQUEST
val requestUrl = "${address}$endpoint" val requestUrl = "${address}$endpoint"
Log.d(tag, "postRequest to $requestUrl") Log.d(tag, "postRequest to $requestUrl")
val request = Request.Builder().post(requestBody) val request = Request.Builder().post(requestBody)
@@ -292,6 +293,13 @@ class ApiHandler(var ctx:Context) {
} }
} }
fun closePlaybackSession(playbackSessionId:String, cb: (Boolean) -> Unit) {
Log.d(tag, "closePlaybackSession: playbackSessionId=$playbackSessionId")
postRequest("/api/session/$playbackSessionId/close", null, null) {
cb(true)
}
}
fun authorize(config:ServerConnectionConfig, cb: (MutableList<MediaProgress>?) -> Unit) { fun authorize(config:ServerConnectionConfig, cb: (MutableList<MediaProgress>?) -> Unit) {
Log.d(tag, "authorize: Authorizing ${config.address}") Log.d(tag, "authorize: Authorizing ${config.address}")
postRequest("/api/authorize", JSObject(), config) { postRequest("/api/authorize", JSObject(), config) {
@@ -326,11 +334,11 @@ class ApiHandler(var ctx:Context) {
if (localSessionSyncResult.progressSynced == true) { if (localSessionSyncResult.progressSynced == true) {
val syncResult = SyncResult(true, true, "Progress synced on server") val syncResult = SyncResult(true, true, "Progress synced on server")
MediaEventManager.saveEvent(session, syncResult) MediaEventManager.saveEvent(session, syncResult)
DeviceManager.dbManager.removePlaybackSession(session.id)
Log.i(tag, "Successfully synced session ${session.displayTitle} with server") Log.i(tag, "Successfully synced session ${session.displayTitle} with server")
} else if (!localSessionSyncResult.success) { } else if (!localSessionSyncResult.success) {
Log.e(tag, "Failed to sync session ${session.displayTitle} with server. Error: ${localSessionSyncResult.error}") Log.e(tag, "Failed to sync session ${session.displayTitle} with server. Error: ${localSessionSyncResult.error}")
} }
DeviceManager.dbManager.removePlaybackSession(session.id)
} }
} }
cb(true, null) cb(true, null)
+3 -4
View File
@@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android"> <paths>
<external-path name="my_images" path="." /> <files-path name="downloads" path="downloads/" />
<cache-path name="my_cache_images" path="." /> </paths>
</paths>
+3
View File
@@ -5,6 +5,9 @@ project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/
include ':capacitor-app' include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android') project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
include ':capacitor-clipboard'
project(':capacitor-clipboard').projectDir = new File('../node_modules/@capacitor/clipboard/android')
include ':capacitor-dialog' include ':capacitor-dialog'
project(':capacitor-dialog').projectDir = new File('../node_modules/@capacitor/dialog/android') project(':capacitor-dialog').projectDir = new File('../node_modules/@capacitor/dialog/android')
+2 -2
View File
@@ -1,7 +1,7 @@
ext { ext {
minSdkVersion = 24 minSdkVersion = 24
compileSdkVersion = 33 compileSdkVersion = 33
targetSdkVersion = 32 targetSdkVersion = 33
androidxActivityVersion = '1.4.0' androidxActivityVersion = '1.4.0'
androidxAppCompatVersion = '1.4.2' androidxAppCompatVersion = '1.4.2'
androidxCoordinatorLayoutVersion = '1.2.0' androidxCoordinatorLayoutVersion = '1.2.0'
@@ -20,7 +20,7 @@ ext {
arch_lifecycle_version = '2.2.0' arch_lifecycle_version = '2.2.0'
constraint_layout_version = '2.0.1' constraint_layout_version = '2.0.1'
espresso_version = '3.3.0' espresso_version = '3.3.0'
exoplayer_version = '2.17.0' exoplayer_version = '2.18.7'
fragment_version = '1.2.5' fragment_version = '1.2.5'
glide_version = '4.11.0' glide_version = '4.11.0'
gms_strict_version_matcher_version = '1.0.3' gms_strict_version_matcher_version = '1.0.3'
+23
View File
@@ -143,4 +143,27 @@ Bookshelf Label
display: -webkit-box; display: -webkit-box;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
-webkit-line-clamp: 4; -webkit-line-clamp: 4;
}
.tracksTable {
border-collapse: collapse;
width: 100%;
border: 1px solid #474747;
}
.tracksTable tr:nth-child(even) {
background-color: #2e2e2e;
}
.tracksTable tr {
background-color: #373838;
}
.tracksTable td {
padding: 8px 8px;
}
.tracksTable th {
padding: 4px 8px;
font-size: 0.75rem;
} }
+1 -1
View File
@@ -79,7 +79,7 @@ export default {
}, },
methods: { methods: {
castClick() { castClick() {
if (this.$store.state.playerIsLocal) { if (this.$store.getters['getIsCurrentSessionLocal']) {
this.$eventBus.$emit('cast-local-item') this.$eventBus.$emit('cast-local-item')
return return
} }
+13 -8
View File
@@ -90,12 +90,14 @@
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 rounded-full pointer-events-none" /> <div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 rounded-full pointer-events-none" />
<div ref="bufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 rounded-full pointer-events-none" /> <div ref="bufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 rounded-full pointer-events-none" />
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 rounded-full pointer-events-none" /> <div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 rounded-full pointer-events-none" />
<div ref="trackCursor" class="h-3.5 w-3.5 -top-1 rounded-full bg-gray-200 absolute pointer-events-auto" :class="{ 'opacity-0': lockUi || !showFullscreen }" @touchstart="touchstartCursor" /> <div ref="trackCursor" class="h-7 w-7 rounded-full absolute pointer-events-auto flex items-center justify-center" :style="{ top: '-11px' }" :class="{ 'opacity-0': lockUi || !showFullscreen }" @touchstart="touchstartCursor">
<div class="bg-gray-200 rounded-full w-3.5 h-3.5 pointer-events-none" />
</div>
</div> </div>
</div> </div>
</div> </div>
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" /> <modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" :playback-rate="currentPlaybackRate" @select="selectChapter" />
<modals-dialog v-model="showMoreMenuDialog" :items="menuItems" @action="clickMenuAction" /> <modals-dialog v-model="showMoreMenuDialog" :items="menuItems" @action="clickMenuAction" />
</div> </div>
</template> </template>
@@ -272,7 +274,7 @@ export default {
return this.playbackSession ? this.playbackSession.localLibraryItem || null : null return this.playbackSession ? this.playbackSession.localLibraryItem || null : null
}, },
localLibraryItemCoverSrc() { localLibraryItemCoverSrc() {
var localItemCover = this.localLibraryItem ? this.localLibraryItem.coverContentUrl : null var localItemCover = this.localLibraryItem?.coverContentUrl || null
if (localItemCover) return Capacitor.convertFileSrc(localItemCover) if (localItemCover) return Capacitor.convertFileSrc(localItemCover)
return null return null
}, },
@@ -551,7 +553,7 @@ export default {
this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px' this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px'
if (this.$refs.trackCursor) { if (this.$refs.trackCursor) {
this.$refs.trackCursor.style.left = ptWidth - 7 + 'px' this.$refs.trackCursor.style.left = ptWidth - 14 + 'px'
} }
if (this.useChapterTrack) { if (this.useChapterTrack) {
@@ -572,8 +574,8 @@ export default {
AbsAudioPlayer.seek({ value: Math.floor(time) }) AbsAudioPlayer.seek({ value: Math.floor(time) })
if (this.$refs.playedTrack) { if (this.$refs.playedTrack) {
var perc = time / this.totalDuration const perc = time / this.totalDuration
var ptWidth = Math.round(perc * this.trackWidth) const ptWidth = Math.round(perc * this.trackWidth)
this.$refs.playedTrack.style.width = ptWidth + 'px' this.$refs.playedTrack.style.width = ptWidth + 'px'
this.$refs.playedTrack.classList.remove('bg-gray-200') this.$refs.playedTrack.classList.remove('bg-gray-200')
@@ -693,6 +695,7 @@ export default {
this.updateTimestamp() this.updateTimestamp()
this.updateTrack() this.updateTrack()
this.updateReadyTrack() this.updateReadyTrack()
this.$localStore.setUseTotalTrack(this.useTotalTrack)
this.$localStore.setUseChapterTrack(this.useChapterTrack) this.$localStore.setUseChapterTrack(this.useChapterTrack)
} else if (action === 'total_track') { } else if (action === 'total_track') {
this.useTotalTrack = !this.useTotalTrack this.useTotalTrack = !this.useTotalTrack
@@ -702,6 +705,7 @@ export default {
this.updateTrack() this.updateTrack()
this.updateReadyTrack() this.updateReadyTrack()
this.$localStore.setUseTotalTrack(this.useTotalTrack) this.$localStore.setUseTotalTrack(this.useTotalTrack)
this.$localStore.setUseChapterTrack(this.useChapterTrack)
} else if (action === 'close') { } else if (action === 'close') {
this.closePlayback() this.closePlayback()
} }
@@ -717,7 +721,7 @@ export default {
AbsAudioPlayer.closePlayback() AbsAudioPlayer.closePlayback()
}, },
endPlayback() { endPlayback() {
this.$store.commit('setPlayerItem', null) this.$store.commit('setPlaybackSession', null)
this.showFullscreen = false this.showFullscreen = false
this.isEnded = false this.isEnded = false
this.isLoading = false this.isLoading = false
@@ -763,7 +767,7 @@ export default {
this.isEnded = false this.isEnded = false
this.isLoading = true this.isLoading = true
this.syncStatus = 0 this.syncStatus = 0
this.$store.commit('setPlayerItem', this.playbackSession) this.$store.commit('setPlaybackSession', this.playbackSession)
// Set track width // Set track width
this.$nextTick(() => { this.$nextTick(() => {
@@ -790,6 +794,7 @@ export default {
}, },
async init() { async init() {
this.useChapterTrack = await this.$localStore.getUseChapterTrack() this.useChapterTrack = await this.$localStore.getUseChapterTrack()
this.useTotalTrack = await this.$localStore.getUseTotalTrack()
this.lockUi = await this.$localStore.getPlayerLock() this.lockUi = await this.$localStore.getPlayerLock()
this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession) this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession)
+7 -3
View File
@@ -110,7 +110,7 @@ export default {
}, },
streamProgress(data) { streamProgress(data) {
if (!data.numSegments) return if (!data.numSegments) return
var chunks = data.chunks const chunks = data.chunks
if (this.$refs.audioPlayer) { if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments) this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
} }
@@ -265,8 +265,7 @@ export default {
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress) this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
}, },
onMediaPlayerChanged(data) { onMediaPlayerChanged(data) {
var mediaPlayer = data.value this.$store.commit('setMediaPlayer', data.value)
this.$store.commit('setMediaPlayer', mediaPlayer)
}, },
onReady() { onReady() {
// The UI is reporting elsewhere we are ready // The UI is reporting elsewhere we are ready
@@ -283,6 +282,9 @@ export default {
this.$store.commit('setIsFirstAudioLoad', false) // Only run this once on app launch this.$store.commit('setIsFirstAudioLoad', false) // Only run this once on app launch
AbsAudioPlayer.onReady() AbsAudioPlayer.onReady()
} }
},
playbackTimeUpdate(currentTime) {
this.$refs.audioPlayer?.seek(currentTime)
} }
}, },
mounted() { mounted() {
@@ -300,6 +302,7 @@ export default {
this.$eventBus.$on('close-stream', this.closeStreamOnly) this.$eventBus.$on('close-stream', this.closeStreamOnly)
this.$eventBus.$on('cast-local-item', this.castLocalItem) this.$eventBus.$on('cast-local-item', this.castLocalItem)
this.$eventBus.$on('user-settings', this.settingsUpdated) this.$eventBus.$on('user-settings', this.settingsUpdated)
this.$eventBus.$on('playback-time-update', this.playbackTimeUpdate)
}, },
beforeDestroy() { beforeDestroy() {
if (this.onLocalMediaProgressUpdateListener) this.onLocalMediaProgressUpdateListener.remove() if (this.onLocalMediaProgressUpdateListener) this.onLocalMediaProgressUpdateListener.remove()
@@ -313,6 +316,7 @@ export default {
this.$eventBus.$off('close-stream', this.closeStreamOnly) this.$eventBus.$off('close-stream', this.closeStreamOnly)
this.$eventBus.$off('cast-local-item', this.castLocalItem) this.$eventBus.$off('cast-local-item', this.castLocalItem)
this.$eventBus.$off('user-settings', this.settingsUpdated) this.$eventBus.$off('user-settings', this.settingsUpdated)
this.$eventBus.$off('playback-time-update', this.playbackTimeUpdate)
} }
} }
</script> </script>
+1 -1
View File
@@ -164,7 +164,7 @@ export default {
const entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `items` : this.entityName const entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `items` : this.entityName
const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : '' const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1` const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1&include=rssfeed`
const payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => { const payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
console.error('failed to fetch books', error) console.error('failed to fetch books', error)
+33 -48
View File
@@ -47,6 +47,7 @@
<!-- No progress shown for collapsed series in library --> <!-- No progress shown for collapsed series in library -->
<div v-if="!collapsedSeries && (!isPodcast || recentEpisode)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div> <div v-if="!collapsedSeries && (!isPodcast || recentEpisode)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
<!-- Downloaded icon -->
<div v-if="showHasLocalDownload" class="absolute right-0 top-0 z-20" :style="{ top: (isPodcast ? 1.75 : 0.375) * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }"> <div v-if="showHasLocalDownload" class="absolute right-0 top-0 z-20" :style="{ top: (isPodcast ? 1.75 : 0.375) * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<span class="material-icons text-2xl text-success">{{ isLocalOnly ? 'task' : 'download_done' }}</span> <span class="material-icons text-2xl text-success">{{ isLocalOnly ? 'task' : 'download_done' }}</span>
</div> </div>
@@ -56,6 +57,11 @@
<span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span> <span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span>
</div> </div>
<!-- rss feed icon -->
<div v-if="rssFeed" class="absolute text-success top-0 left-0 z-10" :style="{ padding: 0.375 * sizeMultiplier + 'rem' }">
<span class="material-icons" :style="{ fontSize: sizeMultiplier * 1.5 + 'rem' }">rss_feed</span>
</div>
<!-- Series sequence --> <!-- Series sequence -->
<div v-if="seriesSequence && showSequence && !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="seriesSequence && showSequence && !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' }">#{{ seriesSequence }}</p> <p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequence }}</p>
@@ -212,7 +218,7 @@ export default {
return this.mediaMetadata.series return this.mediaMetadata.series
}, },
seriesSequence() { seriesSequence() {
return this.series ? this.series.sequence : null return this.series?.sequence || null
}, },
recentEpisode() { recentEpisode() {
// Only added to item when getting currently listening podcasts // Only added to item when getting currently listening podcasts
@@ -231,14 +237,14 @@ export default {
}, },
booksInSeries() { booksInSeries() {
// Only added to item object when collapseSeries is enabled // Only added to item object when collapseSeries is enabled
return this.collapsedSeries ? this.collapsedSeries.numBooks : 0 return this.collapsedSeries?.numBooks || 0
}, },
seriesSequenceList() { seriesSequenceList() {
return this.collapsedSeries ? this.collapsedSeries.seriesSequenceList : null return this.collapsedSeries?.seriesSequenceList || null
}, },
libraryItemIdsInSeries() { libraryItemIdsInSeries() {
// Only added to item object when collapseSeries is enabled // Only added to item object when collapseSeries is enabled
return this.collapsedSeries ? this.collapsedSeries.libraryItemIds || [] : [] return this.collapsedSeries?.libraryItemIds || []
}, },
displayTitle() { displayTitle() {
if (this.recentEpisode) return this.recentEpisode.title if (this.recentEpisode) return this.recentEpisode.title
@@ -282,28 +288,26 @@ export default {
}, },
userProgressPercent() { userProgressPercent() {
if (this.useEBookProgress) return Math.max(Math.min(1, this.userProgress.ebookProgress), 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 return Math.max(Math.min(1, this.userProgress?.progress || 0), 0) || 0
}, },
itemIsFinished() { itemIsFinished() {
return this.userProgress ? !!this.userProgress.isFinished : false return !!this.userProgress?.isFinished
}, },
showError() { showError() {
return this.numMissingParts || this.isMissing || this.isInvalid return this.numMissingParts || this.isMissing || this.isInvalid
}, },
playerIsLocal() {
return !!this.$store.state.playerIsLocal
},
localLibraryItemId() { localLibraryItemId() {
if (this.isLocal) return this.libraryItemId if (this.isLocal) return this.libraryItemId
return this.localLibraryItem ? this.localLibraryItem.id : null return this.localLibraryItem?.id || null
},
localEpisode() {
if (!this.recentEpisode || !this.localLibraryItem) return null
// Current recentEpisode is only implemented server side so this will always be the serverEpisodeId
return this.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId === this.recentEpisode.id)
}, },
isStreaming() { isStreaming() {
if (this.isPodcast) { if (this.isPodcast) {
if (this.playerIsLocal) { return this.$store.getters['getIsMediaStreaming'](this.libraryItemId, this.recentEpisode.id)
// Check is streaming local version of this episode
return false // episode cards not implemented for local yet
}
return this.$store.getters['getIsEpisodeStreaming'](this.libraryItemId, this.recentEpisode.id)
} else { } else {
return false // not yet necessary for books return false // not yet necessary for books
} }
@@ -379,13 +383,16 @@ export default {
showHasLocalDownload() { showHasLocalDownload() {
if (this.localLibraryItem || this.isLocal) { if (this.localLibraryItem || this.isLocal) {
if (this.recentEpisode && !this.isLocal) { if (this.recentEpisode && !this.isLocal) {
const localEpisode = this.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId === this.recentEpisode.id) return !!this.localEpisode
return !!localEpisode
} else { } else {
return true return true
} }
} }
return false return false
},
rssFeed() {
if (this.booksInSeries) return null
return this._libraryItem.rssFeed || null
} }
}, },
methods: { methods: {
@@ -435,18 +442,15 @@ export default {
return return
} }
if (this.localLibraryItem) { if (this.localEpisode) {
const localEpisode = this.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId === this.recentEpisode.id) // Play episode locally
if (localEpisode) { eventBus.$emit('play-item', {
// Play episode locally libraryItemId: this.localLibraryItemId,
eventBus.$emit('play-item', { episodeId: this.localEpisode.id,
libraryItemId: this.localLibraryItemId, serverLibraryItemId: this.libraryItemId,
episodeId: localEpisode.id, serverEpisodeId: this.recentEpisode.id
serverLibraryItemId: this.libraryItemId, })
serverEpisodeId: this.recentEpisode.id return
})
return
}
} }
eventBus.$emit('play-item', { libraryItemId: this.libraryItemId, episodeId: this.recentEpisode.id }) eventBus.$emit('play-item', { libraryItemId: this.libraryItemId, episodeId: this.recentEpisode.id })
@@ -468,25 +472,6 @@ export default {
editClick() { editClick() {
this.$emit('edit', this.libraryItem) this.$emit('edit', this.libraryItem)
}, },
toggleFinished() {
var updatePayload = {
isFinished: !this.itemIsFinished
}
this.isProcessingReadUpdate = true
var toast = this.$toast || this.$nuxt.$toast
var axios = this.$axios || this.$nuxt.$axios
axios
.$patch(`/api/me/progress/${this.libraryItemId}`, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
})
.catch((error) => {
console.error('Failed', error)
this.isProcessingReadUpdate = false
toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
})
},
rescan() { rescan() {
this.rescanning = true this.rescanning = true
this.$axios this.$axios
-19
View File
@@ -349,25 +349,6 @@ export default {
editClick() { editClick() {
this.$emit('edit', this.libraryItem) this.$emit('edit', this.libraryItem)
}, },
toggleFinished() {
var updatePayload = {
isFinished: !this.itemIsFinished
}
this.isProcessingReadUpdate = true
var toast = this.$toast || this.$nuxt.$toast
var axios = this.$axios || this.$nuxt.$axios
axios
.$patch(`/api/me/progress/${this.libraryItemId}`, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
})
.catch((error) => {
console.error('Failed', error)
this.isProcessingReadUpdate = false
toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
})
},
rescan() { rescan() {
this.rescanning = true this.rescanning = true
this.$axios this.$axios
+1 -1
View File
@@ -70,7 +70,7 @@ export default {
return this.libraryItem.isLocal return this.libraryItem.isLocal
}, },
localCover() { localCover() {
return this.libraryItem ? this.libraryItem.coverContentUrl : null return this.libraryItem?.coverContentUrl || null
}, },
squareAspectRatio() { squareAspectRatio() {
return this.bookCoverAspectRatio === 1 return this.bookCoverAspectRatio === 1
+21 -14
View File
@@ -1,20 +1,20 @@
<template> <template>
<modals-modal v-model="show" :width="350" height="100%"> <modals-modal v-model="show" :width="400" height="100%">
<template #outer> <template #outer>
<div v-if="currentChapter" class="absolute top-8 left-4 z-40 pt-0.5" style="max-width: 80%"> <div v-if="currentChapter" class="absolute top-10 left-4 z-40 pt-1" style="max-width: 80%">
<p class="text-white text-lg truncate">Current: {{ currentChapterTitle }}</p> <p class="text-white text-lg truncate">{{ chapters.length }} Chapters</p>
</div> </div>
</template> </template>
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false"> <div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
<div ref="container" 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 ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-secondary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label"> <ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="(chapter, index) in chapters"> <template v-for="(chapter, index) in chapters">
<li :key="chapter.id" :id="`chapter-row-${chapter.id}`" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="currentChapterId === chapter.id ? 'bg-bg bg-opacity-80' : ''" role="option" @click="clickedOption(chapter)"> <li :key="chapter.id" :id="`chapter-row-${chapter.id}`" class="text-gray-50 select-none relative py-4 cursor-pointer" :class="currentChapterId === chapter.id ? 'bg-primary bg-opacity-80' : ''" role="option" @click="clickedOption(chapter)">
<div class="relative flex items-center pl-3 pr-20"> <div class="relative flex items-center pl-3 pr-20">
<p class="font-normal block truncate text-sm text-white text-opacity-80">{{ index + 1 }} - {{ chapter.title }}</p> <p class="font-normal block truncate text-sm text-white text-opacity-80">{{ index + 1 }} - {{ chapter.title }}</p>
<div class="absolute top-0 right-3 -mt-0.5"> <div class="absolute top-0 right-3 -mt-0.5">
<span class="font-mono text-white text-opacity-90 leading-3 text-sm" style="letter-spacing: -0.5px">{{ $secondsToTimestamp(chapter.start) }}</span> <span class="font-mono text-white text-opacity-90 leading-3 text-sm" style="letter-spacing: -0.5px">{{ $secondsToTimestamp(chapter.start / _playbackRate) }}</span>
</div> </div>
</div> </div>
@@ -38,14 +38,17 @@ export default {
currentChapter: { currentChapter: {
type: Object, type: Object,
default: () => null default: () => null
} },
playbackRate: Number
}, },
data() { data() {
return {} return {}
}, },
watch: { watch: {
value(newVal) { value(newVal) {
this.$nextTick(this.scrollToChapter) if (newVal) {
this.$nextTick(this.scrollToChapter)
}
} }
}, },
computed: { computed: {
@@ -57,11 +60,15 @@ export default {
this.$emit('input', val) this.$emit('input', val)
} }
}, },
_playbackRate() {
if (!this.playbackRate || isNaN(this.playbackRate)) return 1
return this.playbackRate
},
currentChapterId() { currentChapterId() {
return this.currentChapter ? this.currentChapter.id : null return this.currentChapter?.id
}, },
currentChapterTitle() { currentChapterTitle() {
return this.currentChapter ? this.currentChapter.title : null return this.currentChapter?.title || null
} }
}, },
methods: { methods: {
@@ -71,12 +78,12 @@ export default {
scrollToChapter() { scrollToChapter() {
if (!this.currentChapterId) return if (!this.currentChapterId) return
var container = this.$refs.container const container = this.$refs.container
if (container) { if (container) {
var currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`) const currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
if (currChapterEl) { if (currChapterEl) {
var offsetTop = currChapterEl.offsetTop const offsetTop = currChapterEl.offsetTop
var containerHeight = container.clientHeight const containerHeight = container.clientHeight
container.scrollTo({ top: offsetTop - containerHeight / 2 }) container.scrollTo({ top: offsetTop - containerHeight / 2 })
} }
} }
+42 -5
View File
@@ -1,5 +1,5 @@
<template> <template>
<modals-modal v-model="show" :width="300" height="100%"> <modals-modal v-model="show" :width="width" height="100%">
<template #outer> <template #outer>
<div v-if="title" class="absolute top-8 left-4 z-40" style="max-width: 80%"> <div v-if="title" class="absolute top-8 left-4 z-40" style="max-width: 80%">
<p class="text-white text-xl truncate">{{ title }}</p> <p class="text-white text-xl truncate">{{ title }}</p>
@@ -7,11 +7,11 @@
</template> </template>
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false"> <div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20 p-2" style="max-height: 75%" @click.stop> <div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white/20 p-2" style="max-height: 75%" @click.stop>
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label"> <ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items"> <template v-for="item in itemsToShow">
<slot :name="item.value" :item="item" :selected="item.value === selected"> <slot :name="item.value" :item="item" :selected="item.value === selected">
<li :key="item.value" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" role="option" @click="clickedOption(item.value)"> <li :key="item.value" :ref="`item-${item.value}`" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" :style="{ paddingTop: itemPaddingY, paddingBottom: itemPaddingY }" role="option" @click="clickedOption(item.value)">
<div class="relative flex items-center px-3"> <div class="relative flex items-center px-3">
<span v-if="item.icon" class="material-icons-outlined text-xl mr-2 text-white text-opacity-80">{{ item.icon }}</span> <span v-if="item.icon" class="material-icons-outlined text-xl mr-2 text-white text-opacity-80">{{ item.icon }}</span>
<p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p> <p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p>
@@ -34,11 +34,27 @@ export default {
type: Array, type: Array,
default: () => [] default: () => []
}, },
selected: String // optional selected: [String, Number], // optional
itemPaddingY: {
type: String,
default: '16px'
},
width: {
type: Number,
default: 300
}
}, },
data() { data() {
return {} return {}
}, },
watch: {
show: {
immediate: true,
handler(newVal) {
if (newVal) this.$nextTick(this.init)
}
}
},
computed: { computed: {
show: { show: {
get() { get() {
@@ -47,11 +63,32 @@ export default {
set(val) { set(val) {
this.$emit('input', val) this.$emit('input', val)
} }
},
itemsToShow() {
return this.items.map((i) => {
if (typeof i === 'string') {
return {
text: i,
value: i
}
}
return i
})
} }
}, },
methods: { methods: {
clickedOption(action) { clickedOption(action) {
this.$emit('action', action) this.$emit('action', action)
},
init() {
if (this.selected && this.$refs[`item-${this.selected}`]?.[0]) {
// Set scroll position so that selected item is in the center
const containerOffset = this.$refs.container.offsetTop + this.$refs.container.clientHeight / 2
const scrollAmount = this.$refs[`item-${this.selected}`][0].offsetTop - containerOffset
this.$refs.container.scrollTo({
top: scrollAmount
})
}
} }
}, },
mounted() {} mounted() {}
+77
View File
@@ -0,0 +1,77 @@
<template>
<div ref="wrapper" class="modal modal-bg w-screen fixed bottom-0 left-0 flex items-center justify-center z-50" :class="halfScreen ? 'h-[50vh]' : 'h-screen'" @click.stop @touchstart.stop @touchend.stop>
<div ref="content" class="relative text-white h-full w-full bg-bg">
<slot />
</div>
</div>
</template>
<script>
export default {
props: {
value: Boolean,
processing: Boolean,
halfScreen: Boolean
},
data() {
return {
el: null,
content: null
}
},
watch: {
show(newVal) {
if (newVal) {
this.setShow()
} else {
this.setHide()
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
}
},
methods: {
setShow() {
this.$store.commit('globals/setIsModalOpen', true)
document.body.appendChild(this.el)
setTimeout(() => {
this.content.style.transform = 'translateY(0)'
}, 10)
document.documentElement.classList.add('modal-open')
},
setHide() {
this.$store.commit('globals/setIsModalOpen', false)
this.content.style.transform = 'translateY(100vh)'
setTimeout(() => {
this.el.remove()
document.documentElement.classList.remove('modal-open')
}, 250)
},
closeModalEvt() {
console.log('Close modal event')
this.show = false
}
},
mounted() {
this.$eventBus.$on('close-modal', this.closeModalEvt)
this.el = this.$refs.wrapper
this.content = this.$refs.content
this.content.style.transform = 'translateY(100vh)'
this.content.style.transition = 'transform 0.25s cubic-bezier(0.16, 1, 0.3, 1)'
this.el.remove()
},
beforeDestroy() {
this.$eventBus.$off('close-modal', this.closeModalEvt)
}
}
</script>
+2 -2
View File
@@ -7,10 +7,10 @@
</template> </template>
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false"> <div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop> <div class="w-full overflow-x-hidden overflow-y-auto bg-secondary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label"> <ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="library in libraries"> <template v-for="library in libraries">
<li :key="library.id" class="text-gray-50 select-none relative py-3 cursor-pointer hover:bg-black-400" :class="currentLibraryId === library.id ? 'bg-bg bg-opacity-80' : ''" role="option" @click="clickedOption(library)"> <li :key="library.id" class="text-gray-50 select-none relative py-3 cursor-pointer" :class="currentLibraryId === library.id ? 'bg-primary bg-opacity-80' : ''" role="option" @click="clickedOption(library)">
<div v-show="currentLibraryId === library.id" class="absolute top-0 left-0 w-0.5 bg-warning h-full" /> <div v-show="currentLibraryId === library.id" class="absolute top-0 left-0 w-0.5 bg-warning h-full" />
<div class="flex items-center px-3"> <div class="flex items-center px-3">
<ui-library-icon :icon="library.icon" /> <ui-library-icon :icon="library.icon" />
+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-10 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-10 w-10 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" />
+34 -13
View File
@@ -1,8 +1,8 @@
<template> <template>
<modals-modal v-model="show" :width="300" height="100%"> <modals-modal v-model="show" :width="300" height="100%">
<template #outer> <template #outer>
<div class="absolute top-8 left-4 z-40" style="max-width: 80%"> <div class="absolute top-10 left-4 z-40" style="max-width: 80%">
<p class="text-white text-lg truncate">Select Local Folder</p> <p class="text-white text-lg truncate">Select Download Location</p>
</div> </div>
</template> </template>
@@ -10,8 +10,9 @@
<div ref="container" 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 ref="container" 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>
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label"> <ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="folder in localFolders"> <template v-for="folder in localFolders">
<li :key="folder.id" :id="`folder-${folder.id}`" class="text-gray-50 select-none relative py-4" role="option" @click="clickedOption(folder)"> <li :key="folder.id" :id="`folder-${folder.id}`" class="text-gray-50 select-none relative py-5" role="option" @click="clickedOption(folder)">
<div class="relative flex items-center pl-3" style="padding-right: 4.5rem"> <div class="relative flex items-center pl-3" style="padding-right: 4.5rem">
<span class="material-icons-outlined text-xl mr-2 text-white text-opacity-80">folder</span>
<p class="font-normal block truncate text-sm text-white text-opacity-80">{{ folder.name }}</p> <p class="font-normal block truncate text-sm text-white text-opacity-80">{{ folder.name }}</p>
</div> </div>
</li> </li>
@@ -24,36 +25,56 @@
<script> <script>
export default { export default {
props: {
value: Boolean,
mediaType: String
},
data() { data() {
return { return {
localFolders: [] localFolders: []
} }
}, },
watch: { watch: {
value(newVal) { show(newVal) {
this.$nextTick(this.init) if (newVal) {
this.$nextTick(this.init)
}
} }
}, },
computed: { computed: {
show: { show: {
get() { get() {
return this.value return this.$store.state.globals.showSelectLocalFolderModal
}, },
set(val) { set(val) {
this.$emit('input', val) this.$store.commit('globals/setShowSelectLocalFolderModal', val)
} }
},
modalData() {
return this.$store.state.globals.localFolderSelectData || {}
},
callback() {
return this.modalData.callback
},
mediaType() {
return this.modalData.mediaType
} }
}, },
methods: { methods: {
clickedOption(folder) { clickedOption(folder) {
this.$emit('select', folder) this.show = false
if (!this.callback) {
console.error('Callback not set')
return
}
this.callback(folder)
}, },
async init() { async init() {
var localFolders = (await this.$db.getLocalFolders()) || [] const localFolders = (await this.$db.getLocalFolders()) || []
if (!localFolders.some((lf) => lf.id === `internal-${this.mediaType}`)) {
localFolders.push({
id: `internal-${this.mediaType}`,
name: 'Internal App Storage',
mediaType: this.mediaType
})
}
this.localFolders = localFolders.filter((lf) => lf.mediaType == this.mediaType) this.localFolders = localFolders.filter((lf) => lf.mediaType == this.mediaType)
} }
}, },
+39 -34
View File
@@ -1,41 +1,49 @@
<template> <template>
<modals-modal v-model="show" :width="360" height="100%" :processing="processing"> <modals-fullscreen-modal v-model="show" :processing="processing">
<template #outer> <div class="flex items-end justify-between h-16 px-4 pb-2">
<div class="absolute top-8 left-4 z-40"> <h1 class="text-lg">Add to Playlist</h1>
<p class="text-white text-2xl truncate">Add to Playlist</p> <button class="flex" @click="show = false">
</div> <span class="material-icons">close</span>
</template> </button>
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false"> </div>
<div ref="container" class="w-full rounded-lg bg-primary border border-white border-opacity-20 overflow-y-auto overflow-x-hidden" style="max-height: 80vh" @click.stop.prevent>
<div class="w-full h-full p-4" v-show="showPlaylistNameInput">
<div class="flex mb-4 items-center">
<div class="w-9 h-9 flex items-center justify-center rounded-full hover:bg-white hover:bg-opacity-10 cursor-pointer" @click.stop="showPlaylistNameInput = false">
<span class="material-icons text-3xl">arrow_back</span>
</div>
<p class="text-xl pl-2">New Playlist</p>
<div class="flex-grow" />
</div>
<ui-text-input-with-label v-model="newPlaylistName" label="Name" /> <!-- create new playlist form -->
<div class="flex justify-end mt-6"> <div v-if="showPlaylistNameInput" class="w-full h-full max-h-[calc(100vh-128px)] flex items-center">
<ui-btn color="success" :loading="processing" class="w-full" @click.stop="submitCreatePlaylist">Create</ui-btn> <div class="w-full px-4">
<div class="flex mb-4 items-center">
<div class="w-9 h-9 flex items-center justify-center rounded-full hover:bg-white hover:bg-opacity-10 cursor-pointer" @click.stop="showPlaylistNameInput = false">
<span class="material-icons text-3xl">arrow_back</span>
</div> </div>
<p class="text-xl pl-2">New Playlist</p>
<div class="flex-grow" />
</div> </div>
<div class="w-full h-full" v-show="!showPlaylistNameInput">
<template v-for="playlist in sortedPlaylists"> <ui-text-input-with-label v-model="newPlaylistName" label="Name" />
<modals-playlists-playlist-row :key="playlist.id" :in-playlist="playlist.isItemIncluded" :playlist="playlist" @click="clickPlaylist" /> <div class="flex justify-end mt-6">
</template> <ui-btn color="success" :loading="processing" class="w-full" @click.stop="submitCreatePlaylist">Create</ui-btn>
<div v-if="!playlists.length" class="flex h-32 items-center justify-center">
<p class="text-xl">{{ loading ? 'Loading..' : 'No Playlists' }}</p>
</div>
<ui-btn :loading="processing" color="success" class="w-full flex items-center justify-center" @click.stop="createPlaylist">
<span class="material-icons text-xl">add</span>
<p class="text-base pl-2">New Playlist</p>
</ui-btn>
</div> </div>
</div> </div>
</div> </div>
</modals-modal>
<!-- playlists list -->
<div v-if="!showPlaylistNameInput" class="w-full overflow-y-auto overflow-x-hidden h-full max-h-[calc(100vh-128px)]">
<div class="w-full h-full" v-show="!showPlaylistNameInput">
<template v-for="playlist in sortedPlaylists">
<modals-playlists-playlist-row :key="playlist.id" :in-playlist="playlist.isItemIncluded" :playlist="playlist" @click="clickPlaylist" @close="show = false" />
</template>
<div v-if="!playlists.length" class="flex h-full items-center justify-center">
<p class="text-xl">{{ loading ? 'Loading..' : 'No Playlists' }}</p>
</div>
</div>
</div>
<!-- create playlist btn -->
<div v-if="!showPlaylistNameInput" class="flex items-start justify-between h-16 pt-2 absolute bottom-0 left-0 w-full">
<ui-btn :loading="processing" color="success" class="w-full h-full flex items-center justify-center" @click.stop="createPlaylist">
<p class="text-base">Create New Playlist</p>
</ui-btn>
</div>
</modals-fullscreen-modal>
</template> </template>
<script> <script>
@@ -131,7 +139,6 @@ export default {
.$post(`/api/playlists/${playlist.id}/batch/remove`, { items: itemObjects }) .$post(`/api/playlists/${playlist.id}/batch/remove`, { items: itemObjects })
.then((updatedPlaylist) => { .then((updatedPlaylist) => {
console.log(`Items removed from playlist`, updatedPlaylist) console.log(`Items removed from playlist`, updatedPlaylist)
this.$toast.success('Playlist item(s) removed')
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to remove items from playlist', error) console.error('Failed to remove items from playlist', error)
@@ -150,7 +157,6 @@ export default {
.$post(`/api/playlists/${playlist.id}/batch/add`, { items: itemObjects }) .$post(`/api/playlists/${playlist.id}/batch/add`, { items: itemObjects })
.then((updatedPlaylist) => { .then((updatedPlaylist) => {
console.log(`Items added to playlist`, updatedPlaylist) console.log(`Items added to playlist`, updatedPlaylist)
this.$toast.success('Items added to playlist')
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to add items to playlist', error) console.error('Failed to add items to playlist', error)
@@ -182,7 +188,6 @@ export default {
.$post('/api/playlists', newPlaylist) .$post('/api/playlists', newPlaylist)
.then((data) => { .then((data) => {
console.log('New playlist created', data) console.log('New playlist created', data)
this.$toast.success(`Playlist "${data.name}" created`)
this.newPlaylistName = '' this.newPlaylistName = ''
this.showPlaylistNameInput = false this.showPlaylistNameInput = false
}) })
+10 -7
View File
@@ -1,16 +1,15 @@
<template> <template>
<div :key="playlist.id" :id="`playlist-row-${playlist.id}`" class="flex items-center px-3 py-2 justify-start cursor-pointer relative" :class="inPlaylist ? 'bg-bg bg-opacity-60' : 'bg-opacity-20'"> <div :key="playlist.id" :id="`playlist-row-${playlist.id}`" class="flex items-center px-3 py-2 justify-start relative border-y border-white/5" :class="inPlaylist ? 'bg-primary/20' : ''">
<div v-if="inPlaylist" class="absolute top-0 left-0 h-full w-1 bg-success z-10" /> <div v-if="inPlaylist" class="absolute top-0 left-0 h-full w-1 bg-success z-10" />
<div class="w-16 max-w-16 text-center"> <div class="w-14 min-w-[56px] text-center" @click.stop="clickCover">
<covers-playlist-cover :items="items" :width="52" :height="52" /> <covers-playlist-cover :items="items" :width="52" :height="52" />
</div> </div>
<div class="flex-grow overflow-hidden"> <div class="flex-grow overflow-hidden">
<p class="pl-1 pr-2 truncate text-sm">{{ playlist.name }}</p> <p class="px-2 truncate text-sm">{{ playlist.name }}</p>
</div> </div>
<div class="w-24 min-w-[96px] px-1">
<div class="absolute top-0 right-0 h-full flex items-center px-3" @click.stop="click"> <ui-btn v-if="inPlaylist" small class="w-full" @click.stop="click">Remove</ui-btn>
<span v-if="inPlaylist" class="material-icons text-error">remove</span> <ui-btn v-else small class="w-full" @click.stop="click">Add</ui-btn>
<span v-else class="material-icons text-success">add</span>
</div> </div>
</div> </div>
</template> </template>
@@ -35,6 +34,10 @@ export default {
methods: { methods: {
click() { click() {
this.$emit('click', this.playlist) this.$emit('click', this.playlist)
},
clickCover() {
this.$emit('close')
this.$router.push(`/playlist/${this.playlist.id}`)
} }
}, },
mounted() {} mounted() {}
@@ -0,0 +1,90 @@
<template>
<div class="w-full py-2 text-sm">
<div class="flex -mb-px">
<div class="w-1/2 h-6 rounded-tl-md relative border border-white/10 flex items-center justify-center cursor-pointer" :class="!showAdvancedView ? 'text-white bg-bg border-b-bg' : 'text-gray-400 bg-primary bg-opacity-70'" @click="showAdvancedView = false">
<p class="text-sm">{{ $strings.HeaderRSSFeedGeneral }}</p>
</div>
<div class="w-1/2 h-6 rounded-tr-md relative border border-white/10 flex items-center justify-center -ml-px cursor-pointer" :class="showAdvancedView ? 'text-white bg-bg border-b-bg' : 'text-gray-400 bg-primary bg-opacity-70'" @click="showAdvancedView = true">
<p class="text-sm">{{ $strings.HeaderAdvanced }}</p>
</div>
</div>
<div class="px-2 py-4 md:p-4 border border-white/10 rounded-b-md mr-px" style="min-height: 220px">
<template v-if="!showAdvancedView">
<div class="flex-grow pt-2 mb-2">
<ui-checkbox v-model="preventIndexing" :label="$strings.LabelPreventIndexing" checkbox-bg="primary" border-color="gray-600" label-class="pl-2" />
</div>
</template>
<template v-else>
<div class="flex-grow pt-2 mb-2">
<ui-checkbox v-model="preventIndexing" :label="$strings.LabelPreventIndexing" checkbox-bg="primary" border-color="gray-600" label-class="pl-2" />
</div>
<div class="w-full relative mb-1">
<ui-text-input-with-label v-model="ownerName" :label="$strings.LabelRSSFeedCustomOwnerName" />
</div>
<div class="w-full relative mb-1">
<ui-text-input-with-label v-model="ownerEmail" :label="$strings.LabelRSSFeedCustomOwnerEmail" />
</div>
</template>
</div>
</div>
</template>
<script>
export default {
props: {
value: {
type: Object,
default: () => {
return {
preventIndexing: true,
ownerName: '',
ownerEmail: ''
}
}
}
},
data() {
return {
showAdvancedView: false
}
},
watch: {},
computed: {
preventIndexing: {
get() {
return this.value.preventIndexing
},
set(value) {
this.$emit('input', {
...this.value,
preventIndexing: value
})
}
},
ownerName: {
get() {
return this.value.ownerName
},
set(value) {
this.$emit('input', {
...this.value,
ownerName: value
})
}
},
ownerEmail: {
get() {
return this.value.ownerEmail
},
set(value) {
this.$emit('input', {
...this.value,
ownerEmail: value
})
}
}
},
methods: {},
mounted() {}
}
</script>
+188
View File
@@ -0,0 +1,188 @@
<template>
<modals-fullscreen-modal v-model="show" :processing="processing">
<div class="flex items-end justify-end h-16 pr-4 pb-2 mb-2">
<!-- <h1 class="text-lg">RSS Feed</h1> -->
<button class="flex" @click="show = false">
<span class="material-icons">close</span>
</button>
</div>
<div class="w-full px-2 h-[calc(100%-138px)] overflow-y-auto">
<div v-if="currentFeed" class="w-full">
<div class="w-full relative">
<h1 class="text-lg mb-4">RSS feed is open</h1>
<ui-text-input v-model="currentFeed.feedUrl" class="text-sm" readonly />
<span class="material-icons absolute right-2 bottom-2 p-0.5 text-base" :class="linkCopied ? 'text-success' : 'text-gray-300'" @click="copyToClipboard(currentFeed.feedUrl)">{{ linkCopied ? 'done' : 'content_copy' }}</span>
</div>
<div v-if="currentFeed.meta" class="mt-5">
<div class="flex py-0.5">
<div class="w-48">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedPreventIndexing }}</span>
</div>
<div>{{ currentFeed.meta.preventIndexing ? 'Yes' : 'No' }}</div>
</div>
<div v-if="currentFeed.meta.ownerName" class="flex py-0.5">
<div class="w-48">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedCustomOwnerName }}</span>
</div>
<div>{{ currentFeed.meta.ownerName }}</div>
</div>
<div v-if="currentFeed.meta.ownerEmail" class="flex py-0.5">
<div class="w-48">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedCustomOwnerEmail }}</span>
</div>
<div>{{ currentFeed.meta.ownerEmail }}</div>
</div>
</div>
</div>
<div v-else class="w-full">
<div class="w-full relative mb-2">
<ui-text-input-with-label v-model="newFeedSlug" :label="$strings.LabelRSSFeedSlug" />
<p class="text-xs text-gray-400 py-0.5 px-1">{{ $getString('MessageFeedURLWillBe', [demoFeedUrl]) }}</p>
</div>
<modals-rssfeeds-rss-feed-metadata-builder v-model="metadataDetails" />
<p v-if="isHttp" class="w-full pt-2 text-warning text-xs">{{ $strings.NoteRSSFeedPodcastAppsHttps }}</p>
<p v-if="hasEpisodesWithoutPubDate" class="w-full pt-2 text-warning text-xs">{{ $strings.NoteRSSFeedPodcastAppsPubDate }}</p>
</div>
</div>
<div v-show="userIsAdminOrUp" class="flex items-center px-2 h-16">
<div class="flex-grow" />
<ui-btn v-if="currentFeed" color="error" class="w-full" @click="closeFeed">{{ $strings.ButtonCloseFeed }}</ui-btn>
<ui-btn v-else color="success" class="w-full" @click="openFeed">{{ $strings.ButtonOpenFeed }}</ui-btn>
</div>
</modals-fullscreen-modal>
</template>
<script>
export default {
data() {
return {
processing: false,
newFeedSlug: null,
currentFeed: null,
metadataDetails: {
preventIndexing: true,
ownerName: '',
ownerEmail: ''
},
linkCopied: false
}
},
watch: {
show: {
immediate: true,
handler(newVal) {
if (newVal) {
this.linkCopied = false
this.init()
}
}
}
},
computed: {
show: {
get() {
return this.$store.state.globals.showRSSFeedOpenCloseModal
},
set(val) {
this.$store.commit('globals/setShowRSSFeedOpenCloseModal', val)
}
},
serverAddress() {
return this.$store.getters['user/getServerAddress']
},
rssFeedEntity() {
return this.$store.state.globals.rssFeedEntity || {}
},
entityId() {
return this.rssFeedEntity.id
},
entityType() {
return this.rssFeedEntity.type
},
entityFeed() {
return this.rssFeedEntity.feed
},
hasEpisodesWithoutPubDate() {
return !!this.rssFeedEntity.hasEpisodesWithoutPubDate
},
title() {
return this.rssFeedEntity.name
},
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
demoFeedUrl() {
return `${this.serverAddress}/feed/${this.newFeedSlug}`
},
isHttp() {
return !!this.serverAddress?.startsWith('http://')
}
},
methods: {
openFeed() {
if (!this.newFeedSlug) {
this.$toast.error('Must set a feed slug')
return
}
const sanitized = this.$sanitizeSlug(this.newFeedSlug)
if (this.newFeedSlug !== sanitized) {
this.newFeedSlug = sanitized
this.$toast.warning('Slug had to be modified - Run again')
return
}
const payload = {
serverAddress: this.serverAddress,
slug: this.newFeedSlug,
metadataDetails: this.metadataDetails
}
console.log('Payload', payload)
this.$axios
.$post(`/api/feeds/${this.entityType}/${this.entityId}/open`, payload)
.then((data) => {
console.log('Opened RSS Feed', data)
this.currentFeed = data.feed
})
.catch((error) => {
console.error('Failed to open RSS Feed', error)
const errorMsg = error.response ? error.response.data : null
this.$toast.error(errorMsg || 'Failed to open RSS Feed')
})
},
async copyToClipboard(str) {
await this.$copyToClipboard(str)
this.linkCopied = true
},
closeFeed() {
this.processing = true
this.$axios
.$post(`/api/feeds/${this.currentFeed.id}/close`)
.then(() => {
this.$toast.success(this.$strings.ToastRSSFeedCloseSuccess)
this.show = false
})
.catch((error) => {
console.error('Failed to close RSS feed', error)
this.$toast.error(this.$strings.ToastRSSFeedCloseFailed)
})
.finally(() => {
this.processing = false
})
},
init() {
if (!this.entityId) return
this.newFeedSlug = this.entityId
this.currentFeed = this.entityFeed
}
},
mounted() {}
}
</script>
+166 -48
View File
@@ -1,28 +1,17 @@
<template> <template>
<div id="comic-reader" class="w-full h-full relative"> <div id="comic-reader" class="w-full h-full relative">
<div v-show="showPageMenu" v-click-outside="clickOutsideObj" class="pagemenu absolute top-12 right-16 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-52"> <modals-modal v-model="showInfoMenu" height="90%">
<div v-for="(file, index) in pages" :key="file" class="w-full cursor-pointer hover:bg-black-200 px-2 py-1" :class="page === index ? 'bg-black-200' : ''" @click.stop="setPage(index)"> <div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click.stop="showInfoMenu = false">
<p class="text-sm truncate">{{ file }}</p> <div class="w-full overflow-x-hidden overflow-y-auto bg-secondary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
<div v-for="key in comicMetadataKeys" :key="key" class="w-full px-2 py-1">
<p class="text-xs">
<strong>{{ key }}</strong>
: {{ comicMetadata[key] }}
</p>
</div>
</div>
</div> </div>
</div> </modals-modal>
<div v-show="showInfoMenu" v-click-outside="clickedOutsideInfoMenu" class="pagemenu absolute top-12 right-0 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-full" @click.stop.prevent>
<div v-for="key in comicMetadataKeys" :key="key" class="w-full px-2 py-1">
<p class="text-xs">
<strong>{{ key }}</strong>
: {{ comicMetadata[key] }}
</p>
</div>
</div>
<div v-if="comicMetadata" class="absolute top-0 right-3 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="clickShowInfoMenu">
<span class="material-icons text-lg">more</span>
</div>
<div class="absolute top-0 right-16 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @mouseup.prevent @click.stop.prevent="clickShowPageMenu">
<span class="material-icons text-lg">menu</span>
</div>
<div class="absolute top-0 left-3 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20" @click.stop>
<p class="font-mono">{{ page + 1 }} / {{ numPages }}</p>
</div>
<div class="overflow-hidden m-auto comicwrapper relative"> <div class="overflow-hidden m-auto comicwrapper relative">
<div class="h-full flex justify-center"> <div class="h-full flex justify-center">
@@ -33,6 +22,13 @@
<ui-loading-indicator /> <ui-loading-indicator />
</div> </div>
</div> </div>
<div class="fixed left-0 h-8 w-full bg-primary px-4 flex items-center text-white/80" :style="{ bottom: isPlayerOpen ? '120px' : '0px' }">
<div class="flex-grow" />
<p class="text-xs">{{ page }} / {{ numPages }}</p>
</div>
<modals-dialog v-model="showPageMenu" :items="pageItems" :selected="page" :width="360" item-padding-y="8px" @action="setPage" />
</div> </div>
</template> </template>
@@ -47,7 +43,13 @@ Archive.init({
export default { export default {
props: { props: {
url: String url: String,
libraryItem: {
type: Object,
default: () => {}
},
isLocal: Boolean,
keepProgress: Boolean
}, },
data() { data() {
return { return {
@@ -62,11 +64,7 @@ export default {
loadTimeout: null, loadTimeout: null,
loadedFirstPage: false, loadedFirstPage: false,
comicMetadata: null, comicMetadata: null,
clickOutsideObj: { pageMenuWidth: 256
handler: this.clickedOutside,
events: ['mousedown'],
isActive: true
}
} }
}, },
watch: { watch: {
@@ -78,31 +76,127 @@ export default {
} }
}, },
computed: { computed: {
userToken() {
return this.$store.getters['user/getToken']
},
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
},
comicMetadataKeys() { comicMetadataKeys() {
return this.comicMetadata ? Object.keys(this.comicMetadata) : [] return this.comicMetadata ? Object.keys(this.comicMetadata) : []
}, },
canGoNext() { canGoNext() {
return this.page < this.numPages - 1 return this.page < this.numPages
}, },
canGoPrev() { canGoPrev() {
return this.page > 0 return this.page > 1
},
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)
},
savedPage() {
if (!this.keepProgress) return 0
// Validate ebookLocation is a number
if (!this.userItemProgress?.ebookLocation || isNaN(this.userItemProgress.ebookLocation)) return 0
return Number(this.userItemProgress.ebookLocation)
},
selectedCleanedPage() {
return this.cleanedPageNames[this.page - 1]
},
cleanedPageNames() {
return (
this.pages?.map((p) => {
if (p.length > 40) {
let firstHalf = p.slice(0, 18)
let lastHalf = p.slice(p.length - 17)
return `${firstHalf} ... ${lastHalf}`
}
return p
}) || []
)
},
pageItems() {
let index = 1
return this.cleanedPageNames.map((p) => {
return {
text: p,
value: index++
}
})
},
isPlayerOpen() {
return this.$store.getters['getIsPlayerOpen']
} }
}, },
methods: { methods: {
async updateProgress() {
if (!this.keepProgress) return
if (!this.numPages) {
console.error('Num pages not loaded')
return
}
if (this.savedPage === this.page) {
return
}
const payload = {
ebookLocation: String(this.page),
ebookProgress: Math.max(0, Math.min(1, (Number(this.page) - 1) / Number(this.numPages)))
}
// Update local item
if (this.localLibraryItemId) {
const localPayload = {
localLibraryItemId: this.localLibraryItemId,
...payload
}
const localResponse = await this.$db.updateLocalEbookProgress(localPayload)
if (localResponse.localMediaProgress) {
this.$store.commit('globals/updateLocalMediaProgress', localResponse.localMediaProgress)
}
}
// Update server item
if (this.serverLibraryItemId) {
this.$axios.$patch(`/api/me/progress/${this.serverLibraryItemId}`, payload).catch((error) => {
console.error('ComicReader.updateProgress failed:', error)
})
}
},
clickShowInfoMenu() { clickShowInfoMenu() {
this.showInfoMenu = !this.showInfoMenu this.showInfoMenu = !this.showInfoMenu
this.showPageMenu = false this.showPageMenu = false
}, },
clickShowPageMenu() { clickShowPageMenu() {
if (!this.numPages) return
this.showPageMenu = !this.showPageMenu this.showPageMenu = !this.showPageMenu
this.showInfoMenu = false this.showInfoMenu = false
}, },
clickedOutside() {
this.showPageMenu = false
},
clickedOutsideInfoMenu() {
this.showInfoMenu = false
},
next() { next() {
if (!this.canGoNext) return if (!this.canGoNext) return
this.setPage(this.page + 1) this.setPage(this.page + 1)
@@ -111,13 +205,15 @@ export default {
if (!this.canGoPrev) return if (!this.canGoPrev) return
this.setPage(this.page - 1) this.setPage(this.page - 1)
}, },
setPage(index) { setPage(page) {
if (index < 0 || index > this.numPages - 1) { if (page <= 0 || page > this.numPages) {
return return
} }
this.showPageMenu = false this.showPageMenu = false
const filename = this.pages[index] const filename = this.pages[page - 1]
this.page = index this.page = page
this.updateProgress()
return this.extractFile(filename) return this.extractFile(filename)
}, },
setLoadTimeout() { setLoadTimeout() {
@@ -147,11 +243,14 @@ export default {
}, },
async extract() { async extract() {
this.loading = true this.loading = true
console.log('Extracting', this.url)
var buff = await this.$axios.$get(this.url, { const buff = await this.$axios.$get(this.url, {
responseType: 'blob' responseType: 'blob',
headers: {
Authorization: `Bearer ${this.userToken}`
}
}) })
const archive = await Archive.open(buff) const archive = await Archive.open(buff)
const originalFilesObject = await archive.getFilesObject() const originalFilesObject = await archive.getFilesObject()
// to support images in subfolders we need to flatten the object // to support images in subfolders we need to flatten the object
@@ -166,10 +265,33 @@ export default {
this.numPages = this.pages.length this.numPages = this.pages.length
// Calculate page menu size
const largestFilename = this.cleanedPageNames
.map((p) => p)
.sort((a, b) => a.length - b.length)
.pop()
const pEl = document.createElement('p')
pEl.innerText = largestFilename
pEl.style.fontSize = '0.875rem'
pEl.style.opacity = 0
pEl.style.position = 'absolute'
document.body.appendChild(pEl)
const textWidth = pEl.getBoundingClientRect()?.width
if (textWidth) {
this.pageMenuWidth = textWidth + (16 + 5 + 2 + 5)
}
pEl.remove()
if (this.pages.length) { if (this.pages.length) {
this.loading = false this.loading = false
await this.setPage(0)
const startPage = this.savedPage > 0 && this.savedPage <= this.numPages ? this.savedPage : 1
await this.setPage(startPage)
this.loadedFirstPage = true this.loadedFirstPage = true
this.$emit('loaded', {
hasMetadata: this.comicMetadata
})
} else { } else {
this.$toast.error('Unable to extract pages') this.$toast.error('Unable to extract pages')
this.loading = false this.loading = false
@@ -196,7 +318,6 @@ export default {
return flattenObject(filesObject) return flattenObject(filesObject)
}, },
async extractXmlFile(filename) { async extractXmlFile(filename) {
console.log('extracting xml filename', filename)
try { try {
var file = await this.filesObject[filename].extract() var file = await this.filesObject[filename].extract()
var reader = new FileReader() var reader = new FileReader()
@@ -261,9 +382,6 @@ export default {
padding-top: 36px; padding-top: 36px;
} }
.pagemenu {
max-height: calc(100% - 80px);
}
.comicimg { .comicimg {
height: 100%; height: 100%;
margin: auto; margin: auto;
+100 -20
View File
@@ -2,11 +2,9 @@
<div id="epub-frame" class="w-full"> <div id="epub-frame" class="w-full">
<div id="viewer" class="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 px-4 flex items-center" :class="isLightTheme ? 'bg-white text-black' : 'bg-primary text-white/80'" :style="{ bottom: isPlayerOpen ? '120px' : '0px' }">
<p class="text-xs">epub</p>
<div class="flex-grow" /> <div class="flex-grow" />
<p class="text-xs">{{ progress }}%</p>
<p class="text-sm">{{ progress }}%</p>
</div> </div>
</div> </div>
</template> </template>
@@ -21,7 +19,8 @@ export default {
type: Object, type: Object,
default: () => {} default: () => {}
}, },
isLocal: Boolean isLocal: Boolean,
keepProgress: Boolean
}, },
data() { data() {
return { return {
@@ -29,15 +28,23 @@ export default {
book: null, book: null,
/** @type {ePub.Rendition} */ /** @type {ePub.Rendition} */
rendition: null, rendition: null,
progress: 0 progress: 0,
ereaderSettings: {
theme: 'dark',
fontScale: 100,
lineSpacing: 115
}
} }
}, },
watch: { watch: {
playerLibraryItemId() { isPlayerOpen() {
this.updateHeight() this.updateHeight()
} }
}, },
computed: { computed: {
userToken() {
return this.$store.getters['user/getToken']
},
/** @returns {string} */ /** @returns {string} */
libraryItemId() { libraryItemId() {
return this.libraryItem?.id return this.libraryItem?.id
@@ -58,15 +65,15 @@ export default {
} }
return null return null
}, },
playerLibraryItemId() { isPlayerOpen() {
return this.$store.state.playerLibraryItemId return this.$store.getters['getIsPlayerOpen']
}, },
readerHeightOffset() { readerHeightOffset() {
return this.playerLibraryItemId ? 196 : 96 return this.isPlayerOpen ? 204 : 104
}, },
/** @returns {Array<ePub.NavItem>} */ /** @returns {Array<ePub.NavItem>} */
chapters() { chapters() {
return this.book ? this.book.navigation.toc : [] return this.book?.navigation?.toc || []
}, },
userItemProgress() { userItemProgress() {
if (this.isLocal) return this.localItemProgress if (this.isLocal) return this.localItemProgress
@@ -80,9 +87,53 @@ export default {
}, },
localStorageLocationsKey() { localStorageLocationsKey() {
return `ebookLocations-${this.libraryItemId}` return `ebookLocations-${this.libraryItemId}`
},
savedEbookLocation() {
if (!this.keepProgress) return null
if (!this.userItemProgress?.ebookLocation) return null
// Validate ebookLocation is an epubcfi
if (!String(this.userItemProgress.ebookLocation).startsWith('epubcfi')) return null
return this.userItemProgress.ebookLocation
},
isLightTheme() {
return this.ereaderSettings.theme === 'light'
},
themeRules() {
const isDark = this.ereaderSettings.theme === 'dark'
const fontColor = isDark ? '#fff' : '#000'
const backgroundColor = isDark ? 'rgb(35 35 35)' : 'rgb(255, 255, 255)'
const lineSpacing = this.ereaderSettings.lineSpacing / 100
const fontScale = this.ereaderSettings.fontScale / 100
return {
'*': {
color: `${fontColor}!important`,
'background-color': `${backgroundColor}!important`,
'line-height': lineSpacing * fontScale + 'rem!important'
},
a: {
color: `${fontColor}!important`
}
}
} }
}, },
methods: { methods: {
updateSettings(settings) {
this.ereaderSettings = settings
if (!this.rendition) return
this.applyTheme()
const fontScale = settings.fontScale || 100
this.rendition.themes.fontSize(`${fontScale}%`)
this.rendition.spread(settings.spread || 'auto')
},
goToChapter(href) {
return this.rendition?.display(href)
},
updateHeight() { updateHeight() {
if (this.rendition && this.rendition.resize) { if (this.rendition && this.rendition.resize) {
this.rendition.resize(window.innerWidth, window.innerHeight - this.readerHeightOffset) this.rendition.resize(window.innerWidth, window.innerHeight - this.readerHeightOffset)
@@ -104,6 +155,8 @@ export default {
* @param {string} payload.ebookProgress - eBook Progress Percentage * @param {string} payload.ebookProgress - eBook Progress Percentage
*/ */
async updateProgress(payload) { async updateProgress(payload) {
if (!this.keepProgress) return
// Update local item // Update local item
if (this.localLibraryItemId) { if (this.localLibraryItemId) {
const localPayload = { const localPayload = {
@@ -210,7 +263,7 @@ export default {
}, },
/** @param {string} location - CFI of the new location */ /** @param {string} location - CFI of the new location */
relocated(location) { relocated(location) {
if (this.userItemProgress?.ebookLocation === location.start.cfi) { if (this.savedEbookLocation === location.start.cfi) {
return return
} }
@@ -231,11 +284,15 @@ export default {
/** @type {EpubReader} */ /** @type {EpubReader} */
const reader = this const reader = this
console.log('initEpub', reader.url)
/** @type {ePub.Book} */ /** @type {ePub.Book} */
reader.book = new ePub(reader.url, { reader.book = new ePub(reader.url, {
width: window.innerWidth, width: window.innerWidth,
height: window.innerHeight - this.readerHeightOffset height: window.innerHeight - this.readerHeightOffset,
openAs: 'epub',
requestHeaders: {
Authorization: `Bearer ${this.userToken}`
}
}) })
/** @type {ePub.Rendition} */ /** @type {ePub.Rendition} */
@@ -247,16 +304,33 @@ export default {
flow: 'paginated' flow: 'paginated'
}) })
// load saved progress
reader.rendition.display(this.userItemProgress?.ebookLocation || reader.book.locations.start)
// load style
reader.rendition.themes.default({ '*': { color: '#fff!important' } })
reader.book.ready.then(() => { reader.book.ready.then(() => {
// load saved progress
// when not checking spine first uncaught exception is thrown
if (this.savedEbookLocation && reader.book.spine.get(this.savedEbookLocation)) {
reader.rendition.display(this.savedEbookLocation)
} else {
reader.rendition.display(reader.book.locations.start)
}
reader.rendition.on('rendered', () => {
this.applyTheme()
})
// set up event listeners // set up event listeners
reader.rendition.on('relocated', reader.relocated) reader.rendition.on('relocated', reader.relocated)
reader.rendition.on('displayError', (err) => {
console.log('Display error', err)
})
reader.rendition.on('touchstart', (event) => {
this.$emit('touchstart', event)
})
reader.rendition.on('touchend', (event) => {
this.$emit('touchend', event)
})
// load ebook cfi locations // load ebook cfi locations
const savedLocations = this.loadLocations() const savedLocations = this.loadLocations()
if (savedLocations) { if (savedLocations) {
@@ -267,6 +341,12 @@ export default {
}) })
} }
}) })
},
applyTheme() {
if (!this.rendition) return
this.rendition.getContents().forEach((c) => {
c.addStylesheetRules(this.themeRules)
})
} }
}, },
beforeDestroy() { beforeDestroy() {
+16 -13
View File
@@ -1,12 +1,8 @@
<template> <template>
<div class="mobi-ebook-viewer w-full relative"> <div class="mobi-ebook-viewer w-full relative">
<div class="absolute overflow-hidden left-0 right-0 w-full max-w-full m-auto z-10 border border-black border-opacity-20 shadow-md bg-white"> <div class="absolute overflow-hidden left-0 top-0 w-screen max-w-screen m-auto z-10 border border-black border-opacity-20 shadow-md bg-white">
<iframe title="html-viewer" class="w-full overflow-hidden"> Loading </iframe> <iframe title="html-viewer" class="w-full overflow-hidden"> Loading </iframe>
</div> </div>
<div class="fixed bottom-0 left-0 h-8 w-full bg-bg px-2 flex items-center z-20" :style="{ bottom: playerLibraryItemId ? '120px' : '0px' }">
<p class="text-xs">mobi</p>
<div class="flex-grow" />
</div>
</div> </div>
</template> </template>
@@ -17,14 +13,18 @@ import defaultCss from '@/assets/ebooks/basic.js'
export default { export default {
props: { props: {
url: String url: String,
libraryItem: {
type: Object,
default: () => {}
}
}, },
data() { data() {
return {} return {}
}, },
computed: { computed: {
playerLibraryItemId() { userToken() {
return this.$store.state.playerLibraryItemId return this.$store.getters['user/getToken']
} }
}, },
methods: { methods: {
@@ -85,7 +85,10 @@ export default {
async initMobi() { async initMobi() {
// Fetch mobi file as blob // Fetch mobi file as blob
var buff = await this.$axios.$get(this.url, { var buff = await this.$axios.$get(this.url, {
responseType: 'blob' responseType: 'blob',
headers: {
Authorization: `Bearer ${this.userToken}`
}
}) })
var reader = new FileReader() var reader = new FileReader()
reader.onload = async (event) => { reader.onload = async (event) => {
@@ -119,14 +122,14 @@ export default {
<style> <style>
.mobi-ebook-viewer { .mobi-ebook-viewer {
height: calc(100% - 52px); height: calc(100% - 32px);
max-height: calc(100% - 52px); max-height: calc(100% - 32px);
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
} }
.reader-player-open .mobi-ebook-viewer { .reader-player-open .mobi-ebook-viewer {
height: calc(100% - 152px); height: calc(100% - 132px);
max-height: calc(100% - 152px); max-height: calc(100% - 132px);
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
} }
+139 -16
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="w-full h-full pt-6 relative"> <div class="w-full h-full relative">
<div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent> <div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent>
<div class="flex items-center justify-center h-full w-1/2"> <div class="flex items-center justify-center h-full w-1/2">
<span class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span> <span class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span>
@@ -11,69 +11,192 @@
</div> </div>
</div> </div>
<div class="absolute top-0 right-1 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-6 flex items-center text-center"> <div class="h-full flex items-center justify-center">
<p class="font-mono text-xs">{{ page }} / {{ numPages }}</p> <div :style="{ width: pdfWidth + 'px', height: pdfHeight + 'px' }" class="w-full h-full overflow-auto">
<div v-if="loadedRatio > 0 && loadedRatio < 1" style="background-color: green; color: white; text-align: center" :style="{ width: loadedRatio * 100 + '%' }">{{ Math.floor(loadedRatio * 100) }}%</div>
<pdf ref="pdf" class="m-auto z-10 border border-black border-opacity-20 shadow-md bg-white" :src="pdfDocInitParams" :page="page" :rotate="rotate" @progress="loadedRatio = $event" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event" @loaded="loadedEvt"></pdf>
</div>
</div> </div>
<div :style="{ height: pdfHeight + 'px' }" class="overflow-hidden m-auto"> <div class="fixed left-0 h-8 w-full bg-primary px-4 flex items-center text-white/80" :style="{ bottom: isPlayerOpen ? '120px' : '0px' }">
<div class="flex items-center justify-center"> <div class="flex-grow" />
<div :style="{ width: pdfWidth + 'px', height: pdfHeight + 'px' }" class="w-full h-full overflow-auto"> <p class="text-xs">{{ page }} / {{ numPages }}</p>
<div v-if="loadedRatio > 0 && loadedRatio < 1" style="background-color: green; color: white; text-align: center" :style="{ width: loadedRatio * 100 + '%' }">{{ Math.floor(loadedRatio * 100) }}%</div>
<pdf ref="pdf" class="m-auto z-10 border border-black border-opacity-20 shadow-md bg-white" :src="url" :page="page" :rotate="rotate" @progress="loadedRatio = $event" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event"></pdf>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import pdf from 'vue-pdf' import pdf from '@teckel/vue-pdf'
export default { export default {
components: { components: {
pdf pdf
}, },
props: { props: {
url: String url: String,
libraryItem: {
type: Object,
default: () => {}
},
isLocal: Boolean,
keepProgress: Boolean
}, },
data() { data() {
return { return {
rotate: 0, rotate: 0,
loadedRatio: 0, loadedRatio: 0,
page: 1, page: 1,
numPages: 0 numPages: 0,
windowWidth: 0,
windowHeight: 0
} }
}, },
computed: { computed: {
userToken() {
return this.$store.getters['user/getToken']
},
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
},
pdfWidth() { pdfWidth() {
return this.pdfHeight * 0.6667 if (this.windowWidth > this.windowHeight) {
// Landscape
return this.windowHeight * 0.6
} else {
// Portrait
return this.windowWidth
}
}, },
pdfHeight() { pdfHeight() {
return window.innerHeight - 120 return this.pdfWidth * 1.667
}, },
canGoNext() { canGoNext() {
return this.page < this.numPages return this.page < this.numPages
}, },
canGoPrev() { canGoPrev() {
return this.page > 1 return this.page > 1
},
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)
},
savedPage() {
if (!this.keepProgress) return 0
// Validate ebookLocation is a number
if (!this.userItemProgress?.ebookLocation || isNaN(this.userItemProgress.ebookLocation)) return 0
return Number(this.userItemProgress.ebookLocation)
},
pdfDocInitParams() {
return {
url: this.url,
httpHeaders: {
Authorization: `Bearer ${this.userToken}`
}
}
},
isPlayerOpen() {
return this.$store.getters['getIsPlayerOpen']
} }
}, },
methods: { methods: {
async updateProgress() {
if (!this.keepProgress) return
if (!this.numPages) {
console.error('Num pages not loaded')
return
}
const payload = {
ebookLocation: String(this.page),
ebookProgress: Math.max(0, Math.min(1, (Number(this.page) - 1) / Number(this.numPages)))
}
// Update local item
if (this.localLibraryItemId) {
const localPayload = {
localLibraryItemId: this.localLibraryItemId,
...payload
}
const localResponse = await this.$db.updateLocalEbookProgress(localPayload)
if (localResponse.localMediaProgress) {
this.$store.commit('globals/updateLocalMediaProgress', localResponse.localMediaProgress)
}
}
// Update server item
if (this.serverLibraryItemId) {
this.$axios.$patch(`/api/me/progress/${this.serverLibraryItemId}`, payload).catch((error) => {
console.error('PdfReader.updateProgress failed:', error)
})
}
},
loadedEvt() {
if (this.savedPage && this.savedPage > 0 && this.savedPage <= this.numPages) {
this.page = this.savedPage
}
},
numPagesLoaded(e) { numPagesLoaded(e) {
this.numPages = e this.numPages = e
}, },
prev() { prev() {
if (this.page <= 1) return if (this.page <= 1) return
this.page-- this.page--
this.updateProgress()
}, },
next() { next() {
if (this.page >= this.numPages) return if (this.page >= this.numPages) return
this.page++ this.page++
this.updateProgress()
}, },
error(err) { error(err) {
console.error(err) console.error(err)
},
screenOrientationChange() {
this.windowWidth = window.innerWidth
this.windowHeight = window.innerHeight
} }
}, },
mounted() {} mounted() {
this.windowWidth = window.innerWidth
this.windowHeight = window.innerHeight
if (screen.orientation) {
// Not available on ios
screen.orientation.addEventListener('change', this.screenOrientationChange)
} else {
document.addEventListener('orientationchange', this.screenOrientationChange)
}
window.addEventListener('resize', this.screenOrientationChange)
},
beforeDestroy() {
if (screen.orientation) {
// Not available on ios
screen.orientation.removeEventListener('change', this.screenOrientationChange)
} else {
document.removeEventListener('orientationchange', this.screenOrientationChange)
}
window.removeEventListener('resize', this.screenOrientationChange)
}
} }
</script> </script>
+228 -36
View File
@@ -1,11 +1,89 @@
<template> <template>
<div v-if="show" class="absolute top-0 left-0 w-full h-full bg-bg z-40 pt-16" :class="{ 'reader-player-open': !!playerLibraryItemId }"> <div v-if="show" :data-theme="ereaderTheme" class="group fixed top-0 left-0 right-0 layout-wrapper w-full z-40 pt-8 data-[theme=dark]:bg-primary data-[theme=dark]:text-white data-[theme=light]:bg-white data-[theme=light]:text-black" :class="{ 'reader-player-open': isPlayerOpen }">
<div class="h-16 pt-8 w-full bg-primary flex items-center px-2 fixed top-0 left-0 z-30 box-shadow-sm"> <!-- toolbar -->
<p class="w-5/6 truncate">{{ title }}</p> <div class="h-32 pt-10 w-full px-2 fixed top-0 left-0 z-30 transition-transform bg-bg text-white" :class="showingToolbar ? 'translate-y-0' : '-translate-y-32'" @touchstart.stop @mousedown.stop @touchend.stop @mouseup.stop>
<div class="flex-grow" /> <div class="flex items-center mb-2">
<span class="material-icons text-xl text-white" @click.stop="show = false">close</span> <button type="button" class="inline-flex mx-2" @click.stop="show = false">
<span class="material-icons-outlined text-3xl text-white">chevron_left</span>
</button>
<div class="flex-grow" />
<button v-if="isComic || isEpub" type="button" class="inline-flex mx-2" @click.stop="clickTOCBtn">
<span class="material-icons-outlined text-2xl text-white">format_list_bulleted</span>
</button>
<button v-if="isEpub" type="button" class="inline-flex mx-2" @click.stop="clickSettingsBtn">
<span class="material-icons text-2xl text-white">settings</span>
</button>
<button v-if="comicHasMetadata" type="button" class="inline-flex mx-2" @click.stop="clickMetadataBtn">
<span class="material-icons text-2xl text-white">more</span>
</button>
</div>
<p class="text-center truncate">{{ title }}</p>
</div> </div>
<component v-if="readerComponentName" ref="readerComponent" :is="readerComponentName" :url="ebookUrl" :library-item="selectedLibraryItem" :is-local="isLocal" />
<!-- ereader -->
<component v-if="readerComponentName" ref="readerComponent" :is="readerComponentName" :url="ebookUrl" :library-item="selectedLibraryItem" :is-local="isLocal" :keep-progress="keepProgress" @touchstart="touchstart" @touchend="touchend" @loaded="readerLoaded" @hook:mounted="readerMounted" />
<!-- table of contents modal -->
<modals-fullscreen-modal v-model="showTOCModal" :theme="ereaderTheme">
<div class="flex items-end justify-between h-20 px-4 pb-2">
<h1 class="text-lg">Table of Contents</h1>
<button class="flex" @click.stop="showTOCModal = false">
<span class="material-icons">close</span>
</button>
</div>
<!-- chapters list -->
<div class="w-full overflow-y-auto overflow-x-hidden h-full max-h-[calc(100vh-85px)]">
<div class="w-full h-full px-4">
<ul>
<li v-for="chapter in chapters" :key="chapter.id" class="py-1">
<a :href="chapter.href" class="opacity-80 hover:opacity-100" @click.prevent="goToChapter(chapter.href)">{{ chapter.label }}</a>
<ul v-if="chapter.subitems.length">
<li v-for="subchapter in chapter.subitems" :key="subchapter.id" class="py-1 pl-4">
<a :href="subchapter.href" class="opacity-80 hover:opacity-100" @click.prevent="goToChapter(subchapter.href)">{{ subchapter.label }}</a>
</li>
</ul>
</li>
</ul>
<div v-if="!chapters.length" class="flex h-full items-center justify-center">
<p class="text-xl">{{ 'No Chapters' }}</p>
</div>
</div>
</div>
</modals-fullscreen-modal>
<!-- ereader settings modal -->
<modals-fullscreen-modal v-model="showSettingsModal" :theme="ereaderTheme" half-screen>
<div class="flex items-end justify-between h-20 px-4 pb-2 mb-8">
<h1 class="text-lg">Ereader Settings</h1>
<button class="flex" @click="showSettingsModal = false">
<span class="material-icons">close</span>
</button>
</div>
<div class="w-full overflow-y-auto overflow-x-hidden h-full max-h-[calc(100vh-85px)]">
<div class="w-full h-full px-4">
<div class="flex items-center mb-8">
<div class="w-32">
<p class="text-base">Theme:</p>
</div>
<ui-toggle-btns v-model="ereaderSettings.theme" :items="themeItems" @input="settingsUpdated" />
</div>
<div class="flex items-center mb-8">
<div class="w-32">
<p class="text-base">Font scale:</p>
</div>
<ui-range-input v-model="ereaderSettings.fontScale" :min="5" :max="300" :step="5" input-width="180px" @input="settingsUpdated" />
</div>
<div class="flex items-center mb-8">
<div class="w-32">
<p class="text-base">Line spacing:</p>
</div>
<ui-range-input v-model="ereaderSettings.lineSpacing" :min="100" :max="300" :step="5" input-width="180px" @input="settingsUpdated" />
</div>
</div>
</div>
</modals-fullscreen-modal>
</div> </div>
</template> </template>
@@ -19,16 +97,32 @@ export default {
touchstartY: 0, touchstartY: 0,
touchendX: 0, touchendX: 0,
touchendY: 0, touchendY: 0,
touchstartTime: 0 touchstartTime: 0,
touchIdentifier: null,
showingToolbar: false,
showTOCModal: false,
showSettingsModal: false,
comicHasMetadata: false,
chapters: [],
ereaderSettings: {
theme: 'dark',
fontScale: 100,
lineSpacing: 115
}
} }
}, },
watch: { watch: {
show: { show: {
handler(newVal) { handler(newVal) {
if (newVal) { if (newVal) {
this.comicHasMetadata = false
this.registerListeners() this.registerListeners()
this.hideToolbar()
console.log('showReader for ebookFile', JSON.stringify(this.ebookFile))
} else { } else {
this.unregisterListeners() this.unregisterListeners()
this.$showHideStatusBar(true)
} }
} }
} }
@@ -49,10 +143,26 @@ export default {
return this.$store.state.selectedLibraryItem return this.$store.state.selectedLibraryItem
}, },
media() { media() {
return this.selectedLibraryItem ? this.selectedLibraryItem.media : null return this.selectedLibraryItem?.media || null
}, },
mediaMetadata() { mediaMetadata() {
return this.media ? this.media.metadata || {} : {} return this.media?.metadata || {}
},
ereaderTheme() {
if (this.isEpub) return this.ereaderSettings.theme
return 'dark'
},
themeItems() {
return [
{
text: 'Dark',
value: 'dark'
},
{
text: 'Light',
value: 'light'
}
]
}, },
readerComponentName() { readerComponentName() {
if (this.ebookType === 'epub') return 'readers-epub-reader' if (this.ebookType === 'epub') return 'readers-epub-reader'
@@ -61,17 +171,20 @@ export default {
else if (this.ebookType === 'pdf') return 'readers-pdf-reader' else if (this.ebookType === 'pdf') return 'readers-pdf-reader'
return null return null
}, },
folderId() {
return this.selectedLibraryItem ? this.selectedLibraryItem.folderId : null
},
libraryId() {
return this.selectedLibraryItem ? this.selectedLibraryItem.libraryId : null
},
ebookFile() { ebookFile() {
return this.media?.ebookFile || null // ebook file id is passed when reading a supplementary ebook
if (this.ebookFileId) {
return this.selectedLibraryItem.libraryFiles.find((lf) => lf.ino === this.ebookFileId)
}
return this.media.ebookFile
}, },
ebookFormat() { ebookFormat() {
return this.ebookFile?.ebookFormat || null if (!this.ebookFile) return null
// Use file extension for supplementary ebook
if (!this.ebookFile.ebookFormat) {
return this.ebookFile.metadata.ext.toLowerCase().slice(1)
}
return this.ebookFile.ebookFormat
}, },
ebookType() { ebookType() {
if (this.isMobi) return 'mobi' if (this.isMobi) return 'mobi'
@@ -93,7 +206,7 @@ export default {
return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr' return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr'
}, },
isLocal() { isLocal() {
return !!this.ebookFile?.isLocal return !!this.ebookFile?.isLocal || !!this.ebookFile?.localFileId
}, },
localContentUrl() { localContentUrl() {
return this.ebookFile?.contentUrl return this.ebookFile?.contentUrl
@@ -103,25 +216,58 @@ export default {
if (this.localContentUrl) { if (this.localContentUrl) {
return Capacitor.convertFileSrc(this.localContentUrl) return Capacitor.convertFileSrc(this.localContentUrl)
} }
let filepath = ''
if (this.selectedLibraryItem.isFile) {
filepath = this.$encodeUriPath(this.ebookFile.metadata.filename)
} else {
const itemRelPath = this.selectedLibraryItem.relPath
if (itemRelPath.startsWith('/')) itemRelPath = itemRelPath.slice(1)
const relPath = this.ebookFile.metadata.relPath
if (relPath.startsWith('/')) relPath = relPath.slice(1)
filepath = this.$encodeUriPath(`${itemRelPath}/${relPath}`)
}
const serverAddress = this.$store.getters['user/getServerAddress'] const serverAddress = this.$store.getters['user/getServerAddress']
return `${serverAddress}/ebook/${this.libraryId}/${this.folderId}/${filepath}`
if (this.ebookFileId) {
return `${serverAddress}/api/items/${this.selectedLibraryItem.id}/ebook/${this.ebookFileId}`
}
return `${serverAddress}/api/items/${this.selectedLibraryItem.id}/ebook`
}, },
playerLibraryItemId() { isPlayerOpen() {
return this.$store.state.playerLibraryItemId return this.$store.getters['getIsPlayerOpen']
},
keepProgress() {
return this.$store.state.ereaderKeepProgress
},
ebookFileId() {
return this.$store.state.ereaderFileId
} }
}, },
methods: { methods: {
settingsUpdated() {
this.$refs.readerComponent?.updateSettings?.(this.ereaderSettings)
localStorage.setItem('ereaderSettings', JSON.stringify(this.ereaderSettings))
},
goToChapter(href) {
this.showTOCModal = false
this.$refs.readerComponent?.goToChapter(href)
},
readerMounted() {
if (this.isEpub) {
this.loadEreaderSettings()
}
},
readerLoaded(data) {
if (this.isComic) {
this.comicHasMetadata = data.hasMetadata
}
},
clickMetadataBtn() {
this.$refs.readerComponent?.clickShowInfoMenu()
},
clickTOCBtn() {
this.hideToolbar()
if (this.isComic) {
this.$refs.readerComponent?.clickShowPageMenu?.()
} else {
this.chapters = this.$refs.readerComponent?.chapters || []
this.showTOCModal = true
}
},
clickSettingsBtn() {
this.hideToolbar()
this.showSettingsModal = true
},
next() { next() {
if (this.$refs.readerComponent && this.$refs.readerComponent.next) { if (this.$refs.readerComponent && this.$refs.readerComponent.next) {
this.$refs.readerComponent.next() this.$refs.readerComponent.next()
@@ -133,7 +279,7 @@ export default {
} }
}, },
handleGesture() { handleGesture() {
// Touch must be less than 1s. Must be > 100px drag and X distance > Y distance // Touch must be less than 1s. Must be > 60px drag and X distance > Y distance
const touchTimeMs = Date.now() - this.touchstartTime const touchTimeMs = Date.now() - this.touchstartTime
if (touchTimeMs >= 1000) { if (touchTimeMs >= 1000) {
console.log('Touch too long', touchTimeMs) console.log('Touch too long', touchTimeMs)
@@ -142,34 +288,80 @@ export default {
const touchDistanceX = Math.abs(this.touchendX - this.touchstartX) const touchDistanceX = Math.abs(this.touchendX - this.touchstartX)
const touchDistanceY = Math.abs(this.touchendY - this.touchstartY) const touchDistanceY = Math.abs(this.touchendY - this.touchstartY)
if (touchDistanceX < 60 || touchDistanceY > touchDistanceX) { const touchDistance = Math.sqrt(Math.pow(this.touchstartX - this.touchendX, 2) + Math.pow(this.touchstartY - this.touchendY, 2))
if (touchDistance < 60) {
if (this.showSettingsModal) {
this.showSettingsModal = false
} else {
this.toggleToolbar()
}
return return
} }
if (touchDistanceX < 60 || touchDistanceY > touchDistanceX) {
return
}
this.hideToolbar()
if (this.touchendX < this.touchstartX) { if (this.touchendX < this.touchstartX) {
console.log('swiped left')
this.next() this.next()
} }
if (this.touchendX > this.touchstartX) { if (this.touchendX > this.touchstartX) {
console.log('swiped right')
this.prev() this.prev()
} }
}, },
showToolbar() {
this.showingToolbar = true
this.$showHideStatusBar(true)
},
hideToolbar() {
this.showingToolbar = false
this.$showHideStatusBar(false)
},
toggleToolbar() {
if (this.showingToolbar) this.hideToolbar()
else this.showToolbar()
},
touchstart(e) { touchstart(e) {
// Ignore rapid touch
if (this.touchstartTime && Date.now() - this.touchstartTime < 250) {
return
}
this.touchstartX = e.touches[0].screenX this.touchstartX = e.touches[0].screenX
this.touchstartY = e.touches[0].screenY this.touchstartY = e.touches[0].screenY
this.touchstartTime = Date.now() this.touchstartTime = Date.now()
this.touchIdentifier = e.touches[0].identifier
}, },
touchend(e) { touchend(e) {
if (this.touchIdentifier !== e.changedTouches[0].identifier) {
return
}
this.touchendX = e.changedTouches[0].screenX this.touchendX = e.changedTouches[0].screenX
this.touchendY = e.changedTouches[0].screenY this.touchendY = e.changedTouches[0].screenY
this.handleGesture() this.handleGesture()
}, },
closeEvt() {
this.show = false
},
loadEreaderSettings() {
try {
const settings = localStorage.getItem('ereaderSettings')
if (settings) {
this.ereaderSettings = JSON.parse(settings)
this.settingsUpdated()
}
} catch (error) {
console.error('Failed to load ereader settings', error)
}
},
registerListeners() { registerListeners() {
this.$eventBus.$on('close-ebook', this.closeEvt)
document.body.addEventListener('touchstart', this.touchstart) document.body.addEventListener('touchstart', this.touchstart)
document.body.addEventListener('touchend', this.touchend) document.body.addEventListener('touchend', this.touchend)
}, },
unregisterListeners() { unregisterListeners() {
this.$eventBus.$on('close-ebook', this.closeEvt)
document.body.removeEventListener('touchstart', this.touchstart) document.body.removeEventListener('touchstart', this.touchstart)
document.body.removeEventListener('touchend', this.touchend) document.body.removeEventListener('touchend', this.touchend)
} }
-25
View File
@@ -70,28 +70,3 @@ export default {
mounted() {} mounted() {}
} }
</script> </script>
<style scoped>
.tracksTable {
border-collapse: collapse;
width: 100%;
border: 1px solid #474747;
}
.tracksTable tr:nth-child(even) {
background-color: #2e2e2e;
}
.tracksTable tr {
background-color: #373838;
}
.tracksTable td {
padding: 8px 8px;
}
.tracksTable th {
padding: 4px 8px;
font-size: 0.75rem;
}
</style>
-25
View File
@@ -58,28 +58,3 @@ export default {
mounted() {} mounted() {}
} }
</script> </script>
<style scoped>
.tracksTable {
border-collapse: collapse;
width: 100%;
border: 1px solid #474747;
}
.tracksTable tr:nth-child(even) {
background-color: #2e2e2e;
}
.tracksTable tr {
background-color: #373838;
}
.tracksTable td {
padding: 8px 8px;
}
.tracksTable th {
padding: 4px 8px;
font-size: 0.75rem;
}
</style>
@@ -74,7 +74,7 @@ export default {
return !this.isMissing && !this.isInvalid && this.tracks.length return !this.isMissing && !this.isInvalid && this.tracks.length
}, },
isStreaming() { isStreaming() {
return this.$store.getters['getIsEpisodeStreaming'](this.libraryItemId) return this.$store.getters['getIsMediaStreaming'](this.libraryItemId)
}, },
streamIsPlaying() { streamIsPlaying() {
return this.$store.state.playerIsPlaying && this.isStreaming return this.$store.state.playerIsPlaying && this.isStreaming
+102
View File
@@ -0,0 +1,102 @@
<template>
<div class="w-full my-2">
<div class="w-full bg-primary px-4 py-2 flex items-center" :class="showFiles ? 'rounded-t-md' : 'rounded-md'" @click.stop="clickBar">
<p class="pr-2">Ebook Files</p>
<div class="h-6 w-6 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
<span class="text-xs font-mono">{{ ebookFiles.length }}</span>
</div>
<div class="flex-grow" />
<div class="h-10 w-10 rounded-full flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''">
<span class="material-icons text-3xl">expand_more</span>
</div>
</div>
<transition name="slide">
<div class="w-full" v-show="showFiles">
<table class="text-sm tracksTable">
<tr>
<th class="text-left px-4">Filename</th>
<th class="text-left px-4 w-16">Read</th>
<th v-if="userCanUpdate && !libraryIsAudiobooksOnly" class="text-center w-16"></th>
</tr>
<template v-for="file in ebookFiles">
<tables-ebook-files-table-row :key="file.path" :libraryItemId="libraryItemId" :file="file" @read="readEbook" @more="showMore" />
</template>
</table>
</div>
</transition>
<modals-dialog v-model="showMoreMenu" :items="moreMenuItems" @action="moreMenuAction" />
</div>
</template>
<script>
export default {
props: {
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {
processing: false,
showFiles: false,
showMoreMenu: false,
moreMenuItems: [],
selectedFile: null
}
},
computed: {
libraryItemId() {
return this.libraryItem.id
},
userToken() {
return this.$store.getters['user/getToken']
},
ebookFiles() {
return (this.libraryItem.libraryFiles || []).filter((lf) => lf.fileType === 'ebook')
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
libraryIsAudiobooksOnly() {
return this.$store.getters['libraries/getLibraryIsAudiobooksOnly']
}
},
methods: {
moreMenuAction(action) {
this.showMoreMenu = false
if (action === 'updateStatus') {
this.updateEbookStatus()
}
},
showMore({ file, items }) {
this.showMoreMenu = true
this.selectedFile = file
this.moreMenuItems = items
},
readEbook(fileIno) {
this.$store.commit('showReader', { libraryItem: this.libraryItem, keepProgress: false, fileId: fileIno })
},
clickBar() {
this.showFiles = !this.showFiles
},
updateEbookStatus() {
this.processing = true
this.$axios
.$patch(`/api/items/${this.libraryItemId}/ebook/${this.selectedFile.ino}/status`)
.then(() => {
this.$toast.success('Ebook updated')
})
.catch((error) => {
console.error('Failed to update ebook', error)
this.$toast.error('Failed to update ebook')
})
.finally(() => {
this.processing = false
})
}
},
mounted() {}
}
</script>
@@ -0,0 +1,63 @@
<template>
<tr>
<td class="px-4">{{ file.metadata.filename }} <span v-if="isPrimary" class="material-icons-outlined text-success align-text-bottom text-base">check_circle</span></td>
<td class="text-xs w-16">
<ui-icon-btn icon="auto_stories" outlined borderless icon-font-size="1.125rem" :size="8" @click="readEbook" />
</td>
<td v-if="contextMenuItems.length" class="text-center">
<ui-icon-btn icon="more_vert" borderless @click="clickMore" />
</td>
</tr>
</template>
<script>
export default {
props: {
libraryItemId: String,
showFullPath: Boolean,
file: {
type: Object,
default: () => {}
}
},
data() {
return {}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
isPrimary() {
return !this.file.isSupplementary
},
libraryIsAudiobooksOnly() {
return this.$store.getters['libraries/getLibraryIsAudiobooksOnly']
},
contextMenuItems() {
const items = []
if (this.userCanUpdate && !this.libraryIsAudiobooksOnly) {
items.push({
text: this.isPrimary ? 'Set as supplementary' : 'Set as primary',
value: 'updateStatus'
})
}
return items
}
},
methods: {
clickMore() {
this.$emit('more', {
file: this.file,
items: this.contextMenuItems
})
},
readEbook() {
this.$emit('read', this.file.ino)
}
},
mounted() {}
}
</script>
+23 -5
View File
@@ -6,9 +6,9 @@
</div> </div>
<div class="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 }} <span v-if="localLibraryItem" class="material-icons text-success text-base align-text-bottom">download_done</span></p>
<p class="truncate block text-gray-400 text-xs">{{ bookAuthorName }}</p> <p v-if="authorName" class="truncate block text-gray-300 text-xs">{{ authorName }}</p>
<p class="text-xxs text-gray-500">{{ itemDuration }}</p> <p class="text-xxs text-gray-400">{{ itemDuration }}</p>
</div> </div>
</div> </div>
<div class="w-8 min-w-8 flex justify-center"> <div class="w-8 min-w-8 flex justify-center">
@@ -39,11 +39,17 @@ export default {
libraryItem() { libraryItem() {
return this.item.libraryItem || {} return this.item.libraryItem || {}
}, },
localLibraryItem() {
return this.item.localLibraryItem
},
episode() { episode() {
return this.item.episode return this.item.episode
}, },
episodeId() { episodeId() {
return this.episode ? this.episode.id : null return this.episode?.id || null
},
localEpisode() {
return this.item.localEpisode
}, },
media() { media() {
return this.libraryItem.media || {} return this.libraryItem.media || {}
@@ -66,6 +72,10 @@ export default {
bookAuthorName() { bookAuthorName() {
return this.bookAuthors.map((au) => au.name).join(', ') return this.bookAuthors.map((au) => au.name).join(', ')
}, },
authorName() {
if (this.episode) return this.mediaMetadata.author
return this.bookAuthorName
},
itemDuration() { itemDuration() {
if (this.episode) return this.$elapsedPretty(this.episode.duration) if (this.episode) return this.$elapsedPretty(this.episode.duration)
return this.$elapsedPretty(this.media.duration) return this.$elapsedPretty(this.media.duration)
@@ -92,7 +102,8 @@ export default {
return !this.isMissing && !this.isInvalid && (this.tracks.length || this.episode) return !this.isMissing && !this.isInvalid && (this.tracks.length || this.episode)
}, },
isStreaming() { isStreaming() {
return this.$store.getters['getIsEpisodeStreaming'](this.libraryItem.id, this.episodeId) if (this.localLibraryItem && this.localEpisode && this.$store.getters['getIsMediaStreaming'](this.localLibraryItem.id, this.localEpisode.id)) return true
return this.$store.getters['getIsMediaStreaming'](this.libraryItem.id, this.episodeId)
}, },
streamIsPlaying() { streamIsPlaying() {
return this.$store.state.playerIsPlaying && this.isStreaming return this.$store.state.playerIsPlaying && this.isStreaming
@@ -103,6 +114,13 @@ export default {
await this.$hapticsImpact() await this.$hapticsImpact()
if (this.streamIsPlaying) { if (this.streamIsPlaying) {
this.$eventBus.$emit('pause-item') this.$eventBus.$emit('pause-item')
} else if (this.localLibraryItem) {
this.$eventBus.$emit('play-item', {
libraryItemId: this.localLibraryItem.id,
episodeId: this.localEpisode?.id,
serverLibraryItemId: this.libraryItem.id,
serverEpisodeId: this.episodeId
})
} else { } else {
this.$eventBus.$emit('play-item', { this.$eventBus.$emit('play-item', {
libraryItemId: this.libraryItem.id, libraryItemId: this.libraryItem.id,
+30 -41
View File
@@ -114,14 +114,7 @@ export default {
return this.$secondsToTimestamp(this.episode.duration) return this.$secondsToTimestamp(this.episode.duration)
}, },
isStreaming() { isStreaming() {
if (this.playerIsLocal && this.localLibraryItemId && this.localEpisode) { return this.$store.getters['getIsMediaStreaming'](this.libraryItemId, this.episode.id)
// Check is streaming local version of this episode
return this.$store.getters['getIsEpisodeStreaming'](this.localLibraryItemId, this.localEpisode.id)
}
return this.$store.getters['getIsEpisodeStreaming'](this.libraryItemId, this.episode.id)
},
playerIsLocal() {
return !!this.$store.state.playerIsLocal
}, },
streamIsPlaying() { streamIsPlaying() {
return this.$store.state.playerIsPlaying && this.isStreaming return this.$store.state.playerIsPlaying && this.isStreaming
@@ -186,29 +179,33 @@ export default {
} }
}, },
async download(selectedLocalFolder = null) { async download(selectedLocalFolder = null) {
var localFolder = selectedLocalFolder let localFolder = selectedLocalFolder
if (!localFolder) { if (!localFolder) {
var localFolders = (await this.$db.getLocalFolders()) || [] const localFolders = (await this.$db.getLocalFolders()) || []
console.log('Local folders loaded', localFolders.length) console.log('Local folders loaded', localFolders.length)
var foldersWithMediaType = localFolders.filter((lf) => { const foldersWithMediaType = localFolders.filter((lf) => {
console.log('Checking local folder', lf.mediaType) console.log('Checking local folder', lf.mediaType)
return lf.mediaType == this.mediaType return lf.mediaType == this.mediaType
}) })
console.log('Folders with media type', this.mediaType, foldersWithMediaType.length) console.log('Folders with media type', this.mediaType, foldersWithMediaType.length)
const internalStorageFolder = foldersWithMediaType.find((f) => f.id === `internal-${this.mediaType}`)
if (!foldersWithMediaType.length) { if (!foldersWithMediaType.length) {
// No local folders or no local folders with this media type localFolder = {
localFolder = await this.selectFolder() id: `internal-${this.mediaType}`,
} else if (foldersWithMediaType.length == 1) { name: 'Internal App Storage',
console.log('Only 1 local folder with this media type - auto select it') mediaType: this.mediaType
localFolder = foldersWithMediaType[0] }
} else if (foldersWithMediaType.length === 1 && internalStorageFolder) {
localFolder = internalStorageFolder
} else { } else {
console.log('Multiple folders with media type') this.$store.commit('globals/showSelectLocalFolderModal', {
// this.showSelectLocalFolder = true mediaType: this.mediaType,
callback: (folder) => {
this.download(folder)
}
})
return return
} }
if (!localFolder) {
return this.$toast.error('Invalid download folder')
}
} }
console.log('Local folder', JSON.stringify(localFolder)) console.log('Local folder', JSON.stringify(localFolder))
@@ -261,45 +258,37 @@ export default {
}, },
async toggleFinished() { async toggleFinished() {
await this.$hapticsImpact() await this.$hapticsImpact()
this.isProcessingReadUpdate = true this.isProcessingReadUpdate = true
if (this.isLocal || this.localEpisode) { if (this.isLocal || this.localEpisode) {
var isFinished = !this.userIsFinished const isFinished = !this.userIsFinished
var localLibraryItemId = this.isLocal ? this.libraryItemId : this.localLibraryItemId const localLibraryItemId = this.isLocal ? this.libraryItemId : this.localLibraryItemId
var localEpisodeId = this.isLocal ? this.episode.id : this.localEpisode.id const localEpisodeId = this.isLocal ? this.episode.id : this.localEpisode.id
var payload = await this.$db.updateLocalMediaProgressFinished({ localLibraryItemId, localEpisodeId, isFinished }) const payload = await this.$db.updateLocalMediaProgressFinished({ localLibraryItemId, localEpisodeId, isFinished })
console.log('toggleFinished payload', JSON.stringify(payload)) console.log('toggleFinished payload', JSON.stringify(payload))
if (!payload || payload.error) { if (payload?.error) {
var errorMsg = payload ? payload.error : 'Unknown error' this.$toast.error(payload?.error || 'Unknown error')
this.$toast.error(errorMsg)
} else { } else {
var localMediaProgress = payload.localMediaProgress const localMediaProgress = payload.localMediaProgress
console.log('toggleFinished localMediaProgress', JSON.stringify(localMediaProgress)) console.log('toggleFinished localMediaProgress', JSON.stringify(localMediaProgress))
if (localMediaProgress) { if (localMediaProgress) {
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress) this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
} }
if (payload.server) {
this.$toast.success(`Local & Server Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
} else {
this.$toast.success(`Local Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
}
} }
this.isProcessingReadUpdate = false this.isProcessingReadUpdate = false
} else { } else {
var updatePayload = { const updatePayload = {
isFinished: !this.userIsFinished isFinished: !this.userIsFinished
} }
this.$axios this.$axios
.$patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload) .$patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
this.$toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
})
.catch((error) => { .catch((error) => {
console.error('Failed', error) console.error('Failed', error)
this.isProcessingReadUpdate = false
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`) this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
}) })
.finally(() => {
this.isProcessingReadUpdate = false
})
} }
} }
} }
+30 -41
View File
@@ -117,14 +117,7 @@ export default {
return this.$secondsToTimestamp(this.episode.duration) return this.$secondsToTimestamp(this.episode.duration)
}, },
isStreaming() { isStreaming() {
if (this.playerIsLocal && this.localLibraryItemId && this.localEpisode) { return this.$store.getters['getIsMediaStreaming'](this.libraryItemId, this.episode.id)
// Check is streaming local version of this episode
return this.$store.getters['getIsEpisodeStreaming'](this.localLibraryItemId, this.localEpisode.id)
}
return this.$store.getters['getIsEpisodeStreaming'](this.libraryItemId, this.episode.id)
},
playerIsLocal() {
return !!this.$store.state.playerIsLocal
}, },
streamIsPlaying() { streamIsPlaying() {
return this.$store.state.playerIsPlaying && this.isStreaming return this.$store.state.playerIsPlaying && this.isStreaming
@@ -192,29 +185,33 @@ export default {
} }
}, },
async download(selectedLocalFolder = null) { async download(selectedLocalFolder = null) {
var localFolder = selectedLocalFolder let localFolder = selectedLocalFolder
if (!localFolder) { if (!localFolder) {
var localFolders = (await this.$db.getLocalFolders()) || [] const localFolders = (await this.$db.getLocalFolders()) || []
console.log('Local folders loaded', localFolders.length) console.log('Local folders loaded', localFolders.length)
var foldersWithMediaType = localFolders.filter((lf) => { const foldersWithMediaType = localFolders.filter((lf) => {
console.log('Checking local folder', lf.mediaType) console.log('Checking local folder', lf.mediaType)
return lf.mediaType == this.mediaType return lf.mediaType == this.mediaType
}) })
console.log('Folders with media type', this.mediaType, foldersWithMediaType.length) console.log('Folders with media type', this.mediaType, foldersWithMediaType.length)
const internalStorageFolder = foldersWithMediaType.find((f) => f.id === `internal-${this.mediaType}`)
if (!foldersWithMediaType.length) { if (!foldersWithMediaType.length) {
// No local folders or no local folders with this media type localFolder = {
localFolder = await this.selectFolder() id: `internal-${this.mediaType}`,
} else if (foldersWithMediaType.length == 1) { name: 'Internal App Storage',
console.log('Only 1 local folder with this media type - auto select it') mediaType: this.mediaType
localFolder = foldersWithMediaType[0] }
} else if (foldersWithMediaType.length === 1 && internalStorageFolder) {
localFolder = internalStorageFolder
} else { } else {
console.log('Multiple folders with media type') this.$store.commit('globals/showSelectLocalFolderModal', {
// this.showSelectLocalFolder = true mediaType: this.mediaType,
callback: (folder) => {
this.download(folder)
}
})
return return
} }
if (!localFolder) {
return this.$toast.error('Invalid download folder')
}
} }
console.log('Local folder', JSON.stringify(localFolder)) console.log('Local folder', JSON.stringify(localFolder))
@@ -267,45 +264,37 @@ export default {
}, },
async toggleFinished() { async toggleFinished() {
await this.$hapticsImpact() await this.$hapticsImpact()
this.isProcessingReadUpdate = true this.isProcessingReadUpdate = true
if (this.isLocal || this.localEpisode) { if (this.isLocal || this.localEpisode) {
var isFinished = !this.userIsFinished const isFinished = !this.userIsFinished
var localLibraryItemId = this.isLocal ? this.libraryItemId : this.localLibraryItemId const localLibraryItemId = this.isLocal ? this.libraryItemId : this.localLibraryItemId
var localEpisodeId = this.isLocal ? this.episode.id : this.localEpisode.id const localEpisodeId = this.isLocal ? this.episode.id : this.localEpisode.id
var payload = await this.$db.updateLocalMediaProgressFinished({ localLibraryItemId, localEpisodeId, isFinished }) const payload = await this.$db.updateLocalMediaProgressFinished({ localLibraryItemId, localEpisodeId, isFinished })
console.log('toggleFinished payload', JSON.stringify(payload)) console.log('toggleFinished payload', JSON.stringify(payload))
if (!payload || payload.error) { if (payload?.error) {
var errorMsg = payload ? payload.error : 'Unknown error' this.$toast.error(payload?.error || 'Unknown error')
this.$toast.error(errorMsg)
} else { } else {
var localMediaProgress = payload.localMediaProgress const localMediaProgress = payload.localMediaProgress
console.log('toggleFinished localMediaProgress', JSON.stringify(localMediaProgress)) console.log('toggleFinished localMediaProgress', JSON.stringify(localMediaProgress))
if (localMediaProgress) { if (localMediaProgress) {
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress) this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
} }
if (payload.server) {
this.$toast.success(`Local & Server Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
} else {
this.$toast.success(`Local Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
}
} }
this.isProcessingReadUpdate = false this.isProcessingReadUpdate = false
} else { } else {
var updatePayload = { const updatePayload = {
isFinished: !this.userIsFinished isFinished: !this.userIsFinished
} }
this.$axios this.$axios
.$patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload) .$patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
this.$toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
})
.catch((error) => { .catch((error) => {
console.error('Failed', error) console.error('Failed', error)
this.isProcessingReadUpdate = false
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`) this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
}) })
.finally(() => {
this.isProcessingReadUpdate = false
})
} }
} }
} }
+90
View File
@@ -0,0 +1,90 @@
<template>
<div class="inline-flex">
<input v-model="input" type="range" :min="min" :max="max" :step="step" :style="{ width: inputWidth }" />
<p class="text-xs ml-2">{{ input }}%</p>
</div>
</template>
<script>
export default {
props: {
value: [String, Number],
min: Number,
max: Number,
step: Number,
inputWidth: {
type: String,
default: 'unset'
}
},
data() {
return {}
},
computed: {
input: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
}
},
methods: {},
mounted() {}
}
</script>
<style scoped>
input[type='range'] {
-webkit-appearance: none;
appearance: none;
background: transparent;
cursor: pointer;
}
input[type='range']:focus {
outline: none;
}
/* chromium */
input[type='range']::-webkit-slider-runnable-track {
background-color: rgb(0 0 0 / 0.25);
border-radius: 9999px;
height: 0.75rem;
}
input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
margin-top: -0.25rem;
border-radius: 9999px;
background-color: rgb(255 255 255 / 0.7);
height: 1.25rem;
width: 1.25rem;
}
input[type='range']:focus::-webkit-slider-thumb {
border: 1px solid #6b6b6b;
outline: 3px solid #6b6b6b;
outline-offset: 0.125rem;
}
/* firefox */
input[type='range']::-moz-range-track {
background-color: rgb(0 0 0 / 0.25);
border-radius: 9999px;
height: 0.75rem;
}
input[type='range']::-moz-range-thumb {
border: none;
border-radius: 9999px;
margin-top: -0.25rem;
background-color: rgb(255 255 255 / 0.7);
height: 1.25rem;
width: 1.25rem;
}
input[type='range']:focus::-moz-range-thumb {
border: 1px solid #6b6b6b;
outline: 3px solid #6b6b6b;
outline-offset: 0.125rem;
}
</style>
+85
View File
@@ -0,0 +1,85 @@
<template>
<div class="inline-flex toggle-btn-wrapper shadow-md">
<button v-for="item in items" :key="item.value" type="button" class="toggle-btn outline-none relative border border-gray-600 px-4 py-1" :class="{ selected: item.value === value }" @click.stop="clickBtn(item.value)">
{{ item.text }}
</button>
</div>
</template>
<script>
export default {
props: {
value: String,
/**
* [{ "text", "", "value": "" }]
*/
items: {
type: Array,
default: Object
}
},
data() {
return {}
},
computed: {},
methods: {
clickBtn(value) {
this.$emit('input', value)
}
},
mounted() {}
}
</script>
<style scoped>
.toggle-btn-wrapper .toggle-btn:first-child {
border-top-left-radius: 0.375rem /* 6px */;
border-bottom-left-radius: 0.375rem /* 6px */;
}
.toggle-btn-wrapper .toggle-btn:last-child {
border-top-right-radius: 0.375rem /* 6px */;
border-bottom-right-radius: 0.375rem /* 6px */;
}
.toggle-btn-wrapper .toggle-btn:first-child::before {
border-top-left-radius: 0.375rem /* 6px */;
border-bottom-left-radius: 0.375rem /* 6px */;
}
.toggle-btn-wrapper .toggle-btn:last-child::before {
border-top-right-radius: 0.375rem /* 6px */;
border-bottom-right-radius: 0.375rem /* 6px */;
}
.toggle-btn-wrapper .toggle-btn:not(:first-child) {
margin-left: -1px;
}
.toggle-btn::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0);
transition: all 0.1s ease-in-out;
}
.toggle-btn:hover:not(:disabled)::before {
background-color: rgba(255, 255, 255, 0.1);
}
.toggle-btn:hover:not(:disabled) {
color: white;
}
.toggle-btn {
color: rgba(255, 255, 255, 0.75);
}
.toggle-btn.selected {
color: white;
}
.toggle-btn.selected::before {
background-color: rgba(255, 255, 255, 0.1);
}
button.toggle-btn:disabled::before {
background-color: rgba(0, 0, 0, 0.2);
}
</style>
+92
View File
@@ -0,0 +1,92 @@
<template>
<div ref="box" class="inline-block" @click.stop="click">
<slot />
</div>
</template>
<script>
export default {
props: {
text: {
type: [String, Number],
required: true
},
direction: {
type: String,
default: 'right'
},
disabled: Boolean
},
data() {
return {
tooltip: null,
tooltipTextEl: null,
tooltipId: null,
isShowing: false,
hideTimeout: null
}
},
watch: {
text() {
this.updateText()
},
disabled(newVal) {
if (newVal && this.isShowing) {
this.hideTooltip()
}
}
},
methods: {
updateText() {
if (this.tooltipTextEl) {
this.tooltipTextEl.innerHTML = this.text
}
},
createTooltip() {
if (!this.$refs.box) return
const tooltip = document.createElement('div')
this.tooltipId = String(Math.floor(Math.random() * 10000))
tooltip.id = this.tooltipId
tooltip.className = 'fixed inset-0 w-screen h-screen bg-black/25 text-xs flex items-center justify-center p-2'
tooltip.style.zIndex = 100
tooltip.style.backgroundColor = 'rgba(0,0,0,0.85)'
tooltip.addEventListener('click', this.hideTooltip)
const innerDiv = document.createElement('div')
innerDiv.className = 'w-full p-2 border border-white/20 pointer-events-none text-white bg-primary'
innerDiv.innerHTML = this.text
tooltip.appendChild(innerDiv)
this.tooltipTextEl = innerDiv
this.tooltip = tooltip
},
showTooltip() {
if (this.disabled) return
if (!this.tooltip) {
this.createTooltip()
if (!this.tooltip) return
}
if (!this.$refs.box) return // Ensure element is not destroyed
try {
document.body.appendChild(this.tooltip)
} catch (error) {
console.error(error)
}
this.isShowing = true
},
hideTooltip() {
if (!this.tooltip) return
this.tooltip.remove()
this.isShowing = false
},
click() {
if (!this.isShowing) this.showTooltip()
}
},
beforeDestroy() {
this.hideTooltip()
}
}
</script>
+9 -2
View File
@@ -30,10 +30,15 @@ export default {
isCellular() { isCellular() {
return this.networkConnectionType === 'cellular' return this.networkConnectionType === 'cellular'
}, },
attemptingConnection() {
return this.$store.state.attemptingConnection
},
icon() { icon() {
if (!this.user) return null // hide when not connected to server if (!this.user) return null // hide when not connected to server
if (!this.networkConnected) { if (this.attemptingConnection) {
return 'cloud_sync'
} else if (!this.networkConnected) {
return 'wifi_off' return 'wifi_off'
} else if (!this.socketConnected) { } else if (!this.socketConnected) {
return 'cloud_off' return 'cloud_off'
@@ -55,7 +60,9 @@ export default {
showAlertDialog() { showAlertDialog() {
var msg = '' var msg = ''
var meteredString = this.isNetworkUnmetered ? 'unmetered' : 'metered' var meteredString = this.isNetworkUnmetered ? 'unmetered' : 'metered'
if (!this.networkConnected) { if (this.attemptingConnection) {
msg = 'Attempting server connection'
} else if (!this.networkConnected) {
msg = 'No internet' msg = 'No internet'
} else if (!this.socketConnected) { } else if (!this.socketConnected) {
msg = 'Socket not connected' msg = 'Socket not connected'
+8 -4
View File
@@ -27,6 +27,7 @@
4D66B954282EE87C008272D4 /* AbsDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B953282EE87C008272D4 /* AbsDownloader.swift */; }; 4D66B954282EE87C008272D4 /* AbsDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B953282EE87C008272D4 /* AbsDownloader.swift */; };
4D66B956282EE951008272D4 /* AbsFileSystem.m in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B955282EE951008272D4 /* AbsFileSystem.m */; }; 4D66B956282EE951008272D4 /* AbsFileSystem.m in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B955282EE951008272D4 /* AbsFileSystem.m */; };
4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B957282EEA14008272D4 /* AbsFileSystem.swift */; }; 4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B957282EEA14008272D4 /* AbsFileSystem.swift */; };
4D91EEC62A40F28D004807ED /* EBookFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D91EEC52A40F28D004807ED /* EBookFile.swift */; };
4DF74912287105C600AC7814 /* DeviceSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DF74911287105C600AC7814 /* DeviceSettings.swift */; }; 4DF74912287105C600AC7814 /* DeviceSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DF74911287105C600AC7814 /* DeviceSettings.swift */; };
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; }; 504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
@@ -97,6 +98,7 @@
4D66B955282EE951008272D4 /* AbsFileSystem.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AbsFileSystem.m; sourceTree = "<group>"; }; 4D66B955282EE951008272D4 /* AbsFileSystem.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AbsFileSystem.m; sourceTree = "<group>"; };
4D66B957282EEA14008272D4 /* AbsFileSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsFileSystem.swift; sourceTree = "<group>"; }; 4D66B957282EEA14008272D4 /* AbsFileSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsFileSystem.swift; sourceTree = "<group>"; };
4D8D412C26E187E400BA5F0D /* App-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "App-Bridging-Header.h"; sourceTree = "<group>"; }; 4D8D412C26E187E400BA5F0D /* App-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "App-Bridging-Header.h"; sourceTree = "<group>"; };
4D91EEC52A40F28D004807ED /* EBookFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EBookFile.swift; sourceTree = "<group>"; };
4DF74911287105C600AC7814 /* DeviceSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceSettings.swift; sourceTree = "<group>"; }; 4DF74911287105C600AC7814 /* DeviceSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceSettings.swift; sourceTree = "<group>"; };
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; }; 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
504EC3041FED79650016851F /* Audiobookshelf.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Audiobookshelf.app; sourceTree = BUILT_PRODUCTS_DIR; }; 504EC3041FED79650016851F /* Audiobookshelf.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Audiobookshelf.app; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -299,6 +301,7 @@
E9D5505928AC1C4500C746DD /* Folder.swift */, E9D5505928AC1C4500C746DD /* Folder.swift */,
E9D5505B28AC1C6200C746DD /* LibraryFile.swift */, E9D5505B28AC1C6200C746DD /* LibraryFile.swift */,
E9D5505D28AC1C8500C746DD /* MediaProgress.swift */, E9D5505D28AC1C8500C746DD /* MediaProgress.swift */,
4D91EEC52A40F28D004807ED /* EBookFile.swift */,
); );
path = server; path = server;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -545,6 +548,7 @@
E9D5506028AC1CA900C746DD /* PlaybackMetadata.swift in Sources */, E9D5506028AC1CA900C746DD /* PlaybackMetadata.swift in Sources */,
E9D5504828AC1A7A00C746DD /* MediaType.swift in Sources */, E9D5504828AC1A7A00C746DD /* MediaType.swift in Sources */,
E9D5504E28AC1B0700C746DD /* AudioFile.swift in Sources */, E9D5504E28AC1B0700C746DD /* AudioFile.swift in Sources */,
4D91EEC62A40F28D004807ED /* EBookFile.swift in Sources */,
E9DFCBFB28C28F4A00B36356 /* AudioPlayerSleepTimer.swift in Sources */, E9DFCBFB28C28F4A00B36356 /* AudioPlayerSleepTimer.swift in Sources */,
E9D5505428AC1B7900C746DD /* AudioTrack.swift in Sources */, E9D5505428AC1B7900C746DD /* AudioTrack.swift in Sources */,
E9D5505C28AC1C6200C746DD /* LibraryFile.swift in Sources */, E9D5505C28AC1C6200C746DD /* LibraryFile.swift in Sources */,
@@ -720,12 +724,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 = 21; CURRENT_PROJECT_VERSION = 22;
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.64; MARKETING_VERSION = 0.9.65;
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 +748,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 = 21; CURRENT_PROJECT_VERSION = 22;
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.64; MARKETING_VERSION = 0.9.65;
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 = "";
+1 -1
View File
@@ -14,7 +14,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
// Override point for customization after application launch. // Override point for customization after application launch.
let configuration = Realm.Configuration( let configuration = Realm.Configuration(
schemaVersion: 8, schemaVersion: 13,
migrationBlock: { [weak self] migration, oldSchemaVersion in migrationBlock: { [weak self] migration, oldSchemaVersion in
if (oldSchemaVersion < 1) { if (oldSchemaVersion < 1) {
self?.logger.log("Realm schema version was \(oldSchemaVersion)") self?.logger.log("Realm schema version was \(oldSchemaVersion)")
+1
View File
@@ -25,4 +25,5 @@ CAP_PLUGIN(AbsDatabase, "AbsDatabase",
CAP_PLUGIN_METHOD(syncServerMediaProgressWithLocalMediaProgress, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(syncServerMediaProgressWithLocalMediaProgress, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(updateLocalMediaProgressFinished, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(updateLocalMediaProgressFinished, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(updateDeviceSettings, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(updateDeviceSettings, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(updateLocalEbookProgress, CAPPluginReturnPromise);
) )
+34 -2
View File
@@ -193,10 +193,14 @@ public class AbsDatabase: CAPPlugin {
@objc func updateLocalMediaProgressFinished(_ call: CAPPluginCall) { @objc func updateLocalMediaProgressFinished(_ call: CAPPluginCall) {
let localLibraryItemId = call.getString("localLibraryItemId") let localLibraryItemId = call.getString("localLibraryItemId")
let localEpisodeId = call.getString("localEpisodeId") let localEpisodeId = call.getString("localEpisodeId")
let localMediaProgressId = call.getString("localMediaProgressId")
let isFinished = call.getBool("isFinished", false) let isFinished = call.getBool("isFinished", false)
logger.log("updateLocalMediaProgressFinished \(localMediaProgressId ?? "Unknown") | Is Finished: \(isFinished)") var localMediaProgressId = localLibraryItemId ?? ""
if localEpisodeId != nil {
localMediaProgressId += "-\(localEpisodeId ?? "")"
}
logger.log("updateLocalMediaProgressFinished \(localMediaProgressId) | Is Finished: \(isFinished)")
do { do {
let localMediaProgress = try LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localMediaProgressId, localLibraryItemId: localLibraryItemId, localEpisodeId: localEpisodeId) let localMediaProgress = try LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localMediaProgressId, localLibraryItemId: localLibraryItemId, localEpisodeId: localEpisodeId)
@@ -251,4 +255,32 @@ public class AbsDatabase: CAPPlugin {
// call.resolve([ "value": [] ]) // call.resolve([ "value": [] ])
getDeviceData(call) getDeviceData(call)
} }
@objc func updateLocalEbookProgress(_ call: CAPPluginCall) {
let localLibraryItemId = call.getString("localLibraryItemId")
let ebookLocation = call.getString("ebookLocation", "")
let ebookProgress = call.getDouble("ebookProgress", 0.0)
logger.log("updateLocalEbookProgress \(localLibraryItemId ?? "Unknown") | ebookLocation: \(ebookLocation) | ebookProgress: \(ebookProgress)")
do {
let localMediaProgress = try LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localLibraryItemId, localLibraryItemId: localLibraryItemId, localEpisodeId: nil)
guard let localMediaProgress = localMediaProgress else {
call.resolve(["error": "Library Item not found"])
return
}
// Update finished status
try localMediaProgress.updateEbookProgress(ebookLocation: ebookLocation, ebookProgress: ebookProgress)
// Build API response
let progressDictionary = try? localMediaProgress.asDictionary()
let response: [String: Any] = ["localMediaProgress": progressDictionary ?? ""]
call.resolve(response)
} catch {
debugPrint(error)
call.resolve(["error": "Failed to update ebook progress"])
return
}
}
} }
+39 -7
View File
@@ -265,7 +265,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
// Handle the different media type downloads // Handle the different media type downloads
switch item.mediaType { switch item.mediaType {
case "book": case "book":
guard item.media?.tracks.count ?? 0 > 0 else { throw LibraryItemDownloadError.noTracks } guard item.media?.tracks.count ?? 0 > 0 || item.media?.ebookFile != nil else { throw LibraryItemDownloadError.noTracks }
item.media?.tracks.forEach { t in tracks.append(AudioTrack.detachCopy(of: t)!) } item.media?.tracks.forEach { t in tracks.append(AudioTrack.detachCopy(of: t)!) }
case "podcast": case "podcast":
guard let episode = episode else { throw LibraryItemDownloadError.podcastEpisodeNotFound } guard let episode = episode else { throw LibraryItemDownloadError.podcastEpisodeNotFound }
@@ -285,6 +285,12 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
tasks.append(task) tasks.append(task)
} }
if (item.media?.ebookFile != nil) {
let task = try startLibraryItemEbookDownload(downloadItemId: downloadItem.id!, item: item, ebookFile: item.media!.ebookFile!)
downloadItem.downloadItemParts.append(task.part)
tasks.append(task)
}
// Also download the cover // Also download the cover
if item.media?.coverPath != nil && !(item.media?.coverPath!.isEmpty ?? true) { if item.media?.coverPath != nil && !(item.media?.coverPath!.isEmpty ?? true) {
if let task = try? startLibraryItemCoverDownload(downloadItemId: downloadItem.id!, item: item) { if let task = try? startLibraryItemCoverDownload(downloadItemId: downloadItem.id!, item: item) {
@@ -318,7 +324,22 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
let localUrl = "\(itemDirectory)/\(filename)" let localUrl = "\(itemDirectory)/\(filename)"
let task = session.downloadTask(with: serverUrl) let task = session.downloadTask(with: serverUrl)
let part = DownloadItemPart(downloadItemId: downloadItemId, filename: filename, destination: localUrl, itemTitle: track.title ?? "Unknown", serverPath: Store.serverConfig!.address, audioTrack: track, episode: episode, size: track.metadata?.size ?? 0) let part = DownloadItemPart(downloadItemId: downloadItemId, filename: filename, destination: localUrl, itemTitle: track.title ?? "Unknown", serverPath: Store.serverConfig!.address, audioTrack: track, episode: episode, ebookFile: nil, size: track.metadata?.size ?? 0)
// Store the id on the task so the download item can be pulled from the database later
task.taskDescription = part.id
return DownloadItemPartTask(part: part, task: task)
}
private func startLibraryItemEbookDownload(downloadItemId: String, item: LibraryItem, ebookFile: EBookFile) throws -> DownloadItemPartTask {
let filename = ebookFile.metadata?.filename ?? "ebook.\(ebookFile.ebookFormat)"
let serverPath = "/api/items/\(item.id)/file/\(ebookFile.ino)/download"
let itemDirectory = try createLibraryItemFileDirectory(item: item)
let localUrl = "\(itemDirectory)/\(filename)"
let part = DownloadItemPart(downloadItemId: downloadItemId, filename: filename, destination: localUrl, itemTitle: filename, serverPath: serverPath, audioTrack: nil, episode: nil, ebookFile: ebookFile, size: ebookFile.metadata?.size ?? 0)
let task = session.downloadTask(with: part.downloadURL!)
// Store the id on the task so the download item can be pulled from the database later // Store the id on the task so the download item can be pulled from the database later
task.taskDescription = part.id task.taskDescription = part.id
@@ -337,7 +358,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
$0.metadata?.path == item.media?.coverPath $0.metadata?.path == item.media?.coverPath
}) })
let part = DownloadItemPart(downloadItemId: downloadItemId, filename: filename, destination: localUrl, itemTitle: "cover", serverPath: serverPath, audioTrack: nil, episode: nil, size: coverLibraryFile?.metadata?.size ?? 0) let part = DownloadItemPart(downloadItemId: downloadItemId, filename: filename, destination: localUrl, itemTitle: "cover", serverPath: serverPath, audioTrack: nil, episode: nil, ebookFile: nil, size: coverLibraryFile?.metadata?.size ?? 0)
let task = session.downloadTask(with: part.downloadURL!) let task = session.downloadTask(with: part.downloadURL!)
// Store the id on the task so the download item can be pulled from the database later // Store the id on the task so the download item can be pulled from the database later
@@ -347,10 +368,21 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
} }
private func urlForTrack(item: LibraryItem, track: AudioTrack) -> URL { private func urlForTrack(item: LibraryItem, track: AudioTrack) -> URL {
// filename needs to be encoded otherwise would just use contentUrl // TODO: Future server release should include ino with AudioFile or FileMetadata
let relPath = track.metadata?.relPath ?? "" let trackPath = track.metadata?.path ?? ""
let filepathEncoded = relPath.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed)
let urlstr = "\(Store.serverConfig!.address)/s/item/\(item.id)/\(filepathEncoded ?? "")?token=\(Store.serverConfig!.token)" var audioFileIno = ""
if (item.mediaType == "podcast") {
let podcastEpisodes = item.media?.episodes ?? List<PodcastEpisode>()
let matchingEpisode = podcastEpisodes.first(where: { $0.audioFile?.metadata?.path == trackPath })
audioFileIno = matchingEpisode?.audioFile?.ino ?? ""
} else {
let audioFiles = item.media?.audioFiles ?? List<AudioFile>()
let matchingAudioFile = audioFiles.first(where: { $0.metadata?.path == trackPath })
audioFileIno = matchingAudioFile?.ino ?? ""
}
let urlstr = "\(Store.serverConfig!.address)/api/items/\(item.id)/file/\(audioFileIno)/download?token=\(Store.serverConfig!.token)"
return URL(string: urlstr)! return URL(string: urlstr)!
} }
+1
View File
@@ -12,6 +12,7 @@ def capacitor_pods
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios' pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios' pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app' pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
pod 'CapacitorClipboard', :path => '../../node_modules/@capacitor/clipboard'
pod 'CapacitorDialog', :path => '../../node_modules/@capacitor/dialog' pod 'CapacitorDialog', :path => '../../node_modules/@capacitor/dialog'
pod 'CapacitorHaptics', :path => '../../node_modules/@capacitor/haptics' pod 'CapacitorHaptics', :path => '../../node_modules/@capacitor/haptics'
pod 'CapacitorNetwork', :path => '../../node_modules/@capacitor/network' pod 'CapacitorNetwork', :path => '../../node_modules/@capacitor/network'
@@ -17,6 +17,7 @@ class DownloadItemPart: Object, Codable {
@Persisted var serverPath: String? @Persisted var serverPath: String?
@Persisted var audioTrack: AudioTrack? @Persisted var audioTrack: AudioTrack?
@Persisted var episode: PodcastEpisode? @Persisted var episode: PodcastEpisode?
@Persisted var ebookFile: EBookFile?
@Persisted var completed: Bool = false @Persisted var completed: Bool = false
@Persisted var moved: Bool = false @Persisted var moved: Bool = false
@Persisted var failed: Bool = false @Persisted var failed: Bool = false
@@ -63,7 +64,7 @@ class DownloadItemPart: Object, Codable {
} }
extension DownloadItemPart { extension DownloadItemPart {
convenience init(downloadItemId: String, filename: String, destination: String, itemTitle: String, serverPath: String, audioTrack: AudioTrack?, episode: PodcastEpisode?, size: Double) { convenience init(downloadItemId: String, filename: String, destination: String, itemTitle: String, serverPath: String, audioTrack: AudioTrack?, episode: PodcastEpisode?, ebookFile: EBookFile?, size: Double) {
self.init() self.init()
self.id = destination.toBase64() self.id = destination.toBase64()
@@ -74,6 +75,7 @@ extension DownloadItemPart {
self.serverPath = serverPath self.serverPath = serverPath
self.audioTrack = AudioTrack.detachCopy(of: audioTrack) self.audioTrack = AudioTrack.detachCopy(of: audioTrack)
self.episode = PodcastEpisode.detachCopy(of: episode) self.episode = PodcastEpisode.detachCopy(of: episode)
self.ebookFile = EBookFile.detachCopy(of: ebookFile)
let config = Store.serverConfig! let config = Store.serverConfig!
var downloadUrl = "\(config.address)\(serverPath)?token=\(config.token)" var downloadUrl = "\(config.address)\(serverPath)?token=\(config.token)"
@@ -101,6 +103,6 @@ extension DownloadItemPart {
} }
func mimeType() -> String? { func mimeType() -> String? {
audioTrack?.mimeType ?? episode?.audioTrack?.mimeType audioTrack?.mimeType ?? episode?.audioTrack?.mimeType ?? ebookFile?.mimeType()
} }
} }
@@ -130,6 +130,12 @@ extension LocalLibraryItem {
for i in fromMedia.tracks.indices { for i in fromMedia.tracks.indices {
_ = fromMedia.tracks[i].setLocalInfo(filenameIdMap: fileIdByFilename, serverIndex: i) _ = fromMedia.tracks[i].setLocalInfo(filenameIdMap: fileIdByFilename, serverIndex: i)
} }
if fromMedia.ebookFile != nil {
let ebookLocalFile = files.first(where: { $0.filename == fromMedia.ebookFile?.metadata?.filename ?? "" })
if ebookLocalFile != nil {
_ = fromMedia.ebookFile?.setLocalInfo(localFile: ebookLocalFile!)
}
}
} else if ( self.isPodcast ) { } else if ( self.isPodcast ) {
let episodes = List<PodcastEpisode>() let episodes = List<PodcastEpisode>()
for episode in fromMedia.episodes { for episode in fromMedia.episodes {
@@ -177,6 +183,7 @@ extension LocalLibraryItem {
audioTracks.append(AudioTrack.detachCopy(of: track)!) audioTracks.append(AudioTrack.detachCopy(of: track)!)
duration = track.duration duration = track.duration
displayTitle = episode.title displayTitle = episode.title
episode.chapters.forEach { chapter in chapters.append(Chapter.detachCopy(of: chapter)!) }
} else if let tracks = self.media?.tracks { } else if let tracks = self.media?.tracks {
tracks.forEach { t in audioTracks.append(AudioTrack.detachCopy(of: t)!) } tracks.forEach { t in audioTracks.append(AudioTrack.detachCopy(of: t)!) }
} }
@@ -16,6 +16,8 @@ class LocalMediaProgress: Object, Codable {
@Persisted var progress: Double = 0 @Persisted var progress: Double = 0
@Persisted var currentTime: Double = 0 @Persisted var currentTime: Double = 0
@Persisted var isFinished: Bool = false @Persisted var isFinished: Bool = false
@Persisted var ebookLocation: String?
@Persisted var ebookProgress: Double?
@Persisted var lastUpdate: Double = 0 @Persisted var lastUpdate: Double = 0
@Persisted var startedAt: Double = 0 @Persisted var startedAt: Double = 0
@Persisted var finishedAt: Double? @Persisted var finishedAt: Double?
@@ -29,7 +31,7 @@ class LocalMediaProgress: Object, Codable {
var progressPercent: Int { Int(self.progress * 100) } var progressPercent: Int { Int(self.progress * 100) }
private enum CodingKeys : String, CodingKey { private enum CodingKeys : String, CodingKey {
case id, localLibraryItemId, localEpisodeId, duration, progress, currentTime, isFinished, lastUpdate, startedAt, finishedAt, serverConnectionConfigId, serverAddress, serverUserId, libraryItemId, episodeId case id, localLibraryItemId, localEpisodeId, duration, progress, currentTime, isFinished, ebookLocation, ebookProgress, lastUpdate, startedAt, finishedAt, serverConnectionConfigId, serverAddress, serverUserId, libraryItemId, episodeId
} }
override init() { override init() {
@@ -47,6 +49,8 @@ class LocalMediaProgress: Object, Codable {
progress = try values.decode(Double.self, forKey: .progress) progress = try values.decode(Double.self, forKey: .progress)
currentTime = try values.decode(Double.self, forKey: .currentTime) currentTime = try values.decode(Double.self, forKey: .currentTime)
isFinished = try values.decode(Bool.self, forKey: .isFinished) isFinished = try values.decode(Bool.self, forKey: .isFinished)
ebookLocation = try values.decodeIfPresent(String.self, forKey: .ebookLocation)
ebookProgress = try values.doubleOrStringDecoder(key: .ebookProgress)
lastUpdate = try values.decode(Double.self, forKey: .lastUpdate) lastUpdate = try values.decode(Double.self, forKey: .lastUpdate)
startedAt = try values.decode(Double.self, forKey: .startedAt) startedAt = try values.decode(Double.self, forKey: .startedAt)
finishedAt = try values.decodeIfPresent(Double.self, forKey: .finishedAt) finishedAt = try values.decodeIfPresent(Double.self, forKey: .finishedAt)
@@ -70,6 +74,8 @@ class LocalMediaProgress: Object, Codable {
try container.encode(currentTime, forKey: .currentTime) try container.encode(currentTime, forKey: .currentTime)
} }
try container.encode(isFinished, forKey: .isFinished) try container.encode(isFinished, forKey: .isFinished)
try container.encode(ebookLocation, forKey: .ebookLocation)
try container.encode(ebookProgress, forKey: .ebookProgress)
try container.encode(lastUpdate, forKey: .lastUpdate) try container.encode(lastUpdate, forKey: .lastUpdate)
try container.encode(startedAt, forKey: .startedAt) try container.encode(startedAt, forKey: .startedAt)
try container.encode(finishedAt, forKey: .finishedAt) try container.encode(finishedAt, forKey: .finishedAt)
@@ -97,6 +103,7 @@ extension LocalMediaProgress {
self.progress = 0.0 self.progress = 0.0
self.currentTime = 0.0 self.currentTime = 0.0
self.isFinished = false self.isFinished = false
self.ebookProgress = 0.0
self.lastUpdate = Date().timeIntervalSince1970 * 1000 self.lastUpdate = Date().timeIntervalSince1970 * 1000
self.startedAt = 0 self.startedAt = 0
self.finishedAt = nil self.finishedAt = nil
@@ -104,6 +111,7 @@ extension LocalMediaProgress {
if let episode = episode { if let episode = episode {
self.id += "-\(episode.id)" self.id += "-\(episode.id)"
self.episodeId = episode.id self.episodeId = episode.id
self.localEpisodeId = episode.id
self.duration = episode.duration ?? 0.0 self.duration = episode.duration ?? 0.0
} }
} }
@@ -114,6 +122,8 @@ extension LocalMediaProgress {
self.progress = progress.progress self.progress = progress.progress
self.currentTime = progress.currentTime self.currentTime = progress.currentTime
self.isFinished = progress.isFinished self.isFinished = progress.isFinished
self.ebookLocation = progress.ebookLocation
self.ebookProgress = progress.ebookProgress
self.lastUpdate = progress.lastUpdate self.lastUpdate = progress.lastUpdate
self.startedAt = progress.startedAt self.startedAt = progress.startedAt
self.finishedAt = progress.finishedAt self.finishedAt = progress.finishedAt
@@ -124,6 +134,10 @@ extension LocalMediaProgress {
if self.isFinished != finished { if self.isFinished != finished {
self.progress = finished ? 1.0 : 0.0 self.progress = finished ? 1.0 : 0.0
} }
if finished {
self.currentTime = 0.0
}
if self.startedAt == 0 && finished { if self.startedAt == 0 && finished {
self.startedAt = Date().timeIntervalSince1970 * 1000 self.startedAt = Date().timeIntervalSince1970 * 1000
@@ -140,7 +154,7 @@ extension LocalMediaProgress {
self.currentTime = playbackSession.currentTime self.currentTime = playbackSession.currentTime
self.progress = playbackSession.progress self.progress = playbackSession.progress
self.lastUpdate = Date().timeIntervalSince1970 * 1000 self.lastUpdate = Date().timeIntervalSince1970 * 1000
self.isFinished = playbackSession.progress >= 100.0 self.isFinished = playbackSession.progress >= 0.99
self.finishedAt = self.isFinished ? self.lastUpdate : nil self.finishedAt = self.isFinished ? self.lastUpdate : nil
} }
} }
@@ -148,6 +162,8 @@ extension LocalMediaProgress {
func updateFromServerMediaProgress(_ serverMediaProgress: MediaProgress) throws { func updateFromServerMediaProgress(_ serverMediaProgress: MediaProgress) throws {
try self.realm?.write { try self.realm?.write {
self.isFinished = serverMediaProgress.isFinished self.isFinished = serverMediaProgress.isFinished
self.ebookLocation = serverMediaProgress.ebookLocation
self.ebookProgress = serverMediaProgress.ebookProgress
self.progress = serverMediaProgress.progress self.progress = serverMediaProgress.progress
self.currentTime = serverMediaProgress.currentTime self.currentTime = serverMediaProgress.currentTime
self.duration = serverMediaProgress.duration self.duration = serverMediaProgress.duration
@@ -157,6 +173,14 @@ extension LocalMediaProgress {
} }
} }
func updateEbookProgress(ebookLocation:String, ebookProgress:Double) throws {
try self.realm?.write {
self.ebookLocation = ebookLocation
self.ebookProgress = ebookProgress
self.lastUpdate = Date().timeIntervalSince1970 * 1000
}
}
static func getLocalMediaProgressId(localLibraryItemId: String?, localEpisodeId: String?) -> String? { static func getLocalMediaProgressId(localLibraryItemId: String?, localEpisodeId: String?) -> String? {
if let itemId = localLibraryItemId, let episodeId = localEpisodeId { if let itemId = localLibraryItemId, let episodeId = localEpisodeId {
return "\(itemId)-\(episodeId)" return "\(itemId)-\(episodeId)"
@@ -18,6 +18,7 @@ class LocalPodcastEpisode: Object, Codable {
@Persisted var desc: String? @Persisted var desc: String?
@Persisted var audioFile: AudioFile? @Persisted var audioFile: AudioFile?
@Persisted var audioTrack: AudioTrack? @Persisted var audioTrack: AudioTrack?
@Persisted var chapters = List<Chapter>()
@Persisted var duration: Double = 0 @Persisted var duration: Double = 0
@Persisted var size: Int = 0 @Persisted var size: Int = 0
@Persisted(indexed: true) var serverEpisodeId: String? @Persisted(indexed: true) var serverEpisodeId: String?
@@ -0,0 +1,85 @@
//
// EBookFile.swift
// Audiobookshelf
//
// Created by Advplyr on 6/19/23.
//
import Foundation
import RealmSwift
class EBookFile: EmbeddedObject, Codable {
@Persisted var ino: String = ""
@Persisted var metadata: FileMetadata?
@Persisted var ebookFormat: String
@Persisted var _contentUrl: String?
@Persisted var localFileId: String?
var contentUrl: String? {
if let path = _contentUrl {
return AbsDownloader.itemDownloadFolder(path: path)!.absoluteString
} else {
return nil
}
}
private enum CodingKeys : String, CodingKey {
case ino, metadata, ebookFormat, contentUrl, localFileId
}
override init() {
super.init()
}
required init(from decoder: Decoder) throws {
super.init()
let values = try decoder.container(keyedBy: CodingKeys.self)
ino = try values.decode(String.self, forKey: .ino)
metadata = try values.decode(FileMetadata.self, forKey: .metadata)
ebookFormat = try values.decode(String.self, forKey: .ebookFormat)
localFileId = try? values.decodeIfPresent(String.self, forKey: .localFileId)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(ino, forKey: .ino)
try container.encode(metadata, forKey: .metadata)
try container.encode(ebookFormat, forKey: .ebookFormat)
try container.encode(contentUrl, forKey: .contentUrl)
try container.encode(localFileId, forKey: .localFileId)
}
}
extension EBookFile {
func setLocalInfo(localFile: LocalFile) -> Bool {
self.localFileId = localFile.id
self._contentUrl = localFile._contentUrl
return false
}
func getLocalFile() -> LocalFile? {
guard let localFileId = self.localFileId else { return nil }
return Database.shared.getLocalFile(localFileId: localFileId)
}
func mimeType() -> String? {
var mimeType = ""
switch ebookFormat {
case "epub":
mimeType = "application/epub+zip"
case "pdf":
mimeType = "application/pdf"
case "mobi":
mimeType = "application/x-mobipocket-ebook"
case "azw3":
mimeType = "application/vnd.amazon.mobi8-ebook"
case "cbr":
mimeType = "application/vnd.comicbook-rar"
case "cbz":
mimeType = "application/vnd.comicbook+zip"
default:
mimeType = "application/epub+zip"
}
return mimeType
}
}
@@ -16,12 +16,14 @@ class MediaProgress: EmbeddedObject, Codable {
@Persisted var progress: Double = 0 @Persisted var progress: Double = 0
@Persisted var currentTime: Double = 0 @Persisted var currentTime: Double = 0
@Persisted var isFinished: Bool = false @Persisted var isFinished: Bool = false
@Persisted var ebookLocation: String?
@Persisted var ebookProgress: Double?
@Persisted var lastUpdate: Double = 0 @Persisted var lastUpdate: Double = 0
@Persisted var startedAt: Double = 0 @Persisted var startedAt: Double = 0
@Persisted var finishedAt: Double? @Persisted var finishedAt: Double?
private enum CodingKeys : String, CodingKey { private enum CodingKeys : String, CodingKey {
case id, libraryItemId, episodeId, duration, progress, currentTime, isFinished, lastUpdate, startedAt, finishedAt case id, libraryItemId, episodeId, duration, progress, currentTime, isFinished, ebookLocation, ebookProgress, lastUpdate, startedAt, finishedAt
} }
override init() { override init() {
@@ -37,6 +39,8 @@ class MediaProgress: EmbeddedObject, Codable {
progress = try values.doubleOrStringDecoder(key: .progress) progress = try values.doubleOrStringDecoder(key: .progress)
currentTime = try values.doubleOrStringDecoder(key: .currentTime) currentTime = try values.doubleOrStringDecoder(key: .currentTime)
isFinished = try values.decode(Bool.self, forKey: .isFinished) isFinished = try values.decode(Bool.self, forKey: .isFinished)
ebookLocation = try values.decodeIfPresent(String.self, forKey: .ebookLocation)
ebookProgress = try values.doubleOrStringDecoder(key: .ebookProgress)
lastUpdate = try values.doubleOrStringDecoder(key: .lastUpdate) lastUpdate = try values.doubleOrStringDecoder(key: .lastUpdate)
startedAt = try values.doubleOrStringDecoder(key: .startedAt) startedAt = try values.doubleOrStringDecoder(key: .startedAt)
finishedAt = try? values.doubleOrStringDecoder(key: .finishedAt) finishedAt = try? values.doubleOrStringDecoder(key: .finishedAt)
@@ -51,6 +55,8 @@ class MediaProgress: EmbeddedObject, Codable {
try container.encode(progress, forKey: .progress) try container.encode(progress, forKey: .progress)
try container.encode(currentTime, forKey: .currentTime) try container.encode(currentTime, forKey: .currentTime)
try container.encode(isFinished, forKey: .isFinished) try container.encode(isFinished, forKey: .isFinished)
try container.encode(ebookLocation, forKey: .ebookLocation)
try container.encode(ebookProgress, forKey: .ebookProgress)
try container.encode(lastUpdate, forKey: .lastUpdate) try container.encode(lastUpdate, forKey: .lastUpdate)
try container.encode(startedAt, forKey: .startedAt) try container.encode(startedAt, forKey: .startedAt)
try container.encode(finishedAt, forKey: .finishedAt) try container.encode(finishedAt, forKey: .finishedAt)
+4 -1
View File
@@ -14,6 +14,7 @@ class MediaType: EmbeddedObject, Codable {
@Persisted var coverPath: String? @Persisted var coverPath: String?
@Persisted var tags = List<String>() @Persisted var tags = List<String>()
@Persisted var audioFiles = List<AudioFile>() @Persisted var audioFiles = List<AudioFile>()
@Persisted var ebookFile: EBookFile?
@Persisted var chapters = List<Chapter>() @Persisted var chapters = List<Chapter>()
@Persisted var tracks = List<AudioTrack>() @Persisted var tracks = List<AudioTrack>()
@Persisted var size: Int? @Persisted var size: Int?
@@ -22,7 +23,7 @@ class MediaType: EmbeddedObject, Codable {
@Persisted var autoDownloadEpisodes: Bool? @Persisted var autoDownloadEpisodes: Bool?
private enum CodingKeys : String, CodingKey { private enum CodingKeys : String, CodingKey {
case libraryItemId, metadata, coverPath, tags, audioFiles, chapters, tracks, size, duration, episodes, autoDownloadEpisodes case libraryItemId, metadata, coverPath, tags, audioFiles, ebookFile, chapters, tracks, size, duration, episodes, autoDownloadEpisodes
} }
override init() { override init() {
@@ -41,6 +42,7 @@ class MediaType: EmbeddedObject, Codable {
if let fileList = try? values.decode([AudioFile].self, forKey: .audioFiles) { if let fileList = try? values.decode([AudioFile].self, forKey: .audioFiles) {
audioFiles.append(objectsIn: fileList) audioFiles.append(objectsIn: fileList)
} }
ebookFile = try? values.decode(EBookFile.self, forKey: .ebookFile)
if let chapterList = try? values.decode([Chapter].self, forKey: .chapters) { if let chapterList = try? values.decode([Chapter].self, forKey: .chapters) {
chapters.append(objectsIn: chapterList) chapters.append(objectsIn: chapterList)
} }
@@ -62,6 +64,7 @@ class MediaType: EmbeddedObject, Codable {
try container.encode(coverPath, forKey: .coverPath) try container.encode(coverPath, forKey: .coverPath)
try container.encode(Array(tags), forKey: .tags) try container.encode(Array(tags), forKey: .tags)
try container.encode(Array(audioFiles), forKey: .audioFiles) try container.encode(Array(audioFiles), forKey: .audioFiles)
try container.encode(ebookFile, forKey: .ebookFile)
try container.encode(Array(chapters), forKey: .chapters) try container.encode(Array(chapters), forKey: .chapters)
try container.encode(Array(tracks), forKey: .tracks) try container.encode(Array(tracks), forKey: .tracks)
try container.encode(size, forKey: .size) try container.encode(size, forKey: .size)
@@ -18,6 +18,7 @@ class PodcastEpisode: EmbeddedObject, Codable {
@Persisted var desc: String? @Persisted var desc: String?
@Persisted var audioFile: AudioFile? @Persisted var audioFile: AudioFile?
@Persisted var audioTrack: AudioTrack? @Persisted var audioTrack: AudioTrack?
@Persisted var chapters = List<Chapter>()
@Persisted var duration: Double? @Persisted var duration: Double?
@Persisted var size: Int? @Persisted var size: Int?
var serverEpisodeId: String { self.id } var serverEpisodeId: String { self.id }
@@ -32,6 +33,7 @@ class PodcastEpisode: EmbeddedObject, Codable {
desc = "description", // Fixes a collision with the base Swift object's field "description" desc = "description", // Fixes a collision with the base Swift object's field "description"
audioFile, audioFile,
audioTrack, audioTrack,
chapters,
duration, duration,
size, size,
serverEpisodeId serverEpisodeId
@@ -40,6 +42,7 @@ class PodcastEpisode: EmbeddedObject, Codable {
override init() {} override init() {}
required init(from decoder: Decoder) throws { required init(from decoder: Decoder) throws {
super.init()
let values = try decoder.container(keyedBy: CodingKeys.self) let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decode(String.self, forKey: .id) id = try values.decode(String.self, forKey: .id)
index = try? values.decode(Int.self, forKey: .index) index = try? values.decode(Int.self, forKey: .index)
@@ -50,6 +53,9 @@ class PodcastEpisode: EmbeddedObject, Codable {
desc = try? values.decode(String.self, forKey: .desc) desc = try? values.decode(String.self, forKey: .desc)
audioFile = try? values.decode(AudioFile.self, forKey: .audioFile) audioFile = try? values.decode(AudioFile.self, forKey: .audioFile)
audioTrack = try? values.decode(AudioTrack.self, forKey: .audioTrack) audioTrack = try? values.decode(AudioTrack.self, forKey: .audioTrack)
if let chapterList = try? values.decode([Chapter].self, forKey: .chapters) {
chapters.append(objectsIn: chapterList)
}
duration = try? values.decode(Double.self, forKey: .duration) duration = try? values.decode(Double.self, forKey: .duration)
size = try? values.decode(Int.self, forKey: .size) size = try? values.decode(Int.self, forKey: .size)
} }
@@ -65,6 +71,7 @@ class PodcastEpisode: EmbeddedObject, Codable {
try container.encode(desc, forKey: .desc) try container.encode(desc, forKey: .desc)
try container.encode(audioFile, forKey: .audioFile) try container.encode(audioFile, forKey: .audioFile)
try container.encode(audioTrack, forKey: .audioTrack) try container.encode(audioTrack, forKey: .audioTrack)
try container.encode(Array(chapters), forKey: .chapters)
try container.encode(duration, forKey: .duration) try container.encode(duration, forKey: .duration)
try container.encode(size, forKey: .size) try container.encode(size, forKey: .size)
try container.encode(serverEpisodeId, forKey: .serverEpisodeId) try container.encode(serverEpisodeId, forKey: .serverEpisodeId)
+19 -9
View File
@@ -9,6 +9,7 @@ import Foundation
import AVFoundation import AVFoundation
import UIKit import UIKit
import MediaPlayer import MediaPlayer
import RealmSwift
enum PlayMethod: Int { enum PlayMethod: Int {
case directplay = 0 case directplay = 0
@@ -86,7 +87,21 @@ class AudioPlayer: NSObject {
self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem), options: .new, context: &playerContext) self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem), options: .new, context: &playerContext)
for track in playbackSession.audioTracks { for track in playbackSession.audioTracks {
if let playerAsset = createAsset(itemId: playbackSession.libraryItemId!, track: track) {
// TODO: All of this to get the ino of the file on the server. Future server release will include the ino with the session tracks
var audioFileIno = ""
let trackPath = track.metadata?.path ?? ""
if (!playbackSession.isLocal && playbackSession.episodeId != nil) {
let episodes = playbackSession.libraryItem?.media?.episodes ?? List<PodcastEpisode>()
let matchingEpisode:PodcastEpisode? = episodes.first(where: { $0.audioFile?.metadata?.path == trackPath })
audioFileIno = matchingEpisode?.audioFile?.ino ?? ""
} else if (!playbackSession.isLocal) {
let audioFiles = playbackSession.libraryItem?.media?.audioFiles ?? List<AudioFile>()
let matchingAudioFile = audioFiles.first(where: { $0.metadata?.path == trackPath })
audioFileIno = matchingAudioFile?.ino ?? ""
}
if let playerAsset = createAsset(itemId: playbackSession.libraryItemId!, track: track, ino: audioFileIno) {
let playerItem = AVPlayerItem(asset: playerAsset) let playerItem = AVPlayerItem(asset: playerAsset)
if (playbackSession.playMethod == PlayMethod.transcode.rawValue) { if (playbackSession.playMethod == PlayMethod.transcode.rawValue) {
playerItem.preferredForwardBufferDuration = 50 playerItem.preferredForwardBufferDuration = 50
@@ -483,23 +498,18 @@ class AudioPlayer: NSObject {
} }
// MARK: - Private // MARK: - Private
private func createAsset(itemId:String, track:AudioTrack) -> AVAsset? { private func createAsset(itemId:String, track:AudioTrack, ino:String) -> AVAsset? {
guard let playbackSession = self.getPlaybackSession() else { return nil } guard let playbackSession = self.getPlaybackSession() else { return nil }
if (playbackSession.playMethod == PlayMethod.directplay.rawValue) { if (playbackSession.playMethod == PlayMethod.directplay.rawValue) {
// The only reason this is separate is because the filename needs to be encoded let urlstr = "\(Store.serverConfig!.address)/api/items/\(itemId)/file/\(ino)?token=\(Store.serverConfig!.token)"
let relPath = track.metadata?.relPath ?? ""
let filepathEncoded = relPath.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed)
let urlstr = "\(Store.serverConfig!.address)/s/item/\(itemId)/\(filepathEncoded ?? "")?token=\(Store.serverConfig!.token)"
let url = URL(string: urlstr)! let url = URL(string: urlstr)!
return AVURLAsset(url: url) return AVURLAsset(url: url)
} else if (playbackSession.playMethod == PlayMethod.local.rawValue) { } else if (playbackSession.playMethod == PlayMethod.local.rawValue) {
guard let localFile = track.getLocalFile() else { guard let localFile = track.getLocalFile() else {
// Worst case we can stream the file // Worst case we can stream the file
logger.log("Unable to play local file. Resulting to streaming \(track.localFileId ?? "Unknown")") logger.log("Unable to play local file. Resulting to streaming \(track.localFileId ?? "Unknown")")
let relPath = track.metadata?.relPath ?? "" let urlstr = "\(Store.serverConfig!.address)/api/items/\(itemId)/file/\(ino)?token=\(Store.serverConfig!.token)"
let filepathEncoded = relPath.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed)
let urlstr = "\(Store.serverConfig!.address)/s/item/\(itemId)/\(filepathEncoded ?? "")?token=\(Store.serverConfig!.token)"
let url = URL(string: urlstr)! let url = URL(string: urlstr)!
return AVURLAsset(url: url) return AVURLAsset(url: url)
} }
+6
View File
@@ -50,6 +50,12 @@ extension KeyedDecodingContainer {
return Int(stringValue) ?? 0 return Int(stringValue) ?? 0
} }
} }
// This will be called when any @Persisted List<> is decoded
func decode<T: Decodable>(_ type: Persisted<List<T>>.Type, forKey key: Key) throws -> Persisted<List<T>> {
// Use decode if present, falling back to an empty list
try decodeIfPresent(type, forKey: key) ?? Persisted<List<T>>(wrappedValue: List<T>())
}
} }
extension CAPPluginCall { extension CAPPluginCall {
+27 -11
View File
@@ -1,16 +1,18 @@
<template> <template>
<div class="w-full layout-wrapper bg-bg text-white"> <div class="w-full layout-wrapper bg-bg text-white">
<app-appbar /> <app-appbar />
<div id="content" class="overflow-hidden relative" :class="playerIsOpen ? 'playerOpen' : ''"> <div id="content" class="overflow-hidden relative" :class="isPlayerOpen ? 'playerOpen' : ''">
<Nuxt /> <Nuxt />
<div v-if="attemptingConnection" class="absolute top-0 left-0 z-50 w-full h-full flex items-center justify-center"> <div v-if="attemptingConnection" class="absolute top-0 left-0 z-50 w-full h-full flex items-center justify-center">
<ui-loading-indicator text="Connecting to server..." /> <ui-loading-indicator text="Connecting to server..." class="mt-9" />
</div> </div>
</div> </div>
<app-audio-player-container ref="streamContainer" /> <app-audio-player-container ref="streamContainer" />
<modals-libraries-modal /> <modals-libraries-modal />
<modals-playlists-add-create-modal /> <modals-playlists-add-create-modal />
<modals-select-local-folder-modal />
<modals-rssfeeds-rss-feed-modal />
<app-side-drawer /> <app-side-drawer />
<readers-reader /> <readers-reader />
</div> </div>
@@ -20,7 +22,6 @@
export default { export default {
data() { data() {
return { return {
attemptingConnection: false,
inittingLibraries: false, inittingLibraries: false,
hasMounted: false, hasMounted: false,
disconnectTime: 0 disconnectTime: 0
@@ -57,8 +58,8 @@ export default {
} }
}, },
computed: { computed: {
playerIsOpen() { isPlayerOpen() {
return this.$store.state.playerLibraryItemId return this.$store.getters['getIsPlayerOpen']
}, },
routeName() { routeName() {
return this.$route.name return this.$route.name
@@ -71,11 +72,19 @@ export default {
}, },
currentLibraryId() { currentLibraryId() {
return this.$store.state.libraries.currentLibraryId return this.$store.state.libraries.currentLibraryId
},
attemptingConnection: {
get() {
return this.$store.state.attemptingConnection
},
set(val) {
this.$store.commit('setAttemptingConnection', val)
}
} }
}, },
methods: { methods: {
initialStream(stream) { initialStream(stream) {
if (this.$refs.streamContainer && this.$refs.streamContainer.audioPlayerReady) { if (this.$refs.streamContainer?.audioPlayerReady) {
this.$refs.streamContainer.streamOpen(stream) this.$refs.streamContainer.streamOpen(stream)
} }
}, },
@@ -223,8 +232,16 @@ export default {
this.$store.commit('user/setUser', user) this.$store.commit('user/setUser', user)
} }
}, },
async userMediaProgressUpdated(prog) { async userMediaProgressUpdated(payload) {
console.log(`[default] userMediaProgressUpdate checking for local media progress ${prog.id}`) const prog = payload.data // MediaProgress
console.log(`[default] userMediaProgressUpdate checking for local media progress ${payload.id}`)
// Check if this media item is currently open in the player, paused, and this progress update is coming from a different session
const isMediaOpenInPlayer = this.$store.getters['getIsMediaStreaming'](prog.libraryItemId, prog.episodeId)
if (isMediaOpenInPlayer && this.$store.getters['getCurrentPlaybackSessionId'] !== payload.sessionId && !this.$store.state.playerIsPlaying) {
console.log('[default] userMediaProgressUpdated for current open media item', payload.data.currentTime)
this.$eventBus.$emit('playback-time-update', payload.data.currentTime)
}
// Update local media progress if exists // Update local media progress if exists
const localProg = await this.$db.getLocalMediaProgressForServerItem({ libraryItemId: prog.libraryItemId, episodeId: prog.episodeId }) const localProg = await this.$db.getLocalMediaProgressForServerItem({ libraryItemId: prog.libraryItemId, episodeId: prog.episodeId })
@@ -276,7 +293,7 @@ export default {
} }
} }
if (newLocalMediaProgress && newLocalMediaProgress.id) { if (newLocalMediaProgress?.id) {
console.log(`[default] local media progress updated for ${newLocalMediaProgress.id}`) console.log(`[default] local media progress updated for ${newLocalMediaProgress.id}`)
this.$store.commit('globals/updateLocalMediaProgress', newLocalMediaProgress) this.$store.commit('globals/updateLocalMediaProgress', newLocalMediaProgress)
} }
@@ -291,6 +308,7 @@ export default {
const deviceData = await this.$db.getDeviceData() const deviceData = await this.$db.getDeviceData()
this.$store.commit('setDeviceData', deviceData) this.$store.commit('setDeviceData', deviceData)
this.$setOrientationLock(this.$store.getters['getOrientationLockSetting']) this.$setOrientationLock(this.$store.getters['getOrientationLockSetting'])
await this.$store.dispatch('setupNetworkListener') await this.$store.dispatch('setupNetworkListener')
@@ -298,10 +316,8 @@ export default {
await this.$store.dispatch('globals/loadLocalMediaProgress') await this.$store.dispatch('globals/loadLocalMediaProgress')
if (this.$store.state.user.serverConnectionConfig) { if (this.$store.state.user.serverConnectionConfig) {
console.log(`[default] server connection config set - call init libraries`)
await this.initLibraries() await this.initLibraries()
} else { } else {
console.log(`[default] no server connection config - call attempt connection`)
await this.attemptConnection() await this.attemptConnection()
} }
+2 -1
View File
@@ -48,7 +48,8 @@ export default {
'@/plugins/capacitor/AbsAudioPlayer.js', '@/plugins/capacitor/AbsAudioPlayer.js',
'@/plugins/toast.js', '@/plugins/toast.js',
'@/plugins/constants.js', '@/plugins/constants.js',
'@/plugins/haptics.js' '@/plugins/haptics.js',
'@/plugins/i18n.js'
], ],
components: true, components: true,
+12167 -12175
View File
File diff suppressed because it is too large Load Diff
+9 -5
View File
@@ -1,12 +1,13 @@
{ {
"name": "audiobookshelf-app", "name": "audiobookshelf-app",
"version": "0.9.64-beta", "version": "0.9.65-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",
"build": "nuxt build", "build": "nuxt build",
"start": "nuxt start", "start": "nuxt start",
"generate": "nuxt generate", "generate": "nuxt generate",
"sync": "nuxt generate && npx cap sync",
"icons-android": "cordova-res android --skip-config --copy", "icons-android": "cordova-res android --skip-config --copy",
"ionic:build": "npm run build", "ionic:build": "npm run build",
"ionic:serve": "npm run start" "ionic:serve": "npm run start"
@@ -14,6 +15,7 @@
"dependencies": { "dependencies": {
"@capacitor/android": "^4.0.0", "@capacitor/android": "^4.0.0",
"@capacitor/app": "^4.0.0", "@capacitor/app": "^4.0.0",
"@capacitor/clipboard": "^4.1.0",
"@capacitor/core": "^4.0.0", "@capacitor/core": "^4.0.0",
"@capacitor/dialog": "^4.0.0", "@capacitor/dialog": "^4.0.0",
"@capacitor/haptics": "^4.0.0", "@capacitor/haptics": "^4.0.0",
@@ -22,16 +24,17 @@
"@capacitor/preferences": "^4.0.2", "@capacitor/preferences": "^4.0.2",
"@capacitor/status-bar": "^4.0.0", "@capacitor/status-bar": "^4.0.0",
"@nuxtjs/axios": "^5.13.6", "@nuxtjs/axios": "^5.13.6",
"@teckel/vue-pdf": "^4.3.5",
"buffer": "^6.0.3",
"cordova-plugin-screen-orientation": "^3.0.2", "cordova-plugin-screen-orientation": "^3.0.2",
"core-js": "^3.15.1", "core-js": "^3.15.1",
"date-fns": "^2.25.0", "date-fns": "^2.25.0",
"epubjs": "^0.3.88", "epubjs": "0.3.88",
"fast-average-color": "^9.3.0", "fast-average-color": "^9.3.0",
"libarchive.js": "^1.3.0", "libarchive.js": "^1.3.0",
"nuxt": "^2.15.7", "nuxt": "^2.15.7",
"socket.io-client": "^4.1.3", "socket.io-client": "^4.1.3",
"v-click-outside": "^3.2.0", "v-click-outside": "^3.2.0",
"vue-pdf": "^4.2.0",
"vue-toastification": "^1.7.11", "vue-toastification": "^1.7.11",
"vuedraggable": "^2.24.3" "vuedraggable": "^2.24.3"
}, },
@@ -39,7 +42,8 @@
"@babel/core": "7.13.15", "@babel/core": "7.13.15",
"@babel/preset-env": "7.13.15", "@babel/preset-env": "7.13.15",
"@capacitor/cli": "^4.0.0", "@capacitor/cli": "^4.0.0",
"@nuxtjs/tailwindcss": "^4.2.0", "@nuxtjs/tailwindcss": "^4.2.1",
"postcss": "^8.3.5" "postcss": "^8.3.5",
"tailwindcss": "^3.3.2"
} }
} }
+2 -7
View File
@@ -3,10 +3,7 @@
<home-bookshelf-nav-bar /> <home-bookshelf-nav-bar />
<home-bookshelf-toolbar v-show="!hideToolbar" /> <home-bookshelf-toolbar v-show="!hideToolbar" />
<div id="bookshelf-wrapper" class="main-content overflow-y-auto overflow-x-hidden relative" :class="hideToolbar ? 'no-toolbar' : ''"> <div id="bookshelf-wrapper" class="main-content overflow-y-auto overflow-x-hidden relative" :class="hideToolbar ? 'no-toolbar' : ''">
<nuxt-child :loading.sync="loading" /> <nuxt-child />
</div>
<div v-if="loading" class="absolute top-0 left-0 z-50 w-full h-full flex items-center justify-center">
<ui-loading-indicator text="Loading..." />
</div> </div>
</div> </div>
</template> </template>
@@ -14,9 +11,7 @@
<script> <script>
export default { export default {
data() { data() {
return { return {}
loading: false
}
}, },
computed: { computed: {
hideToolbar() { hideToolbar() {
+28 -29
View File
@@ -1,12 +1,17 @@
<template> <template>
<div class="w-full h-full min-h-full relative"> <div class="w-full h-full min-h-full relative">
<div v-if="!loading" class="w-full" :class="{ 'py-6': altViewEnabled }"> <div v-if="shelves.length && isLoading" class="w-full pt-4 flex items-center justify-center">
<widgets-loading-spinner />
<p class="pl-4">Loading server data...</p>
</div>
<div class="w-full" :class="{ 'py-6': altViewEnabled }">
<template v-for="(shelf, index) in shelves"> <template v-for="(shelf, index) in shelves">
<bookshelf-shelf :key="shelf.id" :label="shelf.label" :entities="shelf.entities" :type="shelf.type" :style="{ zIndex: shelves.length - index }" /> <bookshelf-shelf :key="shelf.id" :label="shelf.label" :entities="shelf.entities" :type="shelf.type" :style="{ zIndex: shelves.length - index }" />
</template> </template>
</div> </div>
<div v-if="!shelves.length && !loading" class="absolute top-0 left-0 w-full h-full flex items-center justify-center"> <div v-if="!shelves.length && !isLoading" class="absolute top-0 left-0 w-full h-full flex items-center justify-center">
<div> <div>
<p class="mb-4 text-center text-xl"> <p class="mb-4 text-center text-xl">
Bookshelf empty Bookshelf empty
@@ -26,14 +31,15 @@
</div> </div>
</div> </div>
</div> </div>
<div v-else-if="!shelves.length && isLoading && !attemptingConnection" class="absolute top-0 left-0 z-50 w-full h-full flex items-center justify-center">
<ui-loading-indicator text="Loading..." />
</div>
</div> </div>
</template> </template>
<script> <script>
export default { export default {
props: { props: {},
loading: Boolean
},
data() { data() {
return { return {
shelves: [], shelves: [],
@@ -41,7 +47,8 @@ export default {
lastServerFetch: 0, lastServerFetch: 0,
lastServerFetchLibraryId: null, lastServerFetchLibraryId: null,
lastLocalFetch: 0, lastLocalFetch: 0,
localLibraryItems: [] localLibraryItems: [],
isLoading: false
} }
}, },
watch: { watch: {
@@ -97,19 +104,14 @@ export default {
localMediaProgress() { localMediaProgress() {
return this.$store.state.globals.localMediaProgress return this.$store.state.globals.localMediaProgress
}, },
isLoading: { attemptingConnection() {
get() { return this.$store.state.attemptingConnection
return this.loading
},
set(val) {
this.$emit('update:loading', val)
}
} }
}, },
methods: { methods: {
async getLocalMediaItemCategories() { getLocalMediaItemCategories() {
const localMedia = await this.$db.getLocalLibraryItems() const localMedia = this.localLibraryItems
if (!localMedia || !localMedia.length) return [] if (!localMedia?.length) return []
const categories = [] const categories = []
const books = [] const books = []
@@ -198,7 +200,8 @@ export default {
console.log(`[categories] fetchCategories networkConnected=${this.networkConnected}, lastServerFetch=${this.lastServerFetch}, lastLocalFetch=${this.lastLocalFetch}`) console.log(`[categories] fetchCategories networkConnected=${this.networkConnected}, lastServerFetch=${this.lastServerFetch}, lastLocalFetch=${this.lastLocalFetch}`)
// TODO: Find a better way to keep the shelf up-to-date with local vs server library because this is a disaster // TODO: Find a better way to keep the shelf up-to-date with local vs server library because this is a disaster
if (this.user && this.currentLibraryId && this.networkConnected) { const isConnectedToServerWithInternet = this.user && this.currentLibraryId && this.networkConnected
if (isConnectedToServerWithInternet) {
if (this.lastServerFetch && Date.now() - this.lastServerFetch < 5000 && this.lastServerFetchLibraryId == this.currentLibraryId) { if (this.lastServerFetch && Date.now() - this.lastServerFetch < 5000 && this.lastServerFetchLibraryId == this.currentLibraryId) {
console.log(`[categories] fetchCategories server fetch was ${Date.now() - this.lastServerFetch}ms ago so not doing it.`) console.log(`[categories] fetchCategories server fetch was ${Date.now() - this.lastServerFetch}ms ago so not doing it.`)
return return
@@ -221,19 +224,21 @@ export default {
} }
this.isLoading = true this.isLoading = true
this.shelves = []
if (this.user && this.currentLibraryId && this.networkConnected) { // Set local library items first
this.localLibraryItems = await this.$db.getLocalLibraryItems() this.localLibraryItems = await this.$db.getLocalLibraryItems()
const localCategories = await this.getLocalMediaItemCategories() const localCategories = this.getLocalMediaItemCategories()
const categories = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/personalized?minified=1`).catch((error) => { this.shelves = localCategories
console.log('[categories] Local shelves set', this.shelves.length, this.lastLocalFetch)
if (isConnectedToServerWithInternet) {
const categories = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/personalized?minified=1&include=rssfeed`).catch((error) => {
console.error('[categories] Failed to fetch categories', error) console.error('[categories] Failed to fetch categories', error)
return [] return []
}) })
if (!categories.length) { if (!categories.length) {
// Failed to load categories so use local shelves // Failed to load categories so use local shelves
console.warn(`[categories] Failed to get server categories so using local categories`) console.warn(`[categories] Failed to get server categories so using local categories`)
this.shelves = localCategories
this.lastServerFetch = 0 this.lastServerFetch = 0
this.lastLocalFetch = Date.now() this.lastLocalFetch = Date.now()
this.isLoading = false this.isLoading = false
@@ -261,12 +266,6 @@ export default {
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)
console.log('[categories] Server shelves set', this.shelves.length, this.lastServerFetch) console.log('[categories] Server shelves set', this.shelves.length, this.lastServerFetch)
} else {
// Offline only local
this.localLibraryItems = await this.$db.getLocalLibraryItems()
const localCategories = await this.getLocalMediaItemCategories()
this.shelves = localCategories
console.log('[categories] Local shelves set', this.shelves.length, this.lastLocalFetch)
} }
this.isLoading = false this.isLoading = false
+5 -14
View File
@@ -1,8 +1,8 @@
<template> <template>
<div class="w-full h-full relative overflow-hidden"> <div class="w-full h-full relative overflow-hidden">
<template v-if="!showSelectedFeed"> <template v-if="!showSelectedFeed">
<div class="w-full mx-auto py-5 px-2"> <div class="w-full mx-auto h-20 flex items-center px-2">
<form @submit.prevent="submit"> <form class="w-full" @submit.prevent="submit">
<ui-text-input v-model="searchInput" :disabled="processing || !networkConnected" placeholder="Enter search term or RSS feed URL" text-size="sm" /> <ui-text-input v-model="searchInput" :disabled="processing || !networkConnected" placeholder="Enter search term or RSS feed URL" text-size="sm" />
</form> </form>
</div> </div>
@@ -10,7 +10,7 @@
<div v-if="!networkConnected" class="w-full text-center py-6"> <div v-if="!networkConnected" class="w-full text-center py-6">
<p class="text-lg text-error">No network connection</p> <p class="text-lg text-error">No network connection</p>
</div> </div>
<div v-else class="w-full mx-auto pb-2 search-results-container overflow-y-auto overflow-x-hidden"> <div v-else class="w-full mx-auto pb-2 overflow-y-auto overflow-x-hidden h-[calc(100%-85px)]">
<p v-if="termSearched && !results.length && !processing" class="text-center text-xl">No Podcasts Found</p> <p v-if="termSearched && !results.length && !processing" class="text-center text-xl">No Podcasts Found</p>
<template v-for="podcast in results"> <template v-for="podcast in results">
<div :key="podcast.id" class="p-2 border-b border-white border-opacity-10" @click="selectPodcast(podcast)"> <div :key="podcast.id" class="p-2 border-b border-white border-opacity-10" @click="selectPodcast(podcast)">
@@ -33,14 +33,14 @@
</div> </div>
</template> </template>
<template v-else> <template v-else>
<div class="flex items-center py-4 px-2"> <div class="flex items-center px-2 h-16">
<div class="flex items-center" @click="clearSelected"> <div class="flex items-center" @click="clearSelected">
<span class="material-icons text-2xl text-gray-300">arrow_back</span> <span class="material-icons text-2xl text-gray-300">arrow_back</span>
<p class="pl-2 uppercase text-sm font-semibold text-gray-300 leading-4 pb-px">Back</p> <p class="pl-2 uppercase text-sm font-semibold text-gray-300 leading-4 pb-px">Back</p>
</div> </div>
</div> </div>
<div class="w-full py-2 search-results-container overflow-y-auto overflow-x-hidden"> <div class="w-full py-2 overflow-y-auto overflow-x-hidden h-[calc(100%-69px)]">
<forms-new-podcast-form :podcast-data="selectedPodcast" :podcast-feed-data="selectedPodcastFeed" :processing.sync="processing" /> <forms-new-podcast-form :podcast-data="selectedPodcast" :podcast-feed-data="selectedPodcastFeed" :processing.sync="processing" />
</div> </div>
</template> </template>
@@ -147,12 +147,3 @@ export default {
} }
} }
</script> </script>
<style scoped>
.search-results-container {
max-height: calc(100vh - 182px);
}
.playerOpen .search-results-container {
max-height: calc(100vh - 282px);
}
</style>
+1 -1
View File
@@ -75,7 +75,7 @@ export default {
}) })
}, },
streaming() { streaming() {
return !!this.playableBooks.find((b) => this.$store.getters['getIsItemStreaming'](b.id)) return !!this.playableBooks.find((b) => this.$store.getters['getIsMediaStreaming'](b.id))
}, },
showPlayButton() { showPlayButton() {
return this.playableBooks.length return this.playableBooks.length
+84 -56
View File
@@ -12,16 +12,12 @@
</div> </div>
</div> </div>
<p class="text-lg font-semibold"> <p class="text-lg font-semibold">{{ title }}</p>
{{ title }}
</p>
<div v-if="episodeNumber || season || episodeType" class="flex py-2 items-center -mx-0.5"> <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="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="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"> <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>
{{ episodeType }}
</div>
</div> </div>
<!-- user progress card --> <!-- user progress card -->
@@ -209,14 +205,7 @@ export default {
return this.$store.getters['globals/getLibraryItemCoverSrcById'](this.libraryItemId) return this.$store.getters['globals/getLibraryItemCoverSrcById'](this.libraryItemId)
}, },
isPlaying() { isPlaying() {
if (this.playerIsLocal && this.localLibraryItemId && this.localEpisodeId) { return this.$store.getters['getIsMediaStreaming'](this.libraryItemId, this.episode.id)
// Check is streaming local version of this episode
return this.$store.getters['getIsEpisodeStreaming'](this.localLibraryItemId, this.localEpisodeId)
}
return this.$store.getters['getIsEpisodeStreaming'](this.libraryItemId, this.episode.id)
},
playerIsLocal() {
return !!this.$store.state.playerIsLocal
}, },
playerIsPlaying() { playerIsPlaying() {
return this.$store.state.playerIsPlaying && this.isPlaying return this.$store.state.playerIsPlaying && this.isPlaying
@@ -234,13 +223,13 @@ export default {
return this.$store.getters['user/getUserMediaProgress'](this.serverLibraryItemId, this.serverEpisodeId) return this.$store.getters['user/getUserMediaProgress'](this.serverLibraryItemId, this.serverEpisodeId)
}, },
progressPercent() { progressPercent() {
return this.userItemProgress ? this.userItemProgress.progress : 0 return this.userItemProgress?.progress || 0
}, },
userIsFinished() { userIsFinished() {
return this.userItemProgress ? !!this.userItemProgress.isFinished : false return !!this.userItemProgress?.isFinished
}, },
userProgressFinishedAt() { userProgressFinishedAt() {
return this.userItemProgress ? this.userItemProgress.finishedAt : 0 return this.userItemProgress?.finishedAt || 0
}, },
userTimeRemaining() { userTimeRemaining() {
if (!this.userItemProgress) return 0 if (!this.userItemProgress) return 0
@@ -288,20 +277,65 @@ export default {
value: 'playlist', value: 'playlist',
icon: 'playlist_add' icon: 'playlist_add'
}) })
}
if (this.isAdminOrUp) { if (this.localEpisodeId) {
items.push({ items.push({
text: 'Remove from Server', text: 'Delete Local Episode',
value: 'remove_from_server', value: 'deleteLocal',
icon: 'delete_forever' icon: 'delete'
}) })
} }
if (this.isAdminOrUp && this.serverEpisodeId) {
items.push({
text: 'Remove from Server',
value: 'remove_from_server',
icon: 'delete_forever'
})
} }
return items return items
} }
}, },
methods: { methods: {
async deleteLocalEpisode() {
await this.$hapticsImpact()
const localEpisodeAudioTrack = this.localEpisode.audioTrack
const localFile = this.localLibraryItem.localFiles.find((lf) => lf.id === localEpisodeAudioTrack.localFileId)
if (!localFile) {
this.$toast.error('Audio track does not have matching local file..')
return
}
let confirmMessage = `Remove local episode "${localFile.basePath}" from your device?`
if (this.serverLibraryItemId) {
confirmMessage += ' The file on the server will be unaffected.'
}
const { value } = await Dialog.confirm({
title: 'Confirm',
message: confirmMessage
})
if (value) {
const res = await AbsFileSystem.deleteTrackFromItem({ id: this.localLibraryItemId, trackLocalFileId: localFile.id, trackContentUrl: localEpisodeAudioTrack.contentUrl })
if (res?.id) {
this.$toast.success('Deleted episode successfully')
if (this.isLocal) {
// If this is local episode then redirect to server episode when available
if (this.serverEpisodeId) {
this.$router.replace(`/item/${this.serverLibraryItemId}/${this.serverEpisodeId}`)
} else {
this.$router.replace(`/item/${this.localLibraryItemId}`)
}
} else {
// Update local library item and local episode
this.libraryItem.localLibraryItem = res
this.$delete(this.episode, 'localEpisode')
}
} else this.$toast.error('Failed to delete')
}
},
async playClick() { async playClick() {
await this.$hapticsImpact() await this.$hapticsImpact()
if (this.playerIsPlaying) { if (this.playerIsPlaying) {
@@ -346,20 +380,24 @@ export default {
return lf.mediaType == this.mediaType return lf.mediaType == this.mediaType
}) })
console.log('Folders with media type', this.mediaType, foldersWithMediaType.length) console.log('Folders with media type', this.mediaType, foldersWithMediaType.length)
const internalStorageFolder = foldersWithMediaType.find((f) => f.id === `internal-${this.mediaType}`)
if (!foldersWithMediaType.length) { if (!foldersWithMediaType.length) {
// No local folders or no local folders with this media type localFolder = {
localFolder = await this.selectFolder() id: `internal-${this.mediaType}`,
} else if (foldersWithMediaType.length == 1) { name: 'Internal App Storage',
console.log('Only 1 local folder with this media type - auto select it') mediaType: this.mediaType
localFolder = foldersWithMediaType[0] }
} else if (foldersWithMediaType.length === 1 && internalStorageFolder) {
localFolder = internalStorageFolder
} else { } else {
console.log('Multiple folders with media type') this.$store.commit('globals/showSelectLocalFolderModal', {
// this.showSelectLocalFolder = true mediaType: this.mediaType,
callback: (folder) => {
this.download(folder)
}
})
return return
} }
if (!localFolder) {
return this.$toast.error('Invalid download folder')
}
} }
console.log('Local folder', JSON.stringify(localFolder)) console.log('Local folder', JSON.stringify(localFolder))
@@ -404,8 +442,10 @@ export default {
} else if (action === 'playlist' && !this.isLocal) { } else if (action === 'playlist' && !this.isLocal) {
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: this.episode }]) this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: this.episode }])
this.$store.commit('globals/setShowPlaylistsAddCreateModal', true) this.$store.commit('globals/setShowPlaylistsAddCreateModal', true)
} else if (action === 'remove_from_server' && !this.isLocal && this.isAdminOrUp) { } else if (action === 'remove_from_server' && this.serverEpisodeId && this.isAdminOrUp) {
this.deleteEpisodeFromServerClick() this.deleteEpisodeFromServerClick()
} else if (action === 'deleteLocal') {
this.deleteLocalEpisode()
} }
}, },
async discardProgress() { async discardProgress() {
@@ -450,35 +490,23 @@ export default {
const payload = await this.$db.updateLocalMediaProgressFinished({ localLibraryItemId, localEpisodeId, isFinished }) const payload = await this.$db.updateLocalMediaProgressFinished({ localLibraryItemId, localEpisodeId, isFinished })
console.log('toggleFinished payload', JSON.stringify(payload)) console.log('toggleFinished payload', JSON.stringify(payload))
if (!payload || payload.error) { if (payload?.error) {
var errorMsg = payload ? payload.error : 'Unknown error' this.$toast.error(payload?.error || 'Unknown error')
this.$toast.error(errorMsg)
} else { } else {
const localMediaProgress = payload.localMediaProgress const localMediaProgress = payload.localMediaProgress
console.log('toggleFinished localMediaProgress', JSON.stringify(localMediaProgress)) console.log('toggleFinished localMediaProgress', JSON.stringify(localMediaProgress))
if (localMediaProgress) { if (localMediaProgress) {
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress) this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
} }
if (payload.server) {
this.$toast.success(`Local & Server Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
} else {
this.$toast.success(`Local Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
}
} }
} else { } else {
const updatePayload = { const updatePayload = {
isFinished: !this.userIsFinished isFinished: !this.userIsFinished
} }
this.$axios this.$axios.$patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload).catch((error) => {
.$patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload) console.error('Failed', error)
.then(() => { this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
this.$toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`) })
})
.catch((error) => {
console.error('Failed', error)
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
})
} }
}, },
async deleteEpisodeFromServerClick() { async deleteEpisodeFromServerClick() {
@@ -492,13 +520,13 @@ export default {
if (value) { if (value) {
this.processing = true this.processing = true
this.$axios this.$axios
.$delete(`/api/podcasts/${this.libraryItemId}/episode/${this.episode.id}?hard=1`) .$delete(`/api/podcasts/${this.serverLibraryItemId}/episode/${this.serverEpisodeId}?hard=1`)
.then(() => { .then(() => {
this.$toast.success('Episode deleted from server') this.$toast.success('Episode deleted from server')
this.$router.replace(`/item/${this.libraryItemId}`) this.$router.replace(`/item/${this.serverLibraryItemId}`)
}) })
.catch((error) => { .catch((error) => {
const errorMsg = error.response && error.response.data ? error.response.data : 'Failed to delete episode' const errorMsg = error.response?.data || 'Failed to delete episode'
console.error('Failed to delete episode', error) console.error('Failed to delete episode', error)
this.$toast.error(errorMsg) this.$toast.error(errorMsg)
}) })
+156 -58
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="w-full h-full px-3 pb-4 overflow-y-auto overflow-x-hidden relative bg-bg"> <div id="item-page" class="w-full h-full px-3 pb-4 overflow-y-auto overflow-x-hidden relative bg-bg">
<div class="fixed top-0 left-0 w-full h-full pointer-events-none p-px z-10"> <div class="fixed top-0 left-0 w-full h-full pointer-events-none p-px z-10">
<div class="w-full h-full" :style="{ backgroundColor: coverRgb }" /> <div class="w-full h-full" :style="{ backgroundColor: coverRgb }" />
<div class="w-full h-full absolute top-0 left-0" style="background: linear-gradient(169deg, rgba(0, 0, 0, 0.4) 0%, rgba(55, 56, 56, 1) 80%)" /> <div class="w-full h-full absolute top-0 left-0" style="background: linear-gradient(169deg, rgba(0, 0, 0, 0.4) 0%, rgba(55, 56, 56, 1) 80%)" />
@@ -63,13 +63,13 @@
</div> </div>
<!-- metadata --> <!-- metadata -->
<div id="metadata" class="grid gap-2 my-2" style=""> <div id="metadata" class="grid gap-2 my-2" style>
<div v-if="podcastAuthor || (bookAuthors && bookAuthors.length)" class="text-white text-opacity-60 uppercase text-sm">Author</div> <div v-if="podcastAuthor || (bookAuthors && bookAuthors.length)" class="text-white text-opacity-60 uppercase text-sm">Author</div>
<div v-if="podcastAuthor" class="text-sm">{{ podcastAuthor }}</div> <div v-if="podcastAuthor" class="text-sm">{{ podcastAuthor }}</div>
<div v-else-if="bookAuthors && bookAuthors.length" class="text-sm"> <div v-else-if="bookAuthors && bookAuthors.length" class="text-sm">
<template v-for="(author, index) in bookAuthors"> <template v-for="(author, index) in bookAuthors">
<nuxt-link :key="author.id" :to="`/bookshelf/library?filter=authors.${$encode(author.id)}`" class="underline">{{ author.name }}</nuxt-link <nuxt-link :key="author.id" :to="`/bookshelf/library?filter=authors.${$encode(author.id)}`" class="underline">{{ author.name }}</nuxt-link>
><span :key="`${author.id}-comma`" v-if="index < bookAuthors.length - 1">, </span> <span :key="`${author.id}-comma`" v-if="index < bookAuthors.length - 1">,</span>
</template> </template>
</div> </div>
@@ -79,8 +79,8 @@
<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">
<nuxt-link :key="series.id" :to="`/bookshelf/series/${series.id}`" class="underline">{{ series.text }}</nuxt-link <nuxt-link :key="series.id" :to="`/bookshelf/series/${series.id}`" class="underline">{{ series.text }}</nuxt-link>
><span :key="`${series.id}-comma`" v-if="index < seriesList.length - 1">,&nbsp;</span> <span :key="`${series.id}-comma`" v-if="index < seriesList.length - 1">,&nbsp;</span>
</template> </template>
</div> </div>
@@ -90,16 +90,16 @@
<div v-if="narrators && narrators.length" class="text-white text-opacity-60 uppercase text-sm">{{ narrators.length === 1 ? 'Narrator' : 'Narrators' }}</div> <div v-if="narrators && narrators.length" class="text-white text-opacity-60 uppercase text-sm">{{ narrators.length === 1 ? 'Narrator' : 'Narrators' }}</div>
<div v-if="narrators && narrators.length" class="truncate text-sm"> <div v-if="narrators && narrators.length" class="truncate text-sm">
<template v-for="(narrator, index) in narrators"> <template v-for="(narrator, index) in narrators">
<nuxt-link :key="narrator" :to="`/bookshelf/library?filter=narrators.${$encode(narrator)}`" class="underline">{{ narrator }}</nuxt-link <nuxt-link :key="narrator" :to="`/bookshelf/library?filter=narrators.${$encode(narrator)}`" class="underline">{{ narrator }}</nuxt-link>
><span :key="index" v-if="index < narrators.length - 1">, </span> <span :key="index" v-if="index < narrators.length - 1">,</span>
</template> </template>
</div> </div>
<div v-if="genres.length" class="text-white text-opacity-60 uppercase text-sm">{{ genres.length === 1 ? 'Genre' : 'Genres' }}</div> <div v-if="genres.length" class="text-white text-opacity-60 uppercase text-sm">{{ genres.length === 1 ? 'Genre' : 'Genres' }}</div>
<div v-if="genres.length" class="truncate text-sm"> <div v-if="genres.length" class="truncate text-sm">
<template v-for="(genre, index) in genres"> <template v-for="(genre, index) in genres">
<nuxt-link :key="genre" :to="`/bookshelf/library?filter=genres.${$encode(genre)}`" class="underline">{{ genre }}</nuxt-link <nuxt-link :key="genre" :to="`/bookshelf/library?filter=genres.${$encode(genre)}`" class="underline">{{ genre }}</nuxt-link>
><span :key="index" v-if="index < genres.length - 1">, </span> <span :key="index" v-if="index < genres.length - 1">,</span>
</template> </template>
</div> </div>
@@ -123,6 +123,8 @@
<tables-tracks-table v-if="numTracks" :tracks="tracks" :library-item-id="libraryItemId" /> <tables-tracks-table v-if="numTracks" :tracks="tracks" :library-item-id="libraryItemId" />
<tables-ebook-files-table v-if="ebookFiles.length" :library-item="libraryItem" />
<!-- modals --> <!-- modals -->
<modals-select-local-folder-modal v-model="showSelectLocalFolder" :media-type="mediaType" @select="selectedLocalFolder" /> <modals-select-local-folder-modal v-model="showSelectLocalFolder" :media-type="mediaType" @select="selectedLocalFolder" />
@@ -148,7 +150,7 @@ export default {
libraryItem = await app.$db.getLocalLibraryItem(libraryItemId) libraryItem = await app.$db.getLocalLibraryItem(libraryItemId)
console.log('Got lli', libraryItemId) console.log('Got lli', libraryItemId)
} else if (store.state.user.serverConnectionConfig) { } else if (store.state.user.serverConnectionConfig) {
libraryItem = await app.$axios.$get(`/api/items/${libraryItemId}?expanded=1`).catch((error) => { libraryItem = await app.$axios.$get(`/api/items/${libraryItemId}?expanded=1&include=rssfeed`).catch((error) => {
console.error('Failed', error) console.error('Failed', error)
return false return false
}) })
@@ -167,7 +169,8 @@ export default {
return redirect('/') return redirect('/')
} }
return { return {
libraryItem libraryItem,
rssFeed: libraryItem.rssFeed || null
} }
}, },
data() { data() {
@@ -192,6 +195,9 @@ export default {
userCanDownload() { userCanDownload() {
return this.$store.getters['user/getUserCanDownload'] return this.$store.getters['user/getUserCanDownload']
}, },
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
isLocal() { isLocal() {
return this.libraryItem.isLocal return this.libraryItem.isLocal
}, },
@@ -324,11 +330,11 @@ export default {
return this.userItemProgress ? this.userItemProgress.finishedAt : 0 return this.userItemProgress ? this.userItemProgress.finishedAt : 0
}, },
isStreaming() { isStreaming() {
return this.isPlaying && !this.$store.state.playerIsLocal return this.isPlaying && !this.$store.getters['getIsCurrentSessionLocal']
}, },
isPlaying() { isPlaying() {
if (this.localLibraryItemId && this.$store.getters['getIsItemStreaming'](this.localLibraryItemId)) return true if (this.localLibraryItemId && this.$store.getters['getIsMediaStreaming'](this.localLibraryItemId)) return true
return this.$store.getters['getIsItemStreaming'](this.libraryItemId) return this.$store.getters['getIsMediaStreaming'](this.libraryItemId)
}, },
playerIsPlaying() { playerIsPlaying() {
return this.$store.state.playerIsPlaying && (this.isStreaming || this.isPlaying) return this.$store.state.playerIsPlaying && (this.isStreaming || this.isPlaying)
@@ -357,7 +363,13 @@ export default {
}, },
showDownload() { showDownload() {
if (this.isPodcast || this.hasLocal) return false if (this.isPodcast || this.hasLocal) return false
return this.user && this.userCanDownload && (this.showPlay || (this.showRead && !this.isIos)) return this.user && this.userCanDownload && (this.showPlay || this.showRead)
},
libraryFiles() {
return this.libraryItem.libraryFiles || []
},
ebookFiles() {
return this.libraryFiles.filter((lf) => lf.fileType === 'ebook')
}, },
ebookFile() { ebookFile() {
return this.media.ebookFile return this.media.ebookFile
@@ -375,6 +387,13 @@ export default {
isCasting() { isCasting() {
return this.$store.state.isCasting return this.$store.state.isCasting
}, },
showRSSFeedOption() {
if (!this.serverLibraryItemId) return false
if (!this.rssFeed && !this.episodes.length && !this.tracks.length) return false // Cannot open RSS feed with no episodes/tracks
// If rss feed is open then show feed url to users otherwise just show to admins
return this.userIsAdminOrUp || this.rssFeed
},
moreMenuItems() { moreMenuItems() {
const items = [] const items = []
@@ -405,14 +424,6 @@ export default {
} }
} }
if (this.localLibraryItemId) {
items.push({
text: 'Manage Local Files',
value: 'manageLocal',
icon: 'folder'
})
}
if (!this.isPodcast && this.serverLibraryItemId) { if (!this.isPodcast && this.serverLibraryItemId) {
items.push({ items.push({
text: 'Add to Playlist', text: 'Add to Playlist',
@@ -421,6 +432,30 @@ export default {
}) })
} }
if (this.showRSSFeedOption) {
items.push({
text: this.rssFeed ? 'RSS Feed' : 'Open RSS Feed',
value: 'rssFeed',
icon: 'rss_feed'
})
}
if (this.localLibraryItemId) {
items.push({
text: 'Manage Local Files',
value: 'manageLocal',
icon: 'folder'
})
if (!this.isPodcast) {
items.push({
text: 'Delete Local Item',
value: 'deleteLocal',
icon: 'delete'
})
}
}
items.push({ items.push({
text: 'More Info', text: 'More Info',
value: 'details', value: 'details',
@@ -443,6 +478,35 @@ export default {
} }
}, },
methods: { methods: {
async deleteLocalItem() {
await this.$hapticsImpact()
let confirmMessage = 'Remove local files of this item from your device?'
if (this.serverLibraryItemId) {
confirmMessage += ' The files on the server and your progress will be unaffected.'
}
const { value } = await Dialog.confirm({
title: 'Confirm',
message: confirmMessage
})
if (value) {
const res = await AbsFileSystem.deleteItem(this.localLibraryItem)
if (res?.success) {
this.$toast.success('Deleted successfully')
if (this.isLocal) {
// If local then redirect to server version when available
if (this.serverLibraryItemId) {
this.$router.replace(`/item/${this.serverLibraryItemId}`)
} else {
this.$router.replace('/bookshelf')
}
} else {
// Remove localLibraryItem
this.$delete(this.libraryItem, 'localLibraryItem')
}
} else this.$toast.error('Failed to delete')
}
},
async coverImageLoaded(fullCoverUrl) { async coverImageLoaded(fullCoverUrl) {
if (!fullCoverUrl) return if (!fullCoverUrl) return
@@ -475,17 +539,30 @@ export default {
this.$router.push(`/media/${this.mediaId}/history?title=${this.title}`) this.$router.push(`/media/${this.mediaId}/history?title=${this.title}`)
} else if (action === 'discardProgress') { } else if (action === 'discardProgress') {
this.clearProgressClick() this.clearProgressClick()
} else if (action === 'deleteLocal') {
this.deleteLocalItem()
} else if (action === 'rssFeed') {
this.clickRSSFeed()
} }
}, },
clickRSSFeed() {
this.$store.commit('globals/setRSSFeedOpenCloseModal', {
id: this.serverLibraryItemId,
name: this.title,
type: 'item',
feed: this.rssFeed,
hasEpisodesWithoutPubDate: this.episodes.some((ep) => !ep.pubDate)
})
},
moreButtonPress() { moreButtonPress() {
this.showMoreMenu = true this.showMoreMenu = true
}, },
readBook() { readBook() {
if (this.localLibraryItem?.media?.ebookFile) { if (this.localLibraryItem?.media?.ebookFile) {
// Has local ebook file // Has local ebook file
this.$store.commit('openReader', this.localLibraryItem) this.$store.commit('showReader', { libraryItem: this.localLibraryItem, keepProgress: true })
} else { } else {
this.$store.commit('openReader', this.libraryItem) this.$store.commit('showReader', { libraryItem: this.libraryItem, keepProgress: true })
} }
}, },
playAtTimestamp(seconds) { playAtTimestamp(seconds) {
@@ -590,7 +667,7 @@ export default {
} }
}, },
itemUpdated(libraryItem) { itemUpdated(libraryItem) {
if (libraryItem.id === this.libraryItemId) { if (libraryItem.id === this.serverLibraryItemId) {
console.log('Item Updated') console.log('Item Updated')
this.libraryItem = libraryItem this.libraryItem = libraryItem
this.checkDescriptionClamped() this.checkDescriptionClamped()
@@ -622,29 +699,33 @@ export default {
}, },
async download(selectedLocalFolder = null) { async download(selectedLocalFolder = null) {
// Get the local folder to download to // Get the local folder to download to
var localFolder = selectedLocalFolder let localFolder = selectedLocalFolder
if (!localFolder) { if (!localFolder) {
var localFolders = (await this.$db.getLocalFolders()) || [] const localFolders = (await this.$db.getLocalFolders()) || []
console.log('Local folders loaded', localFolders.length) console.log('Local folders loaded', localFolders.length)
var foldersWithMediaType = localFolders.filter((lf) => { const foldersWithMediaType = localFolders.filter((lf) => {
console.log('Checking local folder', lf.mediaType) console.log('Checking local folder', lf.mediaType)
return lf.mediaType == this.mediaType return lf.mediaType == this.mediaType
}) })
console.log('Folders with media type', this.mediaType, foldersWithMediaType.length) console.log('Folders with media type', this.mediaType, foldersWithMediaType.length)
const internalStorageFolder = foldersWithMediaType.find((f) => f.id === `internal-${this.mediaType}`)
if (!foldersWithMediaType.length) { if (!foldersWithMediaType.length) {
// No local folders or no local folders with this media type localFolder = {
localFolder = await this.selectFolder() id: `internal-${this.mediaType}`,
} else if (foldersWithMediaType.length == 1) { name: 'Internal App Storage',
console.log('Only 1 local folder with this media type - auto select it') mediaType: this.mediaType
localFolder = foldersWithMediaType[0] }
} else if (foldersWithMediaType.length === 1 && internalStorageFolder) {
localFolder = internalStorageFolder
} else { } else {
console.log('Multiple folders with media type') this.$store.commit('globals/showSelectLocalFolderModal', {
this.showSelectLocalFolder = true mediaType: this.mediaType,
callback: (folder) => {
this.download(folder)
}
})
return return
} }
if (!localFolder) {
return this.$toast.error('Invalid download folder')
}
} }
console.log('Local folder', JSON.stringify(localFolder)) console.log('Local folder', JSON.stringify(localFolder))
@@ -700,41 +781,32 @@ export default {
this.isProcessingReadUpdate = true this.isProcessingReadUpdate = true
if (this.isLocal) { if (this.isLocal) {
var isFinished = !this.userIsFinished const isFinished = !this.userIsFinished
var payload = await this.$db.updateLocalMediaProgressFinished({ localLibraryItemId: this.localLibraryItemId, isFinished }) const payload = await this.$db.updateLocalMediaProgressFinished({ localLibraryItemId: this.localLibraryItemId, isFinished })
console.log('toggleFinished payload', JSON.stringify(payload)) console.log('toggleFinished payload', JSON.stringify(payload))
if (!payload || payload.error) { if (payload?.error) {
var errorMsg = payload ? payload.error : 'Unknown error' this.$toast.error(payload?.error || 'Unknown error')
this.$toast.error(errorMsg)
} else { } else {
var localMediaProgress = payload.localMediaProgress const localMediaProgress = payload.localMediaProgress
console.log('toggleFinished localMediaProgress', JSON.stringify(localMediaProgress)) console.log('toggleFinished localMediaProgress', JSON.stringify(localMediaProgress))
if (localMediaProgress) { if (localMediaProgress) {
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress) this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
} }
if (payload.server) {
this.$toast.success(`Local & Server Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
} else {
this.$toast.success(`Local Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
}
} }
this.isProcessingReadUpdate = false this.isProcessingReadUpdate = false
} else { } else {
var updatePayload = { const updatePayload = {
isFinished: !this.userIsFinished isFinished: !this.userIsFinished
} }
this.$axios this.$axios
.$patch(`/api/me/progress/${this.libraryItemId}`, updatePayload) .$patch(`/api/me/progress/${this.libraryItemId}`, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
this.$toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
})
.catch((error) => { .catch((error) => {
console.error('Failed', error) console.error('Failed', error)
this.isProcessingReadUpdate = false
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`) this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
}) })
.finally(() => {
this.isProcessingReadUpdate = false
})
} }
}, },
libraryChanged(libraryId) { libraryChanged(libraryId) {
@@ -753,6 +825,18 @@ export default {
windowResized() { windowResized() {
this.windowWidth = window.innerWidth this.windowWidth = window.innerWidth
this.checkDescriptionClamped() this.checkDescriptionClamped()
},
rssFeedOpen(data) {
if (data.entityId === this.serverLibraryItemId) {
console.log('RSS Feed Opened', data)
this.rssFeed = data
}
},
rssFeedClosed(data) {
if (data.entityId === this.serverLibraryItemId) {
console.log('RSS Feed Closed', data)
this.rssFeed = null
}
} }
}, },
mounted() { mounted() {
@@ -761,13 +845,27 @@ export default {
this.$eventBus.$on('library-changed', this.libraryChanged) this.$eventBus.$on('library-changed', this.libraryChanged)
this.$eventBus.$on('new-local-library-item', this.newLocalLibraryItem) this.$eventBus.$on('new-local-library-item', this.newLocalLibraryItem)
this.$socket.$on('item_updated', this.itemUpdated) this.$socket.$on('item_updated', this.itemUpdated)
this.$socket.$on('rss_feed_open', this.rssFeedOpen)
this.$socket.$on('rss_feed_closed', this.rssFeedClosed)
this.checkDescriptionClamped() this.checkDescriptionClamped()
// Set last scroll position if was set for this item
if (this.$store.state.lastItemScrollData.id === this.libraryItemId && window['item-page']) {
window['item-page'].scrollTop = this.$store.state.lastItemScrollData.scrollTop || 0
}
}, },
beforeDestroy() { beforeDestroy() {
window.removeEventListener('resize', this.windowResized) window.removeEventListener('resize', this.windowResized)
this.$eventBus.$off('library-changed', this.libraryChanged) this.$eventBus.$off('library-changed', this.libraryChanged)
this.$eventBus.$off('new-local-library-item', this.newLocalLibraryItem) this.$eventBus.$off('new-local-library-item', this.newLocalLibraryItem)
this.$socket.$off('item_updated', this.itemUpdated) this.$socket.$off('item_updated', this.itemUpdated)
this.$socket.$off('rss_feed_open', this.rssFeedOpen)
this.$socket.$off('rss_feed_closed', this.rssFeedClosed)
// Set scroll position
if (window['item-page']) {
this.$store.commit('setLastItemScrollData', { scrollTop: window['item-page'].scrollTop || 0, id: this.libraryItemId })
}
} }
} }
</script> </script>
+41 -18
View File
@@ -4,7 +4,7 @@
<p class="text-base font-semibold">Folder: {{ folderName }}</p> <p class="text-base font-semibold">Folder: {{ folderName }}</p>
<div class="flex-grow" /> <div class="flex-grow" />
<span class="material-icons" @click="showDialog = true">more_vert</span> <span v-if="dialogItems.length" class="material-icons" @click="showDialog = true">more_vert</span>
</div> </div>
<p class="text-sm mb-4 text-white text-opacity-60">Media Type: {{ mediaType }}</p> <p class="text-sm mb-4 text-white text-opacity-60">Media Type: {{ mediaType }}</p>
@@ -15,15 +15,14 @@
<p>Scanning...</p> <p>Scanning...</p>
</div> </div>
<div v-else class="w-full media-item-container overflow-y-auto"> <div v-else class="w-full media-item-container overflow-y-auto">
<template v-for="mediaItem in localLibraryItems"> <template v-for="localLibraryItem in localLibraryItems">
<nuxt-link :to="`/localMedia/item/${mediaItem.id}`" :key="mediaItem.id" class="flex my-1"> <nuxt-link :to="`/localMedia/item/${localLibraryItem.id}`" :key="localLibraryItem.id" class="flex my-1">
<div class="w-12 h-12 min-w-12 min-h-12 bg-primary"> <div class="w-12 h-12 min-w-12 min-h-12 bg-primary">
<img v-if="mediaItem.coverPathSrc" :src="mediaItem.coverPathSrc" class="w-full h-full object-contain" /> <img v-if="localLibraryItem.coverPathSrc" :src="localLibraryItem.coverPathSrc" class="w-full h-full object-contain" />
</div> </div>
<div class="flex-grow px-2"> <div class="flex-grow px-2">
<p class="text-sm">{{ mediaItem.media.metadata.title }}</p> <p class="text-sm">{{ localLibraryItem.media.metadata.title }}</p>
<p v-if="mediaItem.mediaType == 'book'" class="text-xs text-gray-300">{{ mediaItem.media.tracks.length }} Track{{ mediaItem.media.tracks.length == 1 ? '' : 's' }}</p> <p class="text-xs text-gray-300">{{ getLocalLibraryItemSubText(localLibraryItem) }}</p>
<p v-else-if="mediaItem.mediaType == 'podcast'" class="text-xs text-gray-300">{{ mediaItem.media.episodes.length }} Episode{{ mediaItem.media.episodes.length == 1 ? '' : 's' }}</p>
</div> </div>
<div class="w-12 h-12 flex items-center justify-center"> <div class="w-12 h-12 flex items-center justify-center">
<span class="material-icons text-xl text-gray-300">arrow_right</span> <span class="material-icons text-xl text-gray-300">arrow_right</span>
@@ -59,29 +58,53 @@ export default {
}, },
computed: { computed: {
folderName() { folderName() {
return this.folder ? this.folder.name : null return this.folder?.name || null
}, },
mediaType() { mediaType() {
return this.folder ? this.folder.mediaType : null return this.folder?.mediaType
},
isInternalStorage() {
return this.folder?.id.startsWith('internal-')
}, },
dialogItems() { dialogItems() {
return [ if (this.isInternalStorage) return []
const items = [
{ {
text: 'Scan', text: 'Scan',
value: 'scan' value: 'scan'
}, }
{ ]
if (this.localLibraryItems.length) {
items.push({
text: 'Force Re-Scan', text: 'Force Re-Scan',
value: 'rescan' value: 'rescan'
}, })
{ }
text: 'Remove',
value: 'remove' items.push({
} text: 'Remove',
].filter((i) => i.value != 'rescan' || this.localLibraryItems.length) // Filter out rescan if there are no local library items value: 'remove'
})
return items
} }
}, },
methods: { methods: {
getLocalLibraryItemSubText(localLibraryItem) {
if (!localLibraryItem) return ''
if (localLibraryItem.mediaType == 'book') {
const txts = []
if (localLibraryItem.media.ebookFile) {
txts.push(`${localLibraryItem.media.ebookFile.ebookFormat} EBook`)
}
if (localLibraryItem.media.tracks?.length) {
txts.push(`${localLibraryItem.media.tracks.length} Tracks`)
}
return txts.join(' • ')
} else {
return `${localLibraryItem.media.episodes?.length || 0} Episodes`
}
},
dialogAction(action) { dialogAction(action) {
console.log('Dialog action', action) console.log('Dialog action', action)
if (action == 'scan') { if (action == 'scan') {
+14 -2
View File
@@ -15,12 +15,18 @@
<div v-if="!localFolders.length" class="flex justify-center"> <div v-if="!localFolders.length" class="flex justify-center">
<p class="text-center">No Media Folders</p> <p class="text-center">No Media Folders</p>
</div> </div>
<div class="flex border-t border-white border-opacity-10 my-4 py-4"> <div v-if="!isAndroid10OrBelow || overrideFolderRestriction" class="flex border-t border-white border-opacity-10 my-4 py-4">
<div class="flex-grow pr-1"> <div class="flex-grow pr-1">
<ui-dropdown v-model="newFolderMediaType" placeholder="Select media type" :items="mediaTypeItems" /> <ui-dropdown v-model="newFolderMediaType" placeholder="Select media type" :items="mediaTypeItems" />
</div> </div>
<ui-btn small class="w-28" color="success" @click="selectFolder">New Folder</ui-btn> <ui-btn small class="w-28" color="success" @click="selectFolder">New Folder</ui-btn>
</div> </div>
<div v-else class="flex border-t border-white border-opacity-10 my-4 py-4">
<div class="flex-grow pr-1">
<p class="text-sm">Android 10 and below will use internal app storage for downloads.</p>
</div>
<ui-btn small class="w-28" color="primary" @click="overrideFolderRestriction = true">Override</ui-btn>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -44,7 +50,9 @@ export default {
text: 'Podcasts' text: 'Podcasts'
} }
], ],
syncing: false syncing: false,
isAndroid10OrBelow: false,
overrideFolderRestriction: false
} }
}, },
computed: { computed: {
@@ -82,6 +90,10 @@ export default {
this.$router.push(`/localMedia/folders/${folderObj.id}?scan=1`) this.$router.push(`/localMedia/folders/${folderObj.id}?scan=1`)
}, },
async init() { async init() {
const androidSdkVersion = await this.$getAndroidSDKVersion()
this.isAndroid10OrBelow = !!androidSdkVersion && androidSdkVersion <= 29
console.log(`androidSdkVersion=${androidSdkVersion}, isAndroid10OrBelow=${this.isAndroid10OrBelow}`)
this.localFolders = (await this.$db.getLocalFolders()) || [] this.localFolders = (await this.$db.getLocalFolders()) || []
this.localLibraryItems = await this.$db.getLocalLibraryItems() this.localLibraryItems = await this.$db.getLocalLibraryItems()
} }
+68 -41
View File
@@ -18,8 +18,8 @@
<div v-if="isScanning" class="w-full text-center p-4"> <div v-if="isScanning" class="w-full text-center p-4">
<p>Scanning...</p> <p>Scanning...</p>
</div> </div>
<div v-else class="w-full max-w-full media-item-container overflow-y-auto overflow-x-hidden relative" :class="{ 'media-order-changed': orderChanged }"> <div v-else class="w-full max-w-full media-item-container overflow-y-auto overflow-x-hidden relative pb-4" :class="{ 'media-order-changed': orderChanged }">
<div v-if="!isPodcast" class="w-full"> <div v-if="!isPodcast && audioTracksCopy.length" class="w-full py-2">
<p class="text-base mb-2">Audio Tracks ({{ audioTracks.length }})</p> <p class="text-base mb-2">Audio Tracks ({{ audioTracks.length }})</p>
<draggable v-model="audioTracksCopy" v-bind="dragOptions" handle=".drag-handle" draggable=".item" tag="div" @start="drag = true" @end="drag = false" @update="draggableUpdate" :disabled="isIos"> <draggable v-model="audioTracksCopy" v-bind="dragOptions" handle=".drag-handle" draggable=".item" tag="div" @start="drag = true" @end="drag = false" @update="draggableUpdate" :disabled="isIos">
@@ -47,7 +47,8 @@
</transition-group> </transition-group>
</draggable> </draggable>
</div> </div>
<div v-else class="w-full">
<div v-if="isPodcast" class="w-full py-2">
<p class="text-base mb-2">Episodes ({{ episodes.length }})</p> <p class="text-base mb-2">Episodes ({{ episodes.length }})</p>
<template v-for="episode in episodes"> <template v-for="episode in episodes">
<div :key="episode.id" class="flex items-center my-1"> <div :key="episode.id" class="flex items-center my-1">
@@ -68,7 +69,24 @@
</template> </template>
</div> </div>
<p v-if="otherFiles.length" class="text-lg mb-2 pt-8">Other Files</p> <div v-if="localFileForEbook" class="w-full py-2">
<p class="text-base mb-2">EBook File</p>
<div class="flex items-center my-1">
<div class="w-10 h-12 flex items-center justify-center" style="min-width: 40px">
<p class="font-mono font-bold text-sm">{{ ebookFile.ebookFormat }}</p>
</div>
<div class="flex-grow px-2">
<p class="text-xs">{{ localFileForEbook.filename }}</p>
</div>
<div class="w-24 text-center text-gray-300" style="min-width: 96px">
<p class="text-xs">{{ localFileForEbook.mimeType }}</p>
<p class="text-sm">{{ $bytesPretty(localFileForEbook.size) }}</p>
</div>
</div>
</div>
<p v-if="otherFiles.length" class="text-lg py-2">Other Files</p>
<template v-for="file in otherFiles"> <template v-for="file in otherFiles">
<div :key="file.id" class="flex items-center my-1"> <div :key="file.id" class="flex items-center my-1">
<div class="w-12 h-12 flex items-center justify-center"> <div class="w-12 h-12 flex items-center justify-center">
@@ -78,7 +96,7 @@
<div class="flex-grow px-2"> <div class="flex-grow px-2">
<p class="text-sm">{{ file.filename }}</p> <p class="text-sm">{{ file.filename }}</p>
</div> </div>
<div class="w-20 text-center text-gray-300"> <div class="w-24 text-center text-gray-300" style="min-width: 96px">
<p class="text-xs">{{ file.mimeType }}</p> <p class="text-xs">{{ file.mimeType }}</p>
<p class="text-sm">{{ $bytesPretty(file.size) }}</p> <p class="text-sm">{{ $bytesPretty(file.size) }}</p>
</div> </div>
@@ -90,7 +108,7 @@
<p class="text-lg text-center px-8">{{ failed ? 'Failed to get local library item ' + localLibraryItemId : 'Loading..' }}</p> <p class="text-lg text-center px-8">{{ failed ? 'Failed to get local library item ' + localLibraryItemId : 'Loading..' }}</p>
</div> </div>
<div v-if="orderChanged" class="fixed left-0 w-full py-4 px-4 bg-bg box-shadow-book flex items-center" :style="{ bottom: playerLibraryItemId ? '120px' : '0px' }"> <div v-if="orderChanged" class="fixed left-0 w-full py-4 px-4 bg-bg box-shadow-book flex items-center" :style="{ bottom: isPlayerOpen ? '120px' : '0px' }">
<div class="flex-grow" /> <div class="flex-grow" />
<ui-btn small color="success" @click="saveTrackOrder">Save Order</ui-btn> <ui-btn small color="success" @click="saveTrackOrder">Save Order</ui-btn>
</div> </div>
@@ -138,17 +156,17 @@ export default {
} }
}, },
computed: { computed: {
playerLibraryItemId() { isPlayerOpen() {
return this.$store.state.playerLibraryItemId return this.$store.getters['getIsPlayerOpen']
}, },
isIos() { isIos() {
return this.$platform === 'ios' return this.$platform === 'ios'
}, },
basePath() { basePath() {
return this.localLibraryItem ? this.localLibraryItem.basePath : null return this.localLibraryItem?.basePath
}, },
localFiles() { localFiles() {
return this.localLibraryItem ? this.localLibraryItem.localFiles : [] return this.localLibraryItem?.localFiles || []
}, },
otherFiles() { otherFiles() {
if (!this.localFiles.filter) { if (!this.localFiles.filter) {
@@ -156,29 +174,40 @@ export default {
return [] return []
} }
return this.localFiles.filter((lf) => { return this.localFiles.filter((lf) => {
if (this.localFileForEbook?.id === lf.id) return false
return !this.audioTracks.find((at) => at.localFileId == lf.id) return !this.audioTracks.find((at) => at.localFileId == lf.id)
}) })
}, },
folderName() { folderName() {
return this.folder ? this.folder.name : null return this.folder?.name
},
isInternalStorage() {
return this.folderId?.startsWith('internal-')
}, },
mediaType() { mediaType() {
return this.localLibraryItem ? this.localLibraryItem.mediaType : null return this.localLibraryItem?.mediaType
}, },
isPodcast() { isPodcast() {
return this.mediaType == 'podcast' return this.mediaType == 'podcast'
}, },
libraryItemId() { libraryItemId() {
return this.localLibraryItem ? this.localLibraryItem.libraryItemId : null return this.localLibraryItem?.libraryItemId
}, },
liServerAddress() { liServerAddress() {
return this.localLibraryItem ? this.localLibraryItem.serverAddress : null return this.localLibraryItem?.serverAddress
}, },
media() { media() {
return this.localLibraryItem ? this.localLibraryItem.media : null return this.localLibraryItem?.media
}, },
mediaMetadata() { mediaMetadata() {
return this.media ? this.media.metadata || {} : {} return this.media?.metadata || {}
},
ebookFile() {
return this.media?.ebookFile
},
localFileForEbook() {
if (!this.ebookFile) return null
return this.localFiles.find((lf) => lf.id == this.ebookFile.localFileId)
}, },
episodes() { episodes() {
return this.media.episodes || [] return this.media.episodes || []
@@ -195,18 +224,17 @@ export default {
if (this.selectedAudioTrack || this.selectedEpisode) { if (this.selectedAudioTrack || this.selectedEpisode) {
return [ return [
{ {
text: 'Remove & Delete Files', text: 'Delete local file',
value: 'track-delete' value: 'track-delete'
} }
] ]
} else { } else {
var options = [] var options = []
if (!this.isIos) { if (!this.isIos && !this.isInternalStorage && !this.libraryItemId) {
options.push({ text: 'Scan', value: 'scan' }) options.push({ text: 'Scan', value: 'scan' })
options.push({ text: 'Force Re-Scan', value: 'rescan' }) options.push({ text: 'Force Re-Scan', value: 'rescan' })
options.push({ text: 'Remove', value: 'remove' })
} }
options.push({ text: 'Remove & Delete Files', value: 'delete' }) options.push({ text: 'Delete local item', value: 'delete' })
return options return options
} }
} }
@@ -243,6 +271,7 @@ export default {
}, },
showItemDialog() { showItemDialog() {
this.selectedAudioTrack = null this.selectedAudioTrack = null
this.selectedEpisode = null
this.showDialog = true this.showDialog = true
}, },
showTrackDialog(track) { showTrackDialog(track) {
@@ -265,12 +294,11 @@ export default {
async dialogAction(action) { async dialogAction(action) {
console.log('Dialog action', action) console.log('Dialog action', action)
await this.$hapticsImpact() await this.$hapticsImpact()
if (action == 'scan') { if (action == 'scan') {
this.scanItem() this.scanItem()
} else if (action == 'rescan') { } else if (action == 'rescan') {
this.scanItem(true) this.scanItem(true)
} else if (action == 'remove') {
this.removeItem()
} else if (action == 'delete') { } else if (action == 'delete') {
this.deleteItem() this.deleteItem()
} else if (action == 'track-delete') { } else if (action == 'track-delete') {
@@ -289,10 +317,14 @@ export default {
this.$toast.error('Audio track does not have matching local file..') this.$toast.error('Audio track does not have matching local file..')
return return
} }
var trackPath = localFile ? localFile.basePath : this.selectedEpisode.title
let confirmMessage = `Remove local audio file "${localFile.basePath}" from your device?`
if (this.libraryItemId) {
confirmMessage += ' The file on the server will be unaffected.'
}
const { value } = await Dialog.confirm({ const { value } = await Dialog.confirm({
title: 'Confirm', title: 'Confirm',
message: `Warning! This will delete the audio file "${trackPath}" from your file system. Are you sure?` message: confirmMessage
}) })
if (value) { if (value) {
var res = await AbsFileSystem.deleteTrackFromItem({ id: this.localLibraryItem.id, trackLocalFileId: localFile.id, trackContentUrl: this.selectedEpisode.audioTrack.contentUrl }) var res = await AbsFileSystem.deleteTrackFromItem({ id: this.localLibraryItem.id, trackLocalFileId: localFile.id, trackContentUrl: this.selectedEpisode.audioTrack.contentUrl })
@@ -311,10 +343,14 @@ export default {
this.$toast.error('Audio track does not have matching local file..') this.$toast.error('Audio track does not have matching local file..')
return return
} }
var trackPath = localFile ? localFile.basePath : this.selectedAudioTrack.title
let confirmMessage = `Remove local audio file "${localFile.basePath}" from your device?`
if (this.libraryItemId) {
confirmMessage += ' The file on the server will be unaffected.'
}
const { value } = await Dialog.confirm({ const { value } = await Dialog.confirm({
title: 'Confirm', title: 'Confirm',
message: `Warning! This will delete the audio file "${trackPath}" from your file system. Are you sure?` message: confirmMessage
}) })
if (value) { if (value) {
var res = await AbsFileSystem.deleteTrackFromItem({ id: this.localLibraryItem.id, trackLocalFileId: this.selectedAudioTrack.localFileId, trackContentUrl: this.selectedAudioTrack.contentUrl }) var res = await AbsFileSystem.deleteTrackFromItem({ id: this.localLibraryItem.id, trackLocalFileId: this.selectedAudioTrack.localFileId, trackContentUrl: this.selectedAudioTrack.contentUrl })
@@ -325,31 +361,22 @@ export default {
} }
}, },
async deleteItem() { async deleteItem() {
let confirmMessage = 'Remove local files of this item from your device?'
if (this.libraryItemId) {
confirmMessage += ' The files on the server and your progress will be unaffected.'
}
const { value } = await Dialog.confirm({ const { value } = await Dialog.confirm({
title: 'Confirm', title: 'Confirm',
message: `Warning! This will delete "${this.media.metadata.title}" and all associated local files. Are you sure?` message: confirmMessage
}) })
if (value) { if (value) {
var res = await AbsFileSystem.deleteItem(this.localLibraryItem) var res = await AbsFileSystem.deleteItem(this.localLibraryItem)
if (res && res.success) { if (res && res.success) {
this.$toast.success('Deleted Successfully') this.$toast.success('Deleted Successfully')
this.$router.replace(this.isIos ? '/bookshelf' : `/localMedia/folders/${this.folderId}`) this.$router.replace(this.isIos ? '/downloads' : `/localMedia/folders/${this.folderId}`)
} else this.$toast.error('Failed to delete') } else this.$toast.error('Failed to delete')
} }
}, },
async removeItem() {
var deleteMessage = 'Are you sure you want to remove this local library item? (does not delete anything in your file system)'
const { value } = await Dialog.confirm({
title: 'Confirm',
message: deleteMessage
})
if (value) {
this.removingItem = true
await AbsFileSystem.removeLocalLibraryItem({ localLibraryItemId: this.localLibraryItemId })
this.removingItem = false
this.$router.replace(`/localMedia/folders/${this.folderId}`)
}
},
async scanItem(forceAudioProbe = false) { async scanItem(forceAudioProbe = false) {
if (this.isScanning) return if (this.isScanning) return
+33 -8
View File
@@ -1,10 +1,8 @@
<template> <template>
<div class="w-full h-full"> <div class="w-full h-full">
<div class="w-full h-full overflow-y-auto px-2 py-6 md:p-8"> <div class="w-full h-full overflow-y-auto px-2 py-6 md:p-8">
<div class="w-full flex justify-center md:block sm:w-32 md:w-52" style="min-width: 240px"> <div class="w-full flex justify-center">
<div class="relative" style="height: fit-content"> <covers-playlist-cover :items="playlistItems" :width="180" :height="180" />
<covers-playlist-cover :items="playlistItems" :width="240" :height="120 * bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
</div> </div>
<div class="flex-grow py-6"> <div class="flex-grow py-6">
<div class="flex items-center px-2"> <div class="flex items-center px-2">
@@ -44,6 +42,26 @@ export default {
return redirect('/bookshelf/playlists') return redirect('/bookshelf/playlists')
} }
// Lookup matching local items & episodes and attach to playlist items
if (playlist.items.length) {
const localLibraryItems = (await app.$db.getLocalLibraryItems(playlist.items[0].libraryItem.mediaType)) || []
if (localLibraryItems.length) {
playlist.items.forEach((playlistItem) => {
const matchingLocalLibraryItem = localLibraryItems.find((lli) => lli.libraryItemId === playlistItem.libraryItemId)
if (!matchingLocalLibraryItem) return
if (playlistItem.episode) {
const matchingLocalEpisode = matchingLocalLibraryItem.media.episodes?.find((lep) => lep.serverEpisodeId === playlistItem.episodeId)
if (matchingLocalEpisode) {
playlistItem.localLibraryItem = matchingLocalLibraryItem
playlistItem.localEpisode = matchingLocalEpisode
}
} else {
playlistItem.localLibraryItem = matchingLocalLibraryItem
}
})
}
}
return { return {
playlist playlist
} }
@@ -73,7 +91,10 @@ export default {
}) })
}, },
streaming() { streaming() {
return !!this.playableItems.find((i) => this.$store.getters['getIsMediaStreaming'](i.libraryItemId, i.episodeId)) return !!this.playableItems.find((i) => {
if (i.localLibraryItem && this.$store.getters['getIsMediaStreaming'](i.localLibraryItem.id, i.localEpisode?.id)) return true
return this.$store.getters['getIsMediaStreaming'](i.libraryItemId, i.episodeId)
})
}, },
showPlayButton() { showPlayButton() {
return this.playableItems.length return this.playableItems.length
@@ -82,11 +103,15 @@ export default {
methods: { methods: {
clickPlay() { clickPlay() {
const nextItem = this.playableItems.find((i) => { const nextItem = this.playableItems.find((i) => {
var prog = this.$store.getters['user/getUserMediaProgress'](i.libraryItemId, i.episodeId) const prog = this.$store.getters['user/getUserMediaProgress'](i.libraryItemId, i.episodeId)
return !prog || !prog.isFinished return !prog?.isFinished
}) })
if (nextItem) { if (nextItem) {
this.$eventBus.$emit('play-item', { libraryItemId: nextItem.libraryItemId, episodeId: nextItem.episodeId }) if (nextItem.localLibraryItem) {
this.$eventBus.$emit('play-item', { libraryItemId: nextItem.localLibraryItem.id, episodeId: nextItem.localEpisode?.id, serverLibraryItemId: nextItem.libraryItemId, serverEpisodeId: nextItem.episodeId })
} else {
this.$eventBus.$emit('play-item', { libraryItemId: nextItem.libraryItemId, episodeId: nextItem.episodeId })
}
} }
} }
}, },
+31 -3
View File
@@ -22,7 +22,7 @@
</div> </div>
<!-- Playback settings --> <!-- Playback settings -->
<p class="uppercase text-xs font-semibold text-gray-300 mb-2 mt-6">Playback Settings</p> <p class="uppercase text-xs font-semibold text-gray-300 mb-2 mt-10">Playback Settings</p>
<div v-if="!isiOS" class="flex items-center py-3" @click="toggleDisableAutoRewind"> <div v-if="!isiOS" class="flex items-center py-3" @click="toggleDisableAutoRewind">
<div class="w-10 flex justify-center"> <div class="w-10 flex justify-center">
<ui-toggle-switch v-model="settings.disableAutoRewind" @input="saveSettings" /> <ui-toggle-switch v-model="settings.disableAutoRewind" @input="saveSettings" />
@@ -41,10 +41,17 @@
</div> </div>
<p class="pl-4">Jump forwards time</p> <p class="pl-4">Jump forwards time</p>
</div> </div>
<div v-if="!isiOS" class="flex items-center py-3" @click="toggleEnableMp3IndexSeeking">
<div class="w-10 flex justify-center">
<ui-toggle-switch v-model="settings.enableMp3IndexSeeking" @input="saveSettings" />
</div>
<p class="pl-4">Enable mp3 index seeking</p>
<span class="material-icons-outlined ml-2" @click.stop="showConfirmMp3IndexSeeking">info</span>
</div>
<!-- Sleep timer settings --> <!-- Sleep timer settings -->
<template v-if="!isiOS"> <template v-if="!isiOS">
<p class="uppercase text-xs font-semibold text-gray-300 mb-2 mt-6">Sleep Timer Settings</p> <p class="uppercase text-xs font-semibold text-gray-300 mb-2 mt-10">Sleep Timer Settings</p>
<div class="flex items-center py-3" @click="toggleDisableShakeToResetSleepTimer"> <div class="flex items-center py-3" @click="toggleDisableShakeToResetSleepTimer">
<div class="w-10 flex justify-center"> <div class="w-10 flex justify-center">
<ui-toggle-switch v-model="settings.disableShakeToResetSleepTimer" @input="saveSettings" /> <ui-toggle-switch v-model="settings.disableShakeToResetSleepTimer" @input="saveSettings" />
@@ -131,6 +138,7 @@ export default {
enableAltView: false, enableAltView: false,
jumpForwardTime: 10, jumpForwardTime: 10,
jumpBackwardsTime: 10, jumpBackwardsTime: 10,
enableMp3IndexSeeking: false,
disableShakeToResetSleepTimer: false, disableShakeToResetSleepTimer: false,
shakeSensitivity: 'MEDIUM', shakeSensitivity: 'MEDIUM',
lockOrientation: 0, lockOrientation: 0,
@@ -165,6 +173,10 @@ export default {
autoSleepTimerAutoRewind: { autoSleepTimerAutoRewind: {
name: 'Enable sleep timer auto rewind', name: 'Enable sleep timer auto rewind',
message: 'When the auto sleep timer finishes, playing the item again will automatically rewind your position.' message: 'When the auto sleep timer finishes, playing the item again will automatically rewind your position.'
},
enableMp3IndexSeeking: {
name: 'Enable mp3 index seeking',
message: 'This setting should only be enabled if you have mp3 files that are not seeking correctly. Inaccurate seeking is most likely due to Variable birate (VBR) MP3 files. This setting will force index seeking, in which a time-to-byte mapping is built as the file is read. In some cases with large MP3 files there will be a delay when seeking towards the end of the file.'
} }
}, },
hapticFeedbackItems: [ hapticFeedbackItems: [
@@ -315,6 +327,20 @@ export default {
}) })
} }
}, },
async showConfirmMp3IndexSeeking() {
const confirmResult = await Dialog.confirm({
title: this.settingInfo.enableMp3IndexSeeking.name,
message: this.settingInfo.enableMp3IndexSeeking.message,
cancelButtonTitle: 'View More'
})
if (!confirmResult.value) {
window.open('https://exoplayer.dev/troubleshooting.html#why-is-seeking-inaccurate-in-some-mp3-files', '_blank')
}
},
toggleEnableMp3IndexSeeking() {
this.settings.enableMp3IndexSeeking = !this.settings.enableMp3IndexSeeking
this.saveSettings()
},
toggleAutoSleepTimer() { toggleAutoSleepTimer() {
this.settings.autoSleepTimer = !this.settings.autoSleepTimer this.settings.autoSleepTimer = !this.settings.autoSleepTimer
this.saveSettings() this.saveSettings()
@@ -344,7 +370,7 @@ export default {
this.saveSettings() this.saveSettings()
}, },
getCurrentOrientation() { getCurrentOrientation() {
const orientation = window.screen ? window.screen.orientation || {} : {} const orientation = window.screen?.orientation || {}
const type = orientation.type || '' const type = orientation.type || ''
if (type.includes('landscape')) return 'LANDSCAPE' if (type.includes('landscape')) return 'LANDSCAPE'
@@ -388,6 +414,8 @@ export default {
this.settings.enableAltView = !!deviceSettings.enableAltView this.settings.enableAltView = !!deviceSettings.enableAltView
this.settings.jumpForwardTime = deviceSettings.jumpForwardTime || 10 this.settings.jumpForwardTime = deviceSettings.jumpForwardTime || 10
this.settings.jumpBackwardsTime = deviceSettings.jumpBackwardsTime || 10 this.settings.jumpBackwardsTime = deviceSettings.jumpBackwardsTime || 10
this.settings.toggleEnableMp3IndexSeeking = !!deviceSettings.toggleEnableMp3IndexSeeking
this.settings.lockOrientation = deviceSettings.lockOrientation || 'NONE' this.settings.lockOrientation = deviceSettings.lockOrientation || 'NONE'
this.lockCurrentOrientation = this.settings.lockOrientation !== 'NONE' this.lockCurrentOrientation = this.settings.lockOrientation !== 'NONE'
this.settings.hapticFeedback = deviceSettings.hapticFeedback || 'LIGHT' this.settings.hapticFeedback = deviceSettings.hapticFeedback || 'LIGHT'

Some files were not shown because too many files have changed in this diff Show More