mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-07-27 15:08:40 +02:00
Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdbbd56a58 | ||
|
|
d6a1eba657 | ||
|
|
da899bfddd | ||
|
|
b7b746fb2a | ||
|
|
c8053355df | ||
|
|
1ddb6eca00 | ||
|
|
f42c624cba | ||
|
|
f8fbeef9b7 | ||
|
|
46b82a81ce | ||
|
|
48342e5bd3 | ||
|
|
147f89f870 | ||
|
|
8b39ba9c92 | ||
|
|
13d32dc1f7 | ||
|
|
0d1312e497 | ||
|
|
ff4f8324e7 | ||
|
|
b4bf10d409 | ||
|
|
ae37861f7d | ||
|
|
1da03cdd65 | ||
|
|
a7443d0992 | ||
|
|
5a953464ab | ||
|
|
953bde5a42 | ||
|
|
481d0d0285 | ||
|
|
7b3a005ad6 | ||
|
|
4f9d341f69 | ||
|
|
9e7d1d1617 | ||
|
|
4f03c2a35d | ||
|
|
db3c5396e9 | ||
|
|
455af239a1 | ||
|
|
d8bc26f5f8 | ||
|
|
543ac209e4 | ||
|
|
c8b5cefeb5 | ||
|
|
f1411011e6 | ||
|
|
f3964dda17 | ||
|
|
d207e88e18 | ||
|
|
2643e98fc0 | ||
|
|
410648e316 | ||
|
|
373221703d | ||
|
|
fbcb8620f9 | ||
|
|
a8ac4970cc | ||
|
|
195e33ae32 | ||
|
|
9acf695802 | ||
|
|
e6aaccfc74 | ||
|
|
87c74fe78b | ||
|
|
7dc319c1bd | ||
|
|
c093548b71 | ||
|
|
b067a2d3d5 | ||
|
|
a9ae7bfdb8 | ||
|
|
9dcf22614c | ||
|
|
c7564d439e | ||
|
|
157dc01673 | ||
|
|
cce6e1d0ab | ||
|
|
b935cb5cf1 | ||
|
|
9ab37ac525 |
@@ -21,8 +21,6 @@ kotlin {
|
||||
}
|
||||
|
||||
android {
|
||||
|
||||
|
||||
namespace 'com.audiobookshelf.app'
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
@@ -35,8 +33,8 @@ android {
|
||||
applicationId "com.audiobookshelf.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 95
|
||||
versionName "0.9.64-beta"
|
||||
versionCode 96
|
||||
versionName "0.9.65-beta"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// 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.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 "androidx.core:core-ktx:$androidx_core_ktx_version"
|
||||
|
||||
@@ -10,6 +10,7 @@ android {
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-clipboard')
|
||||
implementation project(':capacitor-dialog')
|
||||
implementation project(':capacitor-haptics')
|
||||
implementation project(':capacitor-network')
|
||||
|
||||
@@ -92,6 +92,16 @@
|
||||
<action android:name="android.media.browse.MediaBrowserService" />
|
||||
</intent-filter>
|
||||
</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>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"pkg": "@capacitor/app",
|
||||
"classpath": "com.capacitorjs.plugins.app.AppPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/clipboard",
|
||||
"classpath": "com.capacitorjs.plugins.clipboard.ClipboardPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/dialog",
|
||||
"classpath": "com.capacitorjs.plugins.dialog.DialogPlugin"
|
||||
|
||||
@@ -12,10 +12,10 @@ import android.util.Log
|
||||
import androidx.core.app.ActivityCompat
|
||||
import com.anggrayudi.storage.SimpleStorage
|
||||
import com.anggrayudi.storage.SimpleStorageHelper
|
||||
import com.audiobookshelf.app.plugins.AbsDatabase
|
||||
import com.audiobookshelf.app.managers.DbManager
|
||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||
import com.audiobookshelf.app.plugins.AbsAudioPlayer
|
||||
import com.audiobookshelf.app.plugins.AbsDatabase
|
||||
import com.audiobookshelf.app.plugins.AbsDownloader
|
||||
import com.audiobookshelf.app.plugins.AbsFileSystem
|
||||
import com.getcapacitor.BridgeActivity
|
||||
|
||||
@@ -15,9 +15,7 @@ import android.widget.RemoteViews
|
||||
import androidx.media.session.MediaButtonReceiver
|
||||
import com.audiobookshelf.app.data.PlaybackSession
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.device.WidgetEventEmitter
|
||||
import com.audiobookshelf.app.managers.DbManager
|
||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import com.bumptech.glide.request.target.AppWidgetTarget
|
||||
@@ -28,12 +26,9 @@ import com.bumptech.glide.request.transition.Transition
|
||||
*/
|
||||
class MediaPlayerWidget : AppWidgetProvider() {
|
||||
val tag = "MediaPlayerWidget"
|
||||
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
|
||||
// There may be multiple widgets active, so update all of them
|
||||
for (appWidgetId in appWidgetIds) {
|
||||
updateAppWidget(context, appWidgetManager, appWidgetId, null, false, PlayerNotificationService.isClosed)
|
||||
}
|
||||
}
|
||||
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
|
||||
Log.d(tag, "onUpdate $appWidgetIds")
|
||||
}
|
||||
|
||||
override fun onEnabled(context: Context) {
|
||||
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
|
||||
DeviceManager.widgetUpdater = (object : WidgetEventEmitter {
|
||||
override fun onPlayerChanged(pns: PlayerNotificationService) {
|
||||
val isPlaying = pns.currentPlayer.isPlaying
|
||||
|
||||
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
|
||||
val ids = appWidgetManager.getAppWidgetIds(componentName)
|
||||
|
||||
val playbackSession = pns.getCurrentPlaybackSessionCopy()
|
||||
|
||||
for (widgetId in ids) {
|
||||
updateAppWidget(context, appWidgetManager, widgetId, playbackSession, isPlaying, PlayerNotificationService.isClosed)
|
||||
}
|
||||
}
|
||||
})
|
||||
DeviceManager.initializeWidgetUpdater(context)
|
||||
}
|
||||
|
||||
override fun onDisabled(context: Context) {
|
||||
// Enter relevant functionality for when the last widget is disabled
|
||||
}
|
||||
}
|
||||
|
||||
internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, playbackSession: PlaybackSession?, isPlaying:Boolean, isAppClosed:Boolean) {
|
||||
val tag = "MediaPlayerWidget"
|
||||
val views = RemoteViews(context.packageName, R.layout.media_player_widget)
|
||||
|
||||
Log.i(tag, "updateAppWidget ${playbackSession?.displayTitle ?: "No Title"} isPlaying=$isPlaying isAppClosed=$isAppClosed")
|
||||
val wholeWidgetClickI = Intent(context, MainActivity::class.java)
|
||||
wholeWidgetClickI.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
val wholeWidgetClickPI = PendingIntent.getActivity(
|
||||
@@ -100,7 +77,7 @@ internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManage
|
||||
|
||||
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) {
|
||||
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||
super.onResourceReady(resource, transition)
|
||||
|
||||
@@ -53,7 +53,7 @@ class Podcast(
|
||||
audioTracks.forEach { at ->
|
||||
if (episodes?.find{ it.audioTrack?.localFileId == at.localFileId } == null) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,7 @@ class Podcast(
|
||||
@JsonIgnore
|
||||
override fun addAudioTrack(audioTrack:AudioTrack) {
|
||||
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)
|
||||
|
||||
var index = 1
|
||||
@@ -96,7 +96,7 @@ class Podcast(
|
||||
@JsonIgnore
|
||||
fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode {
|
||||
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)
|
||||
|
||||
var index = 1
|
||||
@@ -251,15 +251,16 @@ data class PodcastEpisode(
|
||||
var publishedAt:Long?,
|
||||
var audioFile:AudioFile?,
|
||||
var audioTrack:AudioTrack?,
|
||||
var chapters:List<BookChapter>?,
|
||||
var duration:Double?,
|
||||
var size:Long?,
|
||||
var serverEpisodeId:String?, // For local podcasts to match with server podcasts
|
||||
var localEpisodeId:String? // For Android Auto server episodes with local copy
|
||||
) {
|
||||
@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) {
|
||||
libraryItem.getCoverUri()
|
||||
libraryItem.getCoverUri(ctx)
|
||||
} else {
|
||||
(libraryItem as LibraryItem).getCoverUri()
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ data class LocalFolder(
|
||||
)
|
||||
open class LibraryItemWrapper(var id:String) {
|
||||
@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)
|
||||
@@ -91,7 +91,6 @@ data class DeviceInfo(
|
||||
var deviceId:String,
|
||||
var manufacturer:String,
|
||||
var model:String,
|
||||
var brand:String,
|
||||
var sdkVersion:Int,
|
||||
var clientVersion: String
|
||||
)
|
||||
@@ -110,6 +109,7 @@ data class DeviceSettings(
|
||||
var enableAltView:Boolean,
|
||||
var jumpBackwardsTime:Int,
|
||||
var jumpForwardTime:Int,
|
||||
var enableMp3IndexSeeking:Boolean,
|
||||
var disableShakeToResetSleepTimer:Boolean,
|
||||
var shakeSensitivity: ShakeSensitivitySetting,
|
||||
var lockOrientation: LockOrientationSetting,
|
||||
@@ -131,6 +131,7 @@ data class DeviceSettings(
|
||||
enableAltView = true,
|
||||
jumpBackwardsTime = 10,
|
||||
jumpForwardTime = 10,
|
||||
enableMp3IndexSeeking = false,
|
||||
disableShakeToResetSleepTimer = false,
|
||||
shakeSensitivity = ShakeSensitivitySetting.MEDIUM,
|
||||
lockOrientation = LockOrientationSetting.NONE,
|
||||
|
||||
@@ -58,7 +58,7 @@ class LibraryItem(
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat {
|
||||
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat {
|
||||
val extras = Bundle()
|
||||
|
||||
if (localLibraryItemId != null) {
|
||||
|
||||
@@ -9,6 +9,8 @@ import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.util.Log
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.net.toFile
|
||||
import androidx.media.utils.MediaConstants
|
||||
import com.audiobookshelf.app.BuildConfig
|
||||
import com.audiobookshelf.app.R
|
||||
@@ -46,7 +48,10 @@ class LocalLibraryItem(
|
||||
val isPodcast get() = mediaType == "podcast"
|
||||
|
||||
@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)
|
||||
}
|
||||
|
||||
@@ -74,7 +79,7 @@ class LocalLibraryItem(
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getPlaybackSession(episode:PodcastEpisode?):PlaybackSession {
|
||||
fun getPlaybackSession(episode:PodcastEpisode?, deviceInfo:DeviceInfo):PlaybackSession {
|
||||
val localEpisodeId = episode?.id
|
||||
val sessionId = "play_local_${UUID.randomUUID()}"
|
||||
|
||||
@@ -85,19 +90,20 @@ class LocalLibraryItem(
|
||||
|
||||
|
||||
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>
|
||||
val authorName = mediaMetadata.getAuthorDisplayName()
|
||||
val displayTitle = episode?.title ?: mediaMetadata.title
|
||||
var duration = getDuration()
|
||||
if (episode != null) { // Get podcast episode audio track
|
||||
episode.audioTrack?.let { at -> mutableListOf(at) }?.let { tracks -> audioTracks = tracks }
|
||||
chapters = episode.chapters
|
||||
duration = episode.audioTrack?.duration ?: 0.0
|
||||
Log.d("LocalLibraryItem", "getPlaybackSession: Got podcast episode audio track ${audioTracks.size}")
|
||||
}
|
||||
|
||||
val dateNow = System.currentTimeMillis()
|
||||
return PlaybackSession(sessionId,serverUserId,libraryItemId,episode?.serverEpisodeId, mediaType, mediaMetadata, chapters ?: mutableListOf(), 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
|
||||
@@ -106,18 +112,16 @@ class LocalLibraryItem(
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx:Context?): MediaDescriptionCompat {
|
||||
val coverUri = getCoverUri()
|
||||
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx:Context): MediaDescriptionCompat {
|
||||
val coverUri = getCoverUri(ctx)
|
||||
|
||||
var bitmap:Bitmap? = null
|
||||
if (coverContentUrl != null) {
|
||||
ctx?.let {
|
||||
bitmap = if (Build.VERSION.SDK_INT < 28) {
|
||||
MediaStore.Images.Media.getBitmap(it.contentResolver, coverUri)
|
||||
} else {
|
||||
val source: ImageDecoder.Source = ImageDecoder.createSource(it.contentResolver, coverUri)
|
||||
ImageDecoder.decodeBitmap(source)
|
||||
}
|
||||
bitmap = if (Build.VERSION.SDK_INT < 28) {
|
||||
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
|
||||
} else {
|
||||
val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, coverUri)
|
||||
ImageDecoder.decodeBitmap(source)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
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.R
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
@@ -27,6 +29,7 @@ class PlaybackSession(
|
||||
var episodeId:String?,
|
||||
var mediaType:String,
|
||||
var mediaMetadata:MediaTypeMetadata,
|
||||
var deviceInfo:DeviceInfo,
|
||||
var chapters:List<BookChapter>,
|
||||
var displayTitle: String?,
|
||||
var displayAuthor: String?,
|
||||
@@ -137,8 +140,15 @@ class PlaybackSession(
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getCoverUri(): Uri {
|
||||
if (localLibraryItem?.coverContentUrl != null) return Uri.parse(localLibraryItem?.coverContentUrl) ?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||
fun getCoverUri(ctx:Context): Uri {
|
||||
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)
|
||||
return Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")
|
||||
@@ -152,6 +162,8 @@ class PlaybackSession(
|
||||
|
||||
@JsonIgnore
|
||||
fun getMediaMetadataCompat(ctx: Context): MediaMetadataCompat {
|
||||
val coverUri = getCoverUri(ctx)
|
||||
|
||||
val metadataBuilder = MediaMetadataCompat.Builder()
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_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_DISPLAY_DESCRIPTION, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getCoverUri().toString())
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, getCoverUri().toString())
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, getCoverUri().toString())
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString())
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, coverUri.toString())
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString())
|
||||
|
||||
// Local covers get bitmap
|
||||
if (localLibraryItem?.coverContentUrl != null) {
|
||||
val bitmap = if (Build.VERSION.SDK_INT < 28) {
|
||||
MediaStore.Images.Media.getBitmap(ctx.contentResolver, getCoverUri())
|
||||
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
|
||||
} else {
|
||||
val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, getCoverUri())
|
||||
val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, coverUri)
|
||||
ImageDecoder.decodeBitmap(source)
|
||||
}
|
||||
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
|
||||
@@ -182,7 +194,9 @@ class PlaybackSession(
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getExoMediaMetadata(audioTrack:AudioTrack): MediaMetadata {
|
||||
fun getExoMediaMetadata(ctx:Context): MediaMetadata {
|
||||
val coverUri = getCoverUri(ctx)
|
||||
|
||||
val metadataBuilder = MediaMetadata.Builder()
|
||||
.setTitle(displayTitle)
|
||||
.setDisplayTitle(displayTitle)
|
||||
@@ -191,20 +205,18 @@ class PlaybackSession(
|
||||
.setSubtitle(displayAuthor)
|
||||
.setAlbumTitle(displayAuthor)
|
||||
.setDescription(displayAuthor)
|
||||
.setArtworkUri(getCoverUri())
|
||||
|
||||
val contentUri = this.getContentUri(audioTrack)
|
||||
metadataBuilder.setMediaUri(contentUri)
|
||||
.setArtworkUri(coverUri)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_AUDIO_BOOK)
|
||||
|
||||
return metadataBuilder.build()
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getMediaItems():List<MediaItem> {
|
||||
fun getMediaItems(ctx:Context):List<MediaItem> {
|
||||
val mediaItems:MutableList<MediaItem> = mutableListOf()
|
||||
|
||||
for (audioTrack in audioTracks) {
|
||||
val mediaMetadata = this.getExoMediaMetadata(audioTrack)
|
||||
val mediaMetadata = this.getExoMediaMetadata(ctx)
|
||||
val mediaUri = this.getContentUri(audioTrack)
|
||||
val mimeType = audioTrack.mimeType
|
||||
|
||||
@@ -252,7 +264,7 @@ class PlaybackSession(
|
||||
|
||||
@JsonIgnore
|
||||
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
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
package com.audiobookshelf.app.device
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.MediaPlayerWidget
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.managers.DbManager
|
||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||
import com.audiobookshelf.app.updateAppWidget
|
||||
|
||||
interface WidgetEventEmitter {
|
||||
fun onPlayerChanged(pns:PlayerNotificationService)
|
||||
fun onPlayerClosed()
|
||||
}
|
||||
|
||||
object DeviceManager {
|
||||
@@ -76,4 +81,31 @@ object DeviceManager {
|
||||
deviceData.lastPlaybackSession = playbackSession
|
||||
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.getcapacitor.JSObject
|
||||
import org.json.JSONException
|
||||
import java.io.File
|
||||
|
||||
class FolderScanner(var ctx: Context) {
|
||||
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
|
||||
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 foldersFound = folderDf?.search(true, DocumentFileType.FOLDER) ?: mutableListOf()
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.*
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
interface InternalProgressCallback {
|
||||
fun onProgress(totalBytesWritten:Long, progress: Long)
|
||||
fun onComplete(failed: Boolean)
|
||||
}
|
||||
|
||||
companion object {
|
||||
var isDownloading:Boolean = false
|
||||
}
|
||||
@@ -65,11 +72,32 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
||||
|
||||
if (nextDownloadItemParts.size > 0) {
|
||||
nextDownloadItemParts.forEach {
|
||||
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)
|
||||
if (it.isInternalStorage) {
|
||||
val file = File(it.finalDestinationPath)
|
||||
file.parentFile?.mkdirs()
|
||||
|
||||
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
|
||||
|
||||
while (currentDownloadItemParts.size > 0) {
|
||||
|
||||
val itemParts = currentDownloadItemParts.filter { !it.isMoving }.map { it }
|
||||
for (downloadItemPart in itemParts) {
|
||||
val downloadCheckStatus = checkDownloadItemPart(downloadItemPart)
|
||||
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
|
||||
if (downloadItemPart.isInternalStorage) {
|
||||
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
|
||||
|
||||
// Will move to final destination, remove current item parts, and check if download item is finished
|
||||
handleDownloadItemPartCheck(downloadCheckStatus, downloadItemPart)
|
||||
if (downloadItemPart.completed) {
|
||||
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)
|
||||
@@ -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}")
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
} else if (downloadCheckStatus == DownloadCheckStatus.Successful) {
|
||||
|
||||
val file = DocumentFileCompat.fromUri(mainActivity, 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)
|
||||
} else {
|
||||
apiHandler.getAllItemsInProgress { itemsInProgress ->
|
||||
serverItemsInProgress = itemsInProgress
|
||||
serverItemsInProgress = itemsInProgress.filter {
|
||||
val libraryItem = it.libraryItemWrapper as LibraryItem
|
||||
libraryItem.checkHasTracks()
|
||||
}
|
||||
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) {
|
||||
if (libraryItemWrapper is LocalLibraryItem) {
|
||||
cb(libraryItemWrapper.getPlaybackSession(episode))
|
||||
cb(libraryItemWrapper.getPlaybackSession(episode, playItemRequestPayload.deviceInfo))
|
||||
} else {
|
||||
val libraryItem = libraryItemWrapper as LibraryItem
|
||||
apiHandler.playLibraryItem(libraryItem.id,episode?.id ?: "", playItemRequestPayload) {
|
||||
|
||||
@@ -38,8 +38,8 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
||||
var currentLocalMediaProgress: LocalMediaProgress? = null
|
||||
|
||||
private val currentDisplayTitle get() = currentPlaybackSession?.displayTitle ?: "Unset"
|
||||
private val currentIsLocal get() = currentPlaybackSession?.isLocal == true
|
||||
private val currentSessionId get() = currentPlaybackSession?.id ?: ""
|
||||
val currentIsLocal get() = currentPlaybackSession?.isLocal == true
|
||||
val currentSessionId get() = currentPlaybackSession?.id ?: ""
|
||||
private val currentPlaybackDuration get() = currentPlaybackSession?.duration ?: 0.0
|
||||
|
||||
fun start(playbackSession:PlaybackSession) {
|
||||
@@ -215,7 +215,6 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
||||
val listeningTimeToAdd = diffSinceLastSync / 1000L
|
||||
|
||||
val syncData = MediaProgressSyncData(listeningTimeToAdd,currentPlaybackDuration,currentTime)
|
||||
|
||||
currentPlaybackSession?.syncData(syncData)
|
||||
|
||||
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
|
||||
if (hasNetworkConnection && shouldSyncServer && !it.libraryItemId.isNullOrEmpty() && it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
|
||||
apiHandler.sendLocalProgressSync(it) { syncSuccess, errorMsg ->
|
||||
Log.d(
|
||||
tag,
|
||||
"Local progress sync data sent to server $currentDisplayTitle for time $currentTime"
|
||||
)
|
||||
|
||||
if (syncSuccess) {
|
||||
failedSyncs = 0
|
||||
playerNotificationService.alertSyncSuccess()
|
||||
|
||||
@@ -21,6 +21,9 @@ data class DownloadItem(
|
||||
val media: MediaType,
|
||||
val downloadItemParts: MutableList<DownloadItemPart>
|
||||
) {
|
||||
@get:JsonIgnore
|
||||
val isInternalStorage get() = localFolder.id.startsWith("internal-")
|
||||
|
||||
@get:JsonIgnore
|
||||
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
|
||||
fun getDownloadRequest(): DownloadManager.Request {
|
||||
val dlRequest = DownloadManager.Request(uri)
|
||||
|
||||
@@ -123,7 +123,7 @@ class CastManager constructor(val mainActivity:Activity) {
|
||||
}
|
||||
|
||||
fun startRouteScan(connListener:ChromecastListener) {
|
||||
var callback = object : ScanCallback() {
|
||||
val callback = object : ScanCallback() {
|
||||
override fun onRouteUpdate(routes: List<MediaRouter.RouteInfo>?) {
|
||||
Log.d(tag, "CAST On ROUTE UPDATED ${routes?.size} | ${getContext().castState}")
|
||||
// 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
|
||||
connListener.onReceiverAvailableUpdate(true)
|
||||
// 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 (session != null) {
|
||||
// Let the client know
|
||||
@@ -246,15 +246,15 @@ class CastManager constructor(val mainActivity:Activity) {
|
||||
onRouteUpdate(outRoutes)
|
||||
}
|
||||
|
||||
override fun onRouteAdded(router: MediaRouter?, route: MediaRouter.RouteInfo?) {
|
||||
override fun onRouteAdded(router: MediaRouter, route: MediaRouter.RouteInfo) {
|
||||
onFilteredRouteUpdate()
|
||||
}
|
||||
|
||||
override fun onRouteChanged(router: MediaRouter?, route: MediaRouter.RouteInfo?) {
|
||||
override fun onRouteChanged(router: MediaRouter, route: MediaRouter.RouteInfo) {
|
||||
onFilteredRouteUpdate()
|
||||
}
|
||||
|
||||
override fun onRouteRemoved(router: MediaRouter?, route: MediaRouter.RouteInfo?) {
|
||||
override fun onRouteRemoved(router: MediaRouter, route: MediaRouter.RouteInfo) {
|
||||
onFilteredRouteUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,12 +11,12 @@ import com.google.android.exoplayer2.*
|
||||
import com.google.android.exoplayer2.C.TrackType
|
||||
import com.google.android.exoplayer2.MediaMetadata
|
||||
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.ext.cast.SessionAvailabilityListener
|
||||
import com.google.android.exoplayer2.source.TrackGroup
|
||||
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.TrackSelectionArray
|
||||
import com.google.android.exoplayer2.trackselection.TrackSelectionParameters
|
||||
@@ -49,7 +49,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
private var myPlayWhenReady:Boolean = false
|
||||
private var playbackParameters:PlaybackParameters = PlaybackParameters.DEFAULT
|
||||
private var myAvailableCommands: Commands
|
||||
private var myCurrentTracksInfo: TracksInfo
|
||||
private var myCurrentTracksInfo: Tracks
|
||||
private var myCurrentTrackGroups: TrackGroupArray
|
||||
private var myCurrentTrackSelections: TrackSelectionArray
|
||||
|
||||
@@ -76,7 +76,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
COMMAND_GET_MEDIA_ITEMS_METADATA,
|
||||
COMMAND_SET_MEDIA_ITEMS_METADATA,
|
||||
COMMAND_CHANGE_MEDIA_ITEMS,
|
||||
COMMAND_GET_TRACK_INFOS)
|
||||
COMMAND_GET_TRACKS)
|
||||
.build()
|
||||
|
||||
var currentPlaybackState = Player.STATE_IDLE
|
||||
@@ -95,7 +95,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
Log.d(tag, "Init CastPlayer")
|
||||
myCurrentTrackGroups = TrackGroupArray.EMPTY
|
||||
myCurrentTrackSelections = EMPTY_TRACK_SELECTION_ARRAY
|
||||
myCurrentTracksInfo = TracksInfo.EMPTY
|
||||
myCurrentTracksInfo = Tracks.EMPTY
|
||||
statusListener = StatusListener()
|
||||
timelineTracker = CastTimelineTracker()
|
||||
myCurrentTimeline = CastTimeline.EMPTY_CAST_TIMELINE
|
||||
@@ -385,7 +385,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
}
|
||||
if (updateTracksAndSelectionsAndNotifyIfChanged()) {
|
||||
listeners.queueEvent(
|
||||
EVENT_TRACKS_CHANGED) { listener: Listener -> listener.onTracksInfoChanged(currentTracksInfo) }
|
||||
EVENT_TRACKS_CHANGED) { listener: Listener -> listener.onTracksChanged(currentTracks) }
|
||||
}
|
||||
updateAvailableCommandsAndNotifyIfChanged()
|
||||
listeners.flushEvents()
|
||||
@@ -405,7 +405,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
val hasChanged = !myCurrentTrackGroups.isEmpty
|
||||
myCurrentTrackGroups = TrackGroupArray.EMPTY
|
||||
myCurrentTrackSelections = EMPTY_TRACK_SELECTION_ARRAY
|
||||
myCurrentTracksInfo = TracksInfo.EMPTY
|
||||
myCurrentTracksInfo = Tracks.EMPTY
|
||||
return hasChanged
|
||||
}
|
||||
var activeTrackIds = mediaStatus.activeTrackIds
|
||||
@@ -414,7 +414,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
}
|
||||
val trackGroups = arrayOfNulls<TrackGroup>(castMediaTracks.size)
|
||||
val trackSelections = arrayOfNulls<TrackSelection>(RENDERER_COUNT)
|
||||
val trackGroupInfos = arrayOfNulls<TrackGroupInfo>(castMediaTracks.size)
|
||||
val trackGroupInfos = arrayOfNulls<Group>(castMediaTracks.size)
|
||||
for (i in castMediaTracks.indices) {
|
||||
val mediaTrack = castMediaTracks[i]
|
||||
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 trackSelected = booleanArrayOf(selected)
|
||||
trackGroupInfos[i] = TrackGroupInfo(trackGroups[i]!!, trackSupport, trackType, trackSelected)
|
||||
trackGroupInfos[i] = Group(trackGroups[i]!!, false, trackSupport, trackSelected)
|
||||
}
|
||||
|
||||
val tg = trackGroups.filterNotNull().toTypedArray()
|
||||
var ts = trackSelections.filterNotNull().toTypedArray()
|
||||
var tgi = trackGroupInfos.filterNotNull().toMutableList()
|
||||
val ts = trackSelections.filterNotNull().toTypedArray()
|
||||
val tgi = trackGroupInfos.filterNotNull().toMutableList()
|
||||
val newTrackGroups = TrackGroupArray(*tg)
|
||||
val newTrackSelections = TrackSelectionArray(*ts)
|
||||
val newTracksInfo = TracksInfo(tgi)
|
||||
if (newTrackGroups != currentTrackGroups
|
||||
|| newTrackSelections != currentTrackSelections
|
||||
|| newTracksInfo != currentTracksInfo) {
|
||||
val newTracksInfo = Tracks(tgi)
|
||||
if (newTracksInfo != currentTracks) {
|
||||
myCurrentTrackSelections = newTrackSelections
|
||||
myCurrentTrackGroups = newTrackGroups
|
||||
myCurrentTracksInfo = newTracksInfo
|
||||
@@ -593,7 +591,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
listeners.flushEvents()
|
||||
val pendingResult: PendingResult<RemoteMediaClient.MediaChannelResult> = if (playWhenReady) remoteMediaClient!!.play() else remoteMediaClient!!.pause()
|
||||
|
||||
var resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
|
||||
val resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
|
||||
updatePlayerStateAndNotifyIfChanged()
|
||||
}
|
||||
|
||||
@@ -622,10 +620,10 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
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")
|
||||
|
||||
var resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
|
||||
val resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
|
||||
val statusCode: Int = it.status.statusCode
|
||||
if (statusCode != CastStatusCodes.SUCCESS && statusCode != CastStatusCodes.REPLACED) {
|
||||
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
|
||||
// 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 (currentMediaItemIndex != 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)
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
|
||||
var resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
|
||||
val resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
|
||||
updatePlaybackRateAndNotifyIfChanged()
|
||||
listeners.flushEvents()
|
||||
}
|
||||
@@ -736,15 +734,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
sessionManager.endCurrentSession(false)
|
||||
}
|
||||
|
||||
override fun getCurrentTrackGroups(): TrackGroupArray {
|
||||
return this.myCurrentTrackGroups
|
||||
}
|
||||
|
||||
override fun getCurrentTrackSelections(): TrackSelectionArray {
|
||||
return this.myCurrentTrackSelections
|
||||
}
|
||||
|
||||
override fun getCurrentTracksInfo(): TracksInfo {
|
||||
override fun getCurrentTracks(): Tracks {
|
||||
return this.myCurrentTracksInfo
|
||||
}
|
||||
|
||||
@@ -858,8 +848,12 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
return VideoSize.UNKNOWN
|
||||
}
|
||||
|
||||
override fun getCurrentCues(): MutableList<Cue> {
|
||||
return mutableListOf()
|
||||
override fun getSurfaceSize(): Size {
|
||||
return Size.UNKNOWN
|
||||
}
|
||||
|
||||
override fun getCurrentCues(): CueGroup {
|
||||
return CueGroup.EMPTY_TIME_ZERO
|
||||
}
|
||||
|
||||
override fun getDeviceInfo(): DeviceInfo {
|
||||
|
||||
@@ -56,6 +56,9 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
|
||||
|
||||
lazyIsPlaying = isPlaying
|
||||
|
||||
// Update widget
|
||||
DeviceManager.widgetUpdater?.onPlayerChanged(playerNotificationService)
|
||||
|
||||
if (isPlaying) {
|
||||
Log.d(tag, "SeekBackTime: Player is playing")
|
||||
if (lastPauseTime > 0 && DeviceManager.deviceData.deviceSettings?.disableAutoRewind != true) {
|
||||
@@ -109,8 +112,6 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
|
||||
}
|
||||
|
||||
playerNotificationService.clientEventEmitter?.onPlayingUpdate(isPlaying)
|
||||
|
||||
DeviceManager.widgetUpdater?.onPlayerChanged(playerNotificationService)
|
||||
}
|
||||
|
||||
override fun onEvents(player: Player, events: Player.Events) {
|
||||
|
||||
+1
-5
@@ -44,11 +44,7 @@ class PlayerNotificationListener(var playerNotificationService:PlayerNotificatio
|
||||
} else {
|
||||
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.isClosed && !PlayerNotificationService.isSwitchingPlayer) {
|
||||
Log.d(tag, "PNS is not closed - closing it now")
|
||||
playerNotificationService.closePlayback()
|
||||
} else if (PlayerNotificationService.isSwitchingPlayer) {
|
||||
if (PlayerNotificationService.isSwitchingPlayer) {
|
||||
// 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
|
||||
Log.d(tag, "PNS is switching player")
|
||||
|
||||
+57
-22
@@ -2,8 +2,6 @@ package com.audiobookshelf.app.player
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.*
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
@@ -11,10 +9,7 @@ import android.graphics.Color
|
||||
import android.graphics.ImageDecoder
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.net.*
|
||||
import android.os.*
|
||||
import android.provider.MediaStore
|
||||
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.PlaybackStateCompat
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.widget.RemoteViews
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
@@ -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.CustomActionProvider
|
||||
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.ProgressiveMediaSource
|
||||
import com.google.android.exoplayer2.source.hls.HlsMediaSource
|
||||
@@ -203,6 +198,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
super.onCreate()
|
||||
ctx = this
|
||||
|
||||
// Initialize Paper
|
||||
DbManager.initialize(ctx)
|
||||
|
||||
// Initialize widget
|
||||
DeviceManager.initializeWidgetUpdater(ctx)
|
||||
|
||||
// To listen for network change from metered to unmetered
|
||||
val networkRequest = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
@@ -264,7 +265,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
playerNotificationManager.setUseNextAction(false)
|
||||
playerNotificationManager.setUsePreviousAction(false)
|
||||
playerNotificationManager.setUseChronometer(false)
|
||||
playerNotificationManager.setUseStopAction(true)
|
||||
playerNotificationManager.setUseStopAction(false)
|
||||
playerNotificationManager.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||
playerNotificationManager.setUseFastForwardActionInCompactView(true)
|
||||
@@ -288,10 +289,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
return MediaDescriptionCompat.Builder().build()
|
||||
}
|
||||
|
||||
val coverUri = currentPlaybackSession!!.getCoverUri()
|
||||
val coverUri = currentPlaybackSession!!.getCoverUri(ctx)
|
||||
|
||||
var bitmap:Bitmap? = null
|
||||
// Local covers get bitmap
|
||||
var bitmap: Bitmap? = null
|
||||
// Local covers get bitmap
|
||||
if (currentPlaybackSession!!.localLibraryItem?.coverContentUrl != null) {
|
||||
bitmap = if (Build.VERSION.SDK_INT < 28) {
|
||||
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
|
||||
@@ -362,7 +363,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
.build()
|
||||
mPlayer.setHandleAudioBecomingNoisy(true)
|
||||
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)
|
||||
|
||||
//attach player to playerNotificationManager
|
||||
@@ -394,7 +395,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
|
||||
val metadata = playbackSession.getMediaMetadataCompat(ctx)
|
||||
mediaSession.setMetadata(metadata)
|
||||
val mediaItems = playbackSession.getMediaItems()
|
||||
val mediaItems = playbackSession.getMediaItems(ctx)
|
||||
val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f
|
||||
initialPlaybackRate = playbackRate
|
||||
|
||||
@@ -438,12 +439,26 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
if (playbackSession.isLocal) {
|
||||
Log.d(tag, "Playing Local Item")
|
||||
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) {
|
||||
Log.d(tag, "Direct Playing Item")
|
||||
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)
|
||||
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0])
|
||||
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory).createMediaSource(mediaItems[0])
|
||||
} else {
|
||||
Log.d(tag, "Playing HLS Item")
|
||||
val dataSourceFactory = DefaultHttpDataSource.Factory()
|
||||
@@ -483,7 +498,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
}
|
||||
|
||||
private fun setMediaSessionConnectorCustomActions(playbackSession:PlaybackSession) {
|
||||
val mediaItems = playbackSession.getMediaItems()
|
||||
val mediaItems = playbackSession.getMediaItems(ctx)
|
||||
val customActionProviders = mutableListOf(
|
||||
JumpBackwardCustomActionProvider(),
|
||||
JumpForwardCustomActionProvider(),
|
||||
@@ -863,10 +878,26 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
|
||||
fun closePlayback(calledOnError:Boolean? = false) {
|
||||
Log.d(tag, "closePlayback")
|
||||
val isLocal = mediaProgressSyncer.currentIsLocal
|
||||
val currentSessionId = mediaProgressSyncer.currentSessionId
|
||||
if (mediaProgressSyncer.listeningTimerRunning) {
|
||||
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)
|
||||
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
|
||||
mediaProgressSyncer.reset()
|
||||
clientEventEmitter?.onPlaybackClosed()
|
||||
|
||||
PlayerListener.lastPauseTime = 0
|
||||
isClosed = true
|
||||
DeviceManager.widgetUpdater?.onPlayerClosed()
|
||||
stopForeground(Service.STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
@@ -896,7 +929,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
}
|
||||
|
||||
@SuppressLint("HardwareIds")
|
||||
private fun getDeviceInfo(): DeviceInfo {
|
||||
fun getDeviceInfo(): DeviceInfo {
|
||||
/* EXAMPLE
|
||||
manufacturer: Google
|
||||
model: Pixel 6
|
||||
@@ -905,7 +938,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
appVersion: 0.9.46-beta
|
||||
*/
|
||||
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()
|
||||
@@ -1008,10 +1041,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
val localBrowseItems:MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
|
||||
|
||||
localBooks.forEach { localLibraryItem ->
|
||||
val progress = DeviceManager.dbManager.getLocalMediaProgress(localLibraryItem.id)
|
||||
val description = localLibraryItem.getMediaDescription(progress, ctx)
|
||||
if (localLibraryItem.media.getAudioTracks().isNotEmpty()) {
|
||||
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 ->
|
||||
|
||||
@@ -201,7 +201,7 @@ class AbsAudioPlayer : Plugin() {
|
||||
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
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) {
|
||||
Log.d(tag, "prepareLibraryItem: Using start time override $startTimeOverride")
|
||||
playbackSession.currentTime = startTimeOverride
|
||||
|
||||
@@ -234,6 +234,8 @@ class AbsDatabase : Plugin() {
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ class AbsDownloader : Plugin() {
|
||||
val libraryItemId = call.data.getString("libraryItemId").toString()
|
||||
var episodeId = call.data.getString("episodeId").toString()
|
||||
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")
|
||||
|
||||
val downloadId = if (episodeId.isEmpty()) libraryItemId else "$libraryItemId-$episodeId"
|
||||
@@ -72,9 +72,18 @@ class AbsDownloader : Plugin() {
|
||||
} else {
|
||||
Log.d(tag, "Got library item from server ${libraryItem.id}")
|
||||
|
||||
val localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
|
||||
if (localFolder != null) {
|
||||
if (localFolderId == "") {
|
||||
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") {
|
||||
Log.e(tag, "Library item is not a podcast but episode was requested")
|
||||
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
|
||||
private fun getFilenameFromRelPath(relPath: String): String {
|
||||
var cleanedRelPath = relPath.replace("\\", "_").replace("/", "_")
|
||||
@@ -127,7 +130,9 @@ class AbsDownloader : Plugin() {
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
@@ -138,13 +143,13 @@ class AbsDownloader : Plugin() {
|
||||
val tracks = libraryItem.media.getAudioTracks()
|
||||
Log.d(tag, "Starting library item download with ${tracks.size} tracks")
|
||||
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 book = libraryItem.media as Book
|
||||
book.ebookFile?.let { ebookFile ->
|
||||
val fileSize = ebookFile.metadata?.size ?: 0
|
||||
val serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(ebookFile.metadata?.relPath ?: "")}"
|
||||
val serverPath = "/api/items/${libraryItem.id}/file/${ebookFile.ino}/download"
|
||||
val destinationFilename = getFilenameFromRelPath(ebookFile.metadata?.relPath ?: "")
|
||||
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||
val destinationFile = File("$tempFolderPath/$destinationFilename")
|
||||
@@ -164,9 +169,14 @@ class AbsDownloader : Plugin() {
|
||||
}
|
||||
|
||||
// Create download item part for each audio track
|
||||
val audioFiles = (libraryItem.media as Book).audioFiles ?: mutableListOf()
|
||||
tracks.forEach { audioTrack ->
|
||||
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)
|
||||
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 audioTrack = episode?.audioTrack
|
||||
val audioFileIno = episode?.audioFile?.ino
|
||||
val fileSize = audioTrack?.metadata?.size ?: 0
|
||||
|
||||
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 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 ?: "")
|
||||
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
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
@@ -165,6 +164,13 @@ class AbsFileSystem : Plugin() {
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getSDKVersion(call: PluginCall) {
|
||||
val jsObject = JSObject()
|
||||
jsObject.put("version", Build.VERSION.SDK_INT)
|
||||
call.resolve(jsObject)
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun scanFolder(call: PluginCall) {
|
||||
val folderId = call.data.getString("folderId", "").toString()
|
||||
@@ -227,35 +233,44 @@ class AbsFileSystem : Plugin() {
|
||||
val contentUrl = call.data.getString("contentUrl", "").toString()
|
||||
Log.d(tag, "deleteItem $absolutePath | $contentUrl")
|
||||
|
||||
var subfolderPathToDelete = ""
|
||||
|
||||
// Check if should delete subfolder
|
||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||
localLibraryItem?.folderId?.let { folderId ->
|
||||
val folder = DeviceManager.dbManager.getLocalFolder(folderId)
|
||||
folder?.absolutePath?.let { folderPath ->
|
||||
val splitAbsolutePath = absolutePath.split("/")
|
||||
val fullSubDir = splitAbsolutePath.subList(0, splitAbsolutePath.size - 1).joinToString("/")
|
||||
if (fullSubDir != folderPath) {
|
||||
val subdirHasAnItem = DeviceManager.dbManager.getLocalLibraryItems().any { _localLibraryItem ->
|
||||
if (_localLibraryItem.id == localLibraryItemId) {
|
||||
false
|
||||
} else {
|
||||
_localLibraryItem.absolutePath.startsWith(fullSubDir)
|
||||
|
||||
val success: Boolean
|
||||
|
||||
// If internal library item use File to delete
|
||||
if (localLibraryItem?.folderId?.startsWith("internal-") == true) {
|
||||
Log.d(tag, "Deleting internal library item at absolutePath $absolutePath")
|
||||
val file = File(absolutePath)
|
||||
success = file.deleteRecursively()
|
||||
} else {
|
||||
var subfolderPathToDelete = ""
|
||||
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 success = docfile?.delete() == true
|
||||
val docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
|
||||
success = docfile?.delete() == true
|
||||
|
||||
if (subfolderPathToDelete != "") {
|
||||
Log.d(tag, "Deleting empty subfolder at $subfolderPathToDelete")
|
||||
val docfilesub = DocumentFileCompat.fromFullPath(mainActivity, subfolderPathToDelete)
|
||||
docfilesub?.delete()
|
||||
if (subfolderPathToDelete != "") {
|
||||
Log.d(tag, "Deleting empty subfolder at $subfolderPathToDelete")
|
||||
val docfilesub = DocumentFileCompat.fromFullPath(mainActivity, subfolderPathToDelete)
|
||||
docfilesub?.delete()
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import com.getcapacitor.JSObject
|
||||
import okhttp3.*
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.internal.EMPTY_REQUEST
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
@@ -45,11 +46,11 @@ class ApiHandler(var ctx:Context) {
|
||||
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 token = config?.token ?: DeviceManager.token
|
||||
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"
|
||||
Log.d(tag, "postRequest to $requestUrl")
|
||||
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) {
|
||||
Log.d(tag, "authorize: Authorizing ${config.address}")
|
||||
postRequest("/api/authorize", JSObject(), config) {
|
||||
@@ -326,11 +334,11 @@ class ApiHandler(var ctx:Context) {
|
||||
if (localSessionSyncResult.progressSynced == true) {
|
||||
val syncResult = SyncResult(true, true, "Progress synced on server")
|
||||
MediaEventManager.saveEvent(session, syncResult)
|
||||
DeviceManager.dbManager.removePlaybackSession(session.id)
|
||||
Log.i(tag, "Successfully synced session ${session.displayTitle} with server")
|
||||
} else if (!localSessionSyncResult.success) {
|
||||
Log.e(tag, "Failed to sync session ${session.displayTitle} with server. Error: ${localSessionSyncResult.error}")
|
||||
}
|
||||
DeviceManager.dbManager.removePlaybackSession(session.id)
|
||||
}
|
||||
}
|
||||
cb(true, null)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-path name="my_images" path="." />
|
||||
<cache-path name="my_cache_images" path="." />
|
||||
</paths>
|
||||
<paths>
|
||||
<files-path name="downloads" path="downloads/" />
|
||||
</paths>
|
||||
|
||||
@@ -5,6 +5,9 @@ project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/
|
||||
include ':capacitor-app'
|
||||
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'
|
||||
project(':capacitor-dialog').projectDir = new File('../node_modules/@capacitor/dialog/android')
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
ext {
|
||||
minSdkVersion = 24
|
||||
compileSdkVersion = 33
|
||||
targetSdkVersion = 32
|
||||
targetSdkVersion = 33
|
||||
androidxActivityVersion = '1.4.0'
|
||||
androidxAppCompatVersion = '1.4.2'
|
||||
androidxCoordinatorLayoutVersion = '1.2.0'
|
||||
@@ -20,7 +20,7 @@ ext {
|
||||
arch_lifecycle_version = '2.2.0'
|
||||
constraint_layout_version = '2.0.1'
|
||||
espresso_version = '3.3.0'
|
||||
exoplayer_version = '2.17.0'
|
||||
exoplayer_version = '2.18.7'
|
||||
fragment_version = '1.2.5'
|
||||
glide_version = '4.11.0'
|
||||
gms_strict_version_matcher_version = '1.0.3'
|
||||
|
||||
@@ -143,4 +143,27 @@ Bookshelf Label
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-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;
|
||||
}
|
||||
@@ -79,7 +79,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
castClick() {
|
||||
if (this.$store.state.playerIsLocal) {
|
||||
if (this.$store.getters['getIsCurrentSessionLocal']) {
|
||||
this.$eventBus.$emit('cast-local-item')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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="bufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||
<div ref="trackCursor" class="h-3.5 w-3.5 -top-1 rounded-full bg-gray-200 absolute pointer-events-auto" :class="{ 'opacity-0': lockUi || !showFullscreen }" @touchstart="touchstartCursor" />
|
||||
<div 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>
|
||||
|
||||
<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" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -272,7 +274,7 @@ export default {
|
||||
return this.playbackSession ? this.playbackSession.localLibraryItem || null : null
|
||||
},
|
||||
localLibraryItemCoverSrc() {
|
||||
var localItemCover = this.localLibraryItem ? this.localLibraryItem.coverContentUrl : null
|
||||
var localItemCover = this.localLibraryItem?.coverContentUrl || null
|
||||
if (localItemCover) return Capacitor.convertFileSrc(localItemCover)
|
||||
return null
|
||||
},
|
||||
@@ -551,7 +553,7 @@ export default {
|
||||
this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px'
|
||||
|
||||
if (this.$refs.trackCursor) {
|
||||
this.$refs.trackCursor.style.left = ptWidth - 7 + 'px'
|
||||
this.$refs.trackCursor.style.left = ptWidth - 14 + 'px'
|
||||
}
|
||||
|
||||
if (this.useChapterTrack) {
|
||||
@@ -572,8 +574,8 @@ export default {
|
||||
AbsAudioPlayer.seek({ value: Math.floor(time) })
|
||||
|
||||
if (this.$refs.playedTrack) {
|
||||
var perc = time / this.totalDuration
|
||||
var ptWidth = Math.round(perc * this.trackWidth)
|
||||
const perc = time / this.totalDuration
|
||||
const ptWidth = Math.round(perc * this.trackWidth)
|
||||
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
||||
|
||||
this.$refs.playedTrack.classList.remove('bg-gray-200')
|
||||
@@ -693,6 +695,7 @@ export default {
|
||||
this.updateTimestamp()
|
||||
this.updateTrack()
|
||||
this.updateReadyTrack()
|
||||
this.$localStore.setUseTotalTrack(this.useTotalTrack)
|
||||
this.$localStore.setUseChapterTrack(this.useChapterTrack)
|
||||
} else if (action === 'total_track') {
|
||||
this.useTotalTrack = !this.useTotalTrack
|
||||
@@ -702,6 +705,7 @@ export default {
|
||||
this.updateTrack()
|
||||
this.updateReadyTrack()
|
||||
this.$localStore.setUseTotalTrack(this.useTotalTrack)
|
||||
this.$localStore.setUseChapterTrack(this.useChapterTrack)
|
||||
} else if (action === 'close') {
|
||||
this.closePlayback()
|
||||
}
|
||||
@@ -717,7 +721,7 @@ export default {
|
||||
AbsAudioPlayer.closePlayback()
|
||||
},
|
||||
endPlayback() {
|
||||
this.$store.commit('setPlayerItem', null)
|
||||
this.$store.commit('setPlaybackSession', null)
|
||||
this.showFullscreen = false
|
||||
this.isEnded = false
|
||||
this.isLoading = false
|
||||
@@ -763,7 +767,7 @@ export default {
|
||||
this.isEnded = false
|
||||
this.isLoading = true
|
||||
this.syncStatus = 0
|
||||
this.$store.commit('setPlayerItem', this.playbackSession)
|
||||
this.$store.commit('setPlaybackSession', this.playbackSession)
|
||||
|
||||
// Set track width
|
||||
this.$nextTick(() => {
|
||||
@@ -790,6 +794,7 @@ export default {
|
||||
},
|
||||
async init() {
|
||||
this.useChapterTrack = await this.$localStore.getUseChapterTrack()
|
||||
this.useTotalTrack = await this.$localStore.getUseTotalTrack()
|
||||
this.lockUi = await this.$localStore.getPlayerLock()
|
||||
|
||||
this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession)
|
||||
|
||||
@@ -110,7 +110,7 @@ export default {
|
||||
},
|
||||
streamProgress(data) {
|
||||
if (!data.numSegments) return
|
||||
var chunks = data.chunks
|
||||
const chunks = data.chunks
|
||||
if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
|
||||
}
|
||||
@@ -265,8 +265,7 @@ export default {
|
||||
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
|
||||
},
|
||||
onMediaPlayerChanged(data) {
|
||||
var mediaPlayer = data.value
|
||||
this.$store.commit('setMediaPlayer', mediaPlayer)
|
||||
this.$store.commit('setMediaPlayer', data.value)
|
||||
},
|
||||
onReady() {
|
||||
// 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
|
||||
AbsAudioPlayer.onReady()
|
||||
}
|
||||
},
|
||||
playbackTimeUpdate(currentTime) {
|
||||
this.$refs.audioPlayer?.seek(currentTime)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@@ -300,6 +302,7 @@ export default {
|
||||
this.$eventBus.$on('close-stream', this.closeStreamOnly)
|
||||
this.$eventBus.$on('cast-local-item', this.castLocalItem)
|
||||
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
||||
this.$eventBus.$on('playback-time-update', this.playbackTimeUpdate)
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.onLocalMediaProgressUpdateListener) this.onLocalMediaProgressUpdateListener.remove()
|
||||
@@ -313,6 +316,7 @@ export default {
|
||||
this.$eventBus.$off('close-stream', this.closeStreamOnly)
|
||||
this.$eventBus.$off('cast-local-item', this.castLocalItem)
|
||||
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
||||
this.$eventBus.$off('playback-time-update', this.playbackTimeUpdate)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -164,7 +164,7 @@ export default {
|
||||
|
||||
const entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `items` : this.entityName
|
||||
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) => {
|
||||
console.error('failed to fetch books', error)
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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` }">
|
||||
<span class="material-icons text-2xl text-success">{{ isLocalOnly ? 'task' : 'download_done' }}</span>
|
||||
</div>
|
||||
@@ -56,6 +57,11 @@
|
||||
<span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span>
|
||||
</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 -->
|
||||
<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>
|
||||
@@ -212,7 +218,7 @@ export default {
|
||||
return this.mediaMetadata.series
|
||||
},
|
||||
seriesSequence() {
|
||||
return this.series ? this.series.sequence : null
|
||||
return this.series?.sequence || null
|
||||
},
|
||||
recentEpisode() {
|
||||
// Only added to item when getting currently listening podcasts
|
||||
@@ -231,14 +237,14 @@ export default {
|
||||
},
|
||||
booksInSeries() {
|
||||
// Only added to item object when collapseSeries is enabled
|
||||
return this.collapsedSeries ? this.collapsedSeries.numBooks : 0
|
||||
return this.collapsedSeries?.numBooks || 0
|
||||
},
|
||||
seriesSequenceList() {
|
||||
return this.collapsedSeries ? this.collapsedSeries.seriesSequenceList : null
|
||||
return this.collapsedSeries?.seriesSequenceList || null
|
||||
},
|
||||
libraryItemIdsInSeries() {
|
||||
// Only added to item object when collapseSeries is enabled
|
||||
return this.collapsedSeries ? this.collapsedSeries.libraryItemIds || [] : []
|
||||
return this.collapsedSeries?.libraryItemIds || []
|
||||
},
|
||||
displayTitle() {
|
||||
if (this.recentEpisode) return this.recentEpisode.title
|
||||
@@ -282,28 +288,26 @@ export default {
|
||||
},
|
||||
userProgressPercent() {
|
||||
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() {
|
||||
return this.userProgress ? !!this.userProgress.isFinished : false
|
||||
return !!this.userProgress?.isFinished
|
||||
},
|
||||
showError() {
|
||||
return this.numMissingParts || this.isMissing || this.isInvalid
|
||||
},
|
||||
playerIsLocal() {
|
||||
return !!this.$store.state.playerIsLocal
|
||||
},
|
||||
localLibraryItemId() {
|
||||
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() {
|
||||
if (this.isPodcast) {
|
||||
if (this.playerIsLocal) {
|
||||
// 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)
|
||||
return this.$store.getters['getIsMediaStreaming'](this.libraryItemId, this.recentEpisode.id)
|
||||
} else {
|
||||
return false // not yet necessary for books
|
||||
}
|
||||
@@ -379,13 +383,16 @@ export default {
|
||||
showHasLocalDownload() {
|
||||
if (this.localLibraryItem || this.isLocal) {
|
||||
if (this.recentEpisode && !this.isLocal) {
|
||||
const localEpisode = this.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId === this.recentEpisode.id)
|
||||
return !!localEpisode
|
||||
return !!this.localEpisode
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
rssFeed() {
|
||||
if (this.booksInSeries) return null
|
||||
return this._libraryItem.rssFeed || null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -435,18 +442,15 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.localLibraryItem) {
|
||||
const localEpisode = this.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId === this.recentEpisode.id)
|
||||
if (localEpisode) {
|
||||
// Play episode locally
|
||||
eventBus.$emit('play-item', {
|
||||
libraryItemId: this.localLibraryItemId,
|
||||
episodeId: localEpisode.id,
|
||||
serverLibraryItemId: this.libraryItemId,
|
||||
serverEpisodeId: this.recentEpisode.id
|
||||
})
|
||||
return
|
||||
}
|
||||
if (this.localEpisode) {
|
||||
// Play episode locally
|
||||
eventBus.$emit('play-item', {
|
||||
libraryItemId: this.localLibraryItemId,
|
||||
episodeId: this.localEpisode.id,
|
||||
serverLibraryItemId: this.libraryItemId,
|
||||
serverEpisodeId: this.recentEpisode.id
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
eventBus.$emit('play-item', { libraryItemId: this.libraryItemId, episodeId: this.recentEpisode.id })
|
||||
@@ -468,25 +472,6 @@ export default {
|
||||
editClick() {
|
||||
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() {
|
||||
this.rescanning = true
|
||||
this.$axios
|
||||
|
||||
@@ -349,25 +349,6 @@ export default {
|
||||
editClick() {
|
||||
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() {
|
||||
this.rescanning = true
|
||||
this.$axios
|
||||
|
||||
@@ -70,7 +70,7 @@ export default {
|
||||
return this.libraryItem.isLocal
|
||||
},
|
||||
localCover() {
|
||||
return this.libraryItem ? this.libraryItem.coverContentUrl : null
|
||||
return this.libraryItem?.coverContentUrl || null
|
||||
},
|
||||
squareAspectRatio() {
|
||||
return this.bookCoverAspectRatio === 1
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="350" height="100%">
|
||||
<modals-modal v-model="show" :width="400" height="100%">
|
||||
<template #outer>
|
||||
<div v-if="currentChapter" class="absolute top-8 left-4 z-40 pt-0.5" style="max-width: 80%">
|
||||
<p class="text-white text-lg truncate">Current: {{ currentChapterTitle }}</p>
|
||||
<div v-if="currentChapter" class="absolute top-10 left-4 z-40 pt-1" style="max-width: 80%">
|
||||
<p class="text-white text-lg truncate">{{ chapters.length }} Chapters</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||
<div 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">
|
||||
<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">
|
||||
<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">
|
||||
<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>
|
||||
|
||||
@@ -38,14 +38,17 @@ export default {
|
||||
currentChapter: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
playbackRate: Number
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
watch: {
|
||||
value(newVal) {
|
||||
this.$nextTick(this.scrollToChapter)
|
||||
if (newVal) {
|
||||
this.$nextTick(this.scrollToChapter)
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -57,11 +60,15 @@ export default {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
_playbackRate() {
|
||||
if (!this.playbackRate || isNaN(this.playbackRate)) return 1
|
||||
return this.playbackRate
|
||||
},
|
||||
currentChapterId() {
|
||||
return this.currentChapter ? this.currentChapter.id : null
|
||||
return this.currentChapter?.id
|
||||
},
|
||||
currentChapterTitle() {
|
||||
return this.currentChapter ? this.currentChapter.title : null
|
||||
return this.currentChapter?.title || null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -71,12 +78,12 @@ export default {
|
||||
scrollToChapter() {
|
||||
if (!this.currentChapterId) return
|
||||
|
||||
var container = this.$refs.container
|
||||
const container = this.$refs.container
|
||||
if (container) {
|
||||
var currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
|
||||
const currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
|
||||
if (currChapterEl) {
|
||||
var offsetTop = currChapterEl.offsetTop
|
||||
var containerHeight = container.clientHeight
|
||||
const offsetTop = currChapterEl.offsetTop
|
||||
const containerHeight = container.clientHeight
|
||||
container.scrollTo({ top: offsetTop - containerHeight / 2 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="300" height="100%">
|
||||
<modals-modal v-model="show" :width="width" height="100%">
|
||||
<template #outer>
|
||||
<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>
|
||||
@@ -7,11 +7,11 @@
|
||||
</template>
|
||||
|
||||
<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">
|
||||
<template v-for="item in items">
|
||||
<template v-for="item in itemsToShow">
|
||||
<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">
|
||||
<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>
|
||||
@@ -34,11 +34,27 @@ export default {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
selected: String // optional
|
||||
selected: [String, Number], // optional
|
||||
itemPaddingY: {
|
||||
type: String,
|
||||
default: '16px'
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
default: 300
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) this.$nextTick(this.init)
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
@@ -47,11 +63,32 @@ export default {
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
itemsToShow() {
|
||||
return this.items.map((i) => {
|
||||
if (typeof i === 'string') {
|
||||
return {
|
||||
text: i,
|
||||
value: i
|
||||
}
|
||||
}
|
||||
return i
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickedOption(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() {}
|
||||
|
||||
@@ -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>
|
||||
@@ -7,10 +7,10 @@
|
||||
</template>
|
||||
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" 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">
|
||||
<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 class="flex items-center px-3">
|
||||
<ui-library-icon :icon="library.icon" />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div ref="wrapper" class="modal modal-bg w-full h-full max-h-screen fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-50 opacity-0">
|
||||
<div class="absolute top-0 left-0 w-full h-40 bg-gradient-to-b from-black to-transparent opacity-90 pointer-events-none" />
|
||||
|
||||
<div class="absolute z-40 top-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>
|
||||
</div>
|
||||
<slot name="outer" />
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="300" height="100%">
|
||||
<template #outer>
|
||||
<div class="absolute top-8 left-4 z-40" style="max-width: 80%">
|
||||
<p class="text-white text-lg truncate">Select Local Folder</p>
|
||||
<div class="absolute top-10 left-4 z-40" style="max-width: 80%">
|
||||
<p class="text-white text-lg truncate">Select Download Location</p>
|
||||
</div>
|
||||
</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>
|
||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
</li>
|
||||
@@ -24,36 +25,56 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
mediaType: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
localFolders: []
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value(newVal) {
|
||||
this.$nextTick(this.init)
|
||||
show(newVal) {
|
||||
if (newVal) {
|
||||
this.$nextTick(this.init)
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
return this.$store.state.globals.showSelectLocalFolderModal
|
||||
},
|
||||
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: {
|
||||
clickedOption(folder) {
|
||||
this.$emit('select', folder)
|
||||
this.show = false
|
||||
if (!this.callback) {
|
||||
console.error('Callback not set')
|
||||
return
|
||||
}
|
||||
this.callback(folder)
|
||||
},
|
||||
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)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,41 +1,49 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="360" height="100%" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-8 left-4 z-40">
|
||||
<p class="text-white text-2xl truncate">Add to Playlist</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||
<div 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>
|
||||
<modals-fullscreen-modal v-model="show" :processing="processing">
|
||||
<div class="flex items-end justify-between h-16 px-4 pb-2">
|
||||
<h1 class="text-lg">Add to Playlist</h1>
|
||||
<button class="flex" @click="show = false">
|
||||
<span class="material-icons">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ui-text-input-with-label v-model="newPlaylistName" label="Name" />
|
||||
<div class="flex justify-end mt-6">
|
||||
<ui-btn color="success" :loading="processing" class="w-full" @click.stop="submitCreatePlaylist">Create</ui-btn>
|
||||
<!-- create new playlist form -->
|
||||
<div v-if="showPlaylistNameInput" class="w-full h-full max-h-[calc(100vh-128px)] flex items-center">
|
||||
<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>
|
||||
<p class="text-xl pl-2">New Playlist</p>
|
||||
<div class="flex-grow" />
|
||||
</div>
|
||||
<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" />
|
||||
</template>
|
||||
<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>
|
||||
|
||||
<ui-text-input-with-label v-model="newPlaylistName" label="Name" />
|
||||
<div class="flex justify-end mt-6">
|
||||
<ui-btn color="success" :loading="processing" class="w-full" @click.stop="submitCreatePlaylist">Create</ui-btn>
|
||||
</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>
|
||||
|
||||
<script>
|
||||
@@ -131,7 +139,6 @@ export default {
|
||||
.$post(`/api/playlists/${playlist.id}/batch/remove`, { items: itemObjects })
|
||||
.then((updatedPlaylist) => {
|
||||
console.log(`Items removed from playlist`, updatedPlaylist)
|
||||
this.$toast.success('Playlist item(s) removed')
|
||||
})
|
||||
.catch((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 })
|
||||
.then((updatedPlaylist) => {
|
||||
console.log(`Items added to playlist`, updatedPlaylist)
|
||||
this.$toast.success('Items added to playlist')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to add items to playlist', error)
|
||||
@@ -182,7 +188,6 @@ export default {
|
||||
.$post('/api/playlists', newPlaylist)
|
||||
.then((data) => {
|
||||
console.log('New playlist created', data)
|
||||
this.$toast.success(`Playlist "${data.name}" created`)
|
||||
this.newPlaylistName = ''
|
||||
this.showPlaylistNameInput = false
|
||||
})
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
<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 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" />
|
||||
</div>
|
||||
<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 class="absolute top-0 right-0 h-full flex items-center px-3" @click.stop="click">
|
||||
<span v-if="inPlaylist" class="material-icons text-error">remove</span>
|
||||
<span v-else class="material-icons text-success">add</span>
|
||||
<div class="w-24 min-w-[96px] px-1">
|
||||
<ui-btn v-if="inPlaylist" small class="w-full" @click.stop="click">Remove</ui-btn>
|
||||
<ui-btn v-else small class="w-full" @click.stop="click">Add</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -35,6 +34,10 @@ export default {
|
||||
methods: {
|
||||
click() {
|
||||
this.$emit('click', this.playlist)
|
||||
},
|
||||
clickCover() {
|
||||
this.$emit('close')
|
||||
this.$router.push(`/playlist/${this.playlist.id}`)
|
||||
}
|
||||
},
|
||||
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>
|
||||
@@ -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>
|
||||
@@ -1,28 +1,17 @@
|
||||
<template>
|
||||
<div id="comic-reader" class="w-full h-full relative">
|
||||
<div v-show="showPageMenu" v-click-outside="clickOutsideObj" class="pagemenu absolute top-12 right-16 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-52">
|
||||
<div v-for="(file, index) in pages" :key="file" class="w-full cursor-pointer hover:bg-black-200 px-2 py-1" :class="page === index ? 'bg-black-200' : ''" @click.stop="setPage(index)">
|
||||
<p class="text-sm truncate">{{ file }}</p>
|
||||
<modals-modal v-model="showInfoMenu" height="90%">
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click.stop="showInfoMenu = false">
|
||||
<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 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>
|
||||
</modals-modal>
|
||||
|
||||
<div class="overflow-hidden m-auto comicwrapper relative">
|
||||
<div class="h-full flex justify-center">
|
||||
@@ -33,6 +22,13 @@
|
||||
<ui-loading-indicator />
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -47,7 +43,13 @@ Archive.init({
|
||||
|
||||
export default {
|
||||
props: {
|
||||
url: String
|
||||
url: String,
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
isLocal: Boolean,
|
||||
keepProgress: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -62,11 +64,7 @@ export default {
|
||||
loadTimeout: null,
|
||||
loadedFirstPage: false,
|
||||
comicMetadata: null,
|
||||
clickOutsideObj: {
|
||||
handler: this.clickedOutside,
|
||||
events: ['mousedown'],
|
||||
isActive: true
|
||||
}
|
||||
pageMenuWidth: 256
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -78,31 +76,127 @@ export default {
|
||||
}
|
||||
},
|
||||
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() {
|
||||
return this.comicMetadata ? Object.keys(this.comicMetadata) : []
|
||||
},
|
||||
canGoNext() {
|
||||
return this.page < this.numPages - 1
|
||||
return this.page < this.numPages
|
||||
},
|
||||
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: {
|
||||
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() {
|
||||
this.showInfoMenu = !this.showInfoMenu
|
||||
this.showPageMenu = false
|
||||
},
|
||||
clickShowPageMenu() {
|
||||
if (!this.numPages) return
|
||||
this.showPageMenu = !this.showPageMenu
|
||||
this.showInfoMenu = false
|
||||
},
|
||||
clickedOutside() {
|
||||
this.showPageMenu = false
|
||||
},
|
||||
clickedOutsideInfoMenu() {
|
||||
this.showInfoMenu = false
|
||||
},
|
||||
next() {
|
||||
if (!this.canGoNext) return
|
||||
this.setPage(this.page + 1)
|
||||
@@ -111,13 +205,15 @@ export default {
|
||||
if (!this.canGoPrev) return
|
||||
this.setPage(this.page - 1)
|
||||
},
|
||||
setPage(index) {
|
||||
if (index < 0 || index > this.numPages - 1) {
|
||||
setPage(page) {
|
||||
if (page <= 0 || page > this.numPages) {
|
||||
return
|
||||
}
|
||||
this.showPageMenu = false
|
||||
const filename = this.pages[index]
|
||||
this.page = index
|
||||
const filename = this.pages[page - 1]
|
||||
this.page = page
|
||||
|
||||
this.updateProgress()
|
||||
return this.extractFile(filename)
|
||||
},
|
||||
setLoadTimeout() {
|
||||
@@ -147,11 +243,14 @@ export default {
|
||||
},
|
||||
async extract() {
|
||||
this.loading = true
|
||||
console.log('Extracting', this.url)
|
||||
|
||||
var buff = await this.$axios.$get(this.url, {
|
||||
responseType: 'blob'
|
||||
const buff = await this.$axios.$get(this.url, {
|
||||
responseType: 'blob',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.userToken}`
|
||||
}
|
||||
})
|
||||
|
||||
const archive = await Archive.open(buff)
|
||||
const originalFilesObject = await archive.getFilesObject()
|
||||
// to support images in subfolders we need to flatten the object
|
||||
@@ -166,10 +265,33 @@ export default {
|
||||
|
||||
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) {
|
||||
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.$emit('loaded', {
|
||||
hasMetadata: this.comicMetadata
|
||||
})
|
||||
} else {
|
||||
this.$toast.error('Unable to extract pages')
|
||||
this.loading = false
|
||||
@@ -196,7 +318,6 @@ export default {
|
||||
return flattenObject(filesObject)
|
||||
},
|
||||
async extractXmlFile(filename) {
|
||||
console.log('extracting xml filename', filename)
|
||||
try {
|
||||
var file = await this.filesObject[filename].extract()
|
||||
var reader = new FileReader()
|
||||
@@ -261,9 +382,6 @@ export default {
|
||||
padding-top: 36px;
|
||||
}
|
||||
|
||||
.pagemenu {
|
||||
max-height: calc(100% - 80px);
|
||||
}
|
||||
.comicimg {
|
||||
height: 100%;
|
||||
margin: auto;
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
<div id="epub-frame" class="w-full">
|
||||
<div id="viewer" class="h-full w-full"></div>
|
||||
|
||||
<div class="fixed left-0 h-8 w-full bg-bg px-2 flex items-center" :style="{ bottom: playerLibraryItemId ? '120px' : '0px' }">
|
||||
<p class="text-xs">epub</p>
|
||||
<div class="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' }">
|
||||
<div class="flex-grow" />
|
||||
|
||||
<p class="text-sm">{{ progress }}%</p>
|
||||
<p class="text-xs">{{ progress }}%</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -21,7 +19,8 @@ export default {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
isLocal: Boolean
|
||||
isLocal: Boolean,
|
||||
keepProgress: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -29,15 +28,23 @@ export default {
|
||||
book: null,
|
||||
/** @type {ePub.Rendition} */
|
||||
rendition: null,
|
||||
progress: 0
|
||||
progress: 0,
|
||||
ereaderSettings: {
|
||||
theme: 'dark',
|
||||
fontScale: 100,
|
||||
lineSpacing: 115
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
playerLibraryItemId() {
|
||||
isPlayerOpen() {
|
||||
this.updateHeight()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
/** @returns {string} */
|
||||
libraryItemId() {
|
||||
return this.libraryItem?.id
|
||||
@@ -58,15 +65,15 @@ export default {
|
||||
}
|
||||
return null
|
||||
},
|
||||
playerLibraryItemId() {
|
||||
return this.$store.state.playerLibraryItemId
|
||||
isPlayerOpen() {
|
||||
return this.$store.getters['getIsPlayerOpen']
|
||||
},
|
||||
readerHeightOffset() {
|
||||
return this.playerLibraryItemId ? 196 : 96
|
||||
return this.isPlayerOpen ? 204 : 104
|
||||
},
|
||||
/** @returns {Array<ePub.NavItem>} */
|
||||
chapters() {
|
||||
return this.book ? this.book.navigation.toc : []
|
||||
return this.book?.navigation?.toc || []
|
||||
},
|
||||
userItemProgress() {
|
||||
if (this.isLocal) return this.localItemProgress
|
||||
@@ -80,9 +87,53 @@ export default {
|
||||
},
|
||||
localStorageLocationsKey() {
|
||||
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: {
|
||||
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() {
|
||||
if (this.rendition && this.rendition.resize) {
|
||||
this.rendition.resize(window.innerWidth, window.innerHeight - this.readerHeightOffset)
|
||||
@@ -104,6 +155,8 @@ export default {
|
||||
* @param {string} payload.ebookProgress - eBook Progress Percentage
|
||||
*/
|
||||
async updateProgress(payload) {
|
||||
if (!this.keepProgress) return
|
||||
|
||||
// Update local item
|
||||
if (this.localLibraryItemId) {
|
||||
const localPayload = {
|
||||
@@ -210,7 +263,7 @@ export default {
|
||||
},
|
||||
/** @param {string} location - CFI of the new location */
|
||||
relocated(location) {
|
||||
if (this.userItemProgress?.ebookLocation === location.start.cfi) {
|
||||
if (this.savedEbookLocation === location.start.cfi) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -231,11 +284,15 @@ export default {
|
||||
|
||||
/** @type {EpubReader} */
|
||||
const reader = this
|
||||
|
||||
console.log('initEpub', reader.url)
|
||||
/** @type {ePub.Book} */
|
||||
reader.book = new ePub(reader.url, {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight - this.readerHeightOffset
|
||||
height: window.innerHeight - this.readerHeightOffset,
|
||||
openAs: 'epub',
|
||||
requestHeaders: {
|
||||
Authorization: `Bearer ${this.userToken}`
|
||||
}
|
||||
})
|
||||
|
||||
/** @type {ePub.Rendition} */
|
||||
@@ -247,16 +304,33 @@ export default {
|
||||
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(() => {
|
||||
// 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
|
||||
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
|
||||
const savedLocations = this.loadLocations()
|
||||
if (savedLocations) {
|
||||
@@ -267,6 +341,12 @@ export default {
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
applyTheme() {
|
||||
if (!this.rendition) return
|
||||
this.rendition.getContents().forEach((c) => {
|
||||
c.addStylesheetRules(this.themeRules)
|
||||
})
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
<template>
|
||||
<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>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -17,14 +13,18 @@ import defaultCss from '@/assets/ebooks/basic.js'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
url: String
|
||||
url: String,
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
playerLibraryItemId() {
|
||||
return this.$store.state.playerLibraryItemId
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -85,7 +85,10 @@ export default {
|
||||
async initMobi() {
|
||||
// Fetch mobi file as blob
|
||||
var buff = await this.$axios.$get(this.url, {
|
||||
responseType: 'blob'
|
||||
responseType: 'blob',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.userToken}`
|
||||
}
|
||||
})
|
||||
var reader = new FileReader()
|
||||
reader.onload = async (event) => {
|
||||
@@ -119,14 +122,14 @@ export default {
|
||||
|
||||
<style>
|
||||
.mobi-ebook-viewer {
|
||||
height: calc(100% - 52px);
|
||||
max-height: calc(100% - 52px);
|
||||
height: calc(100% - 32px);
|
||||
max-height: calc(100% - 32px);
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.reader-player-open .mobi-ebook-viewer {
|
||||
height: calc(100% - 152px);
|
||||
max-height: calc(100% - 152px);
|
||||
height: calc(100% - 132px);
|
||||
max-height: calc(100% - 132px);
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 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>
|
||||
@@ -11,69 +11,192 @@
|
||||
</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">
|
||||
<p class="font-mono text-xs">{{ page }} / {{ numPages }}</p>
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<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 :style="{ height: pdfHeight + 'px' }" class="overflow-hidden m-auto">
|
||||
<div class="flex items-center justify-center">
|
||||
<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="url" :page="page" :rotate="rotate" @progress="loadedRatio = $event" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event"></pdf>
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import pdf from 'vue-pdf'
|
||||
import pdf from '@teckel/vue-pdf'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
pdf
|
||||
},
|
||||
props: {
|
||||
url: String
|
||||
url: String,
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
isLocal: Boolean,
|
||||
keepProgress: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
rotate: 0,
|
||||
loadedRatio: 0,
|
||||
page: 1,
|
||||
numPages: 0
|
||||
numPages: 0,
|
||||
windowWidth: 0,
|
||||
windowHeight: 0
|
||||
}
|
||||
},
|
||||
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() {
|
||||
return this.pdfHeight * 0.6667
|
||||
if (this.windowWidth > this.windowHeight) {
|
||||
// Landscape
|
||||
return this.windowHeight * 0.6
|
||||
} else {
|
||||
// Portrait
|
||||
return this.windowWidth
|
||||
}
|
||||
},
|
||||
pdfHeight() {
|
||||
return window.innerHeight - 120
|
||||
return this.pdfWidth * 1.667
|
||||
},
|
||||
canGoNext() {
|
||||
return this.page < this.numPages
|
||||
},
|
||||
canGoPrev() {
|
||||
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: {
|
||||
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) {
|
||||
this.numPages = e
|
||||
},
|
||||
prev() {
|
||||
if (this.page <= 1) return
|
||||
this.page--
|
||||
this.updateProgress()
|
||||
},
|
||||
next() {
|
||||
if (this.page >= this.numPages) return
|
||||
this.page++
|
||||
this.updateProgress()
|
||||
},
|
||||
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>
|
||||
+228
-36
@@ -1,11 +1,89 @@
|
||||
<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 class="h-16 pt-8 w-full bg-primary flex items-center px-2 fixed top-0 left-0 z-30 box-shadow-sm">
|
||||
<p class="w-5/6 truncate">{{ title }}</p>
|
||||
<div class="flex-grow" />
|
||||
<span class="material-icons text-xl text-white" @click.stop="show = false">close</span>
|
||||
<div 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 }">
|
||||
<!-- toolbar -->
|
||||
<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 items-center mb-2">
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -19,16 +97,32 @@ export default {
|
||||
touchstartY: 0,
|
||||
touchendX: 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: {
|
||||
show: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.comicHasMetadata = false
|
||||
this.registerListeners()
|
||||
this.hideToolbar()
|
||||
|
||||
console.log('showReader for ebookFile', JSON.stringify(this.ebookFile))
|
||||
} else {
|
||||
this.unregisterListeners()
|
||||
this.$showHideStatusBar(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,10 +143,26 @@ export default {
|
||||
return this.$store.state.selectedLibraryItem
|
||||
},
|
||||
media() {
|
||||
return this.selectedLibraryItem ? this.selectedLibraryItem.media : null
|
||||
return this.selectedLibraryItem?.media || null
|
||||
},
|
||||
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() {
|
||||
if (this.ebookType === 'epub') return 'readers-epub-reader'
|
||||
@@ -61,17 +171,20 @@ export default {
|
||||
else if (this.ebookType === 'pdf') return 'readers-pdf-reader'
|
||||
return null
|
||||
},
|
||||
folderId() {
|
||||
return this.selectedLibraryItem ? this.selectedLibraryItem.folderId : null
|
||||
},
|
||||
libraryId() {
|
||||
return this.selectedLibraryItem ? this.selectedLibraryItem.libraryId : null
|
||||
},
|
||||
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() {
|
||||
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() {
|
||||
if (this.isMobi) return 'mobi'
|
||||
@@ -93,7 +206,7 @@ export default {
|
||||
return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr'
|
||||
},
|
||||
isLocal() {
|
||||
return !!this.ebookFile?.isLocal
|
||||
return !!this.ebookFile?.isLocal || !!this.ebookFile?.localFileId
|
||||
},
|
||||
localContentUrl() {
|
||||
return this.ebookFile?.contentUrl
|
||||
@@ -103,25 +216,58 @@ export default {
|
||||
if (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']
|
||||
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() {
|
||||
return this.$store.state.playerLibraryItemId
|
||||
isPlayerOpen() {
|
||||
return this.$store.getters['getIsPlayerOpen']
|
||||
},
|
||||
keepProgress() {
|
||||
return this.$store.state.ereaderKeepProgress
|
||||
},
|
||||
ebookFileId() {
|
||||
return this.$store.state.ereaderFileId
|
||||
}
|
||||
},
|
||||
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() {
|
||||
if (this.$refs.readerComponent && this.$refs.readerComponent.next) {
|
||||
this.$refs.readerComponent.next()
|
||||
@@ -133,7 +279,7 @@ export default {
|
||||
}
|
||||
},
|
||||
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
|
||||
if (touchTimeMs >= 1000) {
|
||||
console.log('Touch too long', touchTimeMs)
|
||||
@@ -142,34 +288,80 @@ export default {
|
||||
|
||||
const touchDistanceX = Math.abs(this.touchendX - this.touchstartX)
|
||||
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
|
||||
}
|
||||
|
||||
if (touchDistanceX < 60 || touchDistanceY > touchDistanceX) {
|
||||
return
|
||||
}
|
||||
this.hideToolbar()
|
||||
if (this.touchendX < this.touchstartX) {
|
||||
console.log('swiped left')
|
||||
this.next()
|
||||
}
|
||||
if (this.touchendX > this.touchstartX) {
|
||||
console.log('swiped right')
|
||||
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) {
|
||||
// Ignore rapid touch
|
||||
if (this.touchstartTime && Date.now() - this.touchstartTime < 250) {
|
||||
return
|
||||
}
|
||||
|
||||
this.touchstartX = e.touches[0].screenX
|
||||
this.touchstartY = e.touches[0].screenY
|
||||
this.touchstartTime = Date.now()
|
||||
this.touchIdentifier = e.touches[0].identifier
|
||||
},
|
||||
touchend(e) {
|
||||
if (this.touchIdentifier !== e.changedTouches[0].identifier) {
|
||||
return
|
||||
}
|
||||
|
||||
this.touchendX = e.changedTouches[0].screenX
|
||||
this.touchendY = e.changedTouches[0].screenY
|
||||
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() {
|
||||
this.$eventBus.$on('close-ebook', this.closeEvt)
|
||||
document.body.addEventListener('touchstart', this.touchstart)
|
||||
document.body.addEventListener('touchend', this.touchend)
|
||||
},
|
||||
unregisterListeners() {
|
||||
this.$eventBus.$on('close-ebook', this.closeEvt)
|
||||
document.body.removeEventListener('touchstart', this.touchstart)
|
||||
document.body.removeEventListener('touchend', this.touchend)
|
||||
}
|
||||
|
||||
@@ -70,28 +70,3 @@ export default {
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tracksTable {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
border: 1px solid #474747;
|
||||
}
|
||||
|
||||
.tracksTable tr:nth-child(even) {
|
||||
background-color: #2e2e2e;
|
||||
}
|
||||
|
||||
.tracksTable tr {
|
||||
background-color: #373838;
|
||||
}
|
||||
|
||||
.tracksTable td {
|
||||
padding: 8px 8px;
|
||||
}
|
||||
|
||||
.tracksTable th {
|
||||
padding: 4px 8px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -58,28 +58,3 @@ export default {
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tracksTable {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
border: 1px solid #474747;
|
||||
}
|
||||
|
||||
.tracksTable tr:nth-child(even) {
|
||||
background-color: #2e2e2e;
|
||||
}
|
||||
|
||||
.tracksTable tr {
|
||||
background-color: #373838;
|
||||
}
|
||||
|
||||
.tracksTable td {
|
||||
padding: 8px 8px;
|
||||
}
|
||||
|
||||
.tracksTable th {
|
||||
padding: 4px 8px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -74,7 +74,7 @@ export default {
|
||||
return !this.isMissing && !this.isInvalid && this.tracks.length
|
||||
},
|
||||
isStreaming() {
|
||||
return this.$store.getters['getIsEpisodeStreaming'](this.libraryItemId)
|
||||
return this.$store.getters['getIsMediaStreaming'](this.libraryItemId)
|
||||
},
|
||||
streamIsPlaying() {
|
||||
return this.$store.state.playerIsPlaying && this.isStreaming
|
||||
|
||||
@@ -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>
|
||||
@@ -6,9 +6,9 @@
|
||||
</div>
|
||||
<div class="item-table-content h-full px-2 flex items-center">
|
||||
<div class="max-w-full">
|
||||
<p class="truncate block text-sm">{{ itemTitle }}</p>
|
||||
<p class="truncate block text-gray-400 text-xs">{{ bookAuthorName }}</p>
|
||||
<p class="text-xxs text-gray-500">{{ itemDuration }}</p>
|
||||
<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 v-if="authorName" class="truncate block text-gray-300 text-xs">{{ authorName }}</p>
|
||||
<p class="text-xxs text-gray-400">{{ itemDuration }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-8 min-w-8 flex justify-center">
|
||||
@@ -39,11 +39,17 @@ export default {
|
||||
libraryItem() {
|
||||
return this.item.libraryItem || {}
|
||||
},
|
||||
localLibraryItem() {
|
||||
return this.item.localLibraryItem
|
||||
},
|
||||
episode() {
|
||||
return this.item.episode
|
||||
},
|
||||
episodeId() {
|
||||
return this.episode ? this.episode.id : null
|
||||
return this.episode?.id || null
|
||||
},
|
||||
localEpisode() {
|
||||
return this.item.localEpisode
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem.media || {}
|
||||
@@ -66,6 +72,10 @@ export default {
|
||||
bookAuthorName() {
|
||||
return this.bookAuthors.map((au) => au.name).join(', ')
|
||||
},
|
||||
authorName() {
|
||||
if (this.episode) return this.mediaMetadata.author
|
||||
return this.bookAuthorName
|
||||
},
|
||||
itemDuration() {
|
||||
if (this.episode) return this.$elapsedPretty(this.episode.duration)
|
||||
return this.$elapsedPretty(this.media.duration)
|
||||
@@ -92,7 +102,8 @@ export default {
|
||||
return !this.isMissing && !this.isInvalid && (this.tracks.length || this.episode)
|
||||
},
|
||||
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() {
|
||||
return this.$store.state.playerIsPlaying && this.isStreaming
|
||||
@@ -103,6 +114,13 @@ export default {
|
||||
await this.$hapticsImpact()
|
||||
if (this.streamIsPlaying) {
|
||||
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 {
|
||||
this.$eventBus.$emit('play-item', {
|
||||
libraryItemId: this.libraryItem.id,
|
||||
|
||||
@@ -114,14 +114,7 @@ export default {
|
||||
return this.$secondsToTimestamp(this.episode.duration)
|
||||
},
|
||||
isStreaming() {
|
||||
if (this.playerIsLocal && this.localLibraryItemId && this.localEpisode) {
|
||||
// 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
|
||||
return this.$store.getters['getIsMediaStreaming'](this.libraryItemId, this.episode.id)
|
||||
},
|
||||
streamIsPlaying() {
|
||||
return this.$store.state.playerIsPlaying && this.isStreaming
|
||||
@@ -186,29 +179,33 @@ export default {
|
||||
}
|
||||
},
|
||||
async download(selectedLocalFolder = null) {
|
||||
var localFolder = selectedLocalFolder
|
||||
let localFolder = selectedLocalFolder
|
||||
if (!localFolder) {
|
||||
var localFolders = (await this.$db.getLocalFolders()) || []
|
||||
const localFolders = (await this.$db.getLocalFolders()) || []
|
||||
console.log('Local folders loaded', localFolders.length)
|
||||
var foldersWithMediaType = localFolders.filter((lf) => {
|
||||
const foldersWithMediaType = localFolders.filter((lf) => {
|
||||
console.log('Checking local folder', lf.mediaType)
|
||||
return lf.mediaType == this.mediaType
|
||||
})
|
||||
console.log('Folders with media type', this.mediaType, foldersWithMediaType.length)
|
||||
const internalStorageFolder = foldersWithMediaType.find((f) => f.id === `internal-${this.mediaType}`)
|
||||
if (!foldersWithMediaType.length) {
|
||||
// No local folders or no local folders with this media type
|
||||
localFolder = await this.selectFolder()
|
||||
} else if (foldersWithMediaType.length == 1) {
|
||||
console.log('Only 1 local folder with this media type - auto select it')
|
||||
localFolder = foldersWithMediaType[0]
|
||||
localFolder = {
|
||||
id: `internal-${this.mediaType}`,
|
||||
name: 'Internal App Storage',
|
||||
mediaType: this.mediaType
|
||||
}
|
||||
} else if (foldersWithMediaType.length === 1 && internalStorageFolder) {
|
||||
localFolder = internalStorageFolder
|
||||
} else {
|
||||
console.log('Multiple folders with media type')
|
||||
// this.showSelectLocalFolder = true
|
||||
this.$store.commit('globals/showSelectLocalFolderModal', {
|
||||
mediaType: this.mediaType,
|
||||
callback: (folder) => {
|
||||
this.download(folder)
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!localFolder) {
|
||||
return this.$toast.error('Invalid download folder')
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Local folder', JSON.stringify(localFolder))
|
||||
@@ -261,45 +258,37 @@ export default {
|
||||
},
|
||||
async toggleFinished() {
|
||||
await this.$hapticsImpact()
|
||||
|
||||
this.isProcessingReadUpdate = true
|
||||
if (this.isLocal || this.localEpisode) {
|
||||
var isFinished = !this.userIsFinished
|
||||
var localLibraryItemId = this.isLocal ? this.libraryItemId : this.localLibraryItemId
|
||||
var localEpisodeId = this.isLocal ? this.episode.id : this.localEpisode.id
|
||||
var payload = await this.$db.updateLocalMediaProgressFinished({ localLibraryItemId, localEpisodeId, isFinished })
|
||||
const isFinished = !this.userIsFinished
|
||||
const localLibraryItemId = this.isLocal ? this.libraryItemId : this.localLibraryItemId
|
||||
const localEpisodeId = this.isLocal ? this.episode.id : this.localEpisode.id
|
||||
const payload = await this.$db.updateLocalMediaProgressFinished({ localLibraryItemId, localEpisodeId, isFinished })
|
||||
console.log('toggleFinished payload', JSON.stringify(payload))
|
||||
if (!payload || payload.error) {
|
||||
var errorMsg = payload ? payload.error : 'Unknown error'
|
||||
this.$toast.error(errorMsg)
|
||||
if (payload?.error) {
|
||||
this.$toast.error(payload?.error || 'Unknown error')
|
||||
} else {
|
||||
var localMediaProgress = payload.localMediaProgress
|
||||
const localMediaProgress = payload.localMediaProgress
|
||||
console.log('toggleFinished localMediaProgress', JSON.stringify(localMediaProgress))
|
||||
if (localMediaProgress) {
|
||||
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
|
||||
}
|
||||
|
||||
if (payload.server) {
|
||||
this.$toast.success(`Local & Server Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
} else {
|
||||
this.$toast.success(`Local Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
}
|
||||
}
|
||||
this.isProcessingReadUpdate = false
|
||||
} else {
|
||||
var updatePayload = {
|
||||
const updatePayload = {
|
||||
isFinished: !this.userIsFinished
|
||||
}
|
||||
this.$axios
|
||||
.$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) => {
|
||||
console.error('Failed', error)
|
||||
this.isProcessingReadUpdate = false
|
||||
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
})
|
||||
.finally(() => {
|
||||
this.isProcessingReadUpdate = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,14 +117,7 @@ export default {
|
||||
return this.$secondsToTimestamp(this.episode.duration)
|
||||
},
|
||||
isStreaming() {
|
||||
if (this.playerIsLocal && this.localLibraryItemId && this.localEpisode) {
|
||||
// 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
|
||||
return this.$store.getters['getIsMediaStreaming'](this.libraryItemId, this.episode.id)
|
||||
},
|
||||
streamIsPlaying() {
|
||||
return this.$store.state.playerIsPlaying && this.isStreaming
|
||||
@@ -192,29 +185,33 @@ export default {
|
||||
}
|
||||
},
|
||||
async download(selectedLocalFolder = null) {
|
||||
var localFolder = selectedLocalFolder
|
||||
let localFolder = selectedLocalFolder
|
||||
if (!localFolder) {
|
||||
var localFolders = (await this.$db.getLocalFolders()) || []
|
||||
const localFolders = (await this.$db.getLocalFolders()) || []
|
||||
console.log('Local folders loaded', localFolders.length)
|
||||
var foldersWithMediaType = localFolders.filter((lf) => {
|
||||
const foldersWithMediaType = localFolders.filter((lf) => {
|
||||
console.log('Checking local folder', lf.mediaType)
|
||||
return lf.mediaType == this.mediaType
|
||||
})
|
||||
console.log('Folders with media type', this.mediaType, foldersWithMediaType.length)
|
||||
const internalStorageFolder = foldersWithMediaType.find((f) => f.id === `internal-${this.mediaType}`)
|
||||
if (!foldersWithMediaType.length) {
|
||||
// No local folders or no local folders with this media type
|
||||
localFolder = await this.selectFolder()
|
||||
} else if (foldersWithMediaType.length == 1) {
|
||||
console.log('Only 1 local folder with this media type - auto select it')
|
||||
localFolder = foldersWithMediaType[0]
|
||||
localFolder = {
|
||||
id: `internal-${this.mediaType}`,
|
||||
name: 'Internal App Storage',
|
||||
mediaType: this.mediaType
|
||||
}
|
||||
} else if (foldersWithMediaType.length === 1 && internalStorageFolder) {
|
||||
localFolder = internalStorageFolder
|
||||
} else {
|
||||
console.log('Multiple folders with media type')
|
||||
// this.showSelectLocalFolder = true
|
||||
this.$store.commit('globals/showSelectLocalFolderModal', {
|
||||
mediaType: this.mediaType,
|
||||
callback: (folder) => {
|
||||
this.download(folder)
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!localFolder) {
|
||||
return this.$toast.error('Invalid download folder')
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Local folder', JSON.stringify(localFolder))
|
||||
@@ -267,45 +264,37 @@ export default {
|
||||
},
|
||||
async toggleFinished() {
|
||||
await this.$hapticsImpact()
|
||||
|
||||
this.isProcessingReadUpdate = true
|
||||
if (this.isLocal || this.localEpisode) {
|
||||
var isFinished = !this.userIsFinished
|
||||
var localLibraryItemId = this.isLocal ? this.libraryItemId : this.localLibraryItemId
|
||||
var localEpisodeId = this.isLocal ? this.episode.id : this.localEpisode.id
|
||||
var payload = await this.$db.updateLocalMediaProgressFinished({ localLibraryItemId, localEpisodeId, isFinished })
|
||||
const isFinished = !this.userIsFinished
|
||||
const localLibraryItemId = this.isLocal ? this.libraryItemId : this.localLibraryItemId
|
||||
const localEpisodeId = this.isLocal ? this.episode.id : this.localEpisode.id
|
||||
const payload = await this.$db.updateLocalMediaProgressFinished({ localLibraryItemId, localEpisodeId, isFinished })
|
||||
console.log('toggleFinished payload', JSON.stringify(payload))
|
||||
if (!payload || payload.error) {
|
||||
var errorMsg = payload ? payload.error : 'Unknown error'
|
||||
this.$toast.error(errorMsg)
|
||||
if (payload?.error) {
|
||||
this.$toast.error(payload?.error || 'Unknown error')
|
||||
} else {
|
||||
var localMediaProgress = payload.localMediaProgress
|
||||
const localMediaProgress = payload.localMediaProgress
|
||||
console.log('toggleFinished localMediaProgress', JSON.stringify(localMediaProgress))
|
||||
if (localMediaProgress) {
|
||||
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
|
||||
}
|
||||
|
||||
if (payload.server) {
|
||||
this.$toast.success(`Local & Server Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
} else {
|
||||
this.$toast.success(`Local Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
}
|
||||
}
|
||||
this.isProcessingReadUpdate = false
|
||||
} else {
|
||||
var updatePayload = {
|
||||
const updatePayload = {
|
||||
isFinished: !this.userIsFinished
|
||||
}
|
||||
this.$axios
|
||||
.$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) => {
|
||||
console.error('Failed', error)
|
||||
this.isProcessingReadUpdate = false
|
||||
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
})
|
||||
.finally(() => {
|
||||
this.isProcessingReadUpdate = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -30,10 +30,15 @@ export default {
|
||||
isCellular() {
|
||||
return this.networkConnectionType === 'cellular'
|
||||
},
|
||||
attemptingConnection() {
|
||||
return this.$store.state.attemptingConnection
|
||||
},
|
||||
icon() {
|
||||
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'
|
||||
} else if (!this.socketConnected) {
|
||||
return 'cloud_off'
|
||||
@@ -55,7 +60,9 @@ export default {
|
||||
showAlertDialog() {
|
||||
var msg = ''
|
||||
var meteredString = this.isNetworkUnmetered ? 'unmetered' : 'metered'
|
||||
if (!this.networkConnected) {
|
||||
if (this.attemptingConnection) {
|
||||
msg = 'Attempting server connection'
|
||||
} else if (!this.networkConnected) {
|
||||
msg = 'No internet'
|
||||
} else if (!this.socketConnected) {
|
||||
msg = 'Socket not connected'
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
4D66B954282EE87C008272D4 /* AbsDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B953282EE87C008272D4 /* AbsDownloader.swift */; };
|
||||
4D66B956282EE951008272D4 /* AbsFileSystem.m in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B955282EE951008272D4 /* AbsFileSystem.m */; };
|
||||
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 */; };
|
||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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; };
|
||||
@@ -299,6 +301,7 @@
|
||||
E9D5505928AC1C4500C746DD /* Folder.swift */,
|
||||
E9D5505B28AC1C6200C746DD /* LibraryFile.swift */,
|
||||
E9D5505D28AC1C8500C746DD /* MediaProgress.swift */,
|
||||
4D91EEC52A40F28D004807ED /* EBookFile.swift */,
|
||||
);
|
||||
path = server;
|
||||
sourceTree = "<group>";
|
||||
@@ -545,6 +548,7 @@
|
||||
E9D5506028AC1CA900C746DD /* PlaybackMetadata.swift in Sources */,
|
||||
E9D5504828AC1A7A00C746DD /* MediaType.swift in Sources */,
|
||||
E9D5504E28AC1B0700C746DD /* AudioFile.swift in Sources */,
|
||||
4D91EEC62A40F28D004807ED /* EBookFile.swift in Sources */,
|
||||
E9DFCBFB28C28F4A00B36356 /* AudioPlayerSleepTimer.swift in Sources */,
|
||||
E9D5505428AC1B7900C746DD /* AudioTrack.swift in Sources */,
|
||||
E9D5505C28AC1C6200C746DD /* LibraryFile.swift in Sources */,
|
||||
@@ -720,12 +724,12 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 0.9.64;
|
||||
MARKETING_VERSION = 0.9.65;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -744,12 +748,12 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 0.9.64;
|
||||
MARKETING_VERSION = 0.9.65;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
||||
@@ -14,7 +14,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
// Override point for customization after application launch.
|
||||
|
||||
let configuration = Realm.Configuration(
|
||||
schemaVersion: 8,
|
||||
schemaVersion: 13,
|
||||
migrationBlock: { [weak self] migration, oldSchemaVersion in
|
||||
if (oldSchemaVersion < 1) {
|
||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)")
|
||||
|
||||
@@ -25,4 +25,5 @@ CAP_PLUGIN(AbsDatabase, "AbsDatabase",
|
||||
CAP_PLUGIN_METHOD(syncServerMediaProgressWithLocalMediaProgress, CAPPluginReturnPromise);
|
||||
CAP_PLUGIN_METHOD(updateLocalMediaProgressFinished, CAPPluginReturnPromise);
|
||||
CAP_PLUGIN_METHOD(updateDeviceSettings, CAPPluginReturnPromise);
|
||||
CAP_PLUGIN_METHOD(updateLocalEbookProgress, CAPPluginReturnPromise);
|
||||
)
|
||||
|
||||
@@ -193,10 +193,14 @@ public class AbsDatabase: CAPPlugin {
|
||||
@objc func updateLocalMediaProgressFinished(_ call: CAPPluginCall) {
|
||||
let localLibraryItemId = call.getString("localLibraryItemId")
|
||||
let localEpisodeId = call.getString("localEpisodeId")
|
||||
let localMediaProgressId = call.getString("localMediaProgressId")
|
||||
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 {
|
||||
let localMediaProgress = try LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localMediaProgressId, localLibraryItemId: localLibraryItemId, localEpisodeId: localEpisodeId)
|
||||
@@ -251,4 +255,32 @@ public class AbsDatabase: CAPPlugin {
|
||||
// call.resolve([ "value": [] ])
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,7 +265,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
|
||||
// Handle the different media type downloads
|
||||
switch item.mediaType {
|
||||
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)!) }
|
||||
case "podcast":
|
||||
guard let episode = episode else { throw LibraryItemDownloadError.podcastEpisodeNotFound }
|
||||
@@ -285,6 +285,12 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
|
||||
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
|
||||
if item.media?.coverPath != nil && !(item.media?.coverPath!.isEmpty ?? true) {
|
||||
if let task = try? startLibraryItemCoverDownload(downloadItemId: downloadItem.id!, item: item) {
|
||||
@@ -318,7 +324,22 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
|
||||
let localUrl = "\(itemDirectory)/\(filename)"
|
||||
|
||||
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
|
||||
task.taskDescription = part.id
|
||||
@@ -337,7 +358,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
|
||||
$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!)
|
||||
|
||||
// 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 {
|
||||
// filename needs to be encoded otherwise would just use contentUrl
|
||||
let relPath = track.metadata?.relPath ?? ""
|
||||
let filepathEncoded = relPath.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed)
|
||||
let urlstr = "\(Store.serverConfig!.address)/s/item/\(item.id)/\(filepathEncoded ?? "")?token=\(Store.serverConfig!.token)"
|
||||
// TODO: Future server release should include ino with AudioFile or FileMetadata
|
||||
let trackPath = track.metadata?.path ?? ""
|
||||
|
||||
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)!
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ def capacitor_pods
|
||||
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
|
||||
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
|
||||
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
|
||||
pod 'CapacitorClipboard', :path => '../../node_modules/@capacitor/clipboard'
|
||||
pod 'CapacitorDialog', :path => '../../node_modules/@capacitor/dialog'
|
||||
pod 'CapacitorHaptics', :path => '../../node_modules/@capacitor/haptics'
|
||||
pod 'CapacitorNetwork', :path => '../../node_modules/@capacitor/network'
|
||||
|
||||
@@ -17,6 +17,7 @@ class DownloadItemPart: Object, Codable {
|
||||
@Persisted var serverPath: String?
|
||||
@Persisted var audioTrack: AudioTrack?
|
||||
@Persisted var episode: PodcastEpisode?
|
||||
@Persisted var ebookFile: EBookFile?
|
||||
@Persisted var completed: Bool = false
|
||||
@Persisted var moved: Bool = false
|
||||
@Persisted var failed: Bool = false
|
||||
@@ -63,7 +64,7 @@ class DownloadItemPart: Object, Codable {
|
||||
}
|
||||
|
||||
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.id = destination.toBase64()
|
||||
@@ -74,6 +75,7 @@ extension DownloadItemPart {
|
||||
self.serverPath = serverPath
|
||||
self.audioTrack = AudioTrack.detachCopy(of: audioTrack)
|
||||
self.episode = PodcastEpisode.detachCopy(of: episode)
|
||||
self.ebookFile = EBookFile.detachCopy(of: ebookFile)
|
||||
|
||||
let config = Store.serverConfig!
|
||||
var downloadUrl = "\(config.address)\(serverPath)?token=\(config.token)"
|
||||
@@ -101,6 +103,6 @@ extension DownloadItemPart {
|
||||
}
|
||||
|
||||
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 {
|
||||
_ = 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 ) {
|
||||
let episodes = List<PodcastEpisode>()
|
||||
for episode in fromMedia.episodes {
|
||||
@@ -177,6 +183,7 @@ extension LocalLibraryItem {
|
||||
audioTracks.append(AudioTrack.detachCopy(of: track)!)
|
||||
duration = track.duration
|
||||
displayTitle = episode.title
|
||||
episode.chapters.forEach { chapter in chapters.append(Chapter.detachCopy(of: chapter)!) }
|
||||
} else if let tracks = self.media?.tracks {
|
||||
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 currentTime: Double = 0
|
||||
@Persisted var isFinished: Bool = false
|
||||
@Persisted var ebookLocation: String?
|
||||
@Persisted var ebookProgress: Double?
|
||||
@Persisted var lastUpdate: Double = 0
|
||||
@Persisted var startedAt: Double = 0
|
||||
@Persisted var finishedAt: Double?
|
||||
@@ -29,7 +31,7 @@ class LocalMediaProgress: Object, Codable {
|
||||
var progressPercent: Int { Int(self.progress * 100) }
|
||||
|
||||
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() {
|
||||
@@ -47,6 +49,8 @@ class LocalMediaProgress: Object, Codable {
|
||||
progress = try values.decode(Double.self, forKey: .progress)
|
||||
currentTime = try values.decode(Double.self, forKey: .currentTime)
|
||||
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)
|
||||
startedAt = try values.decode(Double.self, forKey: .startedAt)
|
||||
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(isFinished, forKey: .isFinished)
|
||||
try container.encode(ebookLocation, forKey: .ebookLocation)
|
||||
try container.encode(ebookProgress, forKey: .ebookProgress)
|
||||
try container.encode(lastUpdate, forKey: .lastUpdate)
|
||||
try container.encode(startedAt, forKey: .startedAt)
|
||||
try container.encode(finishedAt, forKey: .finishedAt)
|
||||
@@ -97,6 +103,7 @@ extension LocalMediaProgress {
|
||||
self.progress = 0.0
|
||||
self.currentTime = 0.0
|
||||
self.isFinished = false
|
||||
self.ebookProgress = 0.0
|
||||
self.lastUpdate = Date().timeIntervalSince1970 * 1000
|
||||
self.startedAt = 0
|
||||
self.finishedAt = nil
|
||||
@@ -104,6 +111,7 @@ extension LocalMediaProgress {
|
||||
if let episode = episode {
|
||||
self.id += "-\(episode.id)"
|
||||
self.episodeId = episode.id
|
||||
self.localEpisodeId = episode.id
|
||||
self.duration = episode.duration ?? 0.0
|
||||
}
|
||||
}
|
||||
@@ -114,6 +122,8 @@ extension LocalMediaProgress {
|
||||
self.progress = progress.progress
|
||||
self.currentTime = progress.currentTime
|
||||
self.isFinished = progress.isFinished
|
||||
self.ebookLocation = progress.ebookLocation
|
||||
self.ebookProgress = progress.ebookProgress
|
||||
self.lastUpdate = progress.lastUpdate
|
||||
self.startedAt = progress.startedAt
|
||||
self.finishedAt = progress.finishedAt
|
||||
@@ -124,6 +134,10 @@ extension LocalMediaProgress {
|
||||
if self.isFinished != finished {
|
||||
self.progress = finished ? 1.0 : 0.0
|
||||
}
|
||||
|
||||
if finished {
|
||||
self.currentTime = 0.0
|
||||
}
|
||||
|
||||
if self.startedAt == 0 && finished {
|
||||
self.startedAt = Date().timeIntervalSince1970 * 1000
|
||||
@@ -140,7 +154,7 @@ extension LocalMediaProgress {
|
||||
self.currentTime = playbackSession.currentTime
|
||||
self.progress = playbackSession.progress
|
||||
self.lastUpdate = Date().timeIntervalSince1970 * 1000
|
||||
self.isFinished = playbackSession.progress >= 100.0
|
||||
self.isFinished = playbackSession.progress >= 0.99
|
||||
self.finishedAt = self.isFinished ? self.lastUpdate : nil
|
||||
}
|
||||
}
|
||||
@@ -148,6 +162,8 @@ extension LocalMediaProgress {
|
||||
func updateFromServerMediaProgress(_ serverMediaProgress: MediaProgress) throws {
|
||||
try self.realm?.write {
|
||||
self.isFinished = serverMediaProgress.isFinished
|
||||
self.ebookLocation = serverMediaProgress.ebookLocation
|
||||
self.ebookProgress = serverMediaProgress.ebookProgress
|
||||
self.progress = serverMediaProgress.progress
|
||||
self.currentTime = serverMediaProgress.currentTime
|
||||
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? {
|
||||
if let itemId = localLibraryItemId, let episodeId = localEpisodeId {
|
||||
return "\(itemId)-\(episodeId)"
|
||||
|
||||
@@ -18,6 +18,7 @@ class LocalPodcastEpisode: Object, Codable {
|
||||
@Persisted var desc: String?
|
||||
@Persisted var audioFile: AudioFile?
|
||||
@Persisted var audioTrack: AudioTrack?
|
||||
@Persisted var chapters = List<Chapter>()
|
||||
@Persisted var duration: Double = 0
|
||||
@Persisted var size: Int = 0
|
||||
@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 currentTime: Double = 0
|
||||
@Persisted var isFinished: Bool = false
|
||||
@Persisted var ebookLocation: String?
|
||||
@Persisted var ebookProgress: Double?
|
||||
@Persisted var lastUpdate: Double = 0
|
||||
@Persisted var startedAt: Double = 0
|
||||
@Persisted var finishedAt: Double?
|
||||
|
||||
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() {
|
||||
@@ -37,6 +39,8 @@ class MediaProgress: EmbeddedObject, Codable {
|
||||
progress = try values.doubleOrStringDecoder(key: .progress)
|
||||
currentTime = try values.doubleOrStringDecoder(key: .currentTime)
|
||||
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)
|
||||
startedAt = try values.doubleOrStringDecoder(key: .startedAt)
|
||||
finishedAt = try? values.doubleOrStringDecoder(key: .finishedAt)
|
||||
@@ -51,6 +55,8 @@ class MediaProgress: EmbeddedObject, Codable {
|
||||
try container.encode(progress, forKey: .progress)
|
||||
try container.encode(currentTime, forKey: .currentTime)
|
||||
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(startedAt, forKey: .startedAt)
|
||||
try container.encode(finishedAt, forKey: .finishedAt)
|
||||
|
||||
@@ -14,6 +14,7 @@ class MediaType: EmbeddedObject, Codable {
|
||||
@Persisted var coverPath: String?
|
||||
@Persisted var tags = List<String>()
|
||||
@Persisted var audioFiles = List<AudioFile>()
|
||||
@Persisted var ebookFile: EBookFile?
|
||||
@Persisted var chapters = List<Chapter>()
|
||||
@Persisted var tracks = List<AudioTrack>()
|
||||
@Persisted var size: Int?
|
||||
@@ -22,7 +23,7 @@ class MediaType: EmbeddedObject, Codable {
|
||||
@Persisted var autoDownloadEpisodes: Bool?
|
||||
|
||||
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() {
|
||||
@@ -41,6 +42,7 @@ class MediaType: EmbeddedObject, Codable {
|
||||
if let fileList = try? values.decode([AudioFile].self, forKey: .audioFiles) {
|
||||
audioFiles.append(objectsIn: fileList)
|
||||
}
|
||||
ebookFile = try? values.decode(EBookFile.self, forKey: .ebookFile)
|
||||
if let chapterList = try? values.decode([Chapter].self, forKey: .chapters) {
|
||||
chapters.append(objectsIn: chapterList)
|
||||
}
|
||||
@@ -62,6 +64,7 @@ class MediaType: EmbeddedObject, Codable {
|
||||
try container.encode(coverPath, forKey: .coverPath)
|
||||
try container.encode(Array(tags), forKey: .tags)
|
||||
try container.encode(Array(audioFiles), forKey: .audioFiles)
|
||||
try container.encode(ebookFile, forKey: .ebookFile)
|
||||
try container.encode(Array(chapters), forKey: .chapters)
|
||||
try container.encode(Array(tracks), forKey: .tracks)
|
||||
try container.encode(size, forKey: .size)
|
||||
|
||||
@@ -18,6 +18,7 @@ class PodcastEpisode: EmbeddedObject, Codable {
|
||||
@Persisted var desc: String?
|
||||
@Persisted var audioFile: AudioFile?
|
||||
@Persisted var audioTrack: AudioTrack?
|
||||
@Persisted var chapters = List<Chapter>()
|
||||
@Persisted var duration: Double?
|
||||
@Persisted var size: Int?
|
||||
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"
|
||||
audioFile,
|
||||
audioTrack,
|
||||
chapters,
|
||||
duration,
|
||||
size,
|
||||
serverEpisodeId
|
||||
@@ -40,6 +42,7 @@ class PodcastEpisode: EmbeddedObject, Codable {
|
||||
override init() {}
|
||||
|
||||
required init(from decoder: Decoder) throws {
|
||||
super.init()
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try values.decode(String.self, forKey: .id)
|
||||
index = try? values.decode(Int.self, forKey: .index)
|
||||
@@ -50,6 +53,9 @@ class PodcastEpisode: EmbeddedObject, Codable {
|
||||
desc = try? values.decode(String.self, forKey: .desc)
|
||||
audioFile = try? values.decode(AudioFile.self, forKey: .audioFile)
|
||||
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)
|
||||
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(audioFile, forKey: .audioFile)
|
||||
try container.encode(audioTrack, forKey: .audioTrack)
|
||||
try container.encode(Array(chapters), forKey: .chapters)
|
||||
try container.encode(duration, forKey: .duration)
|
||||
try container.encode(size, forKey: .size)
|
||||
try container.encode(serverEpisodeId, forKey: .serverEpisodeId)
|
||||
|
||||
@@ -9,6 +9,7 @@ import Foundation
|
||||
import AVFoundation
|
||||
import UIKit
|
||||
import MediaPlayer
|
||||
import RealmSwift
|
||||
|
||||
enum PlayMethod: Int {
|
||||
case directplay = 0
|
||||
@@ -86,7 +87,21 @@ class AudioPlayer: NSObject {
|
||||
self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem), options: .new, context: &playerContext)
|
||||
|
||||
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)
|
||||
if (playbackSession.playMethod == PlayMethod.transcode.rawValue) {
|
||||
playerItem.preferredForwardBufferDuration = 50
|
||||
@@ -483,23 +498,18 @@ class AudioPlayer: NSObject {
|
||||
}
|
||||
|
||||
// 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 }
|
||||
|
||||
if (playbackSession.playMethod == PlayMethod.directplay.rawValue) {
|
||||
// The only reason this is separate is because the filename needs to be encoded
|
||||
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 urlstr = "\(Store.serverConfig!.address)/api/items/\(itemId)/file/\(ino)?token=\(Store.serverConfig!.token)"
|
||||
let url = URL(string: urlstr)!
|
||||
return AVURLAsset(url: url)
|
||||
} else if (playbackSession.playMethod == PlayMethod.local.rawValue) {
|
||||
guard let localFile = track.getLocalFile() else {
|
||||
// Worst case we can stream the file
|
||||
logger.log("Unable to play local file. Resulting to streaming \(track.localFileId ?? "Unknown")")
|
||||
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 urlstr = "\(Store.serverConfig!.address)/api/items/\(itemId)/file/\(ino)?token=\(Store.serverConfig!.token)"
|
||||
let url = URL(string: urlstr)!
|
||||
return AVURLAsset(url: url)
|
||||
}
|
||||
|
||||
@@ -50,6 +50,12 @@ extension KeyedDecodingContainer {
|
||||
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 {
|
||||
|
||||
+27
-11
@@ -1,16 +1,18 @@
|
||||
<template>
|
||||
<div class="w-full layout-wrapper bg-bg text-white">
|
||||
<app-appbar />
|
||||
<div id="content" class="overflow-hidden relative" :class="playerIsOpen ? 'playerOpen' : ''">
|
||||
<div id="content" class="overflow-hidden relative" :class="isPlayerOpen ? 'playerOpen' : ''">
|
||||
<Nuxt />
|
||||
|
||||
<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>
|
||||
<app-audio-player-container ref="streamContainer" />
|
||||
<modals-libraries-modal />
|
||||
<modals-playlists-add-create-modal />
|
||||
<modals-select-local-folder-modal />
|
||||
<modals-rssfeeds-rss-feed-modal />
|
||||
<app-side-drawer />
|
||||
<readers-reader />
|
||||
</div>
|
||||
@@ -20,7 +22,6 @@
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
attemptingConnection: false,
|
||||
inittingLibraries: false,
|
||||
hasMounted: false,
|
||||
disconnectTime: 0
|
||||
@@ -57,8 +58,8 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
playerIsOpen() {
|
||||
return this.$store.state.playerLibraryItemId
|
||||
isPlayerOpen() {
|
||||
return this.$store.getters['getIsPlayerOpen']
|
||||
},
|
||||
routeName() {
|
||||
return this.$route.name
|
||||
@@ -71,11 +72,19 @@ export default {
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
attemptingConnection: {
|
||||
get() {
|
||||
return this.$store.state.attemptingConnection
|
||||
},
|
||||
set(val) {
|
||||
this.$store.commit('setAttemptingConnection', val)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
initialStream(stream) {
|
||||
if (this.$refs.streamContainer && this.$refs.streamContainer.audioPlayerReady) {
|
||||
if (this.$refs.streamContainer?.audioPlayerReady) {
|
||||
this.$refs.streamContainer.streamOpen(stream)
|
||||
}
|
||||
},
|
||||
@@ -223,8 +232,16 @@ export default {
|
||||
this.$store.commit('user/setUser', user)
|
||||
}
|
||||
},
|
||||
async userMediaProgressUpdated(prog) {
|
||||
console.log(`[default] userMediaProgressUpdate checking for local media progress ${prog.id}`)
|
||||
async userMediaProgressUpdated(payload) {
|
||||
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
|
||||
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}`)
|
||||
this.$store.commit('globals/updateLocalMediaProgress', newLocalMediaProgress)
|
||||
}
|
||||
@@ -291,6 +308,7 @@ export default {
|
||||
|
||||
const deviceData = await this.$db.getDeviceData()
|
||||
this.$store.commit('setDeviceData', deviceData)
|
||||
|
||||
this.$setOrientationLock(this.$store.getters['getOrientationLockSetting'])
|
||||
|
||||
await this.$store.dispatch('setupNetworkListener')
|
||||
@@ -298,10 +316,8 @@ export default {
|
||||
await this.$store.dispatch('globals/loadLocalMediaProgress')
|
||||
|
||||
if (this.$store.state.user.serverConnectionConfig) {
|
||||
console.log(`[default] server connection config set - call init libraries`)
|
||||
await this.initLibraries()
|
||||
} else {
|
||||
console.log(`[default] no server connection config - call attempt connection`)
|
||||
await this.attemptConnection()
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -48,7 +48,8 @@ export default {
|
||||
'@/plugins/capacitor/AbsAudioPlayer.js',
|
||||
'@/plugins/toast.js',
|
||||
'@/plugins/constants.js',
|
||||
'@/plugins/haptics.js'
|
||||
'@/plugins/haptics.js',
|
||||
'@/plugins/i18n.js'
|
||||
],
|
||||
|
||||
components: true,
|
||||
|
||||
Generated
+12167
-12175
File diff suppressed because it is too large
Load Diff
+9
-5
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "audiobookshelf-app",
|
||||
"version": "0.9.64-beta",
|
||||
"version": "0.9.65-beta",
|
||||
"author": "advplyr",
|
||||
"scripts": {
|
||||
"dev": "nuxt --hostname 0.0.0.0 --port 1337",
|
||||
"build": "nuxt build",
|
||||
"start": "nuxt start",
|
||||
"generate": "nuxt generate",
|
||||
"sync": "nuxt generate && npx cap sync",
|
||||
"icons-android": "cordova-res android --skip-config --copy",
|
||||
"ionic:build": "npm run build",
|
||||
"ionic:serve": "npm run start"
|
||||
@@ -14,6 +15,7 @@
|
||||
"dependencies": {
|
||||
"@capacitor/android": "^4.0.0",
|
||||
"@capacitor/app": "^4.0.0",
|
||||
"@capacitor/clipboard": "^4.1.0",
|
||||
"@capacitor/core": "^4.0.0",
|
||||
"@capacitor/dialog": "^4.0.0",
|
||||
"@capacitor/haptics": "^4.0.0",
|
||||
@@ -22,16 +24,17 @@
|
||||
"@capacitor/preferences": "^4.0.2",
|
||||
"@capacitor/status-bar": "^4.0.0",
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
"@teckel/vue-pdf": "^4.3.5",
|
||||
"buffer": "^6.0.3",
|
||||
"cordova-plugin-screen-orientation": "^3.0.2",
|
||||
"core-js": "^3.15.1",
|
||||
"date-fns": "^2.25.0",
|
||||
"epubjs": "^0.3.88",
|
||||
"epubjs": "0.3.88",
|
||||
"fast-average-color": "^9.3.0",
|
||||
"libarchive.js": "^1.3.0",
|
||||
"nuxt": "^2.15.7",
|
||||
"socket.io-client": "^4.1.3",
|
||||
"v-click-outside": "^3.2.0",
|
||||
"vue-pdf": "^4.2.0",
|
||||
"vue-toastification": "^1.7.11",
|
||||
"vuedraggable": "^2.24.3"
|
||||
},
|
||||
@@ -39,7 +42,8 @@
|
||||
"@babel/core": "7.13.15",
|
||||
"@babel/preset-env": "7.13.15",
|
||||
"@capacitor/cli": "^4.0.0",
|
||||
"@nuxtjs/tailwindcss": "^4.2.0",
|
||||
"postcss": "^8.3.5"
|
||||
"@nuxtjs/tailwindcss": "^4.2.1",
|
||||
"postcss": "^8.3.5",
|
||||
"tailwindcss": "^3.3.2"
|
||||
}
|
||||
}
|
||||
+2
-7
@@ -3,10 +3,7 @@
|
||||
<home-bookshelf-nav-bar />
|
||||
<home-bookshelf-toolbar v-show="!hideToolbar" />
|
||||
<div id="bookshelf-wrapper" class="main-content overflow-y-auto overflow-x-hidden relative" :class="hideToolbar ? 'no-toolbar' : ''">
|
||||
<nuxt-child :loading.sync="loading" />
|
||||
</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..." />
|
||||
<nuxt-child />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -14,9 +11,7 @@
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
loading: false
|
||||
}
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
hideToolbar() {
|
||||
|
||||
+28
-29
@@ -1,12 +1,17 @@
|
||||
<template>
|
||||
<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">
|
||||
<bookshelf-shelf :key="shelf.id" :label="shelf.label" :entities="shelf.entities" :type="shelf.type" :style="{ zIndex: shelves.length - index }" />
|
||||
</template>
|
||||
</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>
|
||||
<p class="mb-4 text-center text-xl">
|
||||
Bookshelf empty
|
||||
@@ -26,14 +31,15 @@
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
loading: Boolean
|
||||
},
|
||||
props: {},
|
||||
data() {
|
||||
return {
|
||||
shelves: [],
|
||||
@@ -41,7 +47,8 @@ export default {
|
||||
lastServerFetch: 0,
|
||||
lastServerFetchLibraryId: null,
|
||||
lastLocalFetch: 0,
|
||||
localLibraryItems: []
|
||||
localLibraryItems: [],
|
||||
isLoading: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -97,19 +104,14 @@ export default {
|
||||
localMediaProgress() {
|
||||
return this.$store.state.globals.localMediaProgress
|
||||
},
|
||||
isLoading: {
|
||||
get() {
|
||||
return this.loading
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('update:loading', val)
|
||||
}
|
||||
attemptingConnection() {
|
||||
return this.$store.state.attemptingConnection
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async getLocalMediaItemCategories() {
|
||||
const localMedia = await this.$db.getLocalLibraryItems()
|
||||
if (!localMedia || !localMedia.length) return []
|
||||
getLocalMediaItemCategories() {
|
||||
const localMedia = this.localLibraryItems
|
||||
if (!localMedia?.length) return []
|
||||
|
||||
const categories = []
|
||||
const books = []
|
||||
@@ -198,7 +200,8 @@ export default {
|
||||
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
|
||||
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) {
|
||||
console.log(`[categories] fetchCategories server fetch was ${Date.now() - this.lastServerFetch}ms ago so not doing it.`)
|
||||
return
|
||||
@@ -221,19 +224,21 @@ export default {
|
||||
}
|
||||
|
||||
this.isLoading = true
|
||||
this.shelves = []
|
||||
|
||||
if (this.user && this.currentLibraryId && this.networkConnected) {
|
||||
this.localLibraryItems = await this.$db.getLocalLibraryItems()
|
||||
const localCategories = await this.getLocalMediaItemCategories()
|
||||
const categories = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/personalized?minified=1`).catch((error) => {
|
||||
// Set local library items first
|
||||
this.localLibraryItems = await this.$db.getLocalLibraryItems()
|
||||
const localCategories = this.getLocalMediaItemCategories()
|
||||
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)
|
||||
return []
|
||||
})
|
||||
if (!categories.length) {
|
||||
// Failed to load categories so use local shelves
|
||||
console.warn(`[categories] Failed to get server categories so using local categories`)
|
||||
this.shelves = localCategories
|
||||
this.lastServerFetch = 0
|
||||
this.lastLocalFetch = Date.now()
|
||||
this.isLoading = false
|
||||
@@ -261,12 +266,6 @@ export default {
|
||||
const localShelves = localCategories.filter((cat) => cat.type === this.currentLibraryMediaType && !cat.localOnly)
|
||||
this.shelves.push(...localShelves)
|
||||
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
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="w-full h-full relative overflow-hidden">
|
||||
<template v-if="!showSelectedFeed">
|
||||
<div class="w-full mx-auto py-5 px-2">
|
||||
<form @submit.prevent="submit">
|
||||
<div class="w-full mx-auto h-20 flex items-center px-2">
|
||||
<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" />
|
||||
</form>
|
||||
</div>
|
||||
@@ -10,7 +10,7 @@
|
||||
<div v-if="!networkConnected" class="w-full text-center py-6">
|
||||
<p class="text-lg text-error">No network connection</p>
|
||||
</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>
|
||||
<template v-for="podcast in results">
|
||||
<div :key="podcast.id" class="p-2 border-b border-white border-opacity-10" @click="selectPodcast(podcast)">
|
||||
@@ -33,14 +33,14 @@
|
||||
</div>
|
||||
</template>
|
||||
<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">
|
||||
<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>
|
||||
</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" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -147,12 +147,3 @@ export default {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-results-container {
|
||||
max-height: calc(100vh - 182px);
|
||||
}
|
||||
.playerOpen .search-results-container {
|
||||
max-height: calc(100vh - 282px);
|
||||
}
|
||||
</style>
|
||||
@@ -75,7 +75,7 @@ export default {
|
||||
})
|
||||
},
|
||||
streaming() {
|
||||
return !!this.playableBooks.find((b) => this.$store.getters['getIsItemStreaming'](b.id))
|
||||
return !!this.playableBooks.find((b) => this.$store.getters['getIsMediaStreaming'](b.id))
|
||||
},
|
||||
showPlayButton() {
|
||||
return this.playableBooks.length
|
||||
|
||||
@@ -12,16 +12,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-lg font-semibold">
|
||||
{{ title }}
|
||||
</p>
|
||||
<p class="text-lg font-semibold">{{ title }}</p>
|
||||
|
||||
<div v-if="episodeNumber || season || episodeType" class="flex py-2 items-center -mx-0.5">
|
||||
<div v-if="episodeNumber" class="px-2 pt-px pb-0.5 mx-0.5 bg-primary bg-opacity-60 rounded-full text-xs font-light text-gray-200">Episode #{{ episodeNumber }}</div>
|
||||
<div v-if="season" class="px-2 pt-px pb-0.5 mx-0.5 bg-primary bg-opacity-60 rounded-full text-xs font-light text-gray-200">Season #{{ season }}</div>
|
||||
<div v-if="episodeType" class="px-2 pt-px pb-0.5 mx-0.5 bg-primary bg-opacity-60 rounded-full text-xs font-light text-gray-200 capitalize">
|
||||
{{ episodeType }}
|
||||
</div>
|
||||
<div v-if="episodeType" class="px-2 pt-px pb-0.5 mx-0.5 bg-primary bg-opacity-60 rounded-full text-xs font-light text-gray-200 capitalize">{{ episodeType }}</div>
|
||||
</div>
|
||||
|
||||
<!-- user progress card -->
|
||||
@@ -209,14 +205,7 @@ export default {
|
||||
return this.$store.getters['globals/getLibraryItemCoverSrcById'](this.libraryItemId)
|
||||
},
|
||||
isPlaying() {
|
||||
if (this.playerIsLocal && this.localLibraryItemId && this.localEpisodeId) {
|
||||
// Check is streaming local version of this episode
|
||||
return this.$store.getters['getIsEpisodeStreaming'](this.localLibraryItemId, this.localEpisodeId)
|
||||
}
|
||||
return this.$store.getters['getIsEpisodeStreaming'](this.libraryItemId, this.episode.id)
|
||||
},
|
||||
playerIsLocal() {
|
||||
return !!this.$store.state.playerIsLocal
|
||||
return this.$store.getters['getIsMediaStreaming'](this.libraryItemId, this.episode.id)
|
||||
},
|
||||
playerIsPlaying() {
|
||||
return this.$store.state.playerIsPlaying && this.isPlaying
|
||||
@@ -234,13 +223,13 @@ export default {
|
||||
return this.$store.getters['user/getUserMediaProgress'](this.serverLibraryItemId, this.serverEpisodeId)
|
||||
},
|
||||
progressPercent() {
|
||||
return this.userItemProgress ? this.userItemProgress.progress : 0
|
||||
return this.userItemProgress?.progress || 0
|
||||
},
|
||||
userIsFinished() {
|
||||
return this.userItemProgress ? !!this.userItemProgress.isFinished : false
|
||||
return !!this.userItemProgress?.isFinished
|
||||
},
|
||||
userProgressFinishedAt() {
|
||||
return this.userItemProgress ? this.userItemProgress.finishedAt : 0
|
||||
return this.userItemProgress?.finishedAt || 0
|
||||
},
|
||||
userTimeRemaining() {
|
||||
if (!this.userItemProgress) return 0
|
||||
@@ -288,20 +277,65 @@ export default {
|
||||
value: 'playlist',
|
||||
icon: 'playlist_add'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.isAdminOrUp) {
|
||||
items.push({
|
||||
text: 'Remove from Server',
|
||||
value: 'remove_from_server',
|
||||
icon: 'delete_forever'
|
||||
})
|
||||
}
|
||||
if (this.localEpisodeId) {
|
||||
items.push({
|
||||
text: 'Delete Local Episode',
|
||||
value: 'deleteLocal',
|
||||
icon: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.isAdminOrUp && this.serverEpisodeId) {
|
||||
items.push({
|
||||
text: 'Remove from Server',
|
||||
value: 'remove_from_server',
|
||||
icon: 'delete_forever'
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
},
|
||||
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() {
|
||||
await this.$hapticsImpact()
|
||||
if (this.playerIsPlaying) {
|
||||
@@ -346,20 +380,24 @@ export default {
|
||||
return lf.mediaType == this.mediaType
|
||||
})
|
||||
console.log('Folders with media type', this.mediaType, foldersWithMediaType.length)
|
||||
const internalStorageFolder = foldersWithMediaType.find((f) => f.id === `internal-${this.mediaType}`)
|
||||
if (!foldersWithMediaType.length) {
|
||||
// No local folders or no local folders with this media type
|
||||
localFolder = await this.selectFolder()
|
||||
} else if (foldersWithMediaType.length == 1) {
|
||||
console.log('Only 1 local folder with this media type - auto select it')
|
||||
localFolder = foldersWithMediaType[0]
|
||||
localFolder = {
|
||||
id: `internal-${this.mediaType}`,
|
||||
name: 'Internal App Storage',
|
||||
mediaType: this.mediaType
|
||||
}
|
||||
} else if (foldersWithMediaType.length === 1 && internalStorageFolder) {
|
||||
localFolder = internalStorageFolder
|
||||
} else {
|
||||
console.log('Multiple folders with media type')
|
||||
// this.showSelectLocalFolder = true
|
||||
this.$store.commit('globals/showSelectLocalFolderModal', {
|
||||
mediaType: this.mediaType,
|
||||
callback: (folder) => {
|
||||
this.download(folder)
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!localFolder) {
|
||||
return this.$toast.error('Invalid download folder')
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Local folder', JSON.stringify(localFolder))
|
||||
@@ -404,8 +442,10 @@ export default {
|
||||
} else if (action === 'playlist' && !this.isLocal) {
|
||||
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: this.episode }])
|
||||
this.$store.commit('globals/setShowPlaylistsAddCreateModal', true)
|
||||
} else if (action === 'remove_from_server' && !this.isLocal && this.isAdminOrUp) {
|
||||
} else if (action === 'remove_from_server' && this.serverEpisodeId && this.isAdminOrUp) {
|
||||
this.deleteEpisodeFromServerClick()
|
||||
} else if (action === 'deleteLocal') {
|
||||
this.deleteLocalEpisode()
|
||||
}
|
||||
},
|
||||
async discardProgress() {
|
||||
@@ -450,35 +490,23 @@ export default {
|
||||
const payload = await this.$db.updateLocalMediaProgressFinished({ localLibraryItemId, localEpisodeId, isFinished })
|
||||
console.log('toggleFinished payload', JSON.stringify(payload))
|
||||
|
||||
if (!payload || payload.error) {
|
||||
var errorMsg = payload ? payload.error : 'Unknown error'
|
||||
this.$toast.error(errorMsg)
|
||||
if (payload?.error) {
|
||||
this.$toast.error(payload?.error || 'Unknown error')
|
||||
} else {
|
||||
const localMediaProgress = payload.localMediaProgress
|
||||
console.log('toggleFinished localMediaProgress', JSON.stringify(localMediaProgress))
|
||||
if (localMediaProgress) {
|
||||
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
|
||||
}
|
||||
|
||||
if (payload.server) {
|
||||
this.$toast.success(`Local & Server Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
} else {
|
||||
this.$toast.success(`Local Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const updatePayload = {
|
||||
isFinished: !this.userIsFinished
|
||||
}
|
||||
this.$axios
|
||||
.$patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload)
|
||||
.then(() => {
|
||||
this.$toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
})
|
||||
this.$axios.$patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
})
|
||||
}
|
||||
},
|
||||
async deleteEpisodeFromServerClick() {
|
||||
@@ -492,13 +520,13 @@ export default {
|
||||
if (value) {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$delete(`/api/podcasts/${this.libraryItemId}/episode/${this.episode.id}?hard=1`)
|
||||
.$delete(`/api/podcasts/${this.serverLibraryItemId}/episode/${this.serverEpisodeId}?hard=1`)
|
||||
.then(() => {
|
||||
this.$toast.success('Episode deleted from server')
|
||||
this.$router.replace(`/item/${this.libraryItemId}`)
|
||||
this.$router.replace(`/item/${this.serverLibraryItemId}`)
|
||||
})
|
||||
.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)
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
|
||||
+156
-58
@@ -1,5 +1,5 @@
|
||||
<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="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%)" />
|
||||
@@ -63,13 +63,13 @@
|
||||
</div>
|
||||
|
||||
<!-- 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" class="text-sm">{{ podcastAuthor }}</div>
|
||||
<div v-else-if="bookAuthors && bookAuthors.length" class="text-sm">
|
||||
<template v-for="(author, index) in bookAuthors">
|
||||
<nuxt-link :key="author.id" :to="`/bookshelf/library?filter=authors.${$encode(author.id)}`" class="underline">{{ author.name }}</nuxt-link
|
||||
><span :key="`${author.id}-comma`" v-if="index < bookAuthors.length - 1">, </span>
|
||||
<nuxt-link :key="author.id" :to="`/bookshelf/library?filter=authors.${$encode(author.id)}`" class="underline">{{ author.name }}</nuxt-link>
|
||||
<span :key="`${author.id}-comma`" v-if="index < bookAuthors.length - 1">,</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -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="truncate text-sm">
|
||||
<template v-for="(series, index) in seriesList">
|
||||
<nuxt-link :key="series.id" :to="`/bookshelf/series/${series.id}`" class="underline">{{ series.text }}</nuxt-link
|
||||
><span :key="`${series.id}-comma`" v-if="index < seriesList.length - 1">, </span>
|
||||
<nuxt-link :key="series.id" :to="`/bookshelf/series/${series.id}`" class="underline">{{ series.text }}</nuxt-link>
|
||||
<span :key="`${series.id}-comma`" v-if="index < seriesList.length - 1">, </span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -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="truncate text-sm">
|
||||
<template v-for="(narrator, index) in narrators">
|
||||
<nuxt-link :key="narrator" :to="`/bookshelf/library?filter=narrators.${$encode(narrator)}`" class="underline">{{ narrator }}</nuxt-link
|
||||
><span :key="index" v-if="index < narrators.length - 1">, </span>
|
||||
<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>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="genres.length" class="text-white text-opacity-60 uppercase text-sm">{{ genres.length === 1 ? 'Genre' : 'Genres' }}</div>
|
||||
<div v-if="genres.length" class="truncate text-sm">
|
||||
<template v-for="(genre, index) in genres">
|
||||
<nuxt-link :key="genre" :to="`/bookshelf/library?filter=genres.${$encode(genre)}`" class="underline">{{ genre }}</nuxt-link
|
||||
><span :key="index" v-if="index < genres.length - 1">, </span>
|
||||
<nuxt-link :key="genre" :to="`/bookshelf/library?filter=genres.${$encode(genre)}`" class="underline">{{ genre }}</nuxt-link>
|
||||
<span :key="index" v-if="index < genres.length - 1">,</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -123,6 +123,8 @@
|
||||
|
||||
<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-select-local-folder-modal v-model="showSelectLocalFolder" :media-type="mediaType" @select="selectedLocalFolder" />
|
||||
|
||||
@@ -148,7 +150,7 @@ export default {
|
||||
libraryItem = await app.$db.getLocalLibraryItem(libraryItemId)
|
||||
console.log('Got lli', libraryItemId)
|
||||
} else if (store.state.user.serverConnectionConfig) {
|
||||
libraryItem = await app.$axios.$get(`/api/items/${libraryItemId}?expanded=1`).catch((error) => {
|
||||
libraryItem = await app.$axios.$get(`/api/items/${libraryItemId}?expanded=1&include=rssfeed`).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return false
|
||||
})
|
||||
@@ -167,7 +169,8 @@ export default {
|
||||
return redirect('/')
|
||||
}
|
||||
return {
|
||||
libraryItem
|
||||
libraryItem,
|
||||
rssFeed: libraryItem.rssFeed || null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -192,6 +195,9 @@ export default {
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
isLocal() {
|
||||
return this.libraryItem.isLocal
|
||||
},
|
||||
@@ -324,11 +330,11 @@ export default {
|
||||
return this.userItemProgress ? this.userItemProgress.finishedAt : 0
|
||||
},
|
||||
isStreaming() {
|
||||
return this.isPlaying && !this.$store.state.playerIsLocal
|
||||
return this.isPlaying && !this.$store.getters['getIsCurrentSessionLocal']
|
||||
},
|
||||
isPlaying() {
|
||||
if (this.localLibraryItemId && this.$store.getters['getIsItemStreaming'](this.localLibraryItemId)) return true
|
||||
return this.$store.getters['getIsItemStreaming'](this.libraryItemId)
|
||||
if (this.localLibraryItemId && this.$store.getters['getIsMediaStreaming'](this.localLibraryItemId)) return true
|
||||
return this.$store.getters['getIsMediaStreaming'](this.libraryItemId)
|
||||
},
|
||||
playerIsPlaying() {
|
||||
return this.$store.state.playerIsPlaying && (this.isStreaming || this.isPlaying)
|
||||
@@ -357,7 +363,13 @@ export default {
|
||||
},
|
||||
showDownload() {
|
||||
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() {
|
||||
return this.media.ebookFile
|
||||
@@ -375,6 +387,13 @@ export default {
|
||||
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() {
|
||||
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) {
|
||||
items.push({
|
||||
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({
|
||||
text: 'More Info',
|
||||
value: 'details',
|
||||
@@ -443,6 +478,35 @@ export default {
|
||||
}
|
||||
},
|
||||
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) {
|
||||
if (!fullCoverUrl) return
|
||||
|
||||
@@ -475,17 +539,30 @@ export default {
|
||||
this.$router.push(`/media/${this.mediaId}/history?title=${this.title}`)
|
||||
} else if (action === 'discardProgress') {
|
||||
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() {
|
||||
this.showMoreMenu = true
|
||||
},
|
||||
readBook() {
|
||||
if (this.localLibraryItem?.media?.ebookFile) {
|
||||
// Has local ebook file
|
||||
this.$store.commit('openReader', this.localLibraryItem)
|
||||
this.$store.commit('showReader', { libraryItem: this.localLibraryItem, keepProgress: true })
|
||||
} else {
|
||||
this.$store.commit('openReader', this.libraryItem)
|
||||
this.$store.commit('showReader', { libraryItem: this.libraryItem, keepProgress: true })
|
||||
}
|
||||
},
|
||||
playAtTimestamp(seconds) {
|
||||
@@ -590,7 +667,7 @@ export default {
|
||||
}
|
||||
},
|
||||
itemUpdated(libraryItem) {
|
||||
if (libraryItem.id === this.libraryItemId) {
|
||||
if (libraryItem.id === this.serverLibraryItemId) {
|
||||
console.log('Item Updated')
|
||||
this.libraryItem = libraryItem
|
||||
this.checkDescriptionClamped()
|
||||
@@ -622,29 +699,33 @@ export default {
|
||||
},
|
||||
async download(selectedLocalFolder = null) {
|
||||
// Get the local folder to download to
|
||||
var localFolder = selectedLocalFolder
|
||||
let localFolder = selectedLocalFolder
|
||||
if (!localFolder) {
|
||||
var localFolders = (await this.$db.getLocalFolders()) || []
|
||||
const localFolders = (await this.$db.getLocalFolders()) || []
|
||||
console.log('Local folders loaded', localFolders.length)
|
||||
var foldersWithMediaType = localFolders.filter((lf) => {
|
||||
const foldersWithMediaType = localFolders.filter((lf) => {
|
||||
console.log('Checking local folder', lf.mediaType)
|
||||
return lf.mediaType == this.mediaType
|
||||
})
|
||||
console.log('Folders with media type', this.mediaType, foldersWithMediaType.length)
|
||||
const internalStorageFolder = foldersWithMediaType.find((f) => f.id === `internal-${this.mediaType}`)
|
||||
if (!foldersWithMediaType.length) {
|
||||
// No local folders or no local folders with this media type
|
||||
localFolder = await this.selectFolder()
|
||||
} else if (foldersWithMediaType.length == 1) {
|
||||
console.log('Only 1 local folder with this media type - auto select it')
|
||||
localFolder = foldersWithMediaType[0]
|
||||
localFolder = {
|
||||
id: `internal-${this.mediaType}`,
|
||||
name: 'Internal App Storage',
|
||||
mediaType: this.mediaType
|
||||
}
|
||||
} else if (foldersWithMediaType.length === 1 && internalStorageFolder) {
|
||||
localFolder = internalStorageFolder
|
||||
} else {
|
||||
console.log('Multiple folders with media type')
|
||||
this.showSelectLocalFolder = true
|
||||
this.$store.commit('globals/showSelectLocalFolderModal', {
|
||||
mediaType: this.mediaType,
|
||||
callback: (folder) => {
|
||||
this.download(folder)
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!localFolder) {
|
||||
return this.$toast.error('Invalid download folder')
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Local folder', JSON.stringify(localFolder))
|
||||
@@ -700,41 +781,32 @@ export default {
|
||||
|
||||
this.isProcessingReadUpdate = true
|
||||
if (this.isLocal) {
|
||||
var isFinished = !this.userIsFinished
|
||||
var payload = await this.$db.updateLocalMediaProgressFinished({ localLibraryItemId: this.localLibraryItemId, isFinished })
|
||||
const isFinished = !this.userIsFinished
|
||||
const payload = await this.$db.updateLocalMediaProgressFinished({ localLibraryItemId: this.localLibraryItemId, isFinished })
|
||||
console.log('toggleFinished payload', JSON.stringify(payload))
|
||||
if (!payload || payload.error) {
|
||||
var errorMsg = payload ? payload.error : 'Unknown error'
|
||||
this.$toast.error(errorMsg)
|
||||
if (payload?.error) {
|
||||
this.$toast.error(payload?.error || 'Unknown error')
|
||||
} else {
|
||||
var localMediaProgress = payload.localMediaProgress
|
||||
const localMediaProgress = payload.localMediaProgress
|
||||
console.log('toggleFinished localMediaProgress', JSON.stringify(localMediaProgress))
|
||||
if (localMediaProgress) {
|
||||
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
|
||||
}
|
||||
|
||||
if (payload.server) {
|
||||
this.$toast.success(`Local & Server Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
} else {
|
||||
this.$toast.success(`Local Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
}
|
||||
}
|
||||
this.isProcessingReadUpdate = false
|
||||
} else {
|
||||
var updatePayload = {
|
||||
const updatePayload = {
|
||||
isFinished: !this.userIsFinished
|
||||
}
|
||||
this.$axios
|
||||
.$patch(`/api/me/progress/${this.libraryItemId}`, updatePayload)
|
||||
.then(() => {
|
||||
this.isProcessingReadUpdate = false
|
||||
this.$toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.isProcessingReadUpdate = false
|
||||
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
})
|
||||
.finally(() => {
|
||||
this.isProcessingReadUpdate = false
|
||||
})
|
||||
}
|
||||
},
|
||||
libraryChanged(libraryId) {
|
||||
@@ -753,6 +825,18 @@ export default {
|
||||
windowResized() {
|
||||
this.windowWidth = window.innerWidth
|
||||
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() {
|
||||
@@ -761,13 +845,27 @@ export default {
|
||||
this.$eventBus.$on('library-changed', this.libraryChanged)
|
||||
this.$eventBus.$on('new-local-library-item', this.newLocalLibraryItem)
|
||||
this.$socket.$on('item_updated', this.itemUpdated)
|
||||
this.$socket.$on('rss_feed_open', this.rssFeedOpen)
|
||||
this.$socket.$on('rss_feed_closed', this.rssFeedClosed)
|
||||
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() {
|
||||
window.removeEventListener('resize', this.windowResized)
|
||||
this.$eventBus.$off('library-changed', this.libraryChanged)
|
||||
this.$eventBus.$off('new-local-library-item', this.newLocalLibraryItem)
|
||||
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>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<p class="text-base font-semibold">Folder: {{ folderName }}</p>
|
||||
<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>
|
||||
|
||||
<p class="text-sm mb-4 text-white text-opacity-60">Media Type: {{ mediaType }}</p>
|
||||
@@ -15,15 +15,14 @@
|
||||
<p>Scanning...</p>
|
||||
</div>
|
||||
<div v-else class="w-full media-item-container overflow-y-auto">
|
||||
<template v-for="mediaItem in localLibraryItems">
|
||||
<nuxt-link :to="`/localMedia/item/${mediaItem.id}`" :key="mediaItem.id" class="flex my-1">
|
||||
<template v-for="localLibraryItem in localLibraryItems">
|
||||
<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">
|
||||
<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 class="flex-grow px-2">
|
||||
<p class="text-sm">{{ mediaItem.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 v-else-if="mediaItem.mediaType == 'podcast'" class="text-xs text-gray-300">{{ mediaItem.media.episodes.length }} Episode{{ mediaItem.media.episodes.length == 1 ? '' : 's' }}</p>
|
||||
<p class="text-sm">{{ localLibraryItem.media.metadata.title }}</p>
|
||||
<p class="text-xs text-gray-300">{{ getLocalLibraryItemSubText(localLibraryItem) }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 flex items-center justify-center">
|
||||
<span class="material-icons text-xl text-gray-300">arrow_right</span>
|
||||
@@ -59,29 +58,53 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
folderName() {
|
||||
return this.folder ? this.folder.name : null
|
||||
return this.folder?.name || null
|
||||
},
|
||||
mediaType() {
|
||||
return this.folder ? this.folder.mediaType : null
|
||||
return this.folder?.mediaType
|
||||
},
|
||||
isInternalStorage() {
|
||||
return this.folder?.id.startsWith('internal-')
|
||||
},
|
||||
dialogItems() {
|
||||
return [
|
||||
if (this.isInternalStorage) return []
|
||||
const items = [
|
||||
{
|
||||
text: 'Scan',
|
||||
value: 'scan'
|
||||
},
|
||||
{
|
||||
}
|
||||
]
|
||||
|
||||
if (this.localLibraryItems.length) {
|
||||
items.push({
|
||||
text: 'Force Re-Scan',
|
||||
value: 'rescan'
|
||||
},
|
||||
{
|
||||
text: 'Remove',
|
||||
value: 'remove'
|
||||
}
|
||||
].filter((i) => i.value != 'rescan' || this.localLibraryItems.length) // Filter out rescan if there are no local library items
|
||||
})
|
||||
}
|
||||
|
||||
items.push({
|
||||
text: 'Remove',
|
||||
value: 'remove'
|
||||
})
|
||||
return items
|
||||
}
|
||||
},
|
||||
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) {
|
||||
console.log('Dialog action', action)
|
||||
if (action == 'scan') {
|
||||
|
||||
@@ -15,12 +15,18 @@
|
||||
<div v-if="!localFolders.length" class="flex justify-center">
|
||||
<p class="text-center">No Media Folders</p>
|
||||
</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">
|
||||
<ui-dropdown v-model="newFolderMediaType" placeholder="Select media type" :items="mediaTypeItems" />
|
||||
</div>
|
||||
<ui-btn small class="w-28" color="success" @click="selectFolder">New Folder</ui-btn>
|
||||
</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>
|
||||
</template>
|
||||
@@ -44,7 +50,9 @@ export default {
|
||||
text: 'Podcasts'
|
||||
}
|
||||
],
|
||||
syncing: false
|
||||
syncing: false,
|
||||
isAndroid10OrBelow: false,
|
||||
overrideFolderRestriction: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -82,6 +90,10 @@ export default {
|
||||
this.$router.push(`/localMedia/folders/${folderObj.id}?scan=1`)
|
||||
},
|
||||
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.localLibraryItems = await this.$db.getLocalLibraryItems()
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
<div v-if="isScanning" class="w-full text-center p-4">
|
||||
<p>Scanning...</p>
|
||||
</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-if="!isPodcast" class="w-full">
|
||||
<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 && audioTracksCopy.length" class="w-full py-2">
|
||||
<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">
|
||||
@@ -47,7 +47,8 @@
|
||||
</transition-group>
|
||||
</draggable>
|
||||
</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>
|
||||
<template v-for="episode in episodes">
|
||||
<div :key="episode.id" class="flex items-center my-1">
|
||||
@@ -68,7 +69,24 @@
|
||||
</template>
|
||||
</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">
|
||||
<div :key="file.id" class="flex items-center my-1">
|
||||
<div class="w-12 h-12 flex items-center justify-center">
|
||||
@@ -78,7 +96,7 @@
|
||||
<div class="flex-grow px-2">
|
||||
<p class="text-sm">{{ file.filename }}</p>
|
||||
</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-sm">{{ $bytesPretty(file.size) }}</p>
|
||||
</div>
|
||||
@@ -90,7 +108,7 @@
|
||||
<p class="text-lg text-center px-8">{{ failed ? 'Failed to get local library item ' + localLibraryItemId : 'Loading..' }}</p>
|
||||
</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" />
|
||||
<ui-btn small color="success" @click="saveTrackOrder">Save Order</ui-btn>
|
||||
</div>
|
||||
@@ -138,17 +156,17 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
playerLibraryItemId() {
|
||||
return this.$store.state.playerLibraryItemId
|
||||
isPlayerOpen() {
|
||||
return this.$store.getters['getIsPlayerOpen']
|
||||
},
|
||||
isIos() {
|
||||
return this.$platform === 'ios'
|
||||
},
|
||||
basePath() {
|
||||
return this.localLibraryItem ? this.localLibraryItem.basePath : null
|
||||
return this.localLibraryItem?.basePath
|
||||
},
|
||||
localFiles() {
|
||||
return this.localLibraryItem ? this.localLibraryItem.localFiles : []
|
||||
return this.localLibraryItem?.localFiles || []
|
||||
},
|
||||
otherFiles() {
|
||||
if (!this.localFiles.filter) {
|
||||
@@ -156,29 +174,40 @@ export default {
|
||||
return []
|
||||
}
|
||||
return this.localFiles.filter((lf) => {
|
||||
if (this.localFileForEbook?.id === lf.id) return false
|
||||
return !this.audioTracks.find((at) => at.localFileId == lf.id)
|
||||
})
|
||||
},
|
||||
folderName() {
|
||||
return this.folder ? this.folder.name : null
|
||||
return this.folder?.name
|
||||
},
|
||||
isInternalStorage() {
|
||||
return this.folderId?.startsWith('internal-')
|
||||
},
|
||||
mediaType() {
|
||||
return this.localLibraryItem ? this.localLibraryItem.mediaType : null
|
||||
return this.localLibraryItem?.mediaType
|
||||
},
|
||||
isPodcast() {
|
||||
return this.mediaType == 'podcast'
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.localLibraryItem ? this.localLibraryItem.libraryItemId : null
|
||||
return this.localLibraryItem?.libraryItemId
|
||||
},
|
||||
liServerAddress() {
|
||||
return this.localLibraryItem ? this.localLibraryItem.serverAddress : null
|
||||
return this.localLibraryItem?.serverAddress
|
||||
},
|
||||
media() {
|
||||
return this.localLibraryItem ? this.localLibraryItem.media : null
|
||||
return this.localLibraryItem?.media
|
||||
},
|
||||
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() {
|
||||
return this.media.episodes || []
|
||||
@@ -195,18 +224,17 @@ export default {
|
||||
if (this.selectedAudioTrack || this.selectedEpisode) {
|
||||
return [
|
||||
{
|
||||
text: 'Remove & Delete Files',
|
||||
text: 'Delete local file',
|
||||
value: 'track-delete'
|
||||
}
|
||||
]
|
||||
} else {
|
||||
var options = []
|
||||
if (!this.isIos) {
|
||||
if (!this.isIos && !this.isInternalStorage && !this.libraryItemId) {
|
||||
options.push({ text: 'Scan', value: 'scan' })
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -243,6 +271,7 @@ export default {
|
||||
},
|
||||
showItemDialog() {
|
||||
this.selectedAudioTrack = null
|
||||
this.selectedEpisode = null
|
||||
this.showDialog = true
|
||||
},
|
||||
showTrackDialog(track) {
|
||||
@@ -265,12 +294,11 @@ export default {
|
||||
async dialogAction(action) {
|
||||
console.log('Dialog action', action)
|
||||
await this.$hapticsImpact()
|
||||
|
||||
if (action == 'scan') {
|
||||
this.scanItem()
|
||||
} else if (action == 'rescan') {
|
||||
this.scanItem(true)
|
||||
} else if (action == 'remove') {
|
||||
this.removeItem()
|
||||
} else if (action == 'delete') {
|
||||
this.deleteItem()
|
||||
} else if (action == 'track-delete') {
|
||||
@@ -289,10 +317,14 @@ export default {
|
||||
this.$toast.error('Audio track does not have matching local file..')
|
||||
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({
|
||||
title: 'Confirm',
|
||||
message: `Warning! This will delete the audio file "${trackPath}" from your file system. Are you sure?`
|
||||
message: confirmMessage
|
||||
})
|
||||
if (value) {
|
||||
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..')
|
||||
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({
|
||||
title: 'Confirm',
|
||||
message: `Warning! This will delete the audio file "${trackPath}" from your file system. Are you sure?`
|
||||
message: confirmMessage
|
||||
})
|
||||
if (value) {
|
||||
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() {
|
||||
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({
|
||||
title: 'Confirm',
|
||||
message: `Warning! This will delete "${this.media.metadata.title}" and all associated local files. Are you sure?`
|
||||
message: confirmMessage
|
||||
})
|
||||
if (value) {
|
||||
var res = await AbsFileSystem.deleteItem(this.localLibraryItem)
|
||||
if (res && res.success) {
|
||||
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')
|
||||
}
|
||||
},
|
||||
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) {
|
||||
if (this.isScanning) return
|
||||
|
||||
|
||||
+33
-8
@@ -1,10 +1,8 @@
|
||||
<template>
|
||||
<div class="w-full h-full">
|
||||
<div class="w-full h-full overflow-y-auto px-2 py-6 md:p-8">
|
||||
<div class="w-full flex justify-center md:block sm:w-32 md:w-52" style="min-width: 240px">
|
||||
<div class="relative" style="height: fit-content">
|
||||
<covers-playlist-cover :items="playlistItems" :width="240" :height="120 * bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
<div class="w-full flex justify-center">
|
||||
<covers-playlist-cover :items="playlistItems" :width="180" :height="180" />
|
||||
</div>
|
||||
<div class="flex-grow py-6">
|
||||
<div class="flex items-center px-2">
|
||||
@@ -44,6 +42,26 @@ export default {
|
||||
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 {
|
||||
playlist
|
||||
}
|
||||
@@ -73,7 +91,10 @@ export default {
|
||||
})
|
||||
},
|
||||
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() {
|
||||
return this.playableItems.length
|
||||
@@ -82,11 +103,15 @@ export default {
|
||||
methods: {
|
||||
clickPlay() {
|
||||
const nextItem = this.playableItems.find((i) => {
|
||||
var prog = this.$store.getters['user/getUserMediaProgress'](i.libraryItemId, i.episodeId)
|
||||
return !prog || !prog.isFinished
|
||||
const prog = this.$store.getters['user/getUserMediaProgress'](i.libraryItemId, i.episodeId)
|
||||
return !prog?.isFinished
|
||||
})
|
||||
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
@@ -22,7 +22,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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 class="w-10 flex justify-center">
|
||||
<ui-toggle-switch v-model="settings.disableAutoRewind" @input="saveSettings" />
|
||||
@@ -41,10 +41,17 @@
|
||||
</div>
|
||||
<p class="pl-4">Jump forwards time</p>
|
||||
</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 -->
|
||||
<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="w-10 flex justify-center">
|
||||
<ui-toggle-switch v-model="settings.disableShakeToResetSleepTimer" @input="saveSettings" />
|
||||
@@ -131,6 +138,7 @@ export default {
|
||||
enableAltView: false,
|
||||
jumpForwardTime: 10,
|
||||
jumpBackwardsTime: 10,
|
||||
enableMp3IndexSeeking: false,
|
||||
disableShakeToResetSleepTimer: false,
|
||||
shakeSensitivity: 'MEDIUM',
|
||||
lockOrientation: 0,
|
||||
@@ -165,6 +173,10 @@ export default {
|
||||
autoSleepTimerAutoRewind: {
|
||||
name: 'Enable sleep timer auto rewind',
|
||||
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: [
|
||||
@@ -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() {
|
||||
this.settings.autoSleepTimer = !this.settings.autoSleepTimer
|
||||
this.saveSettings()
|
||||
@@ -344,7 +370,7 @@ export default {
|
||||
this.saveSettings()
|
||||
},
|
||||
getCurrentOrientation() {
|
||||
const orientation = window.screen ? window.screen.orientation || {} : {}
|
||||
const orientation = window.screen?.orientation || {}
|
||||
const type = orientation.type || ''
|
||||
|
||||
if (type.includes('landscape')) return 'LANDSCAPE'
|
||||
@@ -388,6 +414,8 @@ export default {
|
||||
this.settings.enableAltView = !!deviceSettings.enableAltView
|
||||
this.settings.jumpForwardTime = deviceSettings.jumpForwardTime || 10
|
||||
this.settings.jumpBackwardsTime = deviceSettings.jumpBackwardsTime || 10
|
||||
this.settings.toggleEnableMp3IndexSeeking = !!deviceSettings.toggleEnableMp3IndexSeeking
|
||||
|
||||
this.settings.lockOrientation = deviceSettings.lockOrientation || 'NONE'
|
||||
this.lockCurrentOrientation = this.settings.lockOrientation !== 'NONE'
|
||||
this.settings.hapticFeedback = deviceSettings.hapticFeedback || 'LIGHT'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user