mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-07-26 22:48:43 +02:00
Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fd4cc5604 | ||
|
|
c73b71345a | ||
|
|
11f0af1ebd | ||
|
|
a532f988b0 | ||
|
|
bec2796771 | ||
|
|
feec7f7399 | ||
|
|
067c2f84dd | ||
|
|
69996a4346 | ||
|
|
80cda129d2 | ||
|
|
dd0ff04155 | ||
|
|
c69558aa7f | ||
|
|
8fefea0d94 | ||
|
|
0ddee6f44e | ||
|
|
feec1ab55a | ||
|
|
49500aecd6 | ||
|
|
894bf100d2 | ||
|
|
7ba810adb8 | ||
|
|
d10f33362e | ||
|
|
82e8fbba2b | ||
|
|
64325fe2a6 | ||
|
|
99217fee48 | ||
|
|
f12e64990c | ||
|
|
ef7bb9a9d8 | ||
|
|
5ac925e151 | ||
|
|
acc5bde33a | ||
|
|
b521f37ec1 | ||
|
|
379fa21571 | ||
|
|
1aa6a441f3 | ||
|
|
705bbaccc1 | ||
|
|
8365e06762 | ||
|
|
139a0b3412 | ||
|
|
ea6b21169e | ||
|
|
a262efe9da | ||
|
|
ed5fb09007 | ||
|
|
d3c6429fd6 | ||
|
|
371fcacc87 | ||
|
|
75666e523f | ||
|
|
592ea7c533 | ||
|
|
b860dfc791 | ||
|
|
96dde8cf31 | ||
|
|
b4a37fed28 | ||
|
|
ad3fee8907 | ||
|
|
94db55598a | ||
|
|
9687f47b6b | ||
|
|
af216d9b38 | ||
|
|
c7879c2bc0 | ||
|
|
7db503a2cf | ||
|
|
d72295a0e6 | ||
|
|
638da4400f | ||
|
|
af9a7aafae | ||
|
|
ab9f7fed64 | ||
|
|
b62ce27487 | ||
|
|
6d9e902fe7 | ||
|
|
a0faf3f7d4 | ||
|
|
32dfb3b40a |
@@ -33,8 +33,8 @@ android {
|
||||
applicationId "com.audiobookshelf.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 89
|
||||
versionName "0.9.59-beta"
|
||||
versionCode 91
|
||||
versionName "0.9.60-beta"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<receiver android:name="androidx.media.session.MediaButtonReceiver" >
|
||||
<receiver android:name="androidx.media.session.MediaButtonReceiver" android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class AudioTrack(
|
||||
var index:Int,
|
||||
var startOffset:Double,
|
||||
var duration:Double,
|
||||
var title:String,
|
||||
var contentUrl:String,
|
||||
var mimeType:String,
|
||||
var metadata:FileMetadata?,
|
||||
var isLocal:Boolean,
|
||||
var localFileId:String?,
|
||||
var audioProbeResult:AudioProbeResult?,
|
||||
var serverIndex:Int? // Need to know if server track index is different
|
||||
) {
|
||||
|
||||
@get:JsonIgnore
|
||||
val startOffsetMs get() = (startOffset * 1000L).toLong()
|
||||
@get:JsonIgnore
|
||||
val durationMs get() = (duration * 1000L).toLong()
|
||||
@get:JsonIgnore
|
||||
val endOffsetMs get() = startOffsetMs + durationMs
|
||||
@get:JsonIgnore
|
||||
val relPath get() = metadata?.relPath ?: ""
|
||||
|
||||
@JsonIgnore
|
||||
fun getBookChapter():BookChapter {
|
||||
return BookChapter(index + 1,startOffset, startOffset + duration, title)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import androidx.media.utils.MediaConstants
|
||||
import com.audiobookshelf.app.media.MediaManager
|
||||
import com.fasterxml.jackson.annotation.*
|
||||
|
||||
// This auto-detects whether it is a Book or Podcast
|
||||
@@ -50,7 +52,7 @@ class Podcast(
|
||||
// Add new episodes
|
||||
audioTracks.forEach { at ->
|
||||
if (episodes?.find{ it.audioTrack?.localFileId == at.localFileId } == null) {
|
||||
val newEpisode = PodcastEpisode("local_ep_" + at.localFileId,episodes?.size ?: 0 + 1,null,null,at.title,null,null,null,at,at.duration,0, null)
|
||||
val newEpisode = PodcastEpisode("local_ep_" + at.localFileId,(episodes?.size ?: 0) + 1,null,null,at.title,null,null,null, null, null, at,at.duration,0, null)
|
||||
episodes?.add(newEpisode)
|
||||
}
|
||||
}
|
||||
@@ -63,7 +65,7 @@ class Podcast(
|
||||
}
|
||||
@JsonIgnore
|
||||
override fun addAudioTrack(audioTrack:AudioTrack) {
|
||||
val newEpisode = PodcastEpisode("local_" + audioTrack.localFileId,(episodes?.size ?: 0) + 1,null,null,audioTrack.title,null,null,null,audioTrack,audioTrack.duration,0, null)
|
||||
val newEpisode = PodcastEpisode("local_ep_" + audioTrack.localFileId,(episodes?.size ?: 0) + 1,null,null,audioTrack.title,null,null,null, null, null,audioTrack,audioTrack.duration,0, null)
|
||||
episodes?.add(newEpisode)
|
||||
|
||||
var index = 1
|
||||
@@ -82,9 +84,16 @@ class Podcast(
|
||||
index++
|
||||
}
|
||||
}
|
||||
|
||||
// Used for FolderScanner local podcast item to get copy of Podcast excluding episodes
|
||||
@JsonIgnore
|
||||
override fun getLocalCopy(): Podcast {
|
||||
return Podcast(metadata as PodcastMetadata,coverPath,tags, mutableListOf(),autoDownloadEpisodes, 0)
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode {
|
||||
val newEpisode = PodcastEpisode("local_" + episode.id,(episodes?.size ?: 0) + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,audioTrack,audioTrack.duration,0, episode.id)
|
||||
val newEpisode = PodcastEpisode("local_ep_" + episode.id,(episodes?.size ?: 0) + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,null,null,audioTrack,audioTrack.duration,0, episode.id)
|
||||
episodes?.add(newEpisode)
|
||||
|
||||
var index = 1
|
||||
@@ -95,10 +104,14 @@ class Podcast(
|
||||
return newEpisode
|
||||
}
|
||||
|
||||
// Used for FolderScanner local podcast item to get copy of Podcast excluding episodes
|
||||
@JsonIgnore
|
||||
override fun getLocalCopy(): Podcast {
|
||||
return Podcast(metadata as PodcastMetadata,coverPath,tags, mutableListOf(),autoDownloadEpisodes, 0)
|
||||
fun getNextUnfinishedEpisode(libraryItemId:String, mediaManager: MediaManager):PodcastEpisode? {
|
||||
val sortedEpisodes = episodes?.sortedByDescending { it.publishedAt }
|
||||
val podcastEpisode = sortedEpisodes?.find { episode ->
|
||||
val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItemId && it.episodeId == episode.id }
|
||||
progress == null || !progress.isFinished
|
||||
}
|
||||
return podcastEpisode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,10 +135,13 @@ class Book(
|
||||
override fun setAudioTracks(audioTracks:MutableList<AudioTrack>) {
|
||||
tracks = audioTracks
|
||||
tracks?.sortBy { it.index }
|
||||
// TODO: Is it necessary to calculate this each time? check if can remove safely
|
||||
|
||||
var totalDuration = 0.0
|
||||
var currentStartOffset = 0.0
|
||||
tracks?.forEach {
|
||||
totalDuration += it.duration
|
||||
it.startOffset = currentStartOffset
|
||||
currentStartOffset += it.duration
|
||||
}
|
||||
duration = totalDuration
|
||||
}
|
||||
@@ -158,7 +174,6 @@ class Book(
|
||||
}
|
||||
duration = totalDuration
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
override fun getLocalCopy(): Book {
|
||||
return Book(metadata as BookMetadata,coverPath,tags, mutableListOf(),chapters,mutableListOf(),null,null, 0)
|
||||
@@ -228,6 +243,8 @@ data class PodcastEpisode(
|
||||
var title:String?,
|
||||
var subtitle:String?,
|
||||
var description:String?,
|
||||
var pubDate:String?,
|
||||
var publishedAt:Long?,
|
||||
var audioFile:AudioFile?,
|
||||
var audioTrack:AudioTrack?,
|
||||
var duration:Double?,
|
||||
@@ -235,8 +252,8 @@ data class PodcastEpisode(
|
||||
var serverEpisodeId:String? // For local podcasts to match with server podcasts
|
||||
) {
|
||||
@JsonIgnore
|
||||
fun getMediaDescription(libraryItem:LibraryItemWrapper, progress:MediaProgressWrapper?): MediaDescriptionCompat {
|
||||
val coverUri = if(libraryItem is LocalLibraryItem) {
|
||||
fun getMediaDescription(libraryItem:LibraryItemWrapper, progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat {
|
||||
val coverUri = if (libraryItem is LocalLibraryItem) {
|
||||
libraryItem.getCoverUri()
|
||||
} else {
|
||||
(libraryItem as LibraryItem).getCoverUri()
|
||||
@@ -264,22 +281,20 @@ data class PodcastEpisode(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
|
||||
)
|
||||
}
|
||||
// return MediaMetadataCompat.Builder().apply {
|
||||
// putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||
// putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
|
||||
// putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
||||
// putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, podcast.metadata.getAuthorDisplayName())
|
||||
// putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString())
|
||||
//
|
||||
// }.build()
|
||||
val libraryItemDescription = libraryItem.getMediaDescription(null)
|
||||
return MediaDescriptionCompat.Builder()
|
||||
|
||||
val libraryItemDescription = libraryItem.getMediaDescription(null, ctx)
|
||||
val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
|
||||
.setMediaId(id)
|
||||
.setTitle(title)
|
||||
.setIconUri(coverUri)
|
||||
.setSubtitle(libraryItemDescription.title)
|
||||
.setExtras(extras)
|
||||
.build()
|
||||
|
||||
libraryItemDescription.iconBitmap?.let {
|
||||
mediaDescriptionBuilder.setIconBitmap(it)
|
||||
}
|
||||
|
||||
return mediaDescriptionBuilder.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,36 +344,6 @@ data class Folder(
|
||||
var fullPath:String
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class AudioTrack(
|
||||
var index:Int,
|
||||
var startOffset:Double,
|
||||
var duration:Double,
|
||||
var title:String,
|
||||
var contentUrl:String,
|
||||
var mimeType:String,
|
||||
var metadata:FileMetadata?,
|
||||
var isLocal:Boolean,
|
||||
var localFileId:String?,
|
||||
var audioProbeResult:AudioProbeResult?,
|
||||
var serverIndex:Int? // Need to know if server track index is different
|
||||
) {
|
||||
|
||||
@get:JsonIgnore
|
||||
val startOffsetMs get() = (startOffset * 1000L).toLong()
|
||||
@get:JsonIgnore
|
||||
val durationMs get() = (duration * 1000L).toLong()
|
||||
@get:JsonIgnore
|
||||
val endOffsetMs get() = startOffsetMs + durationMs
|
||||
@get:JsonIgnore
|
||||
val relPath get() = metadata?.relPath ?: ""
|
||||
|
||||
@JsonIgnore
|
||||
fun getBookChapter():BookChapter {
|
||||
return BookChapter(index + 1,startOffset, startOffset + duration, title)
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class BookChapter(
|
||||
var id:Int,
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import android.content.Context
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.fasterxml.jackson.annotation.JsonSubTypes
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo
|
||||
|
||||
enum class LockOrientationSetting {
|
||||
NONE, PORTRAIT, LANDSCAPE
|
||||
}
|
||||
|
||||
data class ServerConnectionConfig(
|
||||
var id:String,
|
||||
var index:Int,
|
||||
@@ -22,7 +27,8 @@ data class DeviceSettings(
|
||||
var enableAltView:Boolean,
|
||||
var jumpBackwardsTime:Int,
|
||||
var jumpForwardTime:Int,
|
||||
var disableShakeToResetSleepTimer:Boolean
|
||||
var disableShakeToResetSleepTimer:Boolean,
|
||||
var lockOrientation:LockOrientationSetting
|
||||
) {
|
||||
companion object {
|
||||
// Static method to get default device settings
|
||||
@@ -32,7 +38,8 @@ data class DeviceSettings(
|
||||
enableAltView = false,
|
||||
jumpBackwardsTime = 10,
|
||||
jumpForwardTime = 10,
|
||||
disableShakeToResetSleepTimer = false
|
||||
disableShakeToResetSleepTimer = false,
|
||||
lockOrientation = LockOrientationSetting.NONE
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -95,7 +102,7 @@ data class LocalFolder(
|
||||
)
|
||||
open class LibraryItemWrapper(var id:String) {
|
||||
@JsonIgnore
|
||||
open fun getMediaDescription(progress:MediaProgressWrapper?): MediaDescriptionCompat { return MediaDescriptionCompat.Builder().build() }
|
||||
open fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat { return MediaDescriptionCompat.Builder().build() }
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
@@ -55,8 +56,9 @@ class LibraryItem(
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
override fun getMediaDescription(progress:MediaProgressWrapper?): MediaDescriptionCompat {
|
||||
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat {
|
||||
val extras = Bundle()
|
||||
|
||||
if (progress != null) {
|
||||
if (progress.isFinished) {
|
||||
extras.putInt(
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.ImageDecoder
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.util.Log
|
||||
import androidx.media.utils.MediaConstants
|
||||
@@ -96,9 +101,21 @@ class LocalLibraryItem(
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
override fun getMediaDescription(progress:MediaProgressWrapper?): MediaDescriptionCompat {
|
||||
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx:Context?): MediaDescriptionCompat {
|
||||
val coverUri = getCoverUri()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val extras = Bundle()
|
||||
if (progress != null) {
|
||||
if (progress.isFinished) {
|
||||
@@ -122,12 +139,17 @@ class LocalLibraryItem(
|
||||
)
|
||||
}
|
||||
|
||||
return MediaDescriptionCompat.Builder()
|
||||
val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
|
||||
.setMediaId(id)
|
||||
.setTitle(title)
|
||||
.setIconUri(coverUri)
|
||||
.setSubtitle(authorName)
|
||||
.setExtras(extras)
|
||||
.build()
|
||||
|
||||
bitmap?.let {
|
||||
mediaDescriptionBuilder.setIconBitmap(bitmap)
|
||||
}
|
||||
|
||||
return mediaDescriptionBuilder.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.ImageDecoder
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import com.audiobookshelf.app.R
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.player.MediaProgressSyncData
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
@@ -13,6 +16,7 @@ import com.google.android.gms.cast.MediaInfo
|
||||
import com.google.android.gms.cast.MediaQueueItem
|
||||
import com.google.android.gms.common.images.WebImage
|
||||
|
||||
|
||||
// TODO: enum or something in kotlin?
|
||||
val PLAYMETHOD_DIRECTPLAY = 0
|
||||
val PLAYMETHOD_DIRECTSTREAM = 1
|
||||
@@ -53,6 +57,8 @@ class PlaybackSession(
|
||||
@get:JsonIgnore
|
||||
val isLocal get() = playMethod == PLAYMETHOD_LOCAL
|
||||
@get:JsonIgnore
|
||||
val isPodcastEpisode get() = mediaType == "podcast"
|
||||
@get:JsonIgnore
|
||||
val currentTimeMs get() = (currentTime * 1000L).toLong()
|
||||
@get:JsonIgnore
|
||||
val totalDurationMs get() = (getTotalDuration() * 1000L).toLong()
|
||||
@@ -105,9 +111,9 @@ class PlaybackSession(
|
||||
|
||||
@JsonIgnore
|
||||
fun getCoverUri(): Uri {
|
||||
if (localLibraryItem?.coverContentUrl != null) return Uri.parse(localLibraryItem?.coverContentUrl) ?: Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
|
||||
if (localLibraryItem?.coverContentUrl != null) return Uri.parse(localLibraryItem?.coverContentUrl) ?: Uri.parse("android.resource://com.audiobookshelf.app/" + com.audiobookshelf.app.R.drawable.icon)
|
||||
|
||||
if (coverPath == null) return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
|
||||
if (coverPath == null) return Uri.parse("android.resource://com.audiobookshelf.app/" + com.audiobookshelf.app.R.drawable.icon)
|
||||
return Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")
|
||||
}
|
||||
|
||||
@@ -118,13 +124,33 @@ class PlaybackSession(
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getMediaMetadataCompat(): MediaMetadataCompat {
|
||||
fun getMediaMetadataCompat(ctx: Context): MediaMetadataCompat {
|
||||
val metadataBuilder = MediaMetadataCompat.Builder()
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, displayTitle)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, displayAuthor)
|
||||
.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())
|
||||
|
||||
// Local covers get bitmap
|
||||
if (localLibraryItem?.coverContentUrl != null) {
|
||||
val bitmap = if (Build.VERSION.SDK_INT < 28) {
|
||||
MediaStore.Images.Media.getBitmap(ctx.contentResolver, getCoverUri())
|
||||
} else {
|
||||
val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, getCoverUri())
|
||||
ImageDecoder.decodeBitmap(source)
|
||||
}
|
||||
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
|
||||
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap)
|
||||
}
|
||||
|
||||
return metadataBuilder.build()
|
||||
}
|
||||
|
||||
@@ -136,6 +162,9 @@ class PlaybackSession(
|
||||
.setArtist(displayAuthor)
|
||||
.setAlbumArtist(displayAuthor)
|
||||
.setSubtitle(displayAuthor)
|
||||
.setAlbumTitle(displayAuthor)
|
||||
.setDescription(displayAuthor)
|
||||
.setArtworkUri(getCoverUri())
|
||||
|
||||
val contentUri = this.getContentUri(audioTrack)
|
||||
metadataBuilder.setMediaUri(contentUri)
|
||||
@@ -169,7 +198,9 @@ class PlaybackSession(
|
||||
|
||||
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_TITLE, displayTitle ?: "")
|
||||
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_ARTIST, displayAuthor ?: "")
|
||||
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_ALBUM_TITLE, displayAuthor ?: "")
|
||||
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_CHAPTER_TITLE, audioTrack.title)
|
||||
|
||||
castMetadata.putInt(com.google.android.gms.cast.MediaMetadata.KEY_TRACK_NUMBER, audioTrack.index)
|
||||
return castMetadata
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun loadPodcastEpisodeMediaBrowserItems(libraryItemId:String, cb: (MutableList<MediaBrowserCompat.MediaItem>) -> Unit) {
|
||||
fun loadPodcastEpisodeMediaBrowserItems(libraryItemId:String, ctx:Context, cb: (MutableList<MediaBrowserCompat.MediaItem>) -> Unit) {
|
||||
loadLibraryItem(libraryItemId) { libraryItemWrapper ->
|
||||
Log.d(tag, "Loaded Podcast library item $libraryItemWrapper")
|
||||
|
||||
@@ -138,7 +138,7 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
||||
Log.d(tag, "Local Podcast Episode ${podcastEpisode.title} | ${podcastEpisode.id}")
|
||||
|
||||
val progress = DeviceManager.dbManager.getLocalMediaProgress("${libraryItemWrapper.id}-${podcastEpisode.id}")
|
||||
val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress)
|
||||
val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress, ctx)
|
||||
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||
}
|
||||
children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
|
||||
@@ -157,7 +157,7 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
||||
val children = podcast.episodes?.map { podcastEpisode ->
|
||||
|
||||
val progress = serverUserMediaProgress.find { it.libraryItemId == libraryItemWrapper.id && it.episodeId == podcastEpisode.id }
|
||||
val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress)
|
||||
val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress, ctx)
|
||||
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||
}
|
||||
children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
|
||||
@@ -266,6 +266,23 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
||||
|
||||
}
|
||||
|
||||
fun loadServerUserMediaProgress(cb: () -> Unit) {
|
||||
Log.d(tag, "Loading server media progress")
|
||||
if (DeviceManager.serverConnectionConfig == null) {
|
||||
return cb()
|
||||
}
|
||||
|
||||
DeviceManager.serverConnectionConfig?.let { config ->
|
||||
apiHandler.authorize(config) {
|
||||
Log.d(tag, "loadServerUserMediaProgress: Authorized server config ${config.address} result = $it")
|
||||
if (!it.isNullOrEmpty()) {
|
||||
serverUserMediaProgress = it
|
||||
}
|
||||
cb()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadAndroidAutoItems(cb: () -> Unit) {
|
||||
Log.d(tag, "Load android auto items")
|
||||
|
||||
|
||||
+20
-5
@@ -2,7 +2,10 @@ package com.audiobookshelf.app.player
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.ImageDecoder
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.support.v4.media.session.MediaControllerCompat
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.R
|
||||
@@ -42,13 +45,25 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
|
||||
// `getCurrentLargeIcon` don't cause the bitmap to be recreated.
|
||||
currentIconUri = albumArtUri
|
||||
Log.d(tag, "ART $currentIconUri")
|
||||
serviceScope.launch {
|
||||
currentBitmap = albumArtUri?.let {
|
||||
resolveUriAsBitmap(it)
|
||||
|
||||
if (currentIconUri.toString().startsWith("content://")) {
|
||||
currentBitmap = if (Build.VERSION.SDK_INT < 28) {
|
||||
MediaStore.Images.Media.getBitmap(playerNotificationService.contentResolver, currentIconUri)
|
||||
} else {
|
||||
val source: ImageDecoder.Source = ImageDecoder.createSource(playerNotificationService.contentResolver, currentIconUri!!)
|
||||
ImageDecoder.decodeBitmap(source)
|
||||
}
|
||||
currentBitmap?.let { callback.onBitmap(it) }
|
||||
currentBitmap
|
||||
} else {
|
||||
serviceScope.launch {
|
||||
currentBitmap = albumArtUri?.let {
|
||||
resolveUriAsBitmap(it)
|
||||
}
|
||||
currentBitmap?.let { callback.onBitmap(it) }
|
||||
}
|
||||
null
|
||||
}
|
||||
null
|
||||
|
||||
} else {
|
||||
currentBitmap
|
||||
}
|
||||
|
||||
@@ -115,7 +115,19 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
||||
failedSyncs = 0
|
||||
cb()
|
||||
}
|
||||
}
|
||||
|
||||
fun finished(cb: () -> Unit) {
|
||||
if (!listeningTimerRunning) return
|
||||
listeningTimerTask?.cancel()
|
||||
listeningTimerTask = null
|
||||
listeningTimerRunning = false
|
||||
Log.d(tag, "stop: Stopping listening for $currentDisplayTitle")
|
||||
|
||||
sync(true, currentPlaybackSession?.duration ?: 0.0) {
|
||||
reset()
|
||||
cb()
|
||||
}
|
||||
}
|
||||
|
||||
fun syncFromServerProgress(mediaProgress: MediaProgress) {
|
||||
|
||||
@@ -181,6 +181,12 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
|
||||
playerNotificationService.jumpBackward()
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
|
||||
playerNotificationService.jumpForward()
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_REWIND -> {
|
||||
playerNotificationService.jumpBackward()
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_STOP -> {
|
||||
playerNotificationService.closePlayback()
|
||||
}
|
||||
|
||||
@@ -52,6 +52,8 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
|
||||
if (playerNotificationService.currentPlayer.playbackState == Player.STATE_ENDED) {
|
||||
Log.d(tag, "STATE_ENDED")
|
||||
playerNotificationService.sendClientMetadata(PlayerState.ENDED)
|
||||
|
||||
playerNotificationService.handlePlaybackEnded()
|
||||
}
|
||||
if (playerNotificationService.currentPlayer.playbackState == Player.STATE_IDLE) {
|
||||
Log.d(tag, "STATE_IDLE")
|
||||
|
||||
+88
-17
@@ -3,7 +3,9 @@ package com.audiobookshelf.app.player
|
||||
import android.app.*
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.graphics.ImageDecoder
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorManager
|
||||
import android.net.ConnectivityManager
|
||||
@@ -11,8 +13,10 @@ import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.os.*
|
||||
import android.provider.MediaStore
|
||||
import android.support.v4.media.MediaBrowserCompat
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import android.support.v4.media.session.MediaControllerCompat
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
@@ -250,6 +254,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||
playerNotificationManager.setUseFastForwardActionInCompactView(true)
|
||||
playerNotificationManager.setUseRewindActionInCompactView(true)
|
||||
playerNotificationManager.setSmallIcon(R.drawable.exo_icon_localaudio)
|
||||
|
||||
// Unknown action
|
||||
playerNotificationManager.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE)
|
||||
@@ -270,6 +275,17 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
|
||||
val coverUri = currentPlaybackSession!!.getCoverUri()
|
||||
|
||||
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)
|
||||
} else {
|
||||
val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, coverUri)
|
||||
ImageDecoder.decodeBitmap(source)
|
||||
}
|
||||
}
|
||||
|
||||
// Fix for local images crashing on Android 11 for specific devices
|
||||
// https://stackoverflow.com/questions/64186578/android-11-mediastyle-notification-crash/64232958#64232958
|
||||
try {
|
||||
@@ -282,11 +298,19 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
Log.e(tag, "Grant uri permission error $error")
|
||||
}
|
||||
|
||||
return MediaDescriptionCompat.Builder()
|
||||
.setMediaId(currentPlaybackSession!!.id)
|
||||
val extra = Bundle()
|
||||
extra.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, currentPlaybackSession!!.displayAuthor)
|
||||
|
||||
val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
|
||||
.setExtras(extra)
|
||||
.setTitle(currentPlaybackSession!!.displayTitle)
|
||||
.setSubtitle(currentPlaybackSession!!.displayAuthor)
|
||||
.setIconUri(coverUri).build()
|
||||
.setIconUri(coverUri)
|
||||
|
||||
bitmap?.let {
|
||||
mediaDescriptionBuilder.setIconBitmap(it)
|
||||
}
|
||||
|
||||
return mediaDescriptionBuilder.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,12 +367,20 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: When an item isFinished the currentTime should be reset to 0
|
||||
// will reset the time if currentTime is within 5s of duration (for android auto)
|
||||
Log.d(tag, "Prepare Player Session Current Time=${playbackSession.currentTime}, Duration=${playbackSession.duration}")
|
||||
if (playbackSession.duration - playbackSession.currentTime < 5) {
|
||||
Log.d(tag, "Prepare Player Session is finished, so restart it")
|
||||
playbackSession.currentTime = 0.0
|
||||
}
|
||||
|
||||
isClosed = false
|
||||
val customActionProviders = mutableListOf(
|
||||
JumpBackwardCustomActionProvider(),
|
||||
JumpForwardCustomActionProvider(),
|
||||
)
|
||||
val metadata = playbackSession.getMediaMetadataCompat()
|
||||
val metadata = playbackSession.getMediaMetadataCompat(ctx)
|
||||
mediaSession.setMetadata(metadata)
|
||||
val mediaItems = playbackSession.getMediaItems()
|
||||
val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f
|
||||
@@ -358,7 +390,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
customActionProviders.addAll(listOf(
|
||||
SkipBackwardCustomActionProvider(),
|
||||
SkipForwardCustomActionProvider(),
|
||||
));
|
||||
))
|
||||
}
|
||||
mediaSessionConnector.setCustomActionProviders(*customActionProviders.toTypedArray());
|
||||
|
||||
@@ -464,6 +496,36 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
}
|
||||
}
|
||||
|
||||
fun handlePlaybackEnded() {
|
||||
Log.d(tag, "handlePlaybackEnded")
|
||||
if (isAndroidAuto && currentPlaybackSession?.isPodcastEpisode == true) {
|
||||
Log.d(tag, "Podcast playback ended on android auto")
|
||||
val libraryItem = currentPlaybackSession?.libraryItem ?: return
|
||||
|
||||
// Need to sync with server to set as finished
|
||||
mediaProgressSyncer.finished {
|
||||
// Need to reload media progress
|
||||
mediaManager.loadServerUserMediaProgress {
|
||||
val podcast = libraryItem.media as Podcast
|
||||
val nextEpisode = podcast.getNextUnfinishedEpisode(libraryItem.id, mediaManager)
|
||||
Log.d(tag, "handlePlaybackEnded nextEpisode=$nextEpisode")
|
||||
nextEpisode?.let { podcastEpisode ->
|
||||
mediaManager.play(libraryItem, podcastEpisode, getPlayItemRequestPayload(false)) {
|
||||
if (it == null) {
|
||||
Log.e(tag, "Failed to play library item")
|
||||
} else {
|
||||
val playbackRate = mediaManager.getSavedPlaybackRate()
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
preparePlayer(it,true, playbackRate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun startNewPlaybackSession() {
|
||||
currentPlaybackSession?.let { playbackSession ->
|
||||
val forceTranscode = playbackSession.isHLS // If already HLS then force
|
||||
@@ -639,7 +701,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
// Streaming from server so check if playback session still exists on server
|
||||
Log.d(
|
||||
tag,
|
||||
"checkCurrentSessionProgress: Checking if playback session for server stream is still available"
|
||||
"checkCurrentSessionProgress: Checking if playback session ${playbackSession.id} for server stream is still available"
|
||||
)
|
||||
apiHandler.getPlaybackSession(playbackSession.id) {
|
||||
if (it == null) {
|
||||
@@ -694,14 +756,23 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
}
|
||||
|
||||
fun seekPlayer(time: Long) {
|
||||
Log.d(tag, "seekPlayer mediaCount = ${currentPlayer.mediaItemCount} | $time")
|
||||
var timeToSeek = time
|
||||
Log.d(tag, "seekPlayer mediaCount = ${currentPlayer.mediaItemCount} | $timeToSeek")
|
||||
if (timeToSeek < 0) {
|
||||
Log.w(tag, "seekPlayer invalid time ${timeToSeek} - setting to 0")
|
||||
timeToSeek = 0L
|
||||
} else if (timeToSeek > getDuration()) {
|
||||
Log.w(tag, "seekPlayer invalid time ${timeToSeek} - setting to MAX - 2000")
|
||||
timeToSeek = getDuration() - 2000L
|
||||
}
|
||||
|
||||
if (currentPlayer.mediaItemCount > 1) {
|
||||
currentPlaybackSession?.currentTime = time / 1000.0
|
||||
currentPlaybackSession?.currentTime = timeToSeek / 1000.0
|
||||
val newWindowIndex = currentPlaybackSession?.getCurrentTrackIndex() ?: 0
|
||||
val newTimeOffset = currentPlaybackSession?.getCurrentTrackTimeMs() ?: 0
|
||||
currentPlayer.seekTo(newWindowIndex, newTimeOffset)
|
||||
} else {
|
||||
currentPlayer.seekTo(time)
|
||||
currentPlayer.seekTo(timeToSeek)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -856,7 +927,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
|
||||
if (parentMediaId.startsWith("li_") || parentMediaId.startsWith("local_")) { // Show podcast episodes
|
||||
Log.d(tag, "Loading podcast episodes")
|
||||
mediaManager.loadPodcastEpisodeMediaBrowserItems(parentMediaId) {
|
||||
mediaManager.loadPodcastEpisodeMediaBrowserItems(parentMediaId, ctx) {
|
||||
result.sendResult(it)
|
||||
}
|
||||
} else if (::browseTree.isInitialized && browseTree[parentMediaId] == null && mediaManager.getIsLibrary(parentMediaId)) { // Load library items for library
|
||||
@@ -864,11 +935,11 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
mediaManager.loadLibraryItemsWithAudio(parentMediaId) { libraryItems ->
|
||||
val children = libraryItems.map { libraryItem ->
|
||||
if (libraryItem.mediaType == "podcast") { // Podcasts are browseable
|
||||
val mediaDescription = libraryItem.getMediaDescription(null)
|
||||
val mediaDescription = libraryItem.getMediaDescription(null, ctx)
|
||||
MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
||||
} else {
|
||||
val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.id }
|
||||
val description = libraryItem.getMediaDescription(progress)
|
||||
val description = libraryItem.getMediaDescription(progress, ctx)
|
||||
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||
}
|
||||
}
|
||||
@@ -882,13 +953,13 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
|
||||
localBooks.forEach { localLibraryItem ->
|
||||
val progress = DeviceManager.dbManager.getLocalMediaProgress(localLibraryItem.id)
|
||||
val description = localLibraryItem.getMediaDescription(progress)
|
||||
val description = localLibraryItem.getMediaDescription(progress, ctx)
|
||||
|
||||
localBrowseItems += MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||
}
|
||||
|
||||
localPodcasts.forEach { localLibraryItem ->
|
||||
val mediaDescription = localLibraryItem.getMediaDescription(null)
|
||||
val mediaDescription = localLibraryItem.getMediaDescription(null, ctx)
|
||||
localBrowseItems += MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
||||
}
|
||||
|
||||
@@ -905,14 +976,14 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
} else {
|
||||
progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == itemInProgress.libraryItemWrapper.id && it.episodeId == itemInProgress.episode.id }
|
||||
}
|
||||
mediaDescription = itemInProgress.episode.getMediaDescription(itemInProgress.libraryItemWrapper,progress)
|
||||
mediaDescription = itemInProgress.episode.getMediaDescription(itemInProgress.libraryItemWrapper, progress, ctx)
|
||||
} else {
|
||||
if (itemInProgress.isLocal) {
|
||||
progress = DeviceManager.dbManager.getLocalMediaProgress(itemInProgress.libraryItemWrapper.id)
|
||||
} else {
|
||||
progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == itemInProgress.libraryItemWrapper.id }
|
||||
}
|
||||
mediaDescription = itemInProgress.libraryItemWrapper.getMediaDescription(progress)
|
||||
mediaDescription = itemInProgress.libraryItemWrapper.getMediaDescription(progress, ctx)
|
||||
}
|
||||
localBrowseItems += MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import com.getcapacitor.annotation.CapacitorPlugin
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONObject
|
||||
|
||||
@CapacitorPlugin(name = "AbsDatabase")
|
||||
class AbsDatabase : Plugin() {
|
||||
|
||||
@@ -138,12 +138,16 @@ class ApiHandler(var ctx:Context) {
|
||||
val mapper = jacksonMapper
|
||||
getRequest("/api/libraries", null,null) {
|
||||
val libraries = mutableListOf<Library>()
|
||||
if (it.has("value")) {
|
||||
val array = it.getJSONArray("value")
|
||||
for (i in 0 until array.length()) {
|
||||
val library = mapper.readValue<Library>(array.get(i).toString())
|
||||
libraries.add(library)
|
||||
}
|
||||
|
||||
var array = JSONArray()
|
||||
if (it.has("libraries")) { // TODO: Server 2.2.9 changed to this
|
||||
array = it.getJSONArray("libraries")
|
||||
} else if (it.has("value")) {
|
||||
array = it.getJSONArray("value")
|
||||
}
|
||||
|
||||
for (i in 0 until array.length()) {
|
||||
libraries.add(mapper.readValue(array.get(i).toString()))
|
||||
}
|
||||
cb(libraries)
|
||||
}
|
||||
@@ -317,6 +321,7 @@ class ApiHandler(var ctx:Context) {
|
||||
}
|
||||
|
||||
fun getPlaybackSession(playbackSessionId:String, cb: (PlaybackSession?) -> Unit) {
|
||||
Log.d(tag, "getPlaybackSession for $playbackSessionId for server ${DeviceManager.serverAddress}")
|
||||
val endpoint = "/api/session/$playbackSessionId"
|
||||
getRequest(endpoint, null, null) {
|
||||
val err = it.getString("error")
|
||||
|
||||
@@ -2,5 +2,9 @@
|
||||
<widget version="1.0.0" xmlns="http://www.w3.org/ns/widgets" xmlns:cdv="http://cordova.apache.org/ns/1.0">
|
||||
<access origin="*" />
|
||||
|
||||
<feature name="CDVOrientation">
|
||||
<param name="android-package" value="cordova.plugins.screenorientation.CDVOrientation"/>
|
||||
</feature>
|
||||
|
||||
|
||||
</widget>
|
||||
@@ -1,7 +1,7 @@
|
||||
ext {
|
||||
minSdkVersion = 24
|
||||
compileSdkVersion = 31
|
||||
targetSdkVersion = 30
|
||||
targetSdkVersion = 31
|
||||
androidxActivityVersion = '1.2.0'
|
||||
androidxAppCompatVersion = '1.4.1'
|
||||
androidxCoordinatorLayoutVersion = '1.2.0'
|
||||
|
||||
+16
-1
@@ -13,7 +13,6 @@
|
||||
.abs-icons {
|
||||
/* use !important to prevent issues with browser extensions that change fonts */
|
||||
font-family: 'absicons' !important;
|
||||
speak: never;
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
font-variant: normal;
|
||||
@@ -25,6 +24,18 @@
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.icon-list:before {
|
||||
content: "\e907";
|
||||
}
|
||||
|
||||
.icon-home:before {
|
||||
content: "\e909";
|
||||
}
|
||||
|
||||
.icon-authors:before {
|
||||
content: "\e90a";
|
||||
}
|
||||
|
||||
.icon-books-1:before {
|
||||
content: "\e905";
|
||||
}
|
||||
@@ -53,6 +64,10 @@
|
||||
content: "\e901";
|
||||
}
|
||||
|
||||
.icon-columns:before {
|
||||
content: "\e90b";
|
||||
}
|
||||
|
||||
.icon-headphones:before {
|
||||
content: "\e910";
|
||||
}
|
||||
|
||||
@@ -21,12 +21,12 @@
|
||||
<widgets-download-progress-indicator />
|
||||
|
||||
<!-- Must be connected to a server to cast, only supports media items on server -->
|
||||
<div v-show="isCastAvailable && user" class="mx-2 cursor-pointer mt-1.5">
|
||||
<span class="material-icons text-2xl" :class="isCasting ? 'text-success' : ''" @click="castClick">cast</span>
|
||||
<div v-show="isCastAvailable && user" class="mx-2 cursor-pointer flex items-center pt-0.5" @click="castClick">
|
||||
<span class="material-icons" :class="isCasting ? 'text-success' : ''">cast</span>
|
||||
</div>
|
||||
|
||||
<nuxt-link v-if="user" class="h-7 mx-1.5" to="/search">
|
||||
<span class="material-icons" style="font-size: 1.75rem">search</span>
|
||||
<nuxt-link v-if="user" class="h-7 mx-1.5" style="padding-top: 3px" to="/search">
|
||||
<span class="material-icons">search</span>
|
||||
</nuxt-link>
|
||||
|
||||
<div class="h-7 mx-1.5">
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<template>
|
||||
<div v-if="playbackSession" id="streamContainer" class="playerContainer fixed top-0 left-0 layout-wrapper right-0 z-50 pointer-events-none" :class="{ fullscreen: showFullscreen, 'ios-player': $platform === 'ios', 'web-player': $platform === 'web' }">
|
||||
<div v-if="showFullscreen" class="w-full h-full z-10 bg-bg absolute top-0 left-0 pointer-events-auto">
|
||||
<div class="top-2 left-4 absolute cursor-pointer">
|
||||
<div class="top-4 left-4 absolute cursor-pointer">
|
||||
<span class="material-icons text-5xl" @click="collapseFullscreen">expand_more</span>
|
||||
</div>
|
||||
<div v-show="showCastBtn" class="top-4 right-16 absolute cursor-pointer">
|
||||
<div v-show="showCastBtn" class="top-6 right-16 absolute cursor-pointer">
|
||||
<span class="material-icons text-3xl" :class="isCasting ? 'text-success' : ''" @click="castClick">cast</span>
|
||||
</div>
|
||||
<div class="top-4 right-4 absolute cursor-pointer">
|
||||
<div class="top-6 right-4 absolute cursor-pointer">
|
||||
<span class="material-icons text-3xl" @click="showMoreMenuDialog = true">more_vert</span>
|
||||
</div>
|
||||
<p class="top-2 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" style="font-size: 10px" :class="{ 'text-success': isLocalPlayMethod, 'text-accent': !isLocalPlayMethod }">{{ isDirectPlayMethod ? 'Direct' : isLocalPlayMethod ? 'Local' : 'Transcode' }}</p>
|
||||
<p class="top-4 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" style="font-size: 10px" :class="{ 'text-success': isLocalPlayMethod, 'text-accent': !isLocalPlayMethod }">{{ isDirectPlayMethod ? 'Direct' : isLocalPlayMethod ? 'Local' : 'Transcode' }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="useChapterTrack && showFullscreen" class="absolute total-track w-full px-3 z-30">
|
||||
@@ -46,7 +46,7 @@
|
||||
<div id="playerContent" class="playerContainer w-full z-20 bg-primary absolute bottom-0 left-0 right-0 p-2 pointer-events-auto transition-all" @click="clickContainer">
|
||||
<div v-if="showFullscreen" class="absolute top-0 left-0 right-0 w-full py-3 mx-auto px-3" style="max-width: 380px">
|
||||
<div class="flex items-center justify-between pointer-events-auto">
|
||||
<span v-if="!isPodcast && !isLocalPlayMethod" class="material-icons text-3xl text-white text-opacity-75 cursor-pointer" @click="$emit('showBookmarks')">{{ bookmarks.length ? 'bookmark' : 'bookmark_border' }}</span>
|
||||
<span v-if="!isPodcast && isServerItem && networkConnected" class="material-icons text-3xl text-white text-opacity-75 cursor-pointer" @click="$emit('showBookmarks')">{{ bookmarks.length ? 'bookmark' : 'bookmark_border' }}</span>
|
||||
<!-- hidden for podcasts but still using this as a placeholder -->
|
||||
<span v-else class="material-icons text-3xl text-white text-opacity-0">bookmark</span>
|
||||
|
||||
@@ -75,13 +75,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="playerTrack" class="absolute bottom-0 left-0 w-full px-3">
|
||||
<div id="playerTrack" class="absolute left-0 w-full px-3">
|
||||
<div ref="track" class="h-1.5 w-full bg-gray-500 bg-opacity-50 relative" :class="{ 'animate-pulse': isLoading }" @touchstart="touchstartTrack" @click="clickTrack">
|
||||
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="bufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="draggingTrack" class="h-full bg-warning bg-opacity-25 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="trackCursor" class="h-3.5 w-3.5 rounded-full bg-gray-200 absolute -top-1 pointer-events-none" :class="{ 'opacity-0': lockUi }" />
|
||||
<div ref="trackCursor" class="h-3.5 w-3.5 rounded-full bg-gray-200 absolute -top-1 pointer-events-none" :class="{ 'opacity-0': lockUi || !showFullscreen }" />
|
||||
</div>
|
||||
<div id="timestamp-row" class="flex pt-0.5">
|
||||
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem" ref="currentTimestamp">0:00</p>
|
||||
@@ -126,7 +126,8 @@ export default {
|
||||
default: () => []
|
||||
},
|
||||
sleepTimerRunning: Boolean,
|
||||
sleepTimeRemaining: Number
|
||||
sleepTimeRemaining: Number,
|
||||
isServerItem: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -209,7 +210,7 @@ export default {
|
||||
},
|
||||
bookCoverWidth() {
|
||||
if (this.showFullscreen) return this.fullscreenBookCoverWidth
|
||||
return 60
|
||||
return 46 / this.bookCoverAspectRatio
|
||||
},
|
||||
fullscreenBookCoverWidth() {
|
||||
var heightScale = (this.windowHeight - 200) / 651
|
||||
@@ -324,6 +325,9 @@ export default {
|
||||
} else {
|
||||
return secondsRemaining + 's'
|
||||
}
|
||||
},
|
||||
networkConnected() {
|
||||
return this.$store.state.networkConnected
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -335,15 +339,18 @@ export default {
|
||||
this.showFullscreen = false
|
||||
}
|
||||
},
|
||||
touchstartTrack(e) {
|
||||
async touchstartTrack(e) {
|
||||
await this.$hapticsImpactMedium()
|
||||
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen || this.lockUi) return
|
||||
this.touchTrackStart = true
|
||||
},
|
||||
selectChapter(chapter) {
|
||||
async selectChapter(chapter) {
|
||||
await this.$hapticsImpactMedium()
|
||||
this.seek(chapter.start)
|
||||
this.showChapterModal = false
|
||||
},
|
||||
castClick() {
|
||||
async castClick() {
|
||||
await this.$hapticsImpactMedium()
|
||||
if (this.isLocalPlayMethod) {
|
||||
this.$eventBus.$emit('cast-local-item')
|
||||
return
|
||||
@@ -362,12 +369,14 @@ export default {
|
||||
this.showFullscreen = false
|
||||
this.forceCloseDropdownMenu()
|
||||
},
|
||||
jumpNextChapter() {
|
||||
async jumpNextChapter() {
|
||||
await this.$hapticsImpactMedium()
|
||||
if (this.isLoading) return
|
||||
if (!this.nextChapter) return
|
||||
this.seek(this.nextChapter.start)
|
||||
},
|
||||
jumpChapterStart() {
|
||||
async jumpChapterStart() {
|
||||
await this.$hapticsImpactMedium()
|
||||
if (this.isLoading) return
|
||||
if (!this.currentChapter) {
|
||||
return this.restart()
|
||||
@@ -375,9 +384,9 @@ export default {
|
||||
|
||||
// If 4 seconds or less into current chapter, then go to previous
|
||||
if (this.currentTime - this.currentChapter.start <= 4) {
|
||||
var currChapterIndex = this.chapters.findIndex((ch) => Number(ch.start) <= this.currentTime && Number(ch.end) >= this.currentTime)
|
||||
const currChapterIndex = this.chapters.findIndex((ch) => Number(ch.start) <= this.currentTime && Number(ch.end) >= this.currentTime)
|
||||
if (currChapterIndex > 0) {
|
||||
var prevChapter = this.chapters[currChapterIndex - 1]
|
||||
const prevChapter = this.chapters[currChapterIndex - 1]
|
||||
this.seek(prevChapter.start)
|
||||
}
|
||||
} else {
|
||||
@@ -387,7 +396,8 @@ export default {
|
||||
showSleepTimerModal() {
|
||||
this.$emit('showSleepTimer')
|
||||
},
|
||||
setPlaybackSpeed(speed) {
|
||||
async setPlaybackSpeed(speed) {
|
||||
await this.$hapticsImpactMedium()
|
||||
console.log(`[AudioPlayer] Set Playback Rate: ${speed}`)
|
||||
this.currentPlaybackRate = speed
|
||||
AbsAudioPlayer.setPlaybackSpeed({ value: speed })
|
||||
@@ -395,11 +405,13 @@ export default {
|
||||
restart() {
|
||||
this.seek(0)
|
||||
},
|
||||
jumpBackwards() {
|
||||
async jumpBackwards() {
|
||||
await this.$hapticsImpactMedium()
|
||||
if (this.isLoading) return
|
||||
AbsAudioPlayer.seekBackward({ value: this.jumpBackwardsTime })
|
||||
},
|
||||
jumpForward() {
|
||||
async jumpForward() {
|
||||
await this.$hapticsImpactMedium()
|
||||
if (this.isLoading) return
|
||||
AbsAudioPlayer.seekForward({ value: this.jumpForwardTime })
|
||||
},
|
||||
@@ -408,19 +420,19 @@ export default {
|
||||
this.updateReadyTrack()
|
||||
},
|
||||
setChunksReady(chunks, numSegments) {
|
||||
var largestSeg = 0
|
||||
let largestSeg = 0
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
var chunk = chunks[i]
|
||||
const chunk = chunks[i]
|
||||
if (typeof chunk === 'string') {
|
||||
var chunkRange = chunk.split('-').map((c) => Number(c))
|
||||
const chunkRange = chunk.split('-').map((c) => Number(c))
|
||||
if (chunkRange.length < 2) continue
|
||||
if (chunkRange[1] > largestSeg) largestSeg = chunkRange[1]
|
||||
} else if (chunk > largestSeg) {
|
||||
largestSeg = chunk
|
||||
}
|
||||
}
|
||||
var percentageReady = largestSeg / numSegments
|
||||
var widthReady = Math.round(this.trackWidth * percentageReady)
|
||||
const percentageReady = largestSeg / numSegments
|
||||
const widthReady = Math.round(this.trackWidth * percentageReady)
|
||||
if (this.readyTrackWidth === widthReady) {
|
||||
return
|
||||
}
|
||||
@@ -472,17 +484,17 @@ export default {
|
||||
},
|
||||
updateTrack() {
|
||||
// Update progress track UI
|
||||
var percentDone = this.currentTime / this.totalDuration
|
||||
var totalPercentDone = percentDone
|
||||
var bufferedPercent = this.bufferedTime / this.totalDuration
|
||||
var totalBufferedPercent = bufferedPercent
|
||||
let percentDone = this.currentTime / this.totalDuration
|
||||
const totalPercentDone = percentDone
|
||||
let bufferedPercent = this.bufferedTime / this.totalDuration
|
||||
const totalBufferedPercent = bufferedPercent
|
||||
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
var currChapTime = this.currentTime - this.currentChapter.start
|
||||
const currChapTime = this.currentTime - this.currentChapter.start
|
||||
percentDone = currChapTime / this.currentChapterDuration
|
||||
bufferedPercent = Math.max(0, Math.min(1, (this.bufferedTime - this.currentChapter.start) / this.currentChapterDuration))
|
||||
}
|
||||
var ptWidth = Math.round(percentDone * this.trackWidth)
|
||||
const ptWidth = Math.round(percentDone * this.trackWidth)
|
||||
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
||||
this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px'
|
||||
|
||||
@@ -539,6 +551,7 @@ export default {
|
||||
this.seek(time)
|
||||
},
|
||||
async playPauseClick() {
|
||||
await this.$hapticsImpactMedium()
|
||||
if (this.isLoading) return
|
||||
|
||||
this.isPlaying = !!((await AbsAudioPlayer.playPause()) || {}).playing
|
||||
@@ -641,7 +654,8 @@ export default {
|
||||
ts.innerText = currTimeStr
|
||||
}
|
||||
},
|
||||
clickMenuAction(action) {
|
||||
async clickMenuAction(action) {
|
||||
await this.$hapticsImpactMedium()
|
||||
this.showMoreMenuDialog = false
|
||||
this.$nextTick(() => {
|
||||
if (action === 'lock') {
|
||||
@@ -750,16 +764,22 @@ export default {
|
||||
this.onProgressSyncSuccess = AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess)
|
||||
},
|
||||
screenOrientationChange() {
|
||||
setTimeout(this.updateScreenSize, 50)
|
||||
setTimeout(() => {
|
||||
this.updateScreenSize()
|
||||
if (this.$refs.track) {
|
||||
this.trackWidth = this.$refs.track.clientWidth
|
||||
this.updateTrack()
|
||||
this.updateReadyTrack()
|
||||
}
|
||||
}, 50)
|
||||
},
|
||||
updateScreenSize() {
|
||||
this.windowHeight = window.innerHeight
|
||||
var coverHeight = this.fullscreenBookCoverWidth * this.bookCoverAspectRatio
|
||||
const coverHeight = this.fullscreenBookCoverWidth * this.bookCoverAspectRatio
|
||||
document.documentElement.style.setProperty('--cover-image-width', this.fullscreenBookCoverWidth + 'px')
|
||||
document.documentElement.style.setProperty('--cover-image-height', coverHeight + 'px')
|
||||
},
|
||||
minimizePlayerEvt() {
|
||||
console.log('Minimize Player Evt')
|
||||
this.showFullscreen = false
|
||||
},
|
||||
showProgressSyncIsFailing() {
|
||||
@@ -821,8 +841,8 @@ export default {
|
||||
:root {
|
||||
--cover-image-width: 0px;
|
||||
--cover-image-height: 0px;
|
||||
--cover-image-width-collapsed: 60px;
|
||||
--cover-image-height-collapsed: 60px;
|
||||
--cover-image-width-collapsed: 46px;
|
||||
--cover-image-height-collapsed: 46px;
|
||||
}
|
||||
.bookCoverWrapper {
|
||||
box-shadow: 3px -2px 5px #00000066;
|
||||
@@ -840,10 +860,10 @@ export default {
|
||||
#playerTrack {
|
||||
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
||||
transition-property: margin;
|
||||
margin-bottom: 4px;
|
||||
bottom: 10px;
|
||||
}
|
||||
.fullscreen #playerTrack {
|
||||
margin-bottom: 18px;
|
||||
bottom: 32px;
|
||||
}
|
||||
|
||||
.ios-player #timestamp-row {
|
||||
@@ -852,7 +872,7 @@ export default {
|
||||
}
|
||||
|
||||
.cover-wrapper {
|
||||
bottom: 44px;
|
||||
bottom: 48px;
|
||||
left: 12px;
|
||||
height: var(--cover-image-height-collapsed);
|
||||
width: var(--cover-image-width-collapsed);
|
||||
@@ -873,8 +893,8 @@ export default {
|
||||
transform-origin: left bottom;
|
||||
|
||||
width: 40%;
|
||||
bottom: 50px;
|
||||
left: 80px;
|
||||
bottom: 56px;
|
||||
left: 70px;
|
||||
text-align: left;
|
||||
}
|
||||
.title-author-texts .title-text {
|
||||
@@ -912,7 +932,7 @@ export default {
|
||||
width: 140px;
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
bottom: 45px;
|
||||
bottom: 50px;
|
||||
}
|
||||
#playerControls .jump-icon {
|
||||
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
||||
@@ -949,7 +969,7 @@ export default {
|
||||
|
||||
.fullscreen #playerControls {
|
||||
width: 100%;
|
||||
bottom: 100px;
|
||||
bottom: 105px;
|
||||
}
|
||||
.fullscreen #playerControls .jump-icon {
|
||||
margin: 0px 18px;
|
||||
@@ -970,4 +990,4 @@ export default {
|
||||
.fullscreen #playerControls .play-btn .material-icons {
|
||||
font-size: 2.1rem;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<app-audio-player ref="audioPlayer" :bookmarks="bookmarks" :sleep-timer-running="isSleepTimerRunning" :sleep-time-remaining="sleepTimeRemaining" @selectPlaybackSpeed="showPlaybackSpeedModal = true" @updateTime="(t) => (currentTime = t)" @showSleepTimer="showSleepTimer" @showBookmarks="showBookmarks" />
|
||||
<app-audio-player ref="audioPlayer" :bookmarks="bookmarks" :sleep-timer-running="isSleepTimerRunning" :sleep-time-remaining="sleepTimeRemaining" :is-server-item="!!serverLibraryItemId" @selectPlaybackSpeed="showPlaybackSpeedModal = true" @updateTime="(t) => (currentTime = t)" @showSleepTimer="showSleepTimer" @showBookmarks="showBookmarks" />
|
||||
|
||||
<modals-playback-speed-modal v-model="showPlaybackSpeedModal" :playback-rate.sync="playbackSpeed" @update:playbackRate="updatePlaybackSpeed" @change="changePlaybackSpeed" />
|
||||
<modals-sleep-timer-modal v-model="showSleepTimerModal" :current-time="sleepTimeRemaining" :sleep-timer-running="isSleepTimerRunning" :current-end-of-chapter-time="currentEndOfChapterTime" @change="selectSleepTimeout" @cancel="cancelSleepTimer" @increase="increaseSleepTimer" @decrease="decreaseSleepTimer" />
|
||||
@@ -38,21 +38,10 @@ export default {
|
||||
serverEpisodeId: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
socketConnected(newVal) {
|
||||
if (newVal) {
|
||||
console.log('Socket Connected set listeners')
|
||||
this.setListeners()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
bookmarks() {
|
||||
if (!this.serverLibraryItemId) return []
|
||||
return this.$store.getters['user/getUserBookmarksForItem'](this.serverLibraryItemId)
|
||||
},
|
||||
socketConnected() {
|
||||
return this.$store.state.socketConnected
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -62,7 +51,7 @@ export default {
|
||||
selectBookmark(bookmark) {
|
||||
this.showBookmarksModal = false
|
||||
if (!bookmark || isNaN(bookmark.time)) return
|
||||
var bookmarkTime = Number(bookmark.time)
|
||||
const bookmarkTime = Number(bookmark.time)
|
||||
if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.seek(bookmarkTime)
|
||||
}
|
||||
@@ -71,8 +60,6 @@ export default {
|
||||
this.isSleepTimerRunning = false
|
||||
if (currentPosition) {
|
||||
console.log('Sleep Timer Ended Current Position: ' + currentPosition)
|
||||
var currentTime = Math.floor(currentPosition / 1000)
|
||||
// TODO: Was syncing to the server here before
|
||||
}
|
||||
},
|
||||
onSleepTimerSet({ value: sleepTimeRemaining }) {
|
||||
@@ -160,17 +147,6 @@ export default {
|
||||
this.notifyOnReady()
|
||||
}
|
||||
},
|
||||
setListeners() {
|
||||
// if (!this.$server.socket) {
|
||||
// console.error('Invalid server socket not set')
|
||||
// return
|
||||
// }
|
||||
// this.$server.socket.on('stream_open', this.streamOpen)
|
||||
// this.$server.socket.on('stream_closed', this.streamClosed)
|
||||
// this.$server.socket.on('stream_progress', this.streamProgress)
|
||||
// this.$server.socket.on('stream_ready', this.streamReady)
|
||||
// this.$server.socket.on('stream_reset', this.streamReset)
|
||||
},
|
||||
closeStreamOnly() {
|
||||
// If user logs out or disconnects from server and not playing local
|
||||
if (this.$refs.audioPlayer && !this.$refs.audioPlayer.isLocalPlayMethod) {
|
||||
@@ -206,13 +182,13 @@ export default {
|
||||
})
|
||||
},
|
||||
async playLibraryItem(payload) {
|
||||
var libraryItemId = payload.libraryItemId
|
||||
var episodeId = payload.episodeId
|
||||
const libraryItemId = payload.libraryItemId
|
||||
const episodeId = payload.episodeId
|
||||
|
||||
// When playing local library item and can also play this item from the server
|
||||
// then store the server library item id so it can be used if a cast is made
|
||||
var serverLibraryItemId = payload.serverLibraryItemId || null
|
||||
var serverEpisodeId = payload.serverEpisodeId || null
|
||||
const serverLibraryItemId = payload.serverLibraryItemId || null
|
||||
const serverEpisodeId = payload.serverEpisodeId || null
|
||||
|
||||
if (libraryItemId.startsWith('local') && this.$store.state.isCasting) {
|
||||
const { value } = await Dialog.confirm({
|
||||
@@ -227,7 +203,7 @@ export default {
|
||||
this.serverLibraryItemId = null
|
||||
this.serverEpisodeId = null
|
||||
|
||||
var playbackRate = 1
|
||||
let playbackRate = 1
|
||||
if (this.$refs.audioPlayer) {
|
||||
playbackRate = this.$refs.audioPlayer.currentPlaybackRate || 1
|
||||
}
|
||||
@@ -278,7 +254,7 @@ export default {
|
||||
notifyOnReady() {
|
||||
// If settings aren't loaded yet, native player will receive incorrect settings
|
||||
console.log('Notify on ready... settingsLoaded:', this.settingsLoaded, 'isReady:', this.isReady)
|
||||
if ( this.settingsLoaded && this.isReady ) {
|
||||
if (this.settingsLoaded && this.isReady) {
|
||||
AbsAudioPlayer.onReady()
|
||||
}
|
||||
}
|
||||
@@ -292,7 +268,6 @@ export default {
|
||||
this.playbackSpeed = this.$store.getters['user/getUserSetting']('playbackRate')
|
||||
console.log(`[AudioPlayerContainer] Init Playback Speed: ${this.playbackSpeed}`)
|
||||
|
||||
this.setListeners()
|
||||
this.$eventBus.$on('abs-ui-ready', this.onReady)
|
||||
this.$eventBus.$on('play-item', this.playLibraryItem)
|
||||
this.$eventBus.$on('pause-item', this.pauseItem)
|
||||
@@ -306,13 +281,6 @@ export default {
|
||||
if (this.onSleepTimerSetListener) this.onSleepTimerSetListener.remove()
|
||||
if (this.onMediaPlayerChangedListener) this.onMediaPlayerChangedListener.remove()
|
||||
|
||||
// if (this.$server.socket) {
|
||||
// this.$server.socket.off('stream_open', this.streamOpen)
|
||||
// this.$server.socket.off('stream_closed', this.streamClosed)
|
||||
// this.$server.socket.off('stream_progress', this.streamProgress)
|
||||
// this.$server.socket.off('stream_ready', this.streamReady)
|
||||
// this.$server.socket.off('stream_reset', this.streamReset)
|
||||
// }
|
||||
this.$eventBus.$off('abs-ui-ready', this.onReady)
|
||||
this.$eventBus.$off('play-item', this.playLibraryItem)
|
||||
this.$eventBus.$off('pause-item', this.pauseItem)
|
||||
|
||||
@@ -137,6 +137,7 @@ export default {
|
||||
this.show = false
|
||||
},
|
||||
async logout() {
|
||||
await this.$hapticsImpactMedium()
|
||||
if (this.user) {
|
||||
await this.$axios.$post('/logout').catch((error) => {
|
||||
console.error(error)
|
||||
|
||||
@@ -95,16 +95,16 @@ export default {
|
||||
var coverSize = 100
|
||||
if (window.innerWidth <= 375) coverSize = 90
|
||||
|
||||
if (this.isCoverSquareAspectRatio) return coverSize * 1.6
|
||||
if (this.isCoverSquareAspectRatio || this.entityName === 'playlists') return coverSize * 1.6
|
||||
return coverSize
|
||||
},
|
||||
bookHeight() {
|
||||
if (this.isCoverSquareAspectRatio) return this.bookWidth
|
||||
if (this.isCoverSquareAspectRatio || this.entityName === 'playlists') return this.bookWidth
|
||||
return this.bookWidth * 1.6
|
||||
},
|
||||
entityWidth() {
|
||||
if (this.showBookshelfListView) return this.bookshelfWidth - 16
|
||||
if (this.isBookEntity) return this.bookWidth
|
||||
if (this.isBookEntity || this.entityName === 'playlists') return this.bookWidth
|
||||
return this.bookWidth * 2
|
||||
},
|
||||
entityHeight() {
|
||||
@@ -134,7 +134,7 @@ export default {
|
||||
return this.$store.getters['getAltViewEnabled']
|
||||
},
|
||||
sizeMultiplier() {
|
||||
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
|
||||
const baseSize = this.isCoverSquareAspectRatio ? 192 : 120
|
||||
return this.entityWidth / baseSize
|
||||
}
|
||||
},
|
||||
@@ -145,7 +145,7 @@ export default {
|
||||
})
|
||||
},
|
||||
async fetchEntities(page) {
|
||||
var startIndex = page * this.booksPerFetch
|
||||
const startIndex = page * this.booksPerFetch
|
||||
|
||||
this.isFetchingEntities = true
|
||||
|
||||
@@ -153,11 +153,11 @@ export default {
|
||||
this.currentSFQueryString = this.buildSearchParams()
|
||||
}
|
||||
|
||||
var entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `items` : this.entityName
|
||||
var sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
|
||||
var fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1`
|
||||
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`
|
||||
|
||||
var payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
|
||||
const payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
|
||||
console.error('failed to fetch books', error)
|
||||
return null
|
||||
})
|
||||
@@ -179,13 +179,13 @@ export default {
|
||||
}
|
||||
|
||||
for (let i = 0; i < payload.results.length; i++) {
|
||||
var index = i + startIndex
|
||||
const index = i + startIndex
|
||||
this.entities[index] = payload.results[i]
|
||||
if (this.entityComponentRefs[index]) {
|
||||
this.entityComponentRefs[index].setEntity(this.entities[index])
|
||||
|
||||
if (this.isBookEntity) {
|
||||
var localLibraryItem = this.localLibraryItems.find((lli) => lli.libraryItemId == this.entities[index].id)
|
||||
const localLibraryItem = this.localLibraryItems.find((lli) => lli.libraryItemId == this.entities[index].id)
|
||||
if (localLibraryItem) {
|
||||
this.entityComponentRefs[index].setLocalLibraryItem(localLibraryItem)
|
||||
}
|
||||
@@ -356,8 +356,6 @@ export default {
|
||||
let searchParams = new URLSearchParams()
|
||||
if (this.page === 'series-books') {
|
||||
searchParams.set('filter', `series.${this.$encode(this.seriesId)}`)
|
||||
searchParams.set('sort', 'book.volumeNumber')
|
||||
searchParams.set('desc', 0)
|
||||
} else {
|
||||
if (this.filterBy && this.filterBy !== 'all') {
|
||||
searchParams.set('filter', this.filterBy)
|
||||
|
||||
@@ -319,9 +319,6 @@ export default {
|
||||
userIsRoot() {
|
||||
return this.store.getters['user/getIsRoot']
|
||||
},
|
||||
_socket() {
|
||||
return this.$root.socket || this.$nuxt.$root.socket
|
||||
},
|
||||
titleFontSize() {
|
||||
return 0.75 * this.sizeMultiplier
|
||||
},
|
||||
@@ -404,12 +401,13 @@ export default {
|
||||
// Server books may have a local library item
|
||||
this.localLibraryItem = localLibraryItem
|
||||
},
|
||||
clickCard(e) {
|
||||
async clickCard(e) {
|
||||
if (this.isSelectionMode) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
this.selectBtnClick()
|
||||
} else if (this.recentEpisode) {
|
||||
await this.$hapticsImpactMedium()
|
||||
var eventBus = this.$eventBus || this.$nuxt.$eventBus
|
||||
if (this.streamIsPlaying) {
|
||||
eventBus.$emit('pause-item')
|
||||
|
||||
@@ -63,9 +63,6 @@ export default {
|
||||
var router = this.$router || this.$nuxt.$router
|
||||
router.push(`/collection/${this.collection.id}`)
|
||||
},
|
||||
clickEdit() {
|
||||
this.$emit('edit', this.collection)
|
||||
},
|
||||
destroy() {
|
||||
// destroy the vue listeners, etc
|
||||
this.$destroy()
|
||||
|
||||
@@ -278,9 +278,6 @@ export default {
|
||||
userIsRoot() {
|
||||
return this.store.getters['user/getIsRoot']
|
||||
},
|
||||
_socket() {
|
||||
return this.$root.socket || this.$nuxt.$root.socket
|
||||
},
|
||||
titleFontSize() {
|
||||
return 0.75 * this.sizeMultiplier
|
||||
},
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div ref="card" :id="`playlist-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @click="clickCard">
|
||||
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
||||
<covers-playlist-cover ref="cover" :items="items" :width="width" :height="height" />
|
||||
</div>
|
||||
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
|
||||
<div class="w-full h-full flex items-center justify-center rounded-sm border" :class="isAltViewEnabled ? 'altBookshelfLabel' : 'shinyBlack'" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
index: Number,
|
||||
width: Number,
|
||||
height: Number,
|
||||
bookCoverAspectRatio: Number,
|
||||
playlistMount: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
isAltViewEnabled: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
playlist: null,
|
||||
isSelectionMode: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
labelFontSize() {
|
||||
if (this.width < 160) return 0.75
|
||||
return 0.875
|
||||
},
|
||||
sizeMultiplier() {
|
||||
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
|
||||
return this.width / 240
|
||||
},
|
||||
title() {
|
||||
return this.playlist ? this.playlist.name : ''
|
||||
},
|
||||
items() {
|
||||
return this.playlist ? this.playlist.items || [] : []
|
||||
},
|
||||
store() {
|
||||
return this.$store || this.$nuxt.$store
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.store.state.libraries.currentLibraryId
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setEntity(playlist) {
|
||||
this.playlist = playlist
|
||||
},
|
||||
setSelectionMode(val) {
|
||||
this.isSelectionMode = val
|
||||
},
|
||||
clickCard() {
|
||||
if (!this.playlist) return
|
||||
var router = this.$router || this.$nuxt.$router
|
||||
router.push(`/playlist/${this.playlist.id}`)
|
||||
},
|
||||
destroy() {
|
||||
// destroy the vue listeners, etc
|
||||
this.$destroy()
|
||||
|
||||
// remove the element from the DOM
|
||||
if (this.$el && this.$el.parentNode) {
|
||||
this.$el.parentNode.removeChild(this.$el)
|
||||
} else if (this.$el && this.$el.remove) {
|
||||
this.$el.remove()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.playlistMount) {
|
||||
this.setEntity(this.playlistMount)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -132,6 +132,7 @@ export default {
|
||||
}
|
||||
},
|
||||
async connectToServer(config) {
|
||||
await this.$hapticsImpactMedium()
|
||||
console.log('[ServerConnectForm] connectToServer', config.address)
|
||||
this.processing = true
|
||||
this.serverConfig = {
|
||||
@@ -159,6 +160,7 @@ export default {
|
||||
},
|
||||
async removeServerConfigClick() {
|
||||
if (!this.serverConfig.id) return
|
||||
await this.$hapticsImpactMedium()
|
||||
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
@@ -189,7 +191,8 @@ export default {
|
||||
this.showForm = true
|
||||
this.showAuth = true
|
||||
},
|
||||
newServerConfigClick() {
|
||||
async newServerConfigClick() {
|
||||
await this.$hapticsImpactMedium()
|
||||
this.serverConfig = {
|
||||
address: '',
|
||||
userId: '',
|
||||
@@ -348,4 +351,4 @@ export default {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -47,7 +47,8 @@ export default {
|
||||
default: 120
|
||||
},
|
||||
bookCoverAspectRatio: Number,
|
||||
downloadCover: String
|
||||
downloadCover: String,
|
||||
raw: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -115,7 +116,7 @@ export default {
|
||||
if (this.downloadCover) return this.downloadCover
|
||||
if (!this.libraryItem) return null
|
||||
var store = this.$store || this.$nuxt.$store
|
||||
return store.getters['globals/getLibraryItemCoverSrc'](this.libraryItem, this.placeholderUrl)
|
||||
return store.getters['globals/getLibraryItemCoverSrc'](this.libraryItem, this.placeholderUrl, this.raw)
|
||||
},
|
||||
cover() {
|
||||
return this.media.coverPath || this.placeholderUrl
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div class="relative rounded-sm overflow-hidden" :style="{ width: width + 'px', height: height + 'px' }">
|
||||
<div v-if="items.length" class="flex flex-wrap justify-center h-full relative bg-primary bg-opacity-95 rounded-sm">
|
||||
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
|
||||
<covers-book-cover v-for="(li, index) in libraryItemCovers" :key="index" :library-item="li" :width="itemCoverWidth" :book-cover-aspect-ratio="1" />
|
||||
</div>
|
||||
<div v-else class="relative w-full h-full flex items-center justify-center p-2 bg-primary rounded-sm">
|
||||
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
width: Number,
|
||||
height: Number
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
sizeMultiplier() {
|
||||
return this.width / (120 * 1.6 * 2)
|
||||
},
|
||||
itemCoverWidth() {
|
||||
if (this.libraryItemCovers.length === 1) return this.width
|
||||
return this.width / 2
|
||||
},
|
||||
libraryItemCovers() {
|
||||
if (!this.items.length) return []
|
||||
if (this.items.length === 1) return [this.items[0].libraryItem]
|
||||
|
||||
const covers = []
|
||||
for (let i = 0; i < 4; i++) {
|
||||
let index = i % this.items.length
|
||||
if (this.items.length === 2 && i >= 2) index = (i + 1) % 2 // for playlists with 2 items show covers in checker pattern
|
||||
|
||||
covers.push(this.items[index].libraryItem)
|
||||
}
|
||||
return covers
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<div class="w-full px-2">
|
||||
<img v-if="podcast.imageUrl" :src="podcast.imageUrl" class="h-36 w-36 object-contain mx-auto mb-2" />
|
||||
|
||||
<ui-text-input-with-label v-model="podcast.title" :label="'Title'" class="mb-2 text-sm" @input="titleUpdated" />
|
||||
|
||||
<ui-text-input-with-label v-model="podcast.author" :label="'Author'" class="mb-2 text-sm" />
|
||||
|
||||
<ui-text-input-with-label v-model="podcast.feedUrl" :label="'Feed URL'" readonly class="mb-2 text-sm" />
|
||||
|
||||
<ui-multi-select v-model="podcast.genres" :items="podcast.genres" :label="'Genres'" class="mb-2 text-sm" />
|
||||
|
||||
<ui-textarea-with-label v-model="podcast.description" :label="'Description'" :rows="3" class="mb-2 text-sm" />
|
||||
|
||||
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="processing" :label="'Folder'" class="mb-2 text-sm" @input="folderUpdated" />
|
||||
|
||||
<ui-text-input-with-label v-model="fullPath" :label="'Podcast Path'" input-class="h-10" readonly class="mb-2 text-sm" />
|
||||
|
||||
<div class="flex items-center py-4 px-2">
|
||||
<ui-checkbox v-model="podcast.autoDownloadEpisodes" :label="'Auto Download Episodes'" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-sm font-semibold" />
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" @click="submit">Submit</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Path from 'path'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
processing: Boolean,
|
||||
podcastData: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
podcastFeedData: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedFolderId: null,
|
||||
fullPath: null,
|
||||
podcast: {
|
||||
title: '',
|
||||
author: '',
|
||||
description: '',
|
||||
releaseDate: '',
|
||||
genres: [],
|
||||
feedUrl: '',
|
||||
feedImageUrl: '',
|
||||
itunesPageUrl: '',
|
||||
itunesId: '',
|
||||
itunesArtistId: '',
|
||||
autoDownloadEpisodes: false
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
_processing: {
|
||||
get() {
|
||||
return this.processing
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('update:processing', val)
|
||||
}
|
||||
},
|
||||
title() {
|
||||
return this._podcastData.title
|
||||
},
|
||||
currentLibrary() {
|
||||
return this.$store.getters['libraries/getCurrentLibrary']
|
||||
},
|
||||
folders() {
|
||||
if (!this.currentLibrary) return []
|
||||
return this.currentLibrary.folders || []
|
||||
},
|
||||
folderItems() {
|
||||
return this.folders.map((fold) => {
|
||||
return {
|
||||
value: fold.id,
|
||||
text: fold.fullPath
|
||||
}
|
||||
})
|
||||
},
|
||||
_podcastData() {
|
||||
return this.podcastData || {}
|
||||
},
|
||||
feedMetadata() {
|
||||
if (!this.podcastFeedData) return {}
|
||||
return this.podcastFeedData.metadata || {}
|
||||
},
|
||||
episodes() {
|
||||
if (!this.podcastFeedData) return []
|
||||
return this.podcastFeedData.episodes || []
|
||||
},
|
||||
selectedFolder() {
|
||||
return this.folders.find((f) => f.id === this.selectedFolderId)
|
||||
},
|
||||
selectedFolderPath() {
|
||||
if (!this.selectedFolder) return ''
|
||||
return this.selectedFolder.fullPath
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
titleUpdated() {
|
||||
this.folderUpdated()
|
||||
},
|
||||
folderUpdated() {
|
||||
if (!this.selectedFolderPath || !this.podcast.title) {
|
||||
this.fullPath = ''
|
||||
return
|
||||
}
|
||||
this.fullPath = Path.join(this.selectedFolderPath, this.$sanitizeFilename(this.podcast.title))
|
||||
},
|
||||
submit() {
|
||||
const podcastPayload = {
|
||||
path: this.fullPath,
|
||||
folderId: this.selectedFolderId,
|
||||
libraryId: this.currentLibrary.id,
|
||||
media: {
|
||||
metadata: {
|
||||
title: this.podcast.title,
|
||||
author: this.podcast.author,
|
||||
description: this.podcast.description,
|
||||
releaseDate: this.podcast.releaseDate,
|
||||
genres: [...this.podcast.genres],
|
||||
feedUrl: this.podcast.feedUrl,
|
||||
imageUrl: this.podcast.imageUrl,
|
||||
itunesPageUrl: this.podcast.itunesPageUrl,
|
||||
itunesId: this.podcast.itunesId,
|
||||
itunesArtistId: this.podcast.itunesArtistId,
|
||||
language: this.podcast.language
|
||||
},
|
||||
autoDownloadEpisodes: this.podcast.autoDownloadEpisodes
|
||||
}
|
||||
}
|
||||
console.log('Podcast payload', podcastPayload)
|
||||
|
||||
this._processing = true
|
||||
this.$axios
|
||||
.$post('/api/podcasts', podcastPayload)
|
||||
.then((libraryItem) => {
|
||||
this._processing = false
|
||||
this.$toast.success('Podcast added')
|
||||
this.$router.push(`/item/${libraryItem.id}`)
|
||||
})
|
||||
.catch((error) => {
|
||||
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to add podcast'
|
||||
console.error('Failed to create podcast', error)
|
||||
this._processing = false
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
},
|
||||
init() {
|
||||
// Prefer using itunes podcast data but not always passed in if manually entering rss feed
|
||||
this.podcast.title = this._podcastData.title || this.feedMetadata.title || ''
|
||||
this.podcast.author = this._podcastData.artistName || this.feedMetadata.author || ''
|
||||
this.podcast.description = this._podcastData.description || this.feedMetadata.descriptionPlain || ''
|
||||
this.podcast.releaseDate = this._podcastData.releaseDate || ''
|
||||
this.podcast.genres = this._podcastData.genres || this.feedMetadata.categories || []
|
||||
this.podcast.feedUrl = this._podcastData.feedUrl || this.feedMetadata.feedUrl || ''
|
||||
this.podcast.imageUrl = this._podcastData.cover || this.feedMetadata.image || ''
|
||||
this.podcast.itunesPageUrl = this._podcastData.pageUrl || ''
|
||||
this.podcast.itunesId = this._podcastData.id || ''
|
||||
this.podcast.itunesArtistId = this._podcastData.artistId || ''
|
||||
this.podcast.language = this._podcastData.language || ''
|
||||
this.podcast.autoDownloadEpisodes = false
|
||||
|
||||
if (this.folderItems[0]) {
|
||||
this.selectedFolderId = this.folderItems[0].value
|
||||
this.folderUpdated()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,8 +1,10 @@
|
||||
<template>
|
||||
<div class="w-full h-9 bg-bg relative">
|
||||
<div id="bookshelf-navbar" class="absolute z-10 top-0 left-0 w-full h-full flex bg-secondary text-gray-200">
|
||||
<nuxt-link v-for="item in items" :key="item.to" :to="item.to" class="h-full flex items-center justify-center" :style="{ width: isPodcast ? '50%' : '25%' }" :class="routeName === item.routeName ? 'bg-primary' : 'text-gray-400'">
|
||||
<p>{{ item.text }}</p>
|
||||
<nuxt-link v-for="item in items" :key="item.to" :to="item.to" class="h-full flex-grow flex items-center justify-center" :class="routeName === item.routeName ? 'bg-primary' : 'text-gray-400'">
|
||||
<p v-if="routeName === item.routeName" class="text-sm font-semibold">{{ item.text }}</p>
|
||||
<span v-else-if="item.iconPack === 'abs-icons'" class="abs-icons" :class="`icon-${item.icon} ${item.iconClass || ''}`"></span>
|
||||
<span v-else :class="`${item.iconPack} ${item.iconClass || ''}`">{{ item.icon }}</span>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -14,43 +16,114 @@ export default {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
currentLibrary() {
|
||||
return this.$store.getters['libraries/getCurrentLibrary']
|
||||
},
|
||||
currentLibraryIcon() {
|
||||
return this.currentLibrary ? this.currentLibrary.icon : 'database'
|
||||
},
|
||||
userHasPlaylists() {
|
||||
return this.$store.state.libraries.numUserPlaylists
|
||||
},
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
items() {
|
||||
let items = []
|
||||
if (this.isPodcast) {
|
||||
return [
|
||||
items = [
|
||||
{
|
||||
to: '/bookshelf',
|
||||
routeName: 'bookshelf',
|
||||
iconPack: 'abs-icons',
|
||||
icon: 'home',
|
||||
iconClass: 'text-xl',
|
||||
text: 'Home'
|
||||
},
|
||||
{
|
||||
to: '/bookshelf/latest',
|
||||
routeName: 'bookshelf-latest',
|
||||
iconPack: 'abs-icons',
|
||||
icon: 'list',
|
||||
iconClass: 'text-xl',
|
||||
text: 'Latest'
|
||||
},
|
||||
{
|
||||
to: '/bookshelf/library',
|
||||
routeName: 'bookshelf-library',
|
||||
iconPack: 'abs-icons',
|
||||
icon: this.currentLibraryIcon,
|
||||
iconClass: 'text-lg',
|
||||
text: 'Library'
|
||||
}
|
||||
]
|
||||
|
||||
if (this.userIsAdminOrUp) {
|
||||
items.push({
|
||||
to: '/bookshelf/search',
|
||||
routeName: 'bookshelf-search',
|
||||
iconPack: 'material-icons',
|
||||
icon: 'podcasts',
|
||||
iconClass: 'text-xl',
|
||||
text: 'Search'
|
||||
})
|
||||
}
|
||||
} else {
|
||||
items = [
|
||||
{
|
||||
to: '/bookshelf',
|
||||
routeName: 'bookshelf',
|
||||
iconPack: 'abs-icons',
|
||||
icon: 'home',
|
||||
iconClass: 'text-xl',
|
||||
text: 'Home'
|
||||
},
|
||||
{
|
||||
to: '/bookshelf/library',
|
||||
routeName: 'bookshelf-library',
|
||||
iconPack: 'abs-icons',
|
||||
icon: this.currentLibraryIcon,
|
||||
iconClass: 'text-lg',
|
||||
text: 'Library'
|
||||
},
|
||||
{
|
||||
to: '/bookshelf/series',
|
||||
routeName: 'bookshelf-series',
|
||||
iconPack: 'abs-icons',
|
||||
icon: 'columns',
|
||||
iconClass: 'text-lg pt-px',
|
||||
text: 'Series'
|
||||
},
|
||||
{
|
||||
to: '/bookshelf/collections',
|
||||
routeName: 'bookshelf-collections',
|
||||
iconPack: 'material-icons-outlined',
|
||||
icon: 'collections_bookmark',
|
||||
iconClass: 'text-xl',
|
||||
text: 'Collections'
|
||||
}
|
||||
// {
|
||||
// to: '/bookshelf/authors',
|
||||
// routeName: 'bookshelf-authors',
|
||||
// iconPack: 'abs-icons',
|
||||
// icon: 'authors',
|
||||
// iconClass: 'text-2xl pb-px',
|
||||
// text: 'Authors'
|
||||
// }
|
||||
]
|
||||
}
|
||||
return [
|
||||
{
|
||||
to: '/bookshelf',
|
||||
routeName: 'bookshelf',
|
||||
text: 'Home'
|
||||
},
|
||||
{
|
||||
to: '/bookshelf/library',
|
||||
routeName: 'bookshelf-library',
|
||||
text: 'Library'
|
||||
},
|
||||
{
|
||||
to: '/bookshelf/series',
|
||||
routeName: 'bookshelf-series',
|
||||
text: 'Series'
|
||||
},
|
||||
{
|
||||
to: '/bookshelf/collections',
|
||||
routeName: 'bookshelf-collections',
|
||||
text: 'Collections'
|
||||
}
|
||||
]
|
||||
|
||||
if (this.userHasPlaylists) {
|
||||
items.push({
|
||||
to: '/bookshelf/playlists',
|
||||
routeName: 'bookshelf-playlists',
|
||||
iconPack: 'material-icons',
|
||||
icon: 'queue_music',
|
||||
text: 'Playlists'
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
},
|
||||
routeName() {
|
||||
return this.$route.name
|
||||
@@ -62,7 +135,9 @@ export default {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
methods: {
|
||||
isSelected(item) {}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<p v-show="!selectedSeriesName" class="font-book pt-1">{{ totalEntities }} {{ entityTitle }}</p>
|
||||
<p v-show="selectedSeriesName" class="ml-2 font-book pt-1">{{ selectedSeriesName }} ({{ totalEntities }})</p>
|
||||
<div class="flex-grow" />
|
||||
<span v-if="page == 'library' || seriesBookPage" class="material-icons px-2" @click="bookshelfListView = !bookshelfListView">{{ !bookshelfListView ? 'view_list' : 'grid_view' }}</span>
|
||||
<span v-if="page == 'library' || seriesBookPage" class="material-icons px-2" @click="changeView">{{ !bookshelfListView ? 'view_list' : 'grid_view' }}</span>
|
||||
<template v-if="page === 'library'">
|
||||
<div class="relative flex items-center px-2">
|
||||
<span class="material-icons" @click="showFilterModal = true">filter_alt</span>
|
||||
@@ -64,6 +64,8 @@ export default {
|
||||
return 'Series'
|
||||
} else if (this.page === 'collections') {
|
||||
return 'Collections'
|
||||
} else if (this.page === 'playlists') {
|
||||
return 'Playlists'
|
||||
}
|
||||
return ''
|
||||
},
|
||||
@@ -100,6 +102,10 @@ export default {
|
||||
},
|
||||
setTotalEntities(total) {
|
||||
this.totalEntities = total
|
||||
},
|
||||
async changeView() {
|
||||
this.bookshelfListView = !this.bookshelfListView
|
||||
await this.$hapticsImpactMedium()
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@@ -118,4 +124,4 @@ export default {
|
||||
#bookshelf-toolbar {
|
||||
box-shadow: 0px 5px 5px #11111155;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
|
||||
<script>
|
||||
import { Dialog } from '@capacitor/dialog'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
@@ -94,6 +95,7 @@ export default {
|
||||
this.showBookmarkTitleInput = true
|
||||
},
|
||||
async deleteBookmark(bm) {
|
||||
await this.$hapticsImpactMedium()
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Remove Bookmark',
|
||||
message: `Are you sure you want to remove bookmark?`
|
||||
@@ -111,7 +113,8 @@ export default {
|
||||
})
|
||||
this.show = false
|
||||
},
|
||||
clickBookmark(bm) {
|
||||
async clickBookmark(bm) {
|
||||
await this.$hapticsImpactMedium()
|
||||
this.$emit('select', bm)
|
||||
},
|
||||
submitUpdateBookmark(updatedBookmark) {
|
||||
@@ -155,7 +158,8 @@ export default {
|
||||
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
|
||||
this.showBookmarkTitleInput = true
|
||||
},
|
||||
submitBookmark() {
|
||||
async submitBookmark() {
|
||||
await this.$hapticsImpactMedium()
|
||||
if (this.selectedBookmark) {
|
||||
var updatePayload = {
|
||||
...this.selectedBookmark,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="300" height="100%">
|
||||
<modals-modal v-model="show" :width="350" height="100%">
|
||||
<template #outer>
|
||||
<div v-if="currentChapter" class="absolute top-7 left-4 z-40" style="max-width: 80%">
|
||||
<p class="text-white text-lg truncate">Current: {{ currentChapterTitle }}</p>
|
||||
@@ -11,10 +11,10 @@
|
||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="chapter 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)">
|
||||
<div class="relative flex items-center pl-3" style="padding-right: 4.5rem">
|
||||
<div class="relative flex items-center pl-3 pr-20">
|
||||
<p class="font-normal block truncate text-sm text-white text-opacity-80">{{ chapter.title }}</p>
|
||||
<div class="absolute top-0 right-3 -mt-0.5">
|
||||
<span class="font-mono text-white text-opacity-90 leading-3" 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) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
<template #outer>
|
||||
<div v-show="selected !== 'all'" class="absolute top-4 left-4 z-40">
|
||||
<ui-btn class="text-xl border-yellow-400 border-opacity-40" @click="clearSelected">Clear</ui-btn>
|
||||
<!-- <span class="font-semibold uppercase text-white text-2xl">Clear</span> -->
|
||||
</div>
|
||||
</template>
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||
@@ -174,7 +173,24 @@ export default {
|
||||
return this.filterData.languages || []
|
||||
},
|
||||
progress() {
|
||||
return ['Finished', 'In Progress', 'Not Started', 'Not Finished']
|
||||
return [
|
||||
{
|
||||
id: 'finished',
|
||||
name: 'Finished'
|
||||
},
|
||||
{
|
||||
id: 'in-progress',
|
||||
name: 'In Progress'
|
||||
},
|
||||
{
|
||||
id: 'not-started',
|
||||
name: 'Not Started'
|
||||
},
|
||||
{
|
||||
id: 'not-finished',
|
||||
name: 'Not Finished'
|
||||
}
|
||||
]
|
||||
},
|
||||
sublistItems() {
|
||||
return (this[this.sublist] || []).map((item) => {
|
||||
@@ -196,7 +212,8 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clearSelected() {
|
||||
async clearSelected() {
|
||||
await this.$hapticsImpactMedium()
|
||||
this.selected = 'all'
|
||||
this.show = false
|
||||
this.$nextTick(() => this.$emit('change', 'all'))
|
||||
@@ -204,7 +221,7 @@ export default {
|
||||
clickedSublistOption(item) {
|
||||
this.clickedOption({ value: `${this.sublist}.${item}` })
|
||||
},
|
||||
clickedOption(option) {
|
||||
async clickedOption(option) {
|
||||
if (option.sublist) {
|
||||
this.sublist = option.value
|
||||
return
|
||||
@@ -215,6 +232,7 @@ export default {
|
||||
this.show = false
|
||||
return
|
||||
}
|
||||
await this.$hapticsImpactMedium()
|
||||
this.selected = val
|
||||
this.show = false
|
||||
this.$nextTick(() => this.$emit('change', val))
|
||||
@@ -228,4 +246,4 @@ export default {
|
||||
.filter-modal-wrapper {
|
||||
max-height: calc(100% - 320px);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" width="100%" height="100%" max-width="100%">
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||
<covers-book-cover :library-item="libraryItem" :width="width" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<covers-book-cover :library-item="libraryItem" :width="width" raw :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
@@ -49,6 +49,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
async clickedOption(lib) {
|
||||
await this.$hapticsImpactMedium()
|
||||
this.show = false
|
||||
if (lib.id === this.currentLibraryId) return
|
||||
await this.$store.dispatch('libraries/fetch', lib.id)
|
||||
|
||||
@@ -126,7 +126,8 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickedOption(val) {
|
||||
async clickedOption(val) {
|
||||
await this.$hapticsImpactMedium()
|
||||
if (this.selected === val) {
|
||||
this.selectedDesc = !this.selectedDesc
|
||||
} else {
|
||||
@@ -139,4 +140,4 @@ export default {
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" width="100%" height="100%" max-width="100%">
|
||||
<template #outer>
|
||||
<div class="absolute top-6 left-4 z-40">
|
||||
<p class="text-white text-2xl truncate">Feed Episodes</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||
<div class="feed-content w-full overflow-x-hidden overflow-y-auto bg-bg rounded-lg border border-white border-opacity-20" @click.stop.prevent>
|
||||
<template v-for="(episode, index) in episodes">
|
||||
<div :key="index" class="relative" :class="itemEpisodeMap[episode.enclosure.url] ? 'bg-primary bg-opacity-40' : selectedEpisodes[String(index)] ? 'bg-success bg-opacity-10' : index % 2 == 0 ? 'bg-primary bg-opacity-25' : 'bg-primary bg-opacity-5'" @click="selectEpisode(episode, index)">
|
||||
<div class="absolute top-0 left-0 h-full flex items-center p-2">
|
||||
<span v-if="itemEpisodeMap[episode.enclosure.url]" class="material-icons text-success text-xl">download_done</span>
|
||||
<ui-checkbox v-else v-model="selectedEpisodes[String(index)]" small checkbox-bg="primary" border-color="gray-600" />
|
||||
</div>
|
||||
<div class="pl-9 pr-2 py-2 border-b border-white border-opacity-10">
|
||||
<p v-if="episode.episode" class="font-semibold text-gray-200 text-xs">#{{ episode.episode }}</p>
|
||||
<p class="break-words mb-1 text-sm">{{ episode.title }}</p>
|
||||
<p v-if="episode.subtitle" class="break-words mb-1 text-xs text-gray-300 episode-subtitle">{{ episode.subtitle }}</p>
|
||||
<p class="text-xxs text-gray-300">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 w-full flex items-center" style="height: 50px">
|
||||
<ui-btn class="w-full" :disabled="!episodesSelected.length" color="success" @click.stop="downloadEpisodes">{{ episodesSelected.length ? `Add ${episodesSelected.length} Episode(s) to Server` : 'No Episodes Selected' }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
episodes: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
selectedEpisodes: {}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) this.init()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
allDownloaded() {
|
||||
return !this.episodes.some((episode) => !this.itemEpisodeMap[episode.enclosure.url])
|
||||
},
|
||||
episodesSelected() {
|
||||
return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key])
|
||||
},
|
||||
itemEpisodes() {
|
||||
if (!this.libraryItem) return []
|
||||
return this.libraryItem.media.episodes || []
|
||||
},
|
||||
itemEpisodeMap() {
|
||||
var map = {}
|
||||
this.itemEpisodes.forEach((item) => {
|
||||
if (item.enclosure) map[item.enclosure.url] = true
|
||||
})
|
||||
return map
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
downloadEpisodes() {
|
||||
const episodesToDownload = this.episodesSelected.map((episodeIndex) => this.episodes[Number(episodeIndex)])
|
||||
|
||||
const payloadSize = JSON.stringify(episodesToDownload).length
|
||||
const sizeInMb = payloadSize / 1024 / 1024
|
||||
const sizeInMbPretty = sizeInMb.toFixed(2) + 'MB'
|
||||
console.log('Request size', sizeInMb)
|
||||
if (sizeInMb > 4.99) {
|
||||
return this.$toast.error(`Request is too large (${sizeInMbPretty}) should be < 5Mb`)
|
||||
}
|
||||
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$post(`/api/podcasts/${this.libraryItem.id}/download-episodes`, episodesToDownload)
|
||||
.then(() => {
|
||||
this.processing = false
|
||||
this.$toast.success('Started downloading episodes on server')
|
||||
this.show = false
|
||||
})
|
||||
.catch((error) => {
|
||||
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to download episodes'
|
||||
console.error('Failed to download episodes', error)
|
||||
this.processing = false
|
||||
this.$toast.error(errorMsg)
|
||||
|
||||
this.selectedEpisodes = {}
|
||||
})
|
||||
},
|
||||
selectEpisode(episode, index) {
|
||||
if (this.itemEpisodeMap[episode.enclosure.url]) return
|
||||
this.selectedEpisodes[String(index)] = !this.selectedEpisodes[String(index)]
|
||||
},
|
||||
init() {
|
||||
this.episodes.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1))
|
||||
for (let i = 0; i < this.episodes.length; i++) {
|
||||
// this.selectedEpisodes[String(i)] = false
|
||||
this.$set(this.selectedEpisodes, String(i), false)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.feed-content {
|
||||
height: calc(100vh - 125px);
|
||||
max-height: calc(100vh - 125px);
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -89,30 +89,37 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickedChapterOption() {
|
||||
async clickedChapterOption() {
|
||||
await this.$hapticsImpactMedium()
|
||||
this.show = false
|
||||
this.$nextTick(() => this.$emit('change', { time: this.currentEndOfChapterTime * 1000, isChapterTime: true }))
|
||||
},
|
||||
clickedOption(timeoutMin) {
|
||||
async clickedOption(timeoutMin) {
|
||||
await this.$hapticsImpactMedium()
|
||||
var timeout = timeoutMin * 1000 * 60
|
||||
this.show = false
|
||||
this.manualTimerModal = false
|
||||
this.$nextTick(() => this.$emit('change', { time: timeout, isChapterTime: false }))
|
||||
},
|
||||
cancelSleepTimer() {
|
||||
async cancelSleepTimer() {
|
||||
await this.$hapticsImpactMedium()
|
||||
this.$emit('cancel')
|
||||
this.show = false
|
||||
},
|
||||
increaseSleepTime() {
|
||||
async increaseSleepTime() {
|
||||
await this.$hapticsImpactMedium()
|
||||
this.$emit('increase')
|
||||
},
|
||||
decreaseSleepTime() {
|
||||
async decreaseSleepTime() {
|
||||
await this.$hapticsImpactMedium()
|
||||
this.$emit('decrease')
|
||||
},
|
||||
increaseManualTimeout() {
|
||||
async increaseManualTimeout() {
|
||||
await this.$hapticsImpactMedium()
|
||||
this.manualTimeoutMin++
|
||||
},
|
||||
decreaseManualTimeout() {
|
||||
async decreaseManualTimeout() {
|
||||
await this.$hapticsImpactMedium()
|
||||
if (this.manualTimeoutMin > 1) this.manualTimeoutMin--
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="360" height="100%" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-5 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>
|
||||
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
libraryItemId: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showPlaylistNameInput: false,
|
||||
newPlaylistName: '',
|
||||
playlists: [],
|
||||
processing: false,
|
||||
loading: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show(newVal) {
|
||||
if (newVal) {
|
||||
this.setListeners()
|
||||
this.showPlaylistNameInput = false
|
||||
this.newPlaylistName = ''
|
||||
this.loadPlaylists()
|
||||
} else {
|
||||
this.unsetListeners()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.$store.state.globals.showPlaylistsAddCreateModal
|
||||
},
|
||||
set(val) {
|
||||
this.$store.commit('globals/setShowPlaylistsAddCreateModal', val)
|
||||
}
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
selectedPlaylistItems() {
|
||||
return this.$store.state.globals.selectedPlaylistItems || []
|
||||
},
|
||||
sortedPlaylists() {
|
||||
return this.playlists
|
||||
.map((playlist) => {
|
||||
const includesItem = !this.selectedPlaylistItems.some((item) => !this.checkIsItemInPlaylist(playlist, item))
|
||||
|
||||
return {
|
||||
isItemIncluded: includesItem,
|
||||
...playlist
|
||||
}
|
||||
})
|
||||
.sort((a, b) => (a.isItemIncluded ? -1 : 1))
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
checkIsItemInPlaylist(playlist, item) {
|
||||
if (item.episode) {
|
||||
return playlist.items.some((i) => i.libraryItemId === item.libraryItem.id && i.episodeId === item.episode.id)
|
||||
}
|
||||
return playlist.items.some((i) => i.libraryItemId === item.libraryItem.id)
|
||||
},
|
||||
loadPlaylists() {
|
||||
this.loading = true
|
||||
this.$axios
|
||||
.$get(`/api/libraries/${this.currentLibraryId}/playlists`)
|
||||
.then((data) => {
|
||||
this.playlists = data.results || []
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error('Failed to load playlists')
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
async clickPlaylist(playlist) {
|
||||
await this.$hapticsImpactMedium()
|
||||
if (playlist.isItemIncluded) {
|
||||
this.removeFromPlaylist(playlist)
|
||||
} else {
|
||||
this.addToPlaylist(playlist)
|
||||
}
|
||||
},
|
||||
removeFromPlaylist(playlist) {
|
||||
if (!this.selectedPlaylistItems.length) return
|
||||
this.processing = true
|
||||
|
||||
const itemObjects = this.selectedPlaylistItems.map((pi) => ({ libraryItemId: pi.libraryItem.id, episodeId: pi.episode ? pi.episode.id : null }))
|
||||
this.$axios
|
||||
.$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)
|
||||
this.$toast.error('Failed to remove playlist item(s)')
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
addToPlaylist(playlist) {
|
||||
if (!this.selectedPlaylistItems.length) return
|
||||
this.processing = true
|
||||
|
||||
const itemObjects = this.selectedPlaylistItems.map((pi) => ({ libraryItemId: pi.libraryItem.id, episodeId: pi.episode ? pi.episode.id : null }))
|
||||
this.$axios
|
||||
.$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)
|
||||
this.$toast.error('Failed to add items to playlist')
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
createPlaylist() {
|
||||
this.newPlaylistName = ''
|
||||
this.showPlaylistNameInput = true
|
||||
},
|
||||
async submitCreatePlaylist() {
|
||||
await this.$hapticsImpactMedium()
|
||||
if (!this.newPlaylistName || !this.selectedPlaylistItems.length) {
|
||||
return
|
||||
}
|
||||
this.processing = true
|
||||
|
||||
const itemObjects = this.selectedPlaylistItems.map((pi) => ({ libraryItemId: pi.libraryItem.id, episodeId: pi.episode ? pi.episode.id : null }))
|
||||
const newPlaylist = {
|
||||
items: itemObjects,
|
||||
libraryId: this.currentLibraryId,
|
||||
name: this.newPlaylistName
|
||||
}
|
||||
|
||||
this.$axios
|
||||
.$post('/api/playlists', newPlaylist)
|
||||
.then((data) => {
|
||||
console.log('New playlist created', data)
|
||||
this.$toast.success(`Playlist "${data.name}" created`)
|
||||
this.newPlaylistName = ''
|
||||
this.showPlaylistNameInput = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to create playlist', error)
|
||||
var errMsg = error.response ? error.response.data || '' : ''
|
||||
this.$toast.error(`Failed to create playlist: ${errMsg}`)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
playlistAdded(playlist) {
|
||||
if (!this.playlists.some((p) => p.id === playlist.id)) {
|
||||
this.playlists.push(playlist)
|
||||
}
|
||||
},
|
||||
playlistUpdated(playlist) {
|
||||
const index = this.playlists.findIndex((p) => p.id === playlist.id)
|
||||
if (index >= 0) {
|
||||
this.playlists.splice(index, 1, playlist)
|
||||
} else {
|
||||
this.playlists.push(playlist)
|
||||
}
|
||||
},
|
||||
playlistRemoved(playlist) {
|
||||
this.playlists = this.playlists.filter((p) => p.id !== playlist.id)
|
||||
},
|
||||
setListeners() {
|
||||
this.$socket.$on('playlist_added', this.playlistAdded)
|
||||
this.$socket.$on('playlist_updated', this.playlistUpdated)
|
||||
this.$socket.$on('playlist_removed', this.playlistRemoved)
|
||||
},
|
||||
unsetListeners() {
|
||||
this.$socket.$off('playlist_added', this.playlistAdded)
|
||||
this.$socket.$off('playlist_updated', this.playlistUpdated)
|
||||
this.$socket.$off('playlist_removed', this.playlistRemoved)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,42 @@
|
||||
<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 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">
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
playlist: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
inPlaylist: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
items() {
|
||||
return this.playlist.items || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
click() {
|
||||
this.$emit('click', this.playlist)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div id="epub-frame" class="w-full">
|
||||
<div id="viewer" class="border border-gray-100 bg-white shadow-md h-full w-full"></div>
|
||||
<div class="fixed bottom-0 left-0 h-8 w-full bg-bg px-2 flex items-center">
|
||||
<div class="fixed left-0 h-8 w-full bg-bg px-2 flex items-center" :style="{ bottom: playerLibraryItemId ? '100px' : '0px' }">
|
||||
<p class="text-xs">epub</p>
|
||||
<div class="flex-grow" />
|
||||
|
||||
@@ -29,8 +29,25 @@ export default {
|
||||
hasPrev: false
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
watch: {
|
||||
playerLibraryItemId() {
|
||||
this.updateHeight()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
playerLibraryItemId() {
|
||||
return this.$store.state.playerLibraryItemId
|
||||
},
|
||||
readerHeightOffset() {
|
||||
return this.playerLibraryItemId ? 164 : 64
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateHeight() {
|
||||
if (this.rendition && this.rendition.resize) {
|
||||
this.rendition.resize(window.innerWidth, window.innerHeight - this.readerHeightOffset)
|
||||
}
|
||||
},
|
||||
prev() {
|
||||
if (this.rendition) {
|
||||
this.rendition.prev()
|
||||
@@ -54,12 +71,12 @@ export default {
|
||||
|
||||
this.rendition = book.renderTo('viewer', {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight - 64,
|
||||
height: window.innerHeight - this.readerHeightOffset,
|
||||
snap: true,
|
||||
manager: 'continuous',
|
||||
flow: 'paginated'
|
||||
})
|
||||
var displayed = this.rendition.display()
|
||||
const displayed = this.rendition.display()
|
||||
|
||||
book.ready
|
||||
.then(() => {
|
||||
@@ -127,4 +144,9 @@ export default {
|
||||
max-height: calc(100% - 32px);
|
||||
overflow: hidden;
|
||||
}
|
||||
.reader-player-open #epub-frame {
|
||||
height: calc(100% - 132px);
|
||||
max-height: calc(100% - 132px);
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="ebook-viewer w-full h-full">
|
||||
<div class="absolute overflow-y-scroll left-0 right-0 w-full max-w-screen m-auto z-10 border border-black border-opacity-20 shadow-md bg-white">
|
||||
<iframe title="html-viewer" class="w-screen"> Loading </iframe>
|
||||
<div class="mobi-ebook-viewer w-full">
|
||||
<div class="absolute overflow-hidden left-0 right-0 w-full max-w-full m-auto z-10 border border-black border-opacity-20 shadow-md bg-white">
|
||||
<iframe title="html-viewer" class="w-full overflow-hidden"> Loading </iframe>
|
||||
</div>
|
||||
<div class="fixed bottom-0 left-0 h-8 w-full bg-bg px-2 flex items-center z-20">
|
||||
<div class="fixed bottom-0 left-0 h-8 w-full bg-bg px-2 flex items-center z-20" :style="{ bottom: playerLibraryItemId ? '100px' : '0px' }">
|
||||
<p class="text-xs">mobi</p>
|
||||
<div class="flex-grow" />
|
||||
</div>
|
||||
@@ -22,7 +22,14 @@ export default {
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
computed: {
|
||||
playerLibraryItemId() {
|
||||
return this.$store.state.playerLibraryItemId
|
||||
},
|
||||
readerHeightOffset() {
|
||||
return this.playerLibraryItemId ? 164 : 64
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
addHtmlCss() {
|
||||
let iframe = document.getElementsByTagName('iframe')[0]
|
||||
@@ -114,7 +121,14 @@ export default {
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.ebook-viewer {
|
||||
.mobi-ebook-viewer {
|
||||
height: calc(100% - 32px);
|
||||
max-height: calc(100% - 32px);
|
||||
overflow: hidden;
|
||||
}
|
||||
.reader-player-open .mobi-ebook-viewer {
|
||||
height: calc(100% - 132px);
|
||||
max-height: calc(100% - 132px);
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div class="w-full h-full pt-20 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>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="canGoNext" class="absolute top-0 right-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="next" @mousedown.prevent>
|
||||
<div class="flex items-center justify-center h-full w-1/2 ml-auto">
|
||||
<span class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_forward_ios</span>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import pdf from 'vue-pdf'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
pdf
|
||||
},
|
||||
props: {
|
||||
url: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
rotate: 0,
|
||||
loadedRatio: 0,
|
||||
page: 1,
|
||||
numPages: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
pdfWidth() {
|
||||
return this.pdfHeight * 0.6667
|
||||
},
|
||||
pdfHeight() {
|
||||
return window.innerHeight - 120
|
||||
},
|
||||
canGoNext() {
|
||||
return this.page < this.numPages
|
||||
},
|
||||
canGoPrev() {
|
||||
return this.page > 1
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
numPagesLoaded(e) {
|
||||
this.numPages = e
|
||||
},
|
||||
prev() {
|
||||
if (this.page <= 1) return
|
||||
this.page--
|
||||
},
|
||||
next() {
|
||||
if (this.page >= this.numPages) return
|
||||
this.page++
|
||||
},
|
||||
error(err) {
|
||||
console.error(err)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div v-if="show" class="absolute top-0 left-0 w-full h-full bg-bg z-40 pt-8">
|
||||
<div v-if="show" class="absolute top-0 left-0 w-full h-full bg-bg z-40 pt-8" :class="{ 'reader-player-open': !!playerLibraryItemId }">
|
||||
<div class="h-8 w-full bg-primary flex items-center px-2 fixed top-0 left-0 z-30 box-shadow-sm">
|
||||
<p class="w-5/6 truncate">{{ title }}</p>
|
||||
<div class="flex-grow" />
|
||||
@@ -14,7 +14,10 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
touchstartX: 0,
|
||||
touchendX: 0
|
||||
touchstartY: 0,
|
||||
touchendX: 0,
|
||||
touchendY: 0,
|
||||
touchstartTime: 0
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -53,6 +56,7 @@ export default {
|
||||
if (this.ebookType === 'epub') return 'readers-epub-reader'
|
||||
else if (this.ebookType === 'mobi') return 'readers-mobi-reader'
|
||||
else if (this.ebookType === 'comic') return 'readers-comic-reader'
|
||||
else if (this.ebookType === 'pdf') return 'readers-pdf-reader'
|
||||
return null
|
||||
},
|
||||
folderId() {
|
||||
@@ -89,15 +93,22 @@ export default {
|
||||
},
|
||||
ebookUrl() {
|
||||
if (!this.ebookFile) return null
|
||||
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)
|
||||
|
||||
var itemRelPath = this.selectedLibraryItem.relPath
|
||||
if (itemRelPath.startsWith('/')) itemRelPath = itemRelPath.slice(1)
|
||||
|
||||
var relPath = this.ebookFile.metadata.relPath
|
||||
if (relPath.startsWith('/')) relPath = relPath.slice(1)
|
||||
|
||||
var serverAddress = this.$store.getters['user/getServerAddress']
|
||||
return `${serverAddress}/ebook/${this.libraryId}/${this.folderId}/${itemRelPath}/${relPath}`
|
||||
filepath = this.$encodeUriPath(`${itemRelPath}/${relPath}`)
|
||||
}
|
||||
const serverAddress = this.$store.getters['user/getServerAddress']
|
||||
return `${serverAddress}/ebook/${this.libraryId}/${this.folderId}/${filepath}`
|
||||
},
|
||||
playerLibraryItemId() {
|
||||
return this.$store.state.playerLibraryItemId
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -112,9 +123,19 @@ export default {
|
||||
}
|
||||
},
|
||||
handleGesture() {
|
||||
// Touch must be less than 1s. Must be > 100px drag and X distance > Y distance
|
||||
const touchTimeMs = Date.now() - this.touchstartTime
|
||||
if (touchTimeMs >= 1000) {
|
||||
console.log('Touch too long', touchTimeMs)
|
||||
return
|
||||
}
|
||||
|
||||
const touchDistanceX = Math.abs(this.touchendX - this.touchstartX)
|
||||
const touchDistanceY = Math.abs(this.touchendY - this.touchstartY)
|
||||
if (touchDistanceX < 100 || touchDistanceY > touchDistanceX) return
|
||||
|
||||
if (this.touchendX < this.touchstartX) {
|
||||
console.log('swiped left')
|
||||
|
||||
this.next()
|
||||
}
|
||||
if (this.touchendX > this.touchstartX) {
|
||||
@@ -124,9 +145,12 @@ export default {
|
||||
},
|
||||
touchstart(e) {
|
||||
this.touchstartX = e.changedTouches[0].screenX
|
||||
this.touchstartY = e.changedTouches[0].screenY
|
||||
this.touchstartTime = Date.now()
|
||||
},
|
||||
touchend(e) {
|
||||
this.touchendX = e.changedTouches[0].screenX
|
||||
this.touchendY = e.changedTouches[0].screenY
|
||||
this.handleGesture()
|
||||
},
|
||||
registerListeners() {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div class="w-full bg-primary bg-opacity-40">
|
||||
<div class="w-full h-14 flex items-center px-4 bg-primary">
|
||||
<p class="pr-4">Playlist Items</p>
|
||||
|
||||
<div class="w-6 h-6 md:w-7 md:h-7 bg-white bg-opacity-10 rounded-full flex items-center justify-center">
|
||||
<span class="text-xs md:text-sm font-mono leading-none">{{ items.length }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow" />
|
||||
<p v-if="totalDuration" class="text-sm text-gray-200">{{ totalDurationPretty }}</p>
|
||||
</div>
|
||||
<template v-for="item in items">
|
||||
<tables-playlist-item-table-row :key="item.id" :item="item" :playlist-id="playlistId" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
playlistId: String,
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
totalDuration() {
|
||||
var _total = 0
|
||||
this.items.forEach((item) => {
|
||||
if (item.episode) _total += item.episode.duration
|
||||
else _total += item.libraryItem.media.duration
|
||||
})
|
||||
return _total
|
||||
},
|
||||
totalDurationPretty() {
|
||||
return this.$elapsedPrettyExtended(this.totalDuration)
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div class="w-full px-2 py-2 overflow-hidden relative">
|
||||
<nuxt-link v-if="libraryItem" :to="`/item/${libraryItem.id}`" class="flex w-full">
|
||||
<div class="h-full relative" :style="{ width: '50px' }">
|
||||
<covers-book-cover :library-item="libraryItem" :width="50" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
<div class="flex-grow item-table-content h-full px-2 flex items-center">
|
||||
<div class="max-w-full">
|
||||
<p class="truncate block text-sm">{{ itemTitle }}</p>
|
||||
<p class="truncate block text-gray-400 text-xs">{{ bookAuthorName }}</p>
|
||||
<p class="text-xxs text-gray-500">{{ itemDuration }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
playlistId: String,
|
||||
item: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isProcessingReadUpdate: false,
|
||||
processingRemove: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
libraryItem() {
|
||||
return this.item.libraryItem || {}
|
||||
},
|
||||
episode() {
|
||||
return this.item.episode
|
||||
},
|
||||
episodeId() {
|
||||
return this.episode ? this.episode.id : null
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem.media || {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
tracks() {
|
||||
if (this.episode) return []
|
||||
return this.media.tracks || []
|
||||
},
|
||||
itemTitle() {
|
||||
if (this.episode) return this.episode.title
|
||||
return this.mediaMetadata.title || ''
|
||||
},
|
||||
bookAuthors() {
|
||||
if (this.episode) return []
|
||||
return this.mediaMetadata.authors || []
|
||||
},
|
||||
bookAuthorName() {
|
||||
return this.bookAuthors.map((au) => au.name).join(', ')
|
||||
},
|
||||
itemDuration() {
|
||||
if (this.episode) return this.$elapsedPretty(this.episode.duration)
|
||||
return this.$elapsedPretty(this.media.duration)
|
||||
},
|
||||
isMissing() {
|
||||
return this.libraryItem.isMissing
|
||||
},
|
||||
isInvalid() {
|
||||
return this.libraryItem.isInvalid
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
coverWidth() {
|
||||
return 50
|
||||
},
|
||||
isMissing() {
|
||||
return this.libraryItem.isMissing
|
||||
},
|
||||
isInvalid() {
|
||||
return this.libraryItem.isInvalid
|
||||
},
|
||||
isStreaming() {
|
||||
return this.$store.getters['getIsItemStreaming'](this.item.id)
|
||||
},
|
||||
showPlayBtn() {
|
||||
return !this.isMissing && !this.isInvalid && !this.isStreaming && (this.tracks.length || this.episode)
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.item-table-content {
|
||||
width: calc(100% - 50px);
|
||||
max-width: calc(100% - 50px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="w-full px-0 py-4 overflow-hidden relative border-b border-white border-opacity-10">
|
||||
<div class="w-full py-4 overflow-hidden relative border-b border-white border-opacity-10">
|
||||
<div v-if="episode" class="w-full px-1">
|
||||
<!-- Help debug for testing -->
|
||||
<!-- <template>
|
||||
@@ -26,14 +26,28 @@
|
||||
|
||||
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
|
||||
|
||||
<button v-if="!isLocal" class="mx-1.5 mt-1.5" @click="addToPlaylist">
|
||||
<span class="material-icons text-2xl">playlist_add</span>
|
||||
</button>
|
||||
|
||||
<div v-if="userCanDownload">
|
||||
<span v-if="isLocal" class="material-icons-outlined px-2 text-success text-lg">audio_file</span>
|
||||
<span v-else-if="!localEpisode" class="material-icons mx-1 mt-2" :class="downloadItem ? 'animate-bounce text-warning text-opacity-75 text-xl' : 'text-gray-300 text-xl'" @click="downloadClick">{{ downloadItem ? 'downloading' : 'download' }}</span>
|
||||
<span v-else-if="!localEpisode" class="material-icons mx-1.5 mt-2 text-xl" :class="downloadItem ? 'animate-bounce text-warning text-opacity-75' : ''" @click="downloadClick">{{ downloadItem ? 'downloading' : 'download' }}</span>
|
||||
<span v-else class="material-icons px-2 text-success text-xl">download_done</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow" />
|
||||
|
||||
<button v-if="!isLocal && isAdminOrUp" class="mx-1.5 mt-1.5" @click="deleteEpisodeFromServerClick">
|
||||
<span class="material-icons text-xl">delete_forever</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="processing" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-30 flex items-center justify-center">
|
||||
<widgets-loading-spinner size="la-lg" />
|
||||
</div>
|
||||
|
||||
<div v-if="!userIsFinished" class="absolute bottom-0 left-0 h-0.5 bg-warning" :style="{ width: itemProgressPercent * 100 + '%' }" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -58,10 +72,14 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isProcessingReadUpdate: false
|
||||
isProcessingReadUpdate: false,
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
isIos() {
|
||||
return this.$platform === 'ios'
|
||||
},
|
||||
@@ -134,6 +152,32 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async deleteEpisodeFromServerClick() {
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: `Are you sure you want to delete episode "${this.title}" from the server?\nWarning: This will delete the audio file.`
|
||||
})
|
||||
|
||||
if (value) {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$delete(`/api/podcasts/${this.libraryItemId}/episode/${this.episode.id}?hard=1`)
|
||||
.then(() => {
|
||||
this.$toast.success('Episode deleted from server')
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMsg = error.response && error.response.data ? error.response.data : 'Failed to delete episode'
|
||||
console.error('Failed to delete episode', error)
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
addToPlaylist() {
|
||||
this.$emit('addToPlaylist', this.episode)
|
||||
},
|
||||
async selectFolder() {
|
||||
var folderObj = await AbsFileSystem.selectFolder({ mediaType: this.mediaType })
|
||||
if (folderObj.error) {
|
||||
@@ -141,8 +185,9 @@ export default {
|
||||
}
|
||||
return folderObj
|
||||
},
|
||||
downloadClick() {
|
||||
async downloadClick() {
|
||||
if (this.downloadItem) return
|
||||
await this.$hapticsImpactMedium()
|
||||
if (this.isIos) {
|
||||
// no local folders on iOS
|
||||
this.startDownload()
|
||||
@@ -202,7 +247,8 @@ export default {
|
||||
this.$toast.error(errorMsg)
|
||||
}
|
||||
},
|
||||
playClick() {
|
||||
async playClick() {
|
||||
await this.$hapticsImpactMedium()
|
||||
if (this.streamIsPlaying) {
|
||||
this.$eventBus.$emit('pause-item')
|
||||
} else {
|
||||
@@ -224,6 +270,7 @@ export default {
|
||||
}
|
||||
},
|
||||
async toggleFinished() {
|
||||
await this.$hapticsImpactMedium()
|
||||
this.isProcessingReadUpdate = true
|
||||
if (this.isLocal || this.localEpisode) {
|
||||
var isFinished = !this.userIsFinished
|
||||
@@ -271,4 +318,4 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,8 +1,32 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<!-- Podcast episode downloads queue -->
|
||||
<div v-if="episodeDownloadsQueued.length" class="px-4 py-2 my-2 bg-info bg-opacity-40 text-sm font-semibold rounded-md text-gray-100 relative w-full">
|
||||
<div class="flex items-center">
|
||||
<p class="text-sm py-1">{{ episodeDownloadsQueued.length }} Episode(s) queued for download</p>
|
||||
<div class="flex-grow" />
|
||||
<span v-if="isAdminOrUp" class="material-icons text-xl ml-3 cursor-pointer" @click="clearDownloadQueue">close</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Podcast episodes currently downloading -->
|
||||
<div v-if="episodesDownloading.length" class="px-4 py-2 my-2 bg-success bg-opacity-20 text-sm font-semibold rounded-md text-gray-100 relative w-full">
|
||||
<div v-for="episode in episodesDownloading" :key="episode.id" class="flex items-center">
|
||||
<widgets-loading-spinner />
|
||||
<p class="text-sm py-1 pl-4">Downloading episode "{{ episode.episodeDisplayTitle }}"</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<p class="text-lg mb-1 font-semibold">Episodes ({{ episodesFiltered.length }})</p>
|
||||
|
||||
<div class="flex-grow" />
|
||||
|
||||
<button v-if="isAdminOrUp && !fetchingRSSFeed" class="outline:none mx-1 pt-0.5 relative" @click="searchEpisodes">
|
||||
<span class="material-icons text-xl text-gray-200">search</span>
|
||||
</button>
|
||||
<widgets-loading-spinner v-else-if="fetchingRSSFeed" class="mx-1" />
|
||||
|
||||
<button class="outline:none mx-3 pt-0.5 relative" @click="showFilters">
|
||||
<span class="material-icons text-xl text-gray-200">filter_alt</span>
|
||||
<div v-show="filterKey !== 'all' && episodesAreFiltered" class="absolute top-0 right-0 w-1.5 h-1.5 rounded-full bg-success border border-green-300 shadow-sm z-10 pointer-events-none" />
|
||||
@@ -15,21 +39,28 @@
|
||||
</div>
|
||||
|
||||
<template v-for="episode in episodesSorted">
|
||||
<tables-podcast-episode-row :episode="episode" :local-episode="localEpisodeMap[episode.id]" :library-item-id="libraryItemId" :local-library-item-id="localLibraryItemId" :is-local="isLocal" :key="episode.id" />
|
||||
<tables-podcast-episode-row :episode="episode" :local-episode="localEpisodeMap[episode.id]" :library-item-id="libraryItemId" :local-library-item-id="localLibraryItemId" :is-local="isLocal" :key="episode.id" @addToPlaylist="addEpisodeToPlaylist" />
|
||||
</template>
|
||||
|
||||
<!-- What in tarnation is going on here?
|
||||
<!-- Huhhh?
|
||||
Without anything below the template it will not re-render -->
|
||||
<p> </p>
|
||||
|
||||
<modals-dialog v-model="showFiltersModal" title="Episode Filter" :items="filterItems" :selected="filterKey" @action="setFilter" />
|
||||
|
||||
<modals-podcast-episodes-feed-modal v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Dialog } from '@capacitor/dialog'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
libraryItemId: String,
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
episodes: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
@@ -83,7 +114,12 @@ export default {
|
||||
text: 'Complete',
|
||||
value: 'complete'
|
||||
}
|
||||
]
|
||||
],
|
||||
fetchingRSSFeed: false,
|
||||
podcastFeedEpisodes: [],
|
||||
showPodcastEpisodeFeed: false,
|
||||
episodesDownloading: [],
|
||||
episodeDownloadsQueued: []
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -95,6 +131,21 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
networkConnected() {
|
||||
return this.$store.state.networkConnected
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.libraryItem ? this.libraryItem.id : null
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
episodesAreFiltered() {
|
||||
return this.episodesFiltered.length !== this.episodesCopy.length
|
||||
},
|
||||
@@ -140,9 +191,58 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async clearDownloadQueue() {
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: `Are you sure you want to clear episode download queue?`
|
||||
})
|
||||
|
||||
if (value) {
|
||||
this.$axios
|
||||
.$get(`/api/podcasts/${this.libraryItemId}/clear-queue`)
|
||||
.then(() => {
|
||||
this.$toast.success('Episode download queue cleared')
|
||||
this.episodeDownloadQueued = []
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to clear queue', error)
|
||||
this.$toast.error('Failed to clear queue')
|
||||
})
|
||||
}
|
||||
},
|
||||
async searchEpisodes() {
|
||||
if (!this.networkConnected) {
|
||||
return this.$toast.error('No network connection')
|
||||
}
|
||||
|
||||
if (!this.mediaMetadata.feedUrl) {
|
||||
return this.$toast.error('Podcast does not have an RSS Feed')
|
||||
}
|
||||
this.fetchingRSSFeed = true
|
||||
const payload = await this.$axios.$post(`/api/podcasts/feed`, { rssFeed: this.mediaMetadata.feedUrl }).catch((error) => {
|
||||
console.error('Failed to get feed', error)
|
||||
this.$toast.error('Failed to get podcast feed')
|
||||
return null
|
||||
})
|
||||
this.fetchingRSSFeed = false
|
||||
if (!payload) return
|
||||
|
||||
console.log('Podcast feed', payload)
|
||||
const podcastfeed = payload.podcast
|
||||
if (!podcastfeed.episodes || !podcastfeed.episodes.length) {
|
||||
this.$toast.info('No episodes found in RSS feed')
|
||||
return
|
||||
}
|
||||
|
||||
this.podcastFeedEpisodes = podcastfeed.episodes
|
||||
this.showPodcastEpisodeFeed = true
|
||||
},
|
||||
addEpisodeToPlaylist(episode) {
|
||||
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode }])
|
||||
this.$store.commit('globals/setShowPlaylistsAddCreateModal', true)
|
||||
},
|
||||
setFilter(filter) {
|
||||
this.filterKey = filter
|
||||
console.log('Set filter', this.filterKey)
|
||||
this.showFiltersModal = false
|
||||
},
|
||||
showFilters() {
|
||||
@@ -159,8 +259,34 @@ export default {
|
||||
this.episodesCopy = this.episodes.map((ep) => {
|
||||
return { ...ep }
|
||||
})
|
||||
},
|
||||
episodeDownloadQueued(episodeDownload) {
|
||||
if (episodeDownload.libraryItemId === this.libraryItemId) {
|
||||
this.episodeDownloadsQueued.push(episodeDownload)
|
||||
}
|
||||
},
|
||||
episodeDownloadStarted(episodeDownload) {
|
||||
if (episodeDownload.libraryItemId === this.libraryItemId) {
|
||||
this.episodeDownloadsQueued = this.episodeDownloadsQueued.filter((d) => d.id !== episodeDownload.id)
|
||||
this.episodesDownloading.push(episodeDownload)
|
||||
}
|
||||
},
|
||||
episodeDownloadFinished(episodeDownload) {
|
||||
if (episodeDownload.libraryItemId === this.libraryItemId) {
|
||||
this.episodeDownloadsQueued = this.episodeDownloadsQueued.filter((d) => d.id !== episodeDownload.id)
|
||||
this.episodesDownloading = this.episodesDownloading.filter((d) => d.id !== episodeDownload.id)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
mounted() {
|
||||
this.$socket.$on('episode_download_queued', this.episodeDownloadQueued)
|
||||
this.$socket.$on('episode_download_started', this.episodeDownloadStarted)
|
||||
this.$socket.$on('episode_download_finished', this.episodeDownloadFinished)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$socket.$off('episode_download_queued', this.episodeDownloadQueued)
|
||||
this.$socket.$off('episode_download_started', this.episodeDownloadStarted)
|
||||
this.$socket.$off('episode_download_finished', this.episodeDownloadFinished)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="relative w-full" v-click-outside="clickOutsideObj">
|
||||
<div class="relative w-full" v-click-outside="clickedOutside">
|
||||
<p class="text-sm font-semibold" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
||||
<button type="button" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
||||
<button type="button" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
||||
<span class="flex items-center" :class="!selectedText ? 'text-gray-300' : 'text-white'">
|
||||
<span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText || placeholder || '' }}</span>
|
||||
</span>
|
||||
@@ -11,9 +11,9 @@
|
||||
</button>
|
||||
|
||||
<transition name="menu">
|
||||
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-gray-600 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
|
||||
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-gray-600 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="listbox">
|
||||
<template v-for="item in items">
|
||||
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
|
||||
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate font-sans text-sm">{{ item.text }}</span>
|
||||
</div>
|
||||
@@ -42,11 +42,6 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
clickOutsideObj: {
|
||||
handler: this.clickedOutside,
|
||||
events: ['mousedown'],
|
||||
isActive: true
|
||||
},
|
||||
showMenu: false
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<slot />
|
||||
</div>
|
||||
<transition name="menu">
|
||||
<ul ref="menu" v-show="showMenu" class="absolute z-50 -mt-px bg-primary border border-gray-600 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox" aria-activedescendant="listbox-option-3" style="width: 160px">
|
||||
<ul ref="menu" v-show="showMenu" class="absolute z-50 -mt-px bg-primary border border-gray-600 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" style="width: 160px">
|
||||
<template v-for="item in items">
|
||||
<nuxt-link :key="item.value" v-if="item.to" :to="item.to">
|
||||
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
|
||||
<div ref="wrapper" class="relative">
|
||||
<form @submit.prevent="submitForm">
|
||||
<div ref="inputWrapper" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-1" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
|
||||
<div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 mx-0.5 my-0.5 text-xs bg-bg flex flex-nowrap break-all items-center relative">
|
||||
<div v-if="!disabled" class="w-full h-full rounded-full absolute top-0 left-0 px-1 bg-bg bg-opacity-75 flex items-center justify-end opacity-0 hover:opacity-100">
|
||||
<span v-if="showEdit" class="material-icons text-white hover:text-warning cursor-pointer" style="font-size: 1.1rem" @click.stop="editItem(item)">edit</span>
|
||||
<span class="material-icons text-white hover:text-error cursor-pointer" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span>
|
||||
</div>
|
||||
{{ item }}
|
||||
</div>
|
||||
<input v-show="!readonly" ref="input" v-model="textInput" :disabled="disabled" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ul ref="menu" v-show="showMenu" class="absolute z-50 mt-1 w-full bg-bg border border-gray-600 shadow-lg max-h-56 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in itemsToShow">
|
||||
<li :key="item" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate">{{ item }}</span>
|
||||
</div>
|
||||
<span v-if="selected.includes(item)" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||
<span class="material-icons text-xl">checkmark</span>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
<li v-if="!itemsToShow.length" class="text-gray-50 select-none relative py-2 pr-9" role="option">
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="font-normal">No Items</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
label: String,
|
||||
disabled: Boolean,
|
||||
readonly: Boolean,
|
||||
showEdit: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
textInput: null,
|
||||
currentSearch: null,
|
||||
typingTimeout: null,
|
||||
isFocused: false,
|
||||
menu: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
showMenu(newVal) {
|
||||
if (newVal) this.setListener()
|
||||
else this.removeListener()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
selected: {
|
||||
get() {
|
||||
return this.value || []
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
showMenu() {
|
||||
return this.isFocused
|
||||
},
|
||||
wrapperClass() {
|
||||
var classes = []
|
||||
if (this.disabled) classes.push('bg-black-300')
|
||||
else classes.push('bg-primary')
|
||||
if (!this.readonly) classes.push('cursor-text')
|
||||
return classes.join(' ')
|
||||
},
|
||||
itemsToShow() {
|
||||
if (!this.currentSearch || !this.textInput) {
|
||||
return this.items
|
||||
}
|
||||
|
||||
return this.items.filter((i) => {
|
||||
var iValue = String(i).toLowerCase()
|
||||
return iValue.includes(this.currentSearch.toLowerCase())
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
editItem(item) {
|
||||
this.$emit('edit', item)
|
||||
},
|
||||
keydownInput() {
|
||||
clearTimeout(this.typingTimeout)
|
||||
this.typingTimeout = setTimeout(() => {
|
||||
this.currentSearch = this.textInput
|
||||
}, 100)
|
||||
this.setInputWidth()
|
||||
},
|
||||
setInputWidth() {
|
||||
setTimeout(() => {
|
||||
var value = this.$refs.input.value
|
||||
var len = value.length * 7 + 24
|
||||
this.$refs.input.style.width = len + 'px'
|
||||
this.recalcMenuPos()
|
||||
}, 50)
|
||||
},
|
||||
recalcMenuPos() {
|
||||
if (!this.menu) return
|
||||
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
|
||||
if (boundingBox.y > window.innerHeight - 8) {
|
||||
// Input is off the page
|
||||
return this.forceBlur()
|
||||
}
|
||||
var menuHeight = this.menu.clientHeight
|
||||
var top = boundingBox.y + boundingBox.height - 4
|
||||
if (top + menuHeight > window.innerHeight - 20) {
|
||||
// Reverse menu to open upwards
|
||||
top = boundingBox.y - menuHeight - 4
|
||||
}
|
||||
|
||||
this.menu.style.top = top + 'px'
|
||||
this.menu.style.left = boundingBox.x + 'px'
|
||||
this.menu.style.width = boundingBox.width + 'px'
|
||||
},
|
||||
unmountMountMenu() {
|
||||
if (!this.$refs.menu) return
|
||||
this.menu = this.$refs.menu
|
||||
|
||||
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
|
||||
this.menu.remove()
|
||||
document.body.appendChild(this.menu)
|
||||
this.menu.style.top = boundingBox.y + boundingBox.height - 4 + 'px'
|
||||
this.menu.style.left = boundingBox.x + 'px'
|
||||
this.menu.style.width = boundingBox.width + 'px'
|
||||
},
|
||||
inputFocus() {
|
||||
if (!this.menu) {
|
||||
this.unmountMountMenu()
|
||||
}
|
||||
this.isFocused = true
|
||||
this.$nextTick(this.recalcMenuPos)
|
||||
},
|
||||
inputBlur() {
|
||||
if (!this.isFocused) return
|
||||
|
||||
setTimeout(() => {
|
||||
if (document.activeElement === this.$refs.input) {
|
||||
return
|
||||
}
|
||||
this.isFocused = false
|
||||
if (this.textInput) this.submitForm()
|
||||
}, 50)
|
||||
},
|
||||
focus() {
|
||||
if (this.$refs.input) this.$refs.input.focus()
|
||||
},
|
||||
blur() {
|
||||
if (this.$refs.input) this.$refs.input.blur()
|
||||
},
|
||||
forceBlur() {
|
||||
this.isFocused = false
|
||||
if (this.textInput) this.submitForm()
|
||||
if (this.$refs.input) this.$refs.input.blur()
|
||||
},
|
||||
clickedOption(e, itemValue) {
|
||||
if (e) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}
|
||||
if (this.$refs.input) this.$refs.input.focus()
|
||||
|
||||
var newSelected = null
|
||||
if (this.selected.includes(itemValue)) {
|
||||
newSelected = this.selected.filter((s) => s !== itemValue)
|
||||
this.$emit('removedItem', itemValue)
|
||||
} else {
|
||||
newSelected = this.selected.concat([itemValue])
|
||||
}
|
||||
this.textInput = null
|
||||
this.currentSearch = null
|
||||
this.$emit('input', newSelected)
|
||||
this.$nextTick(() => {
|
||||
this.recalcMenuPos()
|
||||
})
|
||||
},
|
||||
clickWrapper() {
|
||||
if (this.disabled) return
|
||||
if (this.showMenu) {
|
||||
return this.blur()
|
||||
}
|
||||
this.focus()
|
||||
},
|
||||
removeItem(item) {
|
||||
var remaining = this.selected.filter((i) => i !== item)
|
||||
this.$emit('input', remaining)
|
||||
this.$emit('removedItem', item)
|
||||
this.$nextTick(() => {
|
||||
this.recalcMenuPos()
|
||||
})
|
||||
},
|
||||
insertNewItem(item) {
|
||||
this.selected.push(item)
|
||||
this.$emit('input', this.selected)
|
||||
this.$emit('newItem', item)
|
||||
this.textInput = null
|
||||
this.currentSearch = null
|
||||
this.$nextTick(() => {
|
||||
this.blur()
|
||||
})
|
||||
},
|
||||
submitForm() {
|
||||
if (!this.textInput) return
|
||||
|
||||
var cleaned = this.textInput.trim()
|
||||
var matchesItem = this.items.find((i) => {
|
||||
return i === cleaned
|
||||
})
|
||||
if (matchesItem) {
|
||||
this.clickedOption(null, matchesItem)
|
||||
} else {
|
||||
this.insertNewItem(this.textInput)
|
||||
}
|
||||
},
|
||||
scroll() {
|
||||
this.recalcMenuPos()
|
||||
},
|
||||
setListener() {
|
||||
document.addEventListener('scroll', this.scroll, true)
|
||||
},
|
||||
removeListener() {
|
||||
document.removeEventListener('scroll', this.scroll, true)
|
||||
}
|
||||
},
|
||||
mounted() {},
|
||||
beforeDestroy() {
|
||||
if (this.menu) this.menu.remove()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
input {
|
||||
border-style: inherit !important;
|
||||
}
|
||||
input:read-only {
|
||||
color: #aaa;
|
||||
background-color: #444;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<input v-model="input" ref="input" autofocus :type="type" :disabled="disabled" autocorrect="off" autocapitalize="none" autocomplete="off" :placeholder="placeholder" class="py-2 w-full outline-none" :class="inputClass" @keyup="keyup" />
|
||||
<input v-model="input" ref="input" autofocus :type="type" :disabled="disabled" autocorrect="off" autocapitalize="none" autocomplete="off" :placeholder="placeholder" class="py-2 w-full outline-none bg-primary" :class="inputClass" @keyup="keyup" />
|
||||
<div v-if="prependIcon" class="absolute top-0 left-0 h-full px-2 flex items-center justify-center">
|
||||
<span class="material-icons text-lg">{{ prependIcon }}</span>
|
||||
</div>
|
||||
@@ -26,10 +26,6 @@ export default {
|
||||
prependIcon: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
textSize: {
|
||||
type: String,
|
||||
default: 'lg'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -45,7 +41,7 @@ export default {
|
||||
}
|
||||
},
|
||||
inputClass() {
|
||||
var classes = [`bg-${this.bg}`, `rounded-${this.rounded}`, `text-${this.textSize}`]
|
||||
var classes = [`bg-${this.bg}`, `rounded-${this.rounded}`]
|
||||
if (this.disabled) classes.push('text-gray-300')
|
||||
else classes.push('text-white')
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<textarea ref="input" v-model="inputValue" :rows="rows" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="py-2 px-3 rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none" :class="transparent ? '' : 'border border-gray-600'" @change="change" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: [String, Number],
|
||||
placeholder: String,
|
||||
readonly: Boolean,
|
||||
rows: {
|
||||
type: Number,
|
||||
default: 2
|
||||
},
|
||||
transparent: Boolean,
|
||||
disabled: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
inputValue: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
change(e) {
|
||||
this.$emit('change', e.target.value)
|
||||
},
|
||||
blur() {
|
||||
if (this.$refs.input && this.$refs.input.blur) {
|
||||
this.$refs.input.blur()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
textarea {
|
||||
border-style: inherit !important;
|
||||
}
|
||||
textarea:read-only {
|
||||
color: #aaa;
|
||||
background-color: #444;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
|
||||
<ui-textarea-input ref="input" v-model="inputValue" :disabled="disabled" :rows="rows" class="w-full" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: [String, Number],
|
||||
label: String,
|
||||
disabled: Boolean,
|
||||
rows: {
|
||||
type: Number,
|
||||
default: 2
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
inputValue: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
blur() {
|
||||
if (this.$refs.input && this.$refs.input.blur) {
|
||||
this.$refs.input.blur()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -147,6 +147,16 @@ export default {
|
||||
margin-top: -2px;
|
||||
margin-left: -2px;
|
||||
}
|
||||
.la-ball-spin-clockwise.la-lg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
.la-ball-spin-clockwise.la-lg > div {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-top: -4px;
|
||||
margin-left: -4px;
|
||||
}
|
||||
.la-ball-spin-clockwise.la-2x {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
|
||||
@@ -720,12 +720,12 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 16;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
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.59;
|
||||
MARKETING_VERSION = 0.9.60;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -744,12 +744,12 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 16;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
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.59;
|
||||
MARKETING_VERSION = 0.9.60;
|
||||
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: 4,
|
||||
schemaVersion: 5,
|
||||
migrationBlock: { [weak self] migration, oldSchemaVersion in
|
||||
if (oldSchemaVersion < 1) {
|
||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)")
|
||||
@@ -30,6 +30,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
indexCounter += 1
|
||||
}
|
||||
}
|
||||
if (oldSchemaVersion < 5) {
|
||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding lockOrientation setting")
|
||||
migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in
|
||||
newObject?["lockOrientation"] = "NONE"
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
Realm.Configuration.defaultConfiguration = configuration
|
||||
|
||||
@@ -2,5 +2,9 @@
|
||||
<widget version="1.0.0" xmlns="http://www.w3.org/ns/widgets" xmlns:cdv="http://cordova.apache.org/ns/1.0">
|
||||
<access origin="*" />
|
||||
|
||||
<feature name="CDVOrientation">
|
||||
<param name="ios-package" value="CDVOrientation"/>
|
||||
</feature>
|
||||
|
||||
|
||||
</widget>
|
||||
@@ -26,6 +26,7 @@ public class AbsAudioPlayer: CAPPlugin {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(onLocalMediaProgressUpdate), name: NSNotification.Name(PlayerEvents.localProgress.rawValue), object: nil)
|
||||
|
||||
self.bridge?.webView?.allowsBackForwardNavigationGestures = true;
|
||||
self.bridge?.webView?.scrollView.alwaysBounceVertical = false;
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -236,11 +236,13 @@ public class AbsDatabase: CAPPlugin {
|
||||
let enableAltView = call.getBool("enableAltView") ?? false
|
||||
let jumpBackwardsTime = call.getInt("jumpBackwardsTime") ?? 10
|
||||
let jumpForwardTime = call.getInt("jumpForwardTime") ?? 10
|
||||
let lockOrientation = call.getString("lockOrientation") ?? "NONE"
|
||||
let settings = DeviceSettings()
|
||||
settings.disableAutoRewind = disableAutoRewind
|
||||
settings.enableAltView = enableAltView
|
||||
settings.jumpBackwardsTime = jumpBackwardsTime
|
||||
settings.jumpForwardTime = jumpForwardTime
|
||||
settings.lockOrientation = lockOrientation
|
||||
|
||||
Database.shared.setDeviceSettings(deviceSettings: settings)
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ def capacitor_pods
|
||||
pod 'CapacitorNetwork', :path => '../../node_modules/@capacitor/network'
|
||||
pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar'
|
||||
pod 'CapacitorStorage', :path => '../../node_modules/@capacitor/storage'
|
||||
pod 'CordovaPlugins', :path => '../capacitor-cordova-ios-plugins'
|
||||
end
|
||||
|
||||
target 'Audiobookshelf' do
|
||||
|
||||
@@ -13,6 +13,7 @@ class DeviceSettings: Object {
|
||||
@Persisted var enableAltView: Bool = false
|
||||
@Persisted var jumpBackwardsTime: Int = 10
|
||||
@Persisted var jumpForwardTime: Int = 10
|
||||
@Persisted var lockOrientation: String = "NONE"
|
||||
}
|
||||
|
||||
func getDefaultDeviceSettings() -> DeviceSettings {
|
||||
@@ -24,6 +25,7 @@ func deviceSettingsToJSON(settings: DeviceSettings) -> Dictionary<String, Any> {
|
||||
"disableAutoRewind": settings.disableAutoRewind,
|
||||
"enableAltView": settings.enableAltView,
|
||||
"jumpBackwardsTime": settings.jumpBackwardsTime,
|
||||
"jumpForwardTime": settings.jumpForwardTime
|
||||
"jumpForwardTime": settings.jumpForwardTime,
|
||||
"lockOrientation": settings.lockOrientation
|
||||
]
|
||||
}
|
||||
|
||||
@@ -151,6 +151,14 @@ class PlayerProgress {
|
||||
} else {
|
||||
let playbackReport = PlaybackReport(currentTime: session.currentTime, duration: session.duration, timeListened: session.timeListening)
|
||||
success = await ApiClient.reportPlaybackProgress(report: playbackReport, sessionId: session.id)
|
||||
// Reset time listening becuase server expects that time to be since last sync, not for the whole session
|
||||
if success {
|
||||
if let session = session.thaw() {
|
||||
try session.update {
|
||||
session.timeListening = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+4
-2
@@ -1,11 +1,12 @@
|
||||
<template>
|
||||
<div class="w-full layout-wrapper bg-bg text-white">
|
||||
<div class="select-none w-full layout-wrapper bg-bg text-white">
|
||||
<app-appbar />
|
||||
<div id="content" class="overflow-hidden relative" :class="playerIsOpen ? 'playerOpen' : ''">
|
||||
<Nuxt />
|
||||
</div>
|
||||
<app-audio-player-container ref="streamContainer" />
|
||||
<modals-libraries-modal />
|
||||
<modals-playlists-add-create-modal />
|
||||
<app-side-drawer />
|
||||
<readers-reader />
|
||||
</div>
|
||||
@@ -174,7 +175,7 @@ export default {
|
||||
}
|
||||
|
||||
console.log('[default] Calling syncLocalMediaProgress')
|
||||
var response = await this.$db.syncLocalMediaProgressWithServer()
|
||||
const response = await this.$db.syncLocalMediaProgressWithServer()
|
||||
if (!response) {
|
||||
if (this.$platform != 'web') this.$toast.error('Failed to sync local media with server')
|
||||
this.$store.commit('setLastLocalMediaSyncResults', null)
|
||||
@@ -261,6 +262,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')
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import LazyBookCard from '@/components/cards/LazyBookCard'
|
||||
import LazyListBookCard from '@/components/cards/LazyListBookCard'
|
||||
import LazySeriesCard from '@/components/cards/LazySeriesCard'
|
||||
import LazyCollectionCard from '@/components/cards/LazyCollectionCard'
|
||||
import LazyPlaylistCard from '@/components/cards/LazyPlaylistCard'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
@@ -16,6 +17,7 @@ export default {
|
||||
getComponentClass() {
|
||||
if (this.entityName === 'series') return Vue.extend(LazySeriesCard)
|
||||
if (this.entityName === 'collections') return Vue.extend(LazyCollectionCard)
|
||||
if (this.entityName === 'playlists') return Vue.extend(LazyPlaylistCard)
|
||||
if (this.showBookshelfListView) return Vue.extend(LazyListBookCard)
|
||||
return Vue.extend(LazyBookCard)
|
||||
},
|
||||
|
||||
Generated
+238
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf-app",
|
||||
"version": "0.9.59-beta",
|
||||
"version": "0.9.60-beta",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf-app",
|
||||
"version": "0.9.59-beta",
|
||||
"version": "0.9.60-beta",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "^3.4.3",
|
||||
"@capacitor/app": "^1.1.1",
|
||||
@@ -19,12 +19,14 @@
|
||||
"@capacitor/status-bar": "^1.0.8",
|
||||
"@capacitor/storage": "^1.2.5",
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
"cordova-plugin-screen-orientation": "^3.0.2",
|
||||
"core-js": "^3.15.1",
|
||||
"date-fns": "^2.25.0",
|
||||
"epubjs": "^0.3.88",
|
||||
"libarchive.js": "^1.3.0",
|
||||
"nuxt": "^2.15.7",
|
||||
"socket.io-client": "^4.1.3",
|
||||
"vue-pdf": "^4.2.0",
|
||||
"vue-toastification": "^1.7.11",
|
||||
"vuedraggable": "^2.24.3"
|
||||
},
|
||||
@@ -4545,6 +4547,11 @@
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-plugin-syntax-dynamic-import": {
|
||||
"version": "6.18.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-dynamic-import/-/babel-plugin-syntax-dynamic-import-6.18.0.tgz",
|
||||
"integrity": "sha512-MioUE+LfjCEz65Wf7Z/Rm4XCP5k2c+TbMd2Z2JKc7U9uwjBhAfNPE48KC4GTGKhppMeYVepwDBNO/nGY6NYHBA=="
|
||||
},
|
||||
"node_modules/backo2": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz",
|
||||
@@ -5739,6 +5746,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cordova-plugin-screen-orientation": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/cordova-plugin-screen-orientation/-/cordova-plugin-screen-orientation-3.0.2.tgz",
|
||||
"integrity": "sha512-2w6CMC+HGvbhogJetalwGurL2Fx8DQCCPy3wlSZHN1/W7WoQ5n9ujVozcoKrY4VaagK6bxrPFih+ElkO8Uqfzg==",
|
||||
"engines": {
|
||||
"cordovaDependencies": {
|
||||
"4.0.0": {
|
||||
"cordova": ">100"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.21.1",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.21.1.tgz",
|
||||
@@ -10945,6 +10964,11 @@
|
||||
"node": ">=0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "2.6.347",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-2.6.347.tgz",
|
||||
"integrity": "sha512-QC+h7hG2su9v/nU1wEI3SnpPIrqJODL7GTDFvR74ANKGq1AFJW16PH8VWnhpiTi9YcLSFV9xLeWSgq+ckHLdVQ=="
|
||||
},
|
||||
"node_modules/pend": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||
@@ -14438,6 +14462,25 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/raw-loader": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/raw-loader/-/raw-loader-4.0.2.tgz",
|
||||
"integrity": "sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA==",
|
||||
"dependencies": {
|
||||
"loader-utils": "^2.0.0",
|
||||
"schema-utils": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.13.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/webpack"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"webpack": "^4.0.0 || ^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rc9": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/rc9/-/rc9-1.2.0.tgz",
|
||||
@@ -17171,6 +17214,48 @@
|
||||
"resolved": "https://registry.npmjs.org/vue-no-ssr/-/vue-no-ssr-1.1.1.tgz",
|
||||
"integrity": "sha512-ZMjqRpWabMPqPc7gIrG0Nw6vRf1+itwf0Itft7LbMXs2g3Zs/NFmevjZGN1x7K3Q95GmIjWbQZTVerxiBxI+0g=="
|
||||
},
|
||||
"node_modules/vue-pdf": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vue-pdf/-/vue-pdf-4.2.0.tgz",
|
||||
"integrity": "sha512-GpAbZfM48Hom1R8f4XL5ZzoVBLlbyy+4z0VYmTQORVOSieVIIu+XtnNl0RY6EXg60Qni6T6nIgrmsCcCkWv39A==",
|
||||
"dependencies": {
|
||||
"babel-plugin-syntax-dynamic-import": "^6.18.0",
|
||||
"loader-utils": "^1.4.0",
|
||||
"pdfjs-dist": "^2.5.207",
|
||||
"raw-loader": "^4.0.1",
|
||||
"vue-resize-sensor": "^2.0.0",
|
||||
"worker-loader": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-pdf/node_modules/json5": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
|
||||
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"json5": "lib/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-pdf/node_modules/loader-utils": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz",
|
||||
"integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==",
|
||||
"dependencies": {
|
||||
"big.js": "^5.2.2",
|
||||
"emojis-list": "^3.0.0",
|
||||
"json5": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-resize-sensor": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/vue-resize-sensor/-/vue-resize-sensor-2.0.0.tgz",
|
||||
"integrity": "sha512-W+y2EAI/BxS4Vlcca9scQv8ifeBFck56DRtSwWJ2H4Cw1GLNUYxiZxUHHkuzuI5JPW/cYtL1bPO5xPyEXx4LmQ=="
|
||||
},
|
||||
"node_modules/vue-router": {
|
||||
"version": "3.5.3",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-3.5.3.tgz",
|
||||
@@ -18479,6 +18564,57 @@
|
||||
"errno": "~0.1.7"
|
||||
}
|
||||
},
|
||||
"node_modules/worker-loader": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/worker-loader/-/worker-loader-2.0.0.tgz",
|
||||
"integrity": "sha512-tnvNp4K3KQOpfRnD20m8xltE3eWh89Ye+5oj7wXEEHKac1P4oZ6p9oTj8/8ExqoSBnk9nu5Pr4nKfQ1hn2APJw==",
|
||||
"dependencies": {
|
||||
"loader-utils": "^1.0.0",
|
||||
"schema-utils": "^0.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.9.0 || >= 8.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"webpack": "^3.0.0 || ^4.0.0-alpha.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/worker-loader/node_modules/json5": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
|
||||
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"json5": "lib/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/worker-loader/node_modules/loader-utils": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz",
|
||||
"integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==",
|
||||
"dependencies": {
|
||||
"big.js": "^5.2.2",
|
||||
"emojis-list": "^3.0.0",
|
||||
"json5": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/worker-loader/node_modules/schema-utils": {
|
||||
"version": "0.4.7",
|
||||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-0.4.7.tgz",
|
||||
"integrity": "sha512-v/iwU6wvwGK8HbU9yi3/nhGzP0yGSuhQMzL6ySiec1FSrZZDkhm4noOSWzrNFo/jEc+SJY6jRTwuwbSXJPDUnQ==",
|
||||
"dependencies": {
|
||||
"ajv": "^6.1.0",
|
||||
"ajv-keywords": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
@@ -22160,6 +22296,11 @@
|
||||
"@babel/helper-define-polyfill-provider": "^0.2.4"
|
||||
}
|
||||
},
|
||||
"babel-plugin-syntax-dynamic-import": {
|
||||
"version": "6.18.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-dynamic-import/-/babel-plugin-syntax-dynamic-import-6.18.0.tgz",
|
||||
"integrity": "sha512-MioUE+LfjCEz65Wf7Z/Rm4XCP5k2c+TbMd2Z2JKc7U9uwjBhAfNPE48KC4GTGKhppMeYVepwDBNO/nGY6NYHBA=="
|
||||
},
|
||||
"backo2": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz",
|
||||
@@ -23058,6 +23199,11 @@
|
||||
"resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz",
|
||||
"integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40="
|
||||
},
|
||||
"cordova-plugin-screen-orientation": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/cordova-plugin-screen-orientation/-/cordova-plugin-screen-orientation-3.0.2.tgz",
|
||||
"integrity": "sha512-2w6CMC+HGvbhogJetalwGurL2Fx8DQCCPy3wlSZHN1/W7WoQ5n9ujVozcoKrY4VaagK6bxrPFih+ElkO8Uqfzg=="
|
||||
},
|
||||
"core-js": {
|
||||
"version": "3.21.1",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.21.1.tgz",
|
||||
@@ -27073,6 +27219,11 @@
|
||||
"sha.js": "^2.4.8"
|
||||
}
|
||||
},
|
||||
"pdfjs-dist": {
|
||||
"version": "2.6.347",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-2.6.347.tgz",
|
||||
"integrity": "sha512-QC+h7hG2su9v/nU1wEI3SnpPIrqJODL7GTDFvR74ANKGq1AFJW16PH8VWnhpiTi9YcLSFV9xLeWSgq+ckHLdVQ=="
|
||||
},
|
||||
"pend": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||
@@ -29656,6 +29807,15 @@
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="
|
||||
},
|
||||
"raw-loader": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/raw-loader/-/raw-loader-4.0.2.tgz",
|
||||
"integrity": "sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA==",
|
||||
"requires": {
|
||||
"loader-utils": "^2.0.0",
|
||||
"schema-utils": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"rc9": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/rc9/-/rc9-1.2.0.tgz",
|
||||
@@ -31785,6 +31945,44 @@
|
||||
"resolved": "https://registry.npmjs.org/vue-no-ssr/-/vue-no-ssr-1.1.1.tgz",
|
||||
"integrity": "sha512-ZMjqRpWabMPqPc7gIrG0Nw6vRf1+itwf0Itft7LbMXs2g3Zs/NFmevjZGN1x7K3Q95GmIjWbQZTVerxiBxI+0g=="
|
||||
},
|
||||
"vue-pdf": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vue-pdf/-/vue-pdf-4.2.0.tgz",
|
||||
"integrity": "sha512-GpAbZfM48Hom1R8f4XL5ZzoVBLlbyy+4z0VYmTQORVOSieVIIu+XtnNl0RY6EXg60Qni6T6nIgrmsCcCkWv39A==",
|
||||
"requires": {
|
||||
"babel-plugin-syntax-dynamic-import": "^6.18.0",
|
||||
"loader-utils": "^1.4.0",
|
||||
"pdfjs-dist": "^2.5.207",
|
||||
"raw-loader": "^4.0.1",
|
||||
"vue-resize-sensor": "^2.0.0",
|
||||
"worker-loader": "^2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"json5": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
|
||||
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
|
||||
"requires": {
|
||||
"minimist": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"loader-utils": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz",
|
||||
"integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==",
|
||||
"requires": {
|
||||
"big.js": "^5.2.2",
|
||||
"emojis-list": "^3.0.0",
|
||||
"json5": "^1.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"vue-resize-sensor": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/vue-resize-sensor/-/vue-resize-sensor-2.0.0.tgz",
|
||||
"integrity": "sha512-W+y2EAI/BxS4Vlcca9scQv8ifeBFck56DRtSwWJ2H4Cw1GLNUYxiZxUHHkuzuI5JPW/cYtL1bPO5xPyEXx4LmQ=="
|
||||
},
|
||||
"vue-router": {
|
||||
"version": "3.5.3",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-3.5.3.tgz",
|
||||
@@ -32802,6 +33000,44 @@
|
||||
"errno": "~0.1.7"
|
||||
}
|
||||
},
|
||||
"worker-loader": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/worker-loader/-/worker-loader-2.0.0.tgz",
|
||||
"integrity": "sha512-tnvNp4K3KQOpfRnD20m8xltE3eWh89Ye+5oj7wXEEHKac1P4oZ6p9oTj8/8ExqoSBnk9nu5Pr4nKfQ1hn2APJw==",
|
||||
"requires": {
|
||||
"loader-utils": "^1.0.0",
|
||||
"schema-utils": "^0.4.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"json5": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
|
||||
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
|
||||
"requires": {
|
||||
"minimist": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"loader-utils": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz",
|
||||
"integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==",
|
||||
"requires": {
|
||||
"big.js": "^5.2.2",
|
||||
"emojis-list": "^3.0.0",
|
||||
"json5": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"schema-utils": {
|
||||
"version": "0.4.7",
|
||||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-0.4.7.tgz",
|
||||
"integrity": "sha512-v/iwU6wvwGK8HbU9yi3/nhGzP0yGSuhQMzL6ySiec1FSrZZDkhm4noOSWzrNFo/jEc+SJY6jRTwuwbSXJPDUnQ==",
|
||||
"requires": {
|
||||
"ajv": "^6.1.0",
|
||||
"ajv-keywords": "^3.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-app",
|
||||
"version": "0.9.59-beta",
|
||||
"version": "0.9.60-beta",
|
||||
"author": "advplyr",
|
||||
"scripts": {
|
||||
"dev": "nuxt --hostname 0.0.0.0 --port 1337",
|
||||
@@ -23,12 +23,14 @@
|
||||
"@capacitor/status-bar": "^1.0.8",
|
||||
"@capacitor/storage": "^1.2.5",
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
"cordova-plugin-screen-orientation": "^3.0.2",
|
||||
"core-js": "^3.15.1",
|
||||
"date-fns": "^2.25.0",
|
||||
"epubjs": "^0.3.88",
|
||||
"libarchive.js": "^1.3.0",
|
||||
"nuxt": "^2.15.7",
|
||||
"socket.io-client": "^4.1.3",
|
||||
"vue-pdf": "^4.2.0",
|
||||
"vue-toastification": "^1.7.11",
|
||||
"vuedraggable": "^2.24.3"
|
||||
},
|
||||
|
||||
+2
-1
@@ -53,6 +53,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
async logout() {
|
||||
await this.$hapticsImpactMedium()
|
||||
if (this.user) {
|
||||
await this.$axios.$post('/logout').catch((error) => {
|
||||
console.error(error)
|
||||
@@ -68,4 +69,4 @@ export default {
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
+12
-3
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="w-full h-full">
|
||||
<home-bookshelf-nav-bar />
|
||||
<home-bookshelf-toolbar v-show="!isHome" />
|
||||
<div id="bookshelf-wrapper" class="main-content overflow-y-auto overflow-x-hidden relative" :class="isHome ? 'home-page' : ''">
|
||||
<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 />
|
||||
</div>
|
||||
</div>
|
||||
@@ -14,8 +14,17 @@ export default {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
hideToolbar() {
|
||||
return this.isHome || this.isLatest || this.isPodcastSearch
|
||||
},
|
||||
isHome() {
|
||||
return this.$route.name === 'bookshelf'
|
||||
},
|
||||
isLatest() {
|
||||
return this.$route.name === 'bookshelf-latest'
|
||||
},
|
||||
isPodcastSearch() {
|
||||
return this.$route.name === 'bookshelf-search'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,7 +37,7 @@ export default {
|
||||
min-height: calc(100% - 72px);
|
||||
max-width: 100vw;
|
||||
}
|
||||
.main-content.home-page {
|
||||
.main-content.no-toolbar {
|
||||
height: calc(100% - 36px);
|
||||
max-height: calc(100% - 36px);
|
||||
min-height: calc(100% - 36px);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<div>Authors</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
watch: {},
|
||||
computed: {},
|
||||
methods: {}
|
||||
}
|
||||
</script>
|
||||
+24
-22
@@ -41,12 +41,19 @@ export default {
|
||||
localLibraryItems: []
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
networkConnected(newVal) {
|
||||
// Update shelves when network connect status changes
|
||||
console.log(`Network changed to ${newVal} - fetch categories`)
|
||||
this.fetchCategories()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
user() {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
isSocketConnected() {
|
||||
return this.$store.state.socketConnected
|
||||
networkConnected() {
|
||||
return this.$store.state.networkConnected
|
||||
},
|
||||
currentLibraryName() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryName']
|
||||
@@ -54,6 +61,9 @@ export default {
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
currentLibraryMediaType() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||
},
|
||||
altViewEnabled() {
|
||||
return this.$store.getters['getAltViewEnabled']
|
||||
},
|
||||
@@ -111,21 +121,19 @@ export default {
|
||||
this.shelves = []
|
||||
|
||||
this.localLibraryItems = await this.$db.getLocalLibraryItems()
|
||||
|
||||
var localCategories = await this.getLocalMediaItemCategories()
|
||||
this.shelves = this.shelves.concat(localCategories)
|
||||
const localCategories = await this.getLocalMediaItemCategories()
|
||||
|
||||
if (this.user && this.currentLibraryId) {
|
||||
var categories = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/personalized?minified=1`).catch((error) => {
|
||||
const categories = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/personalized?minified=1`).catch((error) => {
|
||||
console.error('Failed to fetch categories', error)
|
||||
return []
|
||||
})
|
||||
categories = categories.map((cat) => {
|
||||
this.shelves = categories.map((cat) => {
|
||||
console.log('[breadcrumb] Personalized category from server', cat.type)
|
||||
if (cat.type == 'book' || cat.type == 'podcast' || cat.type == 'episode') {
|
||||
// Map localLibraryItem to entities
|
||||
cat.entities = cat.entities.map((entity) => {
|
||||
var localLibraryItem = this.localLibraryItems.find((lli) => {
|
||||
const localLibraryItem = this.localLibraryItems.find((lli) => {
|
||||
return lli.libraryItemId == entity.id
|
||||
})
|
||||
if (localLibraryItem) {
|
||||
@@ -136,14 +144,15 @@ export default {
|
||||
}
|
||||
return cat
|
||||
})
|
||||
// Put continue listening shelf first
|
||||
var continueListeningShelf = categories.find((c) => c.id == 'continue-listening')
|
||||
if (continueListeningShelf) {
|
||||
this.shelves = [continueListeningShelf, ...this.shelves]
|
||||
console.log(this.shelves)
|
||||
}
|
||||
this.shelves = this.shelves.concat(categories.filter((c) => c.id != 'continue-listening'))
|
||||
|
||||
// Only add the local shelf with the same media type
|
||||
const localShelves = localCategories.filter((cat) => cat.type === this.currentLibraryMediaType)
|
||||
this.shelves.push(...localShelves)
|
||||
} else {
|
||||
// Offline only local
|
||||
this.shelves = localCategories
|
||||
}
|
||||
|
||||
this.loading = false
|
||||
},
|
||||
async libraryChanged() {
|
||||
@@ -195,21 +204,14 @@ export default {
|
||||
},
|
||||
initListeners() {
|
||||
this.$eventBus.$on('library-changed', this.libraryChanged)
|
||||
// this.$eventBus.$on('downloads-loaded', this.downloadsLoaded)
|
||||
},
|
||||
removeListeners() {
|
||||
this.$eventBus.$off('library-changed', this.libraryChanged)
|
||||
// this.$eventBus.$off('downloads-loaded', this.downloadsLoaded)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.initListeners()
|
||||
this.fetchCategories()
|
||||
// if (this.$server.initialized && this.currentLibraryId) {
|
||||
// this.fetchCategories()
|
||||
// } else {
|
||||
// this.shelves = this.downloadOnlyShelves
|
||||
// }
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.removeListeners()
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div class="w-full p-4">
|
||||
<h1 class="text-xl mb-2 font-semibold">Latest Episodes</h1>
|
||||
|
||||
<template v-for="episode in recentEpisodes">
|
||||
<tables-podcast-episode-row :episode="episode" :local-episode="localEpisodeMap[episode.id]" :library-item-id="episode.libraryItemId" :local-library-item-id="null" :is-local="isLocal" :key="episode.id" @addToPlaylist="addEpisodeToPlaylist" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
recentEpisodes: [],
|
||||
totalEpisodes: 0,
|
||||
currentPage: 0,
|
||||
localEpisodeMap: {},
|
||||
isLocal: false
|
||||
}
|
||||
},
|
||||
watch: {},
|
||||
computed: {
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async addEpisodeToPlaylist(episode) {
|
||||
const libraryItem = await this.$axios.$get(`/api/items/${episode.libraryItemId}`).catch((error) => {
|
||||
console.error('Failed to get library item', error)
|
||||
this.$toast.error('Failed to get library item')
|
||||
return null
|
||||
})
|
||||
if (!libraryItem) return
|
||||
|
||||
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem, episode }])
|
||||
this.$store.commit('globals/setShowPlaylistsAddCreateModal', true)
|
||||
},
|
||||
async loadRecentEpisodes(page = 0) {
|
||||
this.processing = true
|
||||
const episodePayload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/recent-episodes?limit=25&page=${page}`).catch((error) => {
|
||||
console.error('Failed to get recent episodes', error)
|
||||
this.$toast.error('Failed to get recent episodes')
|
||||
return null
|
||||
})
|
||||
this.processing = false
|
||||
console.log('Episodes', episodePayload)
|
||||
this.recentEpisodes = episodePayload.episodes || []
|
||||
this.totalEpisodes = episodePayload.total
|
||||
this.currentPage = page
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadRecentEpisodes()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<bookshelf-lazy-bookshelf page="playlists" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
watch: {},
|
||||
computed: {},
|
||||
methods: {}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,145 @@
|
||||
<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">
|
||||
<ui-text-input v-model="searchInput" :disabled="processing || !networkConnected" placeholder="Enter search term or RSS feed URL" text-size="sm" />
|
||||
<!-- <ui-btn type="submit" :disabled="processing" small>Submit</ui-btn> -->
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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)">
|
||||
<div class="flex">
|
||||
<div class="w-8 min-w-8 py-1">
|
||||
<div class="h-8 w-full bg-primary">
|
||||
<img v-if="podcast.cover" :src="podcast.cover" class="h-full w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow pl-2">
|
||||
<p class="text-xs text-gray-100 whitespace-nowrap truncate">{{ podcast.artistName }}</p>
|
||||
<p class="text-xxs text-gray-300 leading-5">{{ podcast.trackCount }} Episodes</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-200 mb-1">{{ podcast.title }}</p>
|
||||
<p class="text-xs text-gray-400 leading-5">{{ podcast.genres.join(', ') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex items-center py-4 px-2">
|
||||
<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">
|
||||
<forms-new-podcast-form :podcast-data="selectedPodcast" :podcast-feed-data="selectedPodcastFeed" :processing.sync="processing" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-show="processing" class="absolute top-0 left-0 w-full h-full flex items-center justify-center bg-black bg-opacity-25 z-40">
|
||||
<ui-loading-indicator />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
searchInput: '',
|
||||
termSearched: false,
|
||||
processing: false,
|
||||
results: [],
|
||||
selectedPodcastFeed: null,
|
||||
selectedPodcast: null,
|
||||
showSelectedFeed: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
networkConnected() {
|
||||
return this.$store.state.networkConnected
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clearSelected() {
|
||||
this.selectedPodcastFeed = null
|
||||
this.selectedPodcast = null
|
||||
this.showSelectedFeed = false
|
||||
},
|
||||
submit() {
|
||||
if (!this.searchInput) return
|
||||
|
||||
if (this.searchInput.startsWith('http:') || this.searchInput.startsWith('https:')) {
|
||||
this.termSearched = ''
|
||||
this.results = []
|
||||
this.checkRSSFeed(this.searchInput)
|
||||
} else {
|
||||
this.submitSearch(this.searchInput)
|
||||
}
|
||||
},
|
||||
async checkRSSFeed(rssFeed) {
|
||||
this.processing = true
|
||||
var payload = await this.$axios.$post(`/api/podcasts/feed`, { rssFeed }).catch((error) => {
|
||||
console.error('Failed to get feed', error)
|
||||
this.$toast.error('Failed to get podcast feed')
|
||||
return null
|
||||
})
|
||||
this.processing = false
|
||||
if (!payload) return
|
||||
|
||||
this.selectedPodcastFeed = payload.podcast
|
||||
this.selectedPodcast = null
|
||||
this.showSelectedFeed = true
|
||||
},
|
||||
async submitSearch(term) {
|
||||
this.processing = true
|
||||
this.termSearched = ''
|
||||
const results = await this.$axios.$get(`/api/search/podcast?term=${encodeURIComponent(term)}`).catch((error) => {
|
||||
console.error('Search request failed', error)
|
||||
return []
|
||||
})
|
||||
console.log('Got results', results)
|
||||
this.results = results
|
||||
this.termSearched = term
|
||||
this.processing = false
|
||||
},
|
||||
async selectPodcast(podcast) {
|
||||
console.log('Selected podcast', podcast)
|
||||
if (!podcast.feedUrl) {
|
||||
this.$toast.error('Invalid podcast - no feed')
|
||||
return
|
||||
}
|
||||
this.processing = true
|
||||
const payload = await this.$axios.$post(`/api/podcasts/feed`, { rssFeed: podcast.feedUrl }).catch((error) => {
|
||||
console.error('Failed to get feed', error)
|
||||
this.$toast.error('Failed to get podcast feed')
|
||||
return null
|
||||
})
|
||||
this.processing = false
|
||||
if (!payload) return
|
||||
|
||||
this.selectedPodcastFeed = payload.podcast
|
||||
this.selectedPodcast = podcast
|
||||
this.showSelectedFeed = true
|
||||
console.log('Got podcast feed', payload.podcast)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-results-container {
|
||||
max-height: calc(100vh - 180px);
|
||||
}
|
||||
</style>
|
||||
@@ -12,7 +12,7 @@
|
||||
{{ collectionName }}
|
||||
</h1>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-if="showPlayButton" :disabled="streaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2 w-20" @click="clickPlay">
|
||||
<ui-btn v-if="showPlayButton" :disabled="streaming" color="success" :padding-x="4" small class="flex items-center justify-center h-9 mr-2 w-24" @click="clickPlay">
|
||||
<span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">play_arrow</span>
|
||||
{{ streaming ? 'Streaming' : 'Play' }}
|
||||
</ui-btn>
|
||||
|
||||
+100
-64
@@ -1,43 +1,33 @@
|
||||
<template>
|
||||
<div class="w-full h-full px-3 py-4 overflow-y-auto">
|
||||
<div class="flex">
|
||||
<div class="w-16">
|
||||
<div class="relative" @click="showFullscreenCover = true">
|
||||
<covers-book-cover :library-item="libraryItem" :width="64" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<div v-if="!isPodcast" class="absolute bottom-0 left-0 h-1 shadow-sm z-10" :class="userIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: 64 * progressPercent + 'px' }"></div>
|
||||
</div>
|
||||
<div class="w-full flex justify-center relative mb-2">
|
||||
<div class="relative" @click="showFullscreenCover = true">
|
||||
<covers-book-cover :library-item="libraryItem" :width="175" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<div v-if="!isPodcast" class="absolute bottom-0 left-0 h-1 shadow-sm z-10" :class="userIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: 175 * progressPercent + 'px' }"></div>
|
||||
</div>
|
||||
<div class="title-container flex-grow pl-2">
|
||||
<div class="flex relative pr-6">
|
||||
<h1 class="text-base font-semibold">{{ title }}</h1>
|
||||
|
||||
<button class="absolute top-0 right-0 h-full px-1 outline-none" @click="moreButtonPress">
|
||||
<span class="material-icons text-xl">more_vert</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="subtitle" class="text-gray-100 text-sm py-0.5">{{ subtitle }}</p>
|
||||
<p v-if="seriesList && seriesList.length" class="text-sm text-gray-300 py-0.5">
|
||||
<template v-for="(series, index) in seriesList"
|
||||
><nuxt-link :key="series.id" :to="`/bookshelf/series/${series.id}`">{{ series.text }}</nuxt-link
|
||||
><span :key="`${series.id}-comma`" v-if="index < seriesList.length - 1">, </span></template
|
||||
>
|
||||
</p>
|
||||
<p v-if="podcastAuthor" class="text-sm text-gray-300 py-0.5">By {{ podcastAuthor }}</p>
|
||||
<p v-else-if="bookAuthors && bookAuthors.length" class="text-sm text-gray-300 py-0.5">
|
||||
By
|
||||
<template v-for="(author, index) in bookAuthors"
|
||||
><nuxt-link :key="author.id" :to="`/bookshelf/library?filter=authors.${$encode(author.id)}`">{{ author.name }}</nuxt-link
|
||||
><span :key="`${author.id}-comma`" v-if="index < bookAuthors.length - 1">, </span></template
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
<button class="absolute top-0 right-0 px-1 outline-none" @click="moreButtonPress">
|
||||
<span class="material-icons text-xl">more_vert</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="narrators && narrators.length" class="text-sm text-gray-400 py-0.5">
|
||||
Narrated By
|
||||
<template v-for="(narrator, index) in narrators"
|
||||
><nuxt-link :key="narrator" :to="`/bookshelf/library?filter=narrators.${$encode(narrator)}`">{{ narrator }}</nuxt-link
|
||||
><span :key="`${narrator}-comma`" v-if="index < narrators.length - 1">, </span></template
|
||||
<h1 class="text-lg font-semibold">{{ title }}</h1>
|
||||
|
||||
<p v-if="subtitle" class="text-gray-100 text-sm py-0.5 mb-0.5">{{ subtitle }}</p>
|
||||
|
||||
<p v-if="seriesList && seriesList.length" class="text-sm text-gray-300 py-0.5">
|
||||
<template v-for="(series, index) in seriesList"
|
||||
><nuxt-link :key="series.id" :to="`/bookshelf/series/${series.id}`">{{ series.text }}</nuxt-link
|
||||
><span :key="`${series.id}-comma`" v-if="index < seriesList.length - 1">, </span></template
|
||||
>
|
||||
</p>
|
||||
|
||||
<p v-if="podcastAuthor" class="text-sm text-gray-300 py-0.5">by {{ podcastAuthor }}</p>
|
||||
<p v-else-if="bookAuthors && bookAuthors.length" class="text-sm text-gray-300 py-0.5">
|
||||
by
|
||||
<template v-for="(author, index) in bookAuthors"
|
||||
><nuxt-link :key="author.id" :to="`/bookshelf/library?filter=authors.${$encode(author.id)}`">{{ author.name }}</nuxt-link
|
||||
><span :key="`${author.id}-comma`" v-if="index < bookAuthors.length - 1">, </span></template
|
||||
>
|
||||
</p>
|
||||
|
||||
@@ -45,6 +35,37 @@
|
||||
<p v-if="isLocal && serverLibraryItemId" style="font-size: 10px" class="text-success py-1 uppercase tracking-widest">connected</p>
|
||||
<p v-else-if="isLocal && libraryItem.serverAddress" style="font-size: 10px" class="text-gray-400 py-1">{{ libraryItem.serverAddress }}</p>
|
||||
|
||||
<div v-if="narrators && narrators.length" class="flex py-0.5 mt-4">
|
||||
<div class="w-24">
|
||||
<span class="text-white text-opacity-60 uppercase text-xs">Narrators</span>
|
||||
</div>
|
||||
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis text-sm">
|
||||
<template v-for="(narrator, index) in narrators">
|
||||
<nuxt-link :key="narrator" :to="`/bookshelf/library?filter=narrators.${$encode(narrator)}`">{{ narrator }}</nuxt-link
|
||||
><span :key="index" v-if="index < narrators.length - 1">, </span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="publishedYear" class="flex py-0.5">
|
||||
<div class="w-24">
|
||||
<span class="text-white text-opacity-60 uppercase text-xs">Publish Year</span>
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
{{ publishedYear }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex py-0.5" v-if="genres.length">
|
||||
<div class="w-24">
|
||||
<span class="text-white text-opacity-60 uppercase text-xs">Genres</span>
|
||||
</div>
|
||||
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis text-sm">
|
||||
<template v-for="(genre, index) in genres">
|
||||
<nuxt-link :key="genre" :to="`/bookshelf/library?filter=genres.${$encode(genre)}`" class="hover:underline">{{ genre }}</nuxt-link
|
||||
><span :key="index" v-if="index < genres.length - 1">, </span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="numTracks" class="flex text-gray-100 text-xs my-2 -mx-0.5">
|
||||
<div class="bg-primary bg-opacity-80 px-3 py-0.5 rounded-full mx-0.5">
|
||||
<p>{{ $elapsedPretty(duration) }}</p>
|
||||
@@ -106,7 +127,7 @@
|
||||
<p class="text-sm">{{ description }}</p>
|
||||
</div>
|
||||
|
||||
<tables-podcast-episodes-table v-if="isPodcast" :library-item-id="libraryItemId" :local-library-item-id="localLibraryItemId" :episodes="episodes" :local-episodes="localLibraryItemEpisodes" :is-local="isLocal" />
|
||||
<tables-podcast-episodes-table v-if="isPodcast" :library-item="libraryItem" :local-library-item-id="localLibraryItemId" :episodes="episodes" :local-episodes="localLibraryItemEpisodes" :is-local="isLocal" />
|
||||
|
||||
<modals-select-local-folder-modal v-model="showSelectLocalFolder" :media-type="mediaType" @select="selectedLocalFolder" />
|
||||
|
||||
@@ -222,6 +243,12 @@ export default {
|
||||
subtitle() {
|
||||
return this.mediaMetadata.subtitle
|
||||
},
|
||||
genres() {
|
||||
return this.mediaMetadata.genres || []
|
||||
},
|
||||
publishedYear() {
|
||||
return this.mediaMetadata.publishedYear
|
||||
},
|
||||
podcastAuthor() {
|
||||
if (!this.isPodcast) return null
|
||||
return this.mediaMetadata.author || ''
|
||||
@@ -308,7 +335,7 @@ export default {
|
||||
return !this.isMissing && !this.isIncomplete && (this.numTracks || this.episodes.length)
|
||||
},
|
||||
showRead() {
|
||||
return this.ebookFile && this.ebookFormat !== 'pdf'
|
||||
return this.ebookFile
|
||||
},
|
||||
showDownload() {
|
||||
if (this.isPodcast) return false
|
||||
@@ -331,34 +358,39 @@ export default {
|
||||
return this.$store.state.isCasting
|
||||
},
|
||||
moreMenuItems() {
|
||||
if (this.isLocal) {
|
||||
return [
|
||||
{
|
||||
text: 'Manage Local Files',
|
||||
value: 'manageLocal'
|
||||
},
|
||||
{
|
||||
text: 'View Details',
|
||||
value: 'details'
|
||||
}
|
||||
]
|
||||
} else {
|
||||
return [
|
||||
{
|
||||
text: 'View Details',
|
||||
value: 'details'
|
||||
}
|
||||
]
|
||||
const items = []
|
||||
if (this.localLibraryItemId) {
|
||||
items.push({
|
||||
text: 'Manage Local Files',
|
||||
value: 'manageLocal'
|
||||
})
|
||||
}
|
||||
|
||||
items.push({
|
||||
text: 'View Details',
|
||||
value: 'details'
|
||||
})
|
||||
|
||||
if (!this.isPodcast && this.serverLibraryItemId) {
|
||||
items.push({
|
||||
text: 'Add to Playlist',
|
||||
value: 'playlist'
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
moreMenuAction(action) {
|
||||
this.showMoreMenu = false
|
||||
if (action === 'manageLocal') {
|
||||
this.$router.push(`/localMedia/item/${this.libraryItemId}`)
|
||||
this.$router.push(`/localMedia/item/${this.localLibraryItemId}`)
|
||||
} else if (action === 'details') {
|
||||
this.showDetailsModal = true
|
||||
} else if (action === 'playlist') {
|
||||
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: null }])
|
||||
this.$store.commit('globals/setShowPlaylistsAddCreateModal', true)
|
||||
}
|
||||
},
|
||||
moreButtonPress() {
|
||||
@@ -367,15 +399,16 @@ export default {
|
||||
readBook() {
|
||||
this.$store.commit('openReader', this.libraryItem)
|
||||
},
|
||||
playClick() {
|
||||
var episodeId = null
|
||||
async playClick() {
|
||||
let episodeId = null
|
||||
await this.$hapticsImpactMedium()
|
||||
|
||||
if (this.isPodcast) {
|
||||
this.episodes.sort((a, b) => {
|
||||
return String(b.publishedAt).localeCompare(String(a.publishedAt), undefined, { numeric: true, sensitivity: 'base' })
|
||||
})
|
||||
|
||||
var episode = this.episodes.find((ep) => {
|
||||
let episode = this.episodes.find((ep) => {
|
||||
var podcastProgress = null
|
||||
if (!this.isLocal) {
|
||||
podcastProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, ep.id)
|
||||
@@ -389,13 +422,13 @@ export default {
|
||||
|
||||
episodeId = episode.id
|
||||
|
||||
var localEpisode = null
|
||||
let localEpisode = null
|
||||
if (this.hasLocal && !this.isLocal) {
|
||||
localEpisode = this.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId == episodeId)
|
||||
} else if (this.isLocal) {
|
||||
localEpisode = episode
|
||||
}
|
||||
var serverEpisodeId = !this.isLocal ? episodeId : localEpisode ? localEpisode.serverEpisodeId : null
|
||||
const serverEpisodeId = !this.isLocal ? episodeId : localEpisode ? localEpisode.serverEpisodeId : null
|
||||
|
||||
if (serverEpisodeId && this.serverLibraryItemId && this.isCasting) {
|
||||
// If casting and connected to server for local library item then send server library item id
|
||||
@@ -422,6 +455,7 @@ export default {
|
||||
this.$eventBus.$emit('play-item', { libraryItemId: this.libraryItemId, episodeId })
|
||||
},
|
||||
async clearProgressClick() {
|
||||
await this.$hapticsImpactMedium()
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: 'Are you sure you want to reset your progress?'
|
||||
@@ -467,13 +501,14 @@ export default {
|
||||
this.showSelectLocalFolder = false
|
||||
this.download(localFolder)
|
||||
},
|
||||
downloadClick() {
|
||||
async downloadClick() {
|
||||
if (this.downloadItem) {
|
||||
return
|
||||
}
|
||||
if (!this.numTracks) {
|
||||
return
|
||||
}
|
||||
await this.$hapticsImpactMedium()
|
||||
if (this.isIos) {
|
||||
// no local folders on iOS
|
||||
this.startDownload()
|
||||
@@ -541,6 +576,7 @@ export default {
|
||||
}
|
||||
},
|
||||
async toggleFinished() {
|
||||
await this.$hapticsImpactMedium()
|
||||
this.isProcessingReadUpdate = true
|
||||
if (this.isLocal) {
|
||||
var isFinished = !this.userIsFinished
|
||||
@@ -593,12 +629,12 @@ export default {
|
||||
mounted() {
|
||||
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('item_updated', this.itemUpdated)
|
||||
},
|
||||
beforeDestroy() {
|
||||
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('item_updated', this.itemUpdated)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -608,4 +644,4 @@ export default {
|
||||
width: calc(100% - 64px);
|
||||
max-width: calc(100% - 64px);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<template>
|
||||
<div class="w-full h-full py-6">
|
||||
<div v-if="localLibraryItemsOnCurrentServer.length" class="flex items-center justify-between mb-4 pb-2 px-2 border-b border-white border-opacity-10">
|
||||
<p class="text-sm text-gray-100">{{ localLibraryItemsOnCurrentServer.length }} local items on this server</p>
|
||||
<ui-btn small :loading="syncing" @click="syncLocalMedia">Sync</ui-btn>
|
||||
</div>
|
||||
|
||||
<div v-if="lastLocalMediaSyncResults" class="px-2 mb-4">
|
||||
<div class="w-full pl-2 pr-2 py-2 bg-black bg-opacity-25 rounded-lg relative">
|
||||
<div class="flex items-center mb-1">
|
||||
@@ -23,9 +28,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="text-base font-semibold px-3 mb-2">Local Folders</h1>
|
||||
<h1 class="text-base font-semibold px-2 mb-2">Local Folders</h1>
|
||||
|
||||
<div v-if="!isIos" class="w-full max-w-full px-3 py-2">
|
||||
<div v-if="!isIos" class="w-full max-w-full px-2 py-2">
|
||||
<template v-for="folder in localFolders">
|
||||
<nuxt-link :to="`/localMedia/folders/${folder.id}`" :key="folder.id" class="flex items-center px-2 py-4 bg-primary rounded-md border-bg mb-1">
|
||||
<span class="material-icons text-xl text-yellow-400">folder</span>
|
||||
@@ -55,6 +60,7 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
localFolders: [],
|
||||
localLibraryItems: [],
|
||||
newFolderMediaType: null,
|
||||
mediaTypeItems: [
|
||||
{
|
||||
@@ -65,7 +71,8 @@ export default {
|
||||
value: 'podcast',
|
||||
text: 'Podcasts'
|
||||
}
|
||||
]
|
||||
],
|
||||
syncing: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -75,6 +82,14 @@ export default {
|
||||
lastLocalMediaSyncResults() {
|
||||
return this.$store.state.lastLocalMediaSyncResults
|
||||
},
|
||||
serverConnectionConfigId() {
|
||||
return this.$store.getters['user/getServerConnectionConfigId']
|
||||
},
|
||||
localLibraryItemsOnCurrentServer() {
|
||||
return this.localLibraryItems.filter((lli) => {
|
||||
return lli.serverConnectionConfigId === this.serverConnectionConfigId
|
||||
})
|
||||
},
|
||||
numLocalMediaSynced() {
|
||||
if (!this.lastLocalMediaSyncResults) return 0
|
||||
return this.lastLocalMediaSyncResults.numLocalMediaProgressForServer || 0
|
||||
@@ -97,6 +112,33 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async syncLocalMedia() {
|
||||
console.log('[localMedia] Calling syncLocalMediaProgress')
|
||||
this.syncing = true
|
||||
const response = await this.$db.syncLocalMediaProgressWithServer()
|
||||
if (!response) {
|
||||
if (this.$platform != 'web') this.$toast.error('Failed to sync local media with server')
|
||||
this.$store.commit('setLastLocalMediaSyncResults', null)
|
||||
this.syncing = false
|
||||
return
|
||||
}
|
||||
const { numLocalMediaProgressForServer, numServerProgressUpdates, numLocalProgressUpdates } = response
|
||||
if (numLocalMediaProgressForServer > 0) {
|
||||
response.syncedAt = Date.now()
|
||||
response.serverConfigName = this.$store.getters['user/getServerConfigName']
|
||||
this.$store.commit('setLastLocalMediaSyncResults', response)
|
||||
|
||||
if (numServerProgressUpdates > 0 || numLocalProgressUpdates > 0) {
|
||||
console.log(`[localMedia] ${numServerProgressUpdates} Server progress updates | ${numLocalProgressUpdates} Local progress updates`)
|
||||
} else {
|
||||
console.log('[localMedia] syncLocalMediaProgress No updates were necessary')
|
||||
}
|
||||
} else {
|
||||
console.log('[localMedia] syncLocalMediaProgress No local media progress to sync')
|
||||
this.$store.commit('setLastLocalMediaSyncResults', null)
|
||||
}
|
||||
this.syncing = false
|
||||
},
|
||||
async selectFolder() {
|
||||
if (!this.newFolderMediaType) {
|
||||
return this.$toast.error('Must select a media type')
|
||||
@@ -107,14 +149,14 @@ export default {
|
||||
return this.$toast.error(`Error: ${folderObj.error || 'Unknown Error'}`)
|
||||
}
|
||||
|
||||
var indexOfExisting = this.localFolders.findIndex((lf) => lf.id == folderObj.id)
|
||||
const indexOfExisting = this.localFolders.findIndex((lf) => lf.id == folderObj.id)
|
||||
if (indexOfExisting >= 0) {
|
||||
this.localFolders.splice(indexOfExisting, 1, folderObj)
|
||||
} else {
|
||||
this.localFolders.push(folderObj)
|
||||
}
|
||||
|
||||
var permissionsGood = await AbsFileSystem.checkFolderPermissions({ folderUrl: folderObj.contentUrl })
|
||||
const permissionsGood = await AbsFileSystem.checkFolderPermissions({ folderUrl: folderObj.contentUrl })
|
||||
|
||||
if (!permissionsGood) {
|
||||
this.$toast.error('Folder permissions failed')
|
||||
@@ -127,6 +169,7 @@ export default {
|
||||
},
|
||||
async init() {
|
||||
this.localFolders = (await this.$db.getLocalFolders()) || []
|
||||
this.localLibraryItems = await this.$db.getLocalLibraryItems()
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@@ -90,7 +90,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 bottom-0 left-0 w-full py-4 px-4 bg-bg box-shadow-book flex items-center">
|
||||
<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 ? '100px' : '0px' }">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn small color="success" @click="saveTrackOrder">Save Order</ui-btn>
|
||||
</div>
|
||||
@@ -138,6 +138,9 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
playerLibraryItemId() {
|
||||
return this.$store.state.playerLibraryItemId
|
||||
},
|
||||
isIos() {
|
||||
return this.$platform === 'ios'
|
||||
},
|
||||
@@ -198,12 +201,12 @@ export default {
|
||||
]
|
||||
} else {
|
||||
var options = []
|
||||
if ( !this.isIos ) {
|
||||
options.push({ text: 'Scan', value: 'scan'})
|
||||
options.push({ text: 'Force Re-Scan', value: 'rescan'})
|
||||
options.push({ text: 'Remove', value: 'remove'})
|
||||
if (!this.isIos) {
|
||||
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: 'Remove & Delete Files', value: 'delete' })
|
||||
return options
|
||||
}
|
||||
}
|
||||
@@ -252,14 +255,16 @@ export default {
|
||||
}
|
||||
this.showDialog = true
|
||||
},
|
||||
play() {
|
||||
async play() {
|
||||
await this.$hapticsImpactMedium()
|
||||
this.$eventBus.$emit('play-item', { libraryItemId: this.localLibraryItemId })
|
||||
},
|
||||
getCapImageSrc(contentUrl) {
|
||||
return Capacitor.convertFileSrc(contentUrl)
|
||||
},
|
||||
dialogAction(action) {
|
||||
async dialogAction(action) {
|
||||
console.log('Dialog action', action)
|
||||
await this.$hapticsImpactMedium()
|
||||
if (action == 'scan') {
|
||||
this.scanItem()
|
||||
} else if (action == 'rescan') {
|
||||
@@ -402,4 +407,4 @@ export default {
|
||||
.dragtrack-leave-active {
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<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>
|
||||
<div class="flex-grow py-6">
|
||||
<div class="flex items-center px-2">
|
||||
<h1 class="text-xl font-sans">
|
||||
{{ playlistName }}
|
||||
</h1>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-if="showPlayButton" :disabled="streaming" color="success" :padding-x="4" small class="flex items-center justify-center text-center h-9 mr-2 w-24" @click="clickPlay">
|
||||
<span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">play_arrow</span>
|
||||
{{ streaming ? 'Streaming' : 'Play' }}
|
||||
</ui-btn>
|
||||
</div>
|
||||
|
||||
<div class="my-8 max-w-2xl px-2">
|
||||
<p class="text-base text-gray-100">{{ description }}</p>
|
||||
</div>
|
||||
|
||||
<tables-playlist-items-table :items="playlistItems" :playlist-id="playlist.id" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
async asyncData({ store, params, app, redirect, route }) {
|
||||
if (!store.state.user.user) {
|
||||
return redirect(`/connect?redirect=${route.path}`)
|
||||
}
|
||||
|
||||
const playlist = await app.$axios.$get(`/api/playlists/${params.id}`).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return false
|
||||
})
|
||||
|
||||
if (!playlist) {
|
||||
return redirect('/bookshelf/playlists')
|
||||
}
|
||||
|
||||
return {
|
||||
playlist
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
playlistItems() {
|
||||
return this.playlist.items || []
|
||||
},
|
||||
playlistName() {
|
||||
return this.playlist.name || ''
|
||||
},
|
||||
description() {
|
||||
return this.playlist.description || ''
|
||||
},
|
||||
playableItems() {
|
||||
return this.playlistItems.filter((item) => {
|
||||
const libraryItem = item.libraryItem
|
||||
if (libraryItem.isMissing || libraryItem.isInvalid) return false
|
||||
if (item.episode) return item.episode.audioFile
|
||||
return libraryItem.media.tracks.length
|
||||
})
|
||||
},
|
||||
streaming() {
|
||||
return !!this.playableItems.find((i) => this.$store.getters['getIsMediaStreaming'](i.libraryItemId, i.episodeId))
|
||||
},
|
||||
showPlayButton() {
|
||||
return this.playableItems.length
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickPlay() {
|
||||
const nextItem = this.playableItems.find((i) => {
|
||||
var prog = this.$store.getters['user/getUserMediaProgress'](i.libraryItemId, i.episodeId)
|
||||
return !prog || !prog.isFinished
|
||||
})
|
||||
if (nextItem) {
|
||||
this.$eventBus.$emit('play-item', { libraryItemId: nextItem.libraryItemId, episodeId: nextItem.episodeId })
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
+2
-2
@@ -89,9 +89,9 @@ export default {
|
||||
return
|
||||
}
|
||||
this.isFetching = true
|
||||
var results = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/search?q=${value}&limit=5`).catch((error) => {
|
||||
const results = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/search?q=${value}&limit=5`).catch((error) => {
|
||||
console.error('Search error', error)
|
||||
return []
|
||||
return null
|
||||
})
|
||||
if (value !== this.lastSearch) {
|
||||
console.log(`runSearch: New search was made for ${this.lastSearch} - results are from ${value}`)
|
||||
|
||||
+38
-3
@@ -7,6 +7,12 @@
|
||||
</div>
|
||||
<p class="pl-4">Alternative bookshelf view</p>
|
||||
</div>
|
||||
<div class="flex items-center py-3" @click.stop="toggleLockOrientation">
|
||||
<div class="w-10 flex justify-center">
|
||||
<ui-toggle-switch v-model="lockCurrentOrientation" @input="saveSettings" />
|
||||
</div>
|
||||
<p class="pl-4">Lock orientation</p>
|
||||
</div>
|
||||
|
||||
<p class="uppercase text-xs font-semibold text-gray-300 mb-2 mt-6">Playback Settings</p>
|
||||
<div v-if="!isiOS" class="flex items-center py-3" @click="toggleDisableAutoRewind">
|
||||
@@ -51,14 +57,16 @@ export default {
|
||||
enableAltView: false,
|
||||
jumpForwardTime: 10,
|
||||
jumpBackwardsTime: 10,
|
||||
disableShakeToResetSleepTimer: false
|
||||
disableShakeToResetSleepTimer: false,
|
||||
lockOrientation: 0
|
||||
},
|
||||
settingInfo: {
|
||||
disableShakeToResetSleepTimer: {
|
||||
name: 'Disable shake to reset sleep timer',
|
||||
message: 'The sleep timer will start fading out when 30s is remaining. Shaking your device will reset the timer if it is within 30s OR has finished less than 2 mintues ago. Enable this setting to disable that feature.'
|
||||
}
|
||||
}
|
||||
},
|
||||
lockCurrentOrientation: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -107,6 +115,28 @@ export default {
|
||||
this.settings.enableAltView = !this.settings.enableAltView
|
||||
this.saveSettings()
|
||||
},
|
||||
getCurrentOrientation() {
|
||||
const orientation = window.screen ? window.screen.orientation || {} : {}
|
||||
const type = orientation.type || ''
|
||||
console.log('getCurrentOrientation=' + type)
|
||||
|
||||
if (type.includes('landscape')) return 'LANDSCAPE'
|
||||
return 'PORTRAIT' // default
|
||||
},
|
||||
toggleLockOrientation() {
|
||||
console.log('TOGGLE LOCK ORIENTATION', this.lockCurrentOrientation)
|
||||
this.lockCurrentOrientation = !this.lockCurrentOrientation
|
||||
if (this.lockCurrentOrientation) {
|
||||
console.log('CURRENT ORIENTATION=', this.getCurrentOrientation())
|
||||
this.settings.lockOrientation = this.getCurrentOrientation()
|
||||
} else {
|
||||
console.log('SETTING CURRENT ORIENTATION TO NONE')
|
||||
this.settings.lockOrientation = 'NONE'
|
||||
}
|
||||
this.$setOrientationLock(this.settings.lockOrientation)
|
||||
console.log('NOW SAVING SETTINGS', this.settings.lockOrientation)
|
||||
this.saveSettings()
|
||||
},
|
||||
toggleJumpForward() {
|
||||
var next = (this.currentJumpForwardTimeIndex + 1) % 3
|
||||
this.settings.jumpForwardTime = this.jumpForwardItems[next].value
|
||||
@@ -119,6 +149,7 @@ export default {
|
||||
this.saveSettings()
|
||||
},
|
||||
async saveSettings() {
|
||||
await this.$hapticsImpactMedium()
|
||||
const updatedDeviceData = await this.$db.updateDeviceSettings({ ...this.settings })
|
||||
console.log('Saved device data', updatedDeviceData)
|
||||
if (updatedDeviceData) {
|
||||
@@ -136,10 +167,14 @@ export default {
|
||||
this.settings.jumpForwardTime = deviceSettings.jumpForwardTime || 10
|
||||
this.settings.jumpBackwardsTime = deviceSettings.jumpBackwardsTime || 10
|
||||
this.settings.disableShakeToResetSleepTimer = !!deviceSettings.disableShakeToResetSleepTimer
|
||||
this.settings.lockOrientation = deviceSettings.lockOrientation || 'NONE'
|
||||
|
||||
console.log('INIT SETTINGS LOCK ORIENTATION=', this.settings.lockOrientation)
|
||||
this.lockCurrentOrientation = this.settings.lockOrientation !== 'NONE'
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
+3
-3
@@ -5,21 +5,21 @@ export default function ({ $axios, store }) {
|
||||
return
|
||||
}
|
||||
|
||||
var customHeaders = store.getters['user/getCustomHeaders']
|
||||
const customHeaders = store.getters['user/getCustomHeaders']
|
||||
if (customHeaders) {
|
||||
for (const key in customHeaders) {
|
||||
config.headers.common[key] = customHeaders[key]
|
||||
}
|
||||
}
|
||||
|
||||
var bearerToken = store.getters['user/getToken']
|
||||
const bearerToken = store.getters['user/getToken']
|
||||
if (bearerToken) {
|
||||
config.headers.common['Authorization'] = `Bearer ${bearerToken}`
|
||||
} else {
|
||||
console.warn('[Axios] No Bearer Token for request')
|
||||
}
|
||||
|
||||
var serverUrl = store.getters['user/getServerAddress']
|
||||
const serverUrl = store.getters['user/getServerAddress']
|
||||
if (serverUrl) {
|
||||
config.url = `${serverUrl}${config.url}`
|
||||
}
|
||||
|
||||
+23
-23
@@ -1,52 +1,52 @@
|
||||
import Vue from 'vue'
|
||||
import { Haptics, ImpactStyle, NotificationType } from '@capacitor/haptics'
|
||||
import Vue from "vue";
|
||||
import { Haptics, ImpactStyle, NotificationType } from "@capacitor/haptics"
|
||||
|
||||
const hapticsImpactHeavy = async () => {
|
||||
await Haptics.impact({ style: ImpactStyle.Heavy });
|
||||
await Haptics.impact({ style: ImpactStyle.Heavy })
|
||||
}
|
||||
Vue.prototype.$hapticsImpactHeavy = hapticsImpactHeavy
|
||||
Vue.prototype.$hapticsImpactHeavy = hapticsImpactHeavy;
|
||||
|
||||
const hapticsImpactMedium = async () => {
|
||||
await Haptics.impact({ style: ImpactStyle.Medium });
|
||||
await Haptics.impact({ style: ImpactStyle.Medium })
|
||||
}
|
||||
Vue.prototype.$hapticsImpactMedium = hapticsImpactMedium
|
||||
|
||||
const hapticsImpactLight = async () => {
|
||||
await Haptics.impact({ style: ImpactStyle.Light });
|
||||
};
|
||||
await Haptics.impact({ style: ImpactStyle.Light })
|
||||
}
|
||||
Vue.prototype.$hapticsImpactLight = hapticsImpactLight
|
||||
|
||||
const hapticsVibrate = async () => {
|
||||
await Haptics.vibrate();
|
||||
};
|
||||
Vue.prototype.$hapticsVibrate = hapticsVibrate
|
||||
await Haptics.vibrate()
|
||||
}
|
||||
Vue.prototype.$hapticsVibrate = hapticsVibrate;
|
||||
|
||||
const hapticsNotificationSuccess = async () => {
|
||||
await Haptics.notification({ type: NotificationType.Success });
|
||||
};
|
||||
await Haptics.notification({ type: NotificationType.Success })
|
||||
}
|
||||
Vue.prototype.$hapticsNotificationSuccess = hapticsNotificationSuccess
|
||||
|
||||
const hapticsNotificationWarning = async () => {
|
||||
await Haptics.notification({ type: NotificationType.Warning });
|
||||
};
|
||||
await Haptics.notification({ type: NotificationType.Warning })
|
||||
}
|
||||
Vue.prototype.$hapticsNotificationWarning = hapticsNotificationWarning
|
||||
|
||||
const hapticsNotificationError = async () => {
|
||||
await Haptics.notification({ type: NotificationType.Error });
|
||||
};
|
||||
await Haptics.notification({ type: NotificationType.Error })
|
||||
}
|
||||
Vue.prototype.$hapticsNotificationError = hapticsNotificationError
|
||||
|
||||
const hapticsSelectionStart = async () => {
|
||||
await Haptics.selectionStart();
|
||||
};
|
||||
await Haptics.selectionStart()
|
||||
}
|
||||
Vue.prototype.$hapticsSelectionStart = hapticsSelectionStart
|
||||
|
||||
const hapticsSelectionChanged = async () => {
|
||||
await Haptics.selectionChanged();
|
||||
};
|
||||
await Haptics.selectionChanged()
|
||||
}
|
||||
Vue.prototype.$hapticsSelectionChanged = hapticsSelectionChanged
|
||||
|
||||
const hapticsSelectionEnd = async () => {
|
||||
await Haptics.selectionEnd();
|
||||
};
|
||||
Vue.prototype.$hapticsSelectionEnd = hapticsSelectionEnd
|
||||
await Haptics.selectionEnd()
|
||||
}
|
||||
Vue.prototype.$hapticsSelectionEnd = hapticsSelectionEnd
|
||||
|
||||
@@ -16,6 +16,9 @@ if (Capacitor.getPlatform() != 'web') {
|
||||
|
||||
Vue.prototype.$isDev = process.env.NODE_ENV !== 'production'
|
||||
|
||||
Vue.prototype.$encodeUriPath = (path) => {
|
||||
return path.replace(/\\/g, '/').replace(/%/g, '%25').replace(/#/g, '%23')
|
||||
}
|
||||
Vue.prototype.$dateDistanceFromNow = (unixms) => {
|
||||
if (!unixms) return ''
|
||||
return formatDistance(unixms, Date.now(), { addSuffix: true })
|
||||
@@ -102,6 +105,43 @@ Vue.prototype.$secondsToTimestamp = (seconds) => {
|
||||
return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
Vue.prototype.$sanitizeFilename = (input, colonReplacement = ' - ') => {
|
||||
if (typeof input !== 'string') {
|
||||
return false
|
||||
}
|
||||
|
||||
// Max is actually 255-260 for windows but this leaves padding incase ext wasnt put on yet
|
||||
const MAX_FILENAME_LEN = 240
|
||||
|
||||
var replacement = ''
|
||||
var illegalRe = /[\/\?<>\\:\*\|"]/g
|
||||
var controlRe = /[\x00-\x1f\x80-\x9f]/g
|
||||
var reservedRe = /^\.+$/
|
||||
var windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i
|
||||
var windowsTrailingRe = /[\. ]+$/
|
||||
var lineBreaks = /[\n\r]/g
|
||||
|
||||
var sanitized = input
|
||||
.replace(':', colonReplacement) // Replace first occurrence of a colon
|
||||
.replace(illegalRe, replacement)
|
||||
.replace(controlRe, replacement)
|
||||
.replace(reservedRe, replacement)
|
||||
.replace(lineBreaks, replacement)
|
||||
.replace(windowsReservedRe, replacement)
|
||||
.replace(windowsTrailingRe, replacement)
|
||||
|
||||
|
||||
if (sanitized.length > MAX_FILENAME_LEN) {
|
||||
var lenToRemove = sanitized.length - MAX_FILENAME_LEN
|
||||
var ext = Path.extname(sanitized)
|
||||
var basename = Path.basename(sanitized, ext)
|
||||
basename = basename.slice(0, basename.length - lenToRemove)
|
||||
sanitized = basename + ext
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
function isClickedOutsideEl(clickEvent, elToCheckOutside, ignoreSelectors = [], ignoreElems = []) {
|
||||
const isDOMElement = (element) => {
|
||||
return element instanceof Element || element instanceof HTMLDocument
|
||||
@@ -156,6 +196,16 @@ Vue.prototype.$encode = encode
|
||||
const decode = (text) => Buffer.from(decodeURIComponent(text), 'base64').toString()
|
||||
Vue.prototype.$decode = decode
|
||||
|
||||
Vue.prototype.$setOrientationLock = (orientationLockSetting) => {
|
||||
if (orientationLockSetting == 'PORTRAIT') {
|
||||
window.screen.orientation.lock('portrait')
|
||||
} else if (orientationLockSetting == 'LANDSCAPE') {
|
||||
window.screen.orientation.lock('landscape')
|
||||
} else {
|
||||
window.screen.orientation.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
export default ({ store, app }) => {
|
||||
// iOS Only
|
||||
// backButton event does not work with iOS swipe navigation so use this workaround
|
||||
|
||||
Binary file not shown.
@@ -13,12 +13,16 @@
|
||||
<glyph unicode="" glyph-name="radio" d="M989.6 866c25.4 7.4 40 34.2 32.6 59.6s-34.2 40-59.8 32.4l-859-251.8c-18.8-5.4-35.8-14.6-50.2-26.4-32.2-23.2-53.2-61-53.2-103.8v-512c0-70.6 57.4-128 128-128h768c70.6 0 128 57.4 128 128v512c0 70.6-57.4 128-128 128h-459l552.6 162zM736 160c-88.4 0-160 71.6-160 160s71.6 160 160 160 160-71.6 160-160-71.6-160-160-160zM160 448c0 17.6 14.4 32 32 32h192c17.6 0 32-14.4 32-32s-14.4-32-32-32h-192c-17.6 0-32 14.4-32 32zM128 320c0 17.6 14.4 32 32 32h256c17.6 0 32-14.4 32-32s-14.4-32-32-32h-256c-17.6 0-32 14.4-32 32zM160 192c0 17.6 14.4 32 32 32h192c17.6 0 32-14.4 32-32s-14.4-32-32-32h-192c-17.6 0-32 14.4-32 32z" />
|
||||
<glyph unicode="" glyph-name="podcast" horiz-adv-x="1043" d="M585.253 366.417v-430.408h-127.13v430.408c-41.76 22.603-70.128 66.8-70.128 117.637 0 73.822 59.863 133.685 133.693 133.685 73.822 0 133.693-59.863 133.693-133.685 0-50.837-28.369-95.033-70.128-117.637v0zM333.872 865.196c24.76 12.21 34.907 42.193 22.697 66.927-12.21 24.743-42.167 34.907-66.91 22.705-101.045-49.843-179.63-132.301-229.287-229.397-36.682-71.708-57.596-151.499-60.117-232.021-2.539-81.201 13.475-163.226 50.718-238.72 45.861-93.004 123.614-175.725 238.084-234.288 24.531-12.541 54.59-2.802 67.123 21.729 12.541 24.531 2.811 54.581-21.72 67.114-93.717 47.933-156.968 114.843-193.879 189.642-29.77 60.381-42.575 126.153-40.537 191.382 2.063 65.866 19.156 131.12 49.164 189.76 40.18 78.552 103.49 145.139 184.666 185.167v0zM753.718 954.837c-24.743 12.21-54.709 2.038-66.919-22.705s-2.055-54.726 22.705-66.927c81.184-40.036 144.494-106.615 184.657-185.175 29.999-58.631 47.1-123.886 49.155-189.76 2.038-65.229-10.767-131.002-40.528-191.382-36.911-74.799-100.162-141.709-193.879-189.642-24.531-12.541-34.253-42.592-21.72-67.114 12.533-24.531 42.592-34.262 67.123-21.729 114.461 58.555 192.223 141.284 238.084 234.288 37.242 75.495 53.257 157.52 50.718 238.72-2.522 80.522-23.436 160.313-60.109 232.021-49.665 97.096-128.242 179.554-229.287 229.406v0zM702.992 734.942c-18.638 13.425-44.63 9.187-58.054-9.451-13.416-18.638-9.187-44.63 9.451-58.054 7.515-5.409 14.885-11.48 22.111-18.12 42.21-38.915 67.241-90.804 72.498-145.708 5.315-55.396-9.434-114.096-46.854-166.019-6.351-8.822-13.671-17.764-21.95-26.747-15.581-16.914-14.503-43.288 2.437-58.861 16.931-15.573 43.296-14.477 58.869 2.437 10.020 10.86 19.411 22.425 28.148 34.576 49.775 69.11 69.339 147.746 62.172 222.443-7.201 75.172-41.36 146.116-98.905 199.177-9.052 8.347-19.029 16.464-29.923 24.327v0zM388.98 667.412c18.638 13.425 22.875 39.416 9.451 58.054s-39.416 22.875-58.054 9.459c-10.903-7.863-20.871-15.98-29.923-24.319-57.553-53.070-91.713-124.005-98.914-199.177-7.167-74.688 12.397-153.35 62.181-222.46 8.754-12.142 18.137-23.699 28.148-34.559 15.573-16.914 41.938-18.010 58.861-2.437 16.948 15.573 18.027 41.938 2.445 58.861-8.279 8.992-15.598 17.925-21.95 26.747-37.412 51.923-52.17 110.614-46.854 166.011 5.256 54.895 30.288 106.793 72.498 145.708 7.234 6.632 14.605 12.703 22.111 18.112v0z" />
|
||||
<glyph unicode="" glyph-name="books-1" d="M384 832v-640h128v640h-128zM512 746.667l170.667-554.667 128 42.667-170.667 554.667-128-42.667zM213.333 746.667v-554.667h128v554.667h-128zM128 149.333v-85.333h768v85.333h-768z" />
|
||||
<glyph unicode="" glyph-name="database-2" horiz-adv-x="876" d="M437.75 960c240.583 0 435.583-91.333 435.583-203.833 0-112.583-195.083-203.833-435.583-203.833s-435.583 91.333-435.583 203.833c0 112.5 195.083 203.833 435.583 203.833v0zM2.167 278.083v-156.5c77.5-275.25 843.167-222.083 871.25 14.083v156.333c-38.25-259.25-810-277.917-871.25-13.917v0 0zM0 685.5v-152.833c77.5-268.833 847.417-232.5 875.583-1.917v152.75c-38.333-253.25-814.333-255.833-875.583 2v0zM0 488v-156.5c77.5-275.25 847.417-238.083 875.583-1.917v156.333c-38.333-259.25-814.333-261.917-875.583 2.083v0z" />
|
||||
<glyph unicode="" glyph-name="database" horiz-adv-x="876" d="M437.75 960c240.583 0 435.583-91.333 435.583-203.833 0-112.583-195.083-203.833-435.583-203.833s-435.583 91.333-435.583 203.833c0 112.5 195.083 203.833 435.583 203.833v0zM2.167 278.083v-156.5c77.5-275.25 843.167-222.083 871.25 14.083v156.333c-38.25-259.25-810-277.917-871.25-13.917v0 0zM0 685.5v-152.833c77.5-268.833 847.417-232.5 875.583-1.917v152.75c-38.333-253.25-814.333-255.833-875.583 2v0zM0 488v-156.5c77.5-275.25 847.417-238.083 875.583-1.917v156.333c-38.333-259.25-814.333-261.917-875.583 2.083v0z" />
|
||||
<glyph unicode="" glyph-name="list" d="M384 724.667h512v-170h-512v170zM384 128.667v170h512v-170h-512zM384 340.667v172h512v-172h-512zM170 554.667v170h172v-170h-172zM170 128.667v170h172v-170h-172zM170 340.667v172h172v-172h-172z" />
|
||||
<glyph unicode="" glyph-name="home" d="M949.845 492.032c-144.64 121.771-407.296 348.629-409.899 350.933l-27.947 24.021-27.819-24.021c-2.645-2.261-265.429-229.035-412.16-351.915-18.688-16.811-29.355-40.32-29.355-64.384 0-47.104 38.229-85.333 85.333-85.333h42.667v-256c0-47.104 38.229-85.333 85.333-85.333h512c47.104 0 85.333 38.229 85.333 85.333v256h42.667c47.104 0 85.333 38.229 85.333 85.333 0 25.515-11.733 49.536-31.488 65.365zM597.333 85.334h-170.667v213.333h170.667v-213.333zM768 426.667l0.085-341.333c-0.085 0-128.085 0-128.085 0v256h-256v-256h-128v341.333h-128.043c117.973 98.645 312.107 265.685 384.043 327.68 71.936-61.995 265.984-228.992 384-327.723 0 0-128 0-128 0.043z" />
|
||||
<glyph unicode="" glyph-name="authors" d="M512 725.333c82.475 0 149.333-66.859 149.333-149.333v0c0-82.475-66.859-149.333-149.333-149.333v0c-82.475 0-149.333 66.859-149.333 149.333v0c0 82.475 66.859 149.333 149.333 149.333v0zM213.333 618.667c23.893 0 46.080-6.4 65.28-17.92-6.4-61.013 11.52-121.6 48.213-168.96-21.333-40.96-64-69.12-113.493-69.12-70.692 0-128 57.308-128 128v0c0 70.692 57.308 128 128 128v0zM810.667 618.667c70.692 0 128-57.308 128-128v0c0-70.692-57.308-128-128-128v0c-49.493 0-92.16 28.16-113.493 69.12 36.693 47.36 54.613 107.947 48.213 168.96 19.2 11.52 41.387 17.92 65.28 17.92zM234.667 181.333c0 88.32 124.16 160 277.333 160s277.333-71.68 277.333-160v-74.667h-554.667v74.667zM0 106.667v64c0 59.307 80.64 109.227 189.867 123.733-25.173-29.013-40.533-69.12-40.533-113.067v-74.667h-149.333zM1024 106.667h-149.333v74.667c0 43.947-15.36 84.053-40.533 113.067 109.227-14.507 189.867-64.427 189.867-123.733v-64z" />
|
||||
<glyph unicode="" glyph-name="columns" d="M614.4 768h-204.8v-614.4h204.8v614.4zM716.8 768v-614.4h204.8v614.4h-204.8zM307.2 768h-204.8v-614.4h204.8v614.4zM0 870.4h1024v-819.2h-1024v819.2z" />
|
||||
<glyph unicode="" glyph-name="headphones" d="M288 384h-64v-448h64c17.6 0 32 14.4 32 32v384c0 17.6-14.4 32-32 32zM736 384c-17.602 0-32-14.4-32-32v-384c0-17.6 14.398-32 32-32h64v448h-64zM1024 448c0 282.77-229.23 512-512 512s-512-229.23-512-512c0-61.412 10.83-120.29 30.656-174.848-19.478-33.206-30.656-71.87-30.656-113.152 0-112.846 83.448-206.188 192-221.716v443.418c-31.914-4.566-61.664-15.842-87.754-32.378-5.392 26.718-8.246 54.364-8.246 82.676 0 229.75 186.25 416 416 416s416-186.25 416-416c0-28.314-2.83-55.968-8.22-82.696-26.1 16.546-55.854 27.848-87.78 32.418v-443.44c108.548 15.532 192 108.874 192 221.714 0 41.274-11.178 79.934-30.648 113.138 19.828 54.566 30.648 113.452 30.648 174.866z" />
|
||||
<glyph unicode="" glyph-name="music" d="M960 960h64v-736c0-88.366-100.29-160-224-160s-224 71.634-224 160c0 88.368 100.29 160 224 160 62.684 0 119.342-18.4 160-48.040v368.040l-512-113.778v-494.222c0-88.366-100.288-160-224-160s-224 71.634-224 160c0 88.368 100.288 160 224 160 62.684 0 119.342-18.4 160-48.040v624.040l576 128z" />
|
||||
<glyph unicode="" glyph-name="video" d="M384 672c0 88.366 71.634 160 160 160s160-71.634 160-160c0-88.366-71.634-160-160-160s-160 71.634-160 160zM0 672c0 88.366 71.634 160 160 160s160-71.634 160-160c0-88.366-71.634-160-160-160s-160 71.634-160 160zM768 352v96c0 35.2-28.8 64-64 64h-640c-35.2 0-64-28.8-64-64v-320c0-35.2 28.8-64 64-64h640c35.2 0 64 28.8 64 64v96l256-160v448l-256-160zM640 192h-512v192h512v-192z" />
|
||||
<glyph unicode="" glyph-name="microphone-3" d="M480 256c88.366 0 160 71.634 160 160v384c0 88.366-71.634 160-160 160s-160-71.634-160-160v-384c0-88.366 71.636-160 160-160zM704 512v-96c0-123.71-100.29-224-224-224-123.712 0-224 100.29-224 224v96h-64v-96c0-148.238 112.004-270.3 256-286.22v-129.78h-128v-64h320v64h-128v129.78c143.994 15.92 256 137.982 256 286.22v96h-64z" />
|
||||
<glyph unicode="" glyph-name="book" d="M896 832v-832h-672c-53.026 0-96 42.98-96 96s42.974 96 96 96h608v768h-640c-70.398 0-128-57.6-128-128v-768c0-70.4 57.602-128 128-128h768v896h-64zM224.056 128v0c-0.018-0.002-0.038 0-0.056 0-17.672 0-32-14.326-32-32s14.328-32 32-32c0.018 0 0.038 0.002 0.056 0.002v-0.002h607.89v64h-607.89z" />
|
||||
<glyph unicode="" glyph-name="book-1" d="M896 832v-832h-672c-53.026 0-96 42.98-96 96s42.974 96 96 96h608v768h-640c-70.398 0-128-57.6-128-128v-768c0-70.4 57.602-128 128-128h768v896h-64zM224.056 128v0c-0.018-0.002-0.038 0-0.056 0-17.672 0-32-14.326-32-32s14.328-32 32-32c0.018 0 0.038 0.002 0.056 0.002v-0.002h607.89v64h-607.89z" />
|
||||
<glyph unicode="" glyph-name="books-2" horiz-adv-x="1152" d="M224 832h-192c-17.6 0-32-14.4-32-32v-704c0-17.6 14.4-32 32-32h192c17.6 0 32 14.4 32 32v704c0 17.6-14.4 32-32 32zM192 640h-128v64h128v-64zM544 832h-192c-17.6 0-32-14.4-32-32v-704c0-17.6 14.4-32 32-32h192c17.6 0 32 14.4 32 32v704c0 17.6-14.4 32-32 32zM512 640h-128v64h128v-64zM765.088 782.52l-171.464-86.394c-15.716-7.918-22.096-27.258-14.178-42.976l287.978-571.548c7.918-15.718 27.258-22.098 42.976-14.178l171.464 86.392c15.716 7.92 22.096 27.26 14.178 42.974l-287.978 571.55c-7.92 15.718-27.26 22.1-42.976 14.18z" />
|
||||
<glyph unicode="" glyph-name="file-picture" d="M832 64h-640v128l192 320 263-320 185 128v-256zM832 480c0-53.020-42.98-96-96-96-53.022 0-96 42.98-96 96s42.978 96 96 96c53.020 0 96-42.98 96-96zM917.806 730.924c-22.212 30.292-53.174 65.7-87.178 99.704s-69.412 64.964-99.704 87.178c-51.574 37.82-76.592 42.194-90.924 42.194h-496c-44.112 0-80-35.888-80-80v-864c0-44.112 35.888-80 80-80h736c44.112 0 80 35.888 80 80v624c0 14.332-4.372 39.35-42.194 90.924zM785.374 785.374c30.7-30.7 54.8-58.398 72.58-81.374h-153.954v153.946c22.984-17.78 50.678-41.878 81.374-72.572zM896 16c0-8.672-7.328-16-16-16h-736c-8.672 0-16 7.328-16 16v864c0 8.672 7.328 16 16 16 0 0 495.956 0.002 496 0v-224c0-17.672 14.326-32 32-32h224v-624z" />
|
||||
<glyph unicode="" glyph-name="database-1" d="M512 960c-282.77 0-512-71.634-512-160v-128c0-88.366 229.23-160 512-160s512 71.634 512 160v128c0 88.366-229.23 160-512 160zM512 416c-282.77 0-512 71.634-512 160v-192c0-88.366 229.23-160 512-160s512 71.634 512 160v192c0-88.366-229.23-160-512-160zM512 128c-282.77 0-512 71.634-512 160v-192c0-88.366 229.23-160 512-160s512 71.634 512 160v192c0-88.366-229.23-160-512-160z" />
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 15 KiB |
Binary file not shown.
Binary file not shown.
+16
-11
@@ -33,7 +33,9 @@ export const state = () => ({
|
||||
value: 30
|
||||
}
|
||||
],
|
||||
libraryIcons: ['database', 'audiobookshelf', 'books-1', 'books-2', 'book-1', 'microphone-1', 'microphone-3', 'radio', 'podcast', 'rss', 'headphones', 'music', 'file-picture', 'rocket', 'power', 'star', 'heart']
|
||||
libraryIcons: ['database', 'audiobookshelf', 'books-1', 'books-2', 'book-1', 'microphone-1', 'microphone-3', 'radio', 'podcast', 'rss', 'headphones', 'music', 'file-picture', 'rocket', 'power', 'star', 'heart'],
|
||||
selectedPlaylistItems: [],
|
||||
showPlaylistsAddCreateModal: false
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
@@ -43,26 +45,26 @@ export const getters = {
|
||||
return i.libraryItemId == libraryItemId
|
||||
})
|
||||
},
|
||||
getLibraryItemCoverSrc: (state, getters, rootState, rootGetters) => (libraryItem, placeholder = '/book_placeholder.jpg') => {
|
||||
getLibraryItemCoverSrc: (state, getters, rootState, rootGetters) => (libraryItem, placeholder, raw = false) => {
|
||||
if (!libraryItem) return placeholder
|
||||
var media = libraryItem.media
|
||||
const media = libraryItem.media
|
||||
if (!media || !media.coverPath || media.coverPath === placeholder) return placeholder
|
||||
|
||||
// Absolute URL covers (should no longer be used)
|
||||
if (media.coverPath.startsWith('http:') || media.coverPath.startsWith('https:')) return media.coverPath
|
||||
|
||||
var userToken = rootGetters['user/getToken']
|
||||
var serverAddress = rootGetters['user/getServerAddress']
|
||||
const userToken = rootGetters['user/getToken']
|
||||
const serverAddress = rootGetters['user/getServerAddress']
|
||||
if (!userToken || !serverAddress) return placeholder
|
||||
|
||||
var lastUpdate = libraryItem.updatedAt || Date.now()
|
||||
const lastUpdate = libraryItem.updatedAt || Date.now()
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') { // Testing
|
||||
// return `http://localhost:3333/api/items/${libraryItem.id}/cover?token=${userToken}&ts=${lastUpdate}`
|
||||
}
|
||||
|
||||
var url = new URL(`/api/items/${libraryItem.id}/cover`, serverAddress)
|
||||
return `${url}?token=${userToken}&ts=${lastUpdate}`
|
||||
const url = new URL(`/api/items/${libraryItem.id}/cover`, serverAddress)
|
||||
return `${url}?token=${userToken}&ts=${lastUpdate}${raw ? '&raw=1' : ''}`
|
||||
},
|
||||
getLocalMediaProgressById: (state) => (localLibraryItemId, episodeId = null) => {
|
||||
return state.localMediaProgress.find(lmp => {
|
||||
@@ -89,7 +91,6 @@ export const getters = {
|
||||
export const actions = {
|
||||
async loadLocalMediaProgress({ state, commit }) {
|
||||
var mediaProgress = await this.$db.getAllLocalMediaProgress()
|
||||
console.log('Got all local media progress', JSON.stringify(mediaProgress))
|
||||
commit('setLocalMediaProgress', mediaProgress)
|
||||
}
|
||||
}
|
||||
@@ -124,10 +125,8 @@ export const mutations = {
|
||||
}
|
||||
var index = state.localMediaProgress.findIndex(lmp => lmp.id == prog.id)
|
||||
if (index >= 0) {
|
||||
console.log('UpdateLocalMediaProgress updating', prog.id, prog.progress)
|
||||
state.localMediaProgress.splice(index, 1, prog)
|
||||
} else {
|
||||
console.log('updateLocalMediaProgress inserting new progress', prog.id, prog.progress)
|
||||
state.localMediaProgress.push(prog)
|
||||
}
|
||||
},
|
||||
@@ -139,5 +138,11 @@ export const mutations = {
|
||||
},
|
||||
setLastSearch(state, val) {
|
||||
state.lastSearch = val
|
||||
},
|
||||
setSelectedPlaylistItems(state, items) {
|
||||
state.selectedPlaylistItems = items
|
||||
},
|
||||
setShowPlaylistsAddCreateModal(state, val) {
|
||||
state.showPlaylistsAddCreateModal = val
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,11 @@ export const state = () => ({
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
getIsMediaStreaming: state => (libraryItemId, episodeId) => {
|
||||
if (!state.playerLibraryItemId) return null
|
||||
if (!episodeId) return state.playerLibraryItemId == libraryItemId
|
||||
return state.playerLibraryItemId == libraryItemId && state.playerEpisodeId == episodeId
|
||||
},
|
||||
getIsItemStreaming: state => libraryItemId => {
|
||||
return state.playerLibraryItemId == libraryItemId
|
||||
},
|
||||
@@ -47,6 +52,10 @@ export const getters = {
|
||||
getAltViewEnabled: state => {
|
||||
if (!state.deviceData || !state.deviceData.deviceSettings) return false
|
||||
return state.deviceData.deviceSettings.enableAltView
|
||||
},
|
||||
getOrientationLockSetting: state => {
|
||||
if (!state.deviceData || !state.deviceData.deviceSettings) return false
|
||||
return state.deviceData.deviceSettings.lockOrientation
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+16
-7
@@ -6,7 +6,8 @@ export const state = () => ({
|
||||
currentLibraryId: '',
|
||||
showModal: false,
|
||||
issues: 0,
|
||||
filterData: null
|
||||
filterData: null,
|
||||
numUserPlaylists: 0
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
@@ -41,15 +42,17 @@ export const actions = {
|
||||
return this.$axios
|
||||
.$get(`/api/libraries/${libraryId}?include=filterdata`)
|
||||
.then((data) => {
|
||||
var library = data.library
|
||||
var filterData = data.filterdata
|
||||
var issues = data.issues || 0
|
||||
const library = data.library
|
||||
const filterData = data.filterdata
|
||||
const issues = data.issues || 0
|
||||
const numUserPlaylists = data.numUserPlaylists || 0
|
||||
|
||||
dispatch('user/checkUpdateLibrarySortFilter', library.mediaType, { root: true })
|
||||
|
||||
commit('addUpdate', library)
|
||||
commit('setLibraryIssues', issues)
|
||||
commit('setLibraryFilterData', filterData)
|
||||
commit('setNumUserPlaylists', numUserPlaylists)
|
||||
commit('setCurrentLibrary', libraryId)
|
||||
return data
|
||||
})
|
||||
@@ -75,12 +78,15 @@ export const actions = {
|
||||
return this.$axios
|
||||
.$get(`/api/libraries`)
|
||||
.then((data) => {
|
||||
// TODO: Server release 2.2.9 changed response to an object. Remove after a few releases
|
||||
const libraries = data.libraries || data
|
||||
|
||||
// Set current library if not already set or was not returned in results
|
||||
if (data.length && (!state.currentLibraryId || !data.find(li => li.id == state.currentLibraryId))) {
|
||||
commit('setCurrentLibrary', data[0].id)
|
||||
if (libraries.length && (!state.currentLibraryId || !libraries.find(li => li.id == state.currentLibraryId))) {
|
||||
commit('setCurrentLibrary', libraries[0].id)
|
||||
}
|
||||
|
||||
commit('set', data)
|
||||
commit('set', libraries)
|
||||
commit('setLastLoad', Date.now())
|
||||
return true
|
||||
})
|
||||
@@ -125,6 +131,9 @@ export const mutations = {
|
||||
setLibraryIssues(state, val) {
|
||||
state.issues = val
|
||||
},
|
||||
setNumUserPlaylists(state, numUserPlaylists) {
|
||||
state.numUserPlaylists = numUserPlaylists
|
||||
},
|
||||
setLibraryFilterData(state, filterData) {
|
||||
state.filterData = filterData
|
||||
},
|
||||
|
||||
@@ -16,9 +16,13 @@ export const state = () => ({
|
||||
|
||||
export const getters = {
|
||||
getIsRoot: (state) => state.user && state.user.type === 'root',
|
||||
getIsAdminOrUp: (state) => state.user && (state.user.type === 'admin' || state.user.type === 'root'),
|
||||
getToken: (state) => {
|
||||
return state.user ? state.user.token : null
|
||||
},
|
||||
getServerConnectionConfigId: (state) => {
|
||||
return state.serverConnectionConfig ? state.serverConnectionConfig.id : null
|
||||
},
|
||||
getServerAddress: (state) => {
|
||||
return state.serverConnectionConfig ? state.serverConnectionConfig.address : null
|
||||
},
|
||||
|
||||
@@ -42,6 +42,8 @@ module.exports = {
|
||||
'24': '6rem'
|
||||
},
|
||||
minWidth: {
|
||||
'4': '1rem',
|
||||
'8': '2rem',
|
||||
'12': '3rem'
|
||||
},
|
||||
minHeight: {
|
||||
|
||||
Reference in New Issue
Block a user