Compare commits

...
Author SHA1 Message Date
advplyr 4fd4cc5604 iOS version bump 0.9.60 2022-12-11 17:11:37 -06:00
advplyr c73b71345a Version bump 0.9.60 & android target sdk version to 31 2022-12-11 17:03:56 -06:00
advplyr 11f0af1ebd Update:Android Auto play next podcast episode after finishing one 2022-12-11 16:09:00 -06:00
advplyr a532f988b0 Update:Local covers for queue items 2022-12-11 14:00:27 -06:00
advplyr bec2796771 Update cast icon position in app bar 2022-12-11 12:18:10 -06:00
advplyr feec7f7399 Add:Sync local media progress button on local media page 2022-12-11 12:01:54 -06:00
advplyr 067c2f84dd Fix:Bookmarks for downloaded books linked to server #163 2022-12-11 11:09:50 -06:00
advplyr 69996a4346 Fix:Changing orientation update audio player progress bar 2022-12-11 10:49:05 -06:00
advplyr 80cda129d2 Fix:Progress bar width on item cover image 2022-12-11 10:29:43 -06:00
advplyr dd0ff04155 Fix:Android Auto downloaded item cover image #141, Update:Lockscreen display title/subtitle #431 2022-12-10 18:09:27 -06:00
advplyr c69558aa7f Fix:Chapters modal overflowing title/time #460 2022-12-10 14:55:33 -06:00
advplyr 8fefea0d94 Fix:Library icons min width 2022-12-10 14:34:21 -06:00
advplyr 0ddee6f44e Fix:Show Manage Local Files & Add to Playlist in more menu correctly 2022-12-10 14:26:58 -06:00
advplyr feec1ab55a Update:Local books/podcasts shelves on home page 2022-12-10 14:17:18 -06:00
advplyr 49500aecd6 Fix network flag 2022-12-10 11:35:16 -06:00
advplyr 894bf100d2 Update:Disable pod search when no network connection #225 2022-12-10 10:44:21 -06:00
advplyr 7ba810adb8 Update:Show error when searching for episodes with no network connection 2022-12-10 10:32:16 -06:00
advplyr d10f33362e Add:Option to delete episodes from server #225 2022-12-10 10:29:56 -06:00
advplyr 82e8fbba2b Download episodes to server & see downloads queued/in progress #225 2022-12-10 10:12:58 -06:00
advplyr 64325fe2a6 Add:Feed podcast episodes select modal #225 2022-12-09 18:03:17 -06:00
advplyr 99217fee48 Merge pull request #459 from benonymity/hapticFeedback
Add haptic feedback
2022-12-09 17:08:17 -06:00
advplyr f12e64990c Update components/home/BookshelfToolbar.vue 2022-12-09 17:08:00 -06:00
advplyr ef7bb9a9d8 Update pages/settings.vue 2022-12-09 17:07:50 -06:00
advplyr 5ac925e151 Update pages/settings.vue 2022-12-09 17:07:45 -06:00
benonymity acc5bde33a fix: use native haptic plugin and remove semicolons 2022-12-08 19:09:46 -05:00
advplyr b521f37ec1 Update:Only show podcast search page for admin or up #225 2022-12-08 17:56:58 -06:00
advplyr 379fa21571 Add:Podcast create form #225 2022-12-08 17:54:56 -06:00
benonymity 1aa6a441f3 feat: add haptic feedback to all action buttons 2022-12-08 00:28:28 -05:00
advplyr 705bbaccc1 Add:Podcast search page #225 2022-12-07 17:57:42 -06:00
advplyr 8365e06762 Merge pull request #458 from benonymity/noTextSelect
Prevent selecting of text in the app
2022-12-07 16:37:47 -06:00
advplyr 139a0b3412 Merge pull request #456 from benonymity/listeningStatsFix
Fix Listening Stats
2022-12-07 16:28:37 -06:00
advplyr ea6b21169e Merge pull request #455 from benonymity/vertical-bounce
Don't allow webview to scroll out of bounds vertically
2022-12-07 16:13:17 -06:00
advplyr a262efe9da Fix lockOrientation setting and add Realm db migration; 2022-12-07 16:12:09 -06:00
benonymity ed5fb09007 fix: prevent selecting of text in the app 2022-12-07 15:41:32 -05:00
benonymity d3c6429fd6 fix: accurate time for streamed sessions 2022-12-07 14:40:50 -05:00
benonymity 371fcacc87 Fix: don't allow webview to scroll out of bounds vertically 2022-12-07 13:31:15 -05:00
advplyr 75666e523f Add:PDF EReader #420 2022-12-06 17:07:27 -06:00
advplyr 592ea7c533 Fix:EReader for ebooks in library folder root 2022-12-06 16:21:12 -06:00
advplyr b860dfc791 Fix:Library filter by progress #451 2022-12-05 17:30:15 -06:00
advplyr 96dde8cf31 Fix:Android seeking to invalid times 2022-12-04 16:57:03 -06:00
advplyr b4a37fed28 Update:Showing fullscreen cover uses raw cover 2022-12-04 16:30:18 -06:00
advplyr ad3fee8907 Fix:Save track order button hidden when player is open #380 2022-12-04 15:04:46 -06:00
advplyr 94db55598a Fix:Changing track sort order (Android) #380 2022-12-04 14:49:24 -06:00
advplyr 9687f47b6b Add:Device setting for locking screen orientation #449 2022-12-04 10:41:09 -06:00
advplyr af216d9b38 Update:Media session media button events for fast forward and rewind #423 2022-12-04 09:34:39 -06:00
advplyr c7879c2bc0 Update:Audio player UI updates #418 #408 2022-12-04 09:19:50 -06:00
advplyr 7db503a2cf Fix:EReader when player is open #442 2022-12-04 08:29:55 -06:00
advplyr d72295a0e6 Fix filter urls on items page 2022-12-03 17:58:22 -06:00
advplyr 638da4400f Update:Item page UI 2022-12-03 17:43:47 -06:00
advplyr af9a7aafae Add:Podcast latest page 2022-12-03 17:24:24 -06:00
advplyr ab9f7fed64 Add:Playlists management 2022-12-03 17:05:43 -06:00
advplyr b62ce27487 Add:Playlists 2022-12-03 15:20:27 -06:00
advplyr 6d9e902fe7 Update:Abs icon font and navbar to use icons 2022-12-03 12:06:24 -06:00
advplyr a0faf3f7d4 Update get libraries API call to support updated response payload 2022-12-02 17:39:45 -06:00
advplyr 32dfb3b40a Fix:Series books page sort order #443 2022-11-17 17:08:30 -06:00
97 changed files with 3150 additions and 478 deletions
+2 -2
View File
@@ -33,8 +33,8 @@ android {
applicationId "com.audiobookshelf.app" applicationId "com.audiobookshelf.app"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 89 versionCode 91
versionName "0.9.59-beta" versionName "0.9.60-beta"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+1 -1
View File
@@ -75,7 +75,7 @@
</intent-filter> </intent-filter>
</activity> </activity>
<receiver android:name="androidx.media.session.MediaButtonReceiver" > <receiver android:name="androidx.media.session.MediaButtonReceiver" android:exported="false">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" /> <action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter> </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 package com.audiobookshelf.app.data
import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.MediaMetadataCompat
import androidx.media.utils.MediaConstants import androidx.media.utils.MediaConstants
import com.audiobookshelf.app.media.MediaManager
import com.fasterxml.jackson.annotation.* import com.fasterxml.jackson.annotation.*
// This auto-detects whether it is a Book or Podcast // This auto-detects whether it is a Book or Podcast
@@ -50,7 +52,7 @@ class Podcast(
// Add new episodes // Add new episodes
audioTracks.forEach { at -> audioTracks.forEach { at ->
if (episodes?.find{ it.audioTrack?.localFileId == at.localFileId } == null) { if (episodes?.find{ it.audioTrack?.localFileId == at.localFileId } == null) {
val 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) episodes?.add(newEpisode)
} }
} }
@@ -63,7 +65,7 @@ class Podcast(
} }
@JsonIgnore @JsonIgnore
override fun addAudioTrack(audioTrack:AudioTrack) { 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) episodes?.add(newEpisode)
var index = 1 var index = 1
@@ -82,9 +84,16 @@ class Podcast(
index++ 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 @JsonIgnore
fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode { 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) episodes?.add(newEpisode)
var index = 1 var index = 1
@@ -95,10 +104,14 @@ class Podcast(
return newEpisode return newEpisode
} }
// Used for FolderScanner local podcast item to get copy of Podcast excluding episodes
@JsonIgnore @JsonIgnore
override fun getLocalCopy(): Podcast { fun getNextUnfinishedEpisode(libraryItemId:String, mediaManager: MediaManager):PodcastEpisode? {
return Podcast(metadata as PodcastMetadata,coverPath,tags, mutableListOf(),autoDownloadEpisodes, 0) 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>) { override fun setAudioTracks(audioTracks:MutableList<AudioTrack>) {
tracks = audioTracks tracks = audioTracks
tracks?.sortBy { it.index } tracks?.sortBy { it.index }
// TODO: Is it necessary to calculate this each time? check if can remove safely
var totalDuration = 0.0 var totalDuration = 0.0
var currentStartOffset = 0.0
tracks?.forEach { tracks?.forEach {
totalDuration += it.duration totalDuration += it.duration
it.startOffset = currentStartOffset
currentStartOffset += it.duration
} }
duration = totalDuration duration = totalDuration
} }
@@ -158,7 +174,6 @@ class Book(
} }
duration = totalDuration duration = totalDuration
} }
@JsonIgnore @JsonIgnore
override fun getLocalCopy(): Book { override fun getLocalCopy(): Book {
return Book(metadata as BookMetadata,coverPath,tags, mutableListOf(),chapters,mutableListOf(),null,null, 0) return Book(metadata as BookMetadata,coverPath,tags, mutableListOf(),chapters,mutableListOf(),null,null, 0)
@@ -228,6 +243,8 @@ data class PodcastEpisode(
var title:String?, var title:String?,
var subtitle:String?, var subtitle:String?,
var description:String?, var description:String?,
var pubDate:String?,
var publishedAt:Long?,
var audioFile:AudioFile?, var audioFile:AudioFile?,
var audioTrack:AudioTrack?, var audioTrack:AudioTrack?,
var duration:Double?, var duration:Double?,
@@ -235,7 +252,7 @@ data class PodcastEpisode(
var serverEpisodeId:String? // For local podcasts to match with server podcasts var serverEpisodeId:String? // For local podcasts to match with server podcasts
) { ) {
@JsonIgnore @JsonIgnore
fun getMediaDescription(libraryItem:LibraryItemWrapper, progress:MediaProgressWrapper?): MediaDescriptionCompat { fun getMediaDescription(libraryItem:LibraryItemWrapper, progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat {
val coverUri = if (libraryItem is LocalLibraryItem) { val coverUri = if (libraryItem is LocalLibraryItem) {
libraryItem.getCoverUri() libraryItem.getCoverUri()
} else { } else {
@@ -264,22 +281,20 @@ data class PodcastEpisode(
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
) )
} }
// return MediaMetadataCompat.Builder().apply {
// putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id) val libraryItemDescription = libraryItem.getMediaDescription(null, ctx)
// putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title) val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
// 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()
.setMediaId(id) .setMediaId(id)
.setTitle(title) .setTitle(title)
.setIconUri(coverUri) .setIconUri(coverUri)
.setSubtitle(libraryItemDescription.title) .setSubtitle(libraryItemDescription.title)
.setExtras(extras) .setExtras(extras)
.build()
libraryItemDescription.iconBitmap?.let {
mediaDescriptionBuilder.setIconBitmap(it)
}
return mediaDescriptionBuilder.build()
} }
} }
@@ -329,36 +344,6 @@ data class Folder(
var fullPath:String 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) @JsonIgnoreProperties(ignoreUnknown = true)
data class BookChapter( data class BookChapter(
var id:Int, var id:Int,
@@ -1,11 +1,16 @@
package com.audiobookshelf.app.data package com.audiobookshelf.app.data
import android.content.Context
import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaDescriptionCompat
import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonSubTypes import com.fasterxml.jackson.annotation.JsonSubTypes
import com.fasterxml.jackson.annotation.JsonTypeInfo import com.fasterxml.jackson.annotation.JsonTypeInfo
enum class LockOrientationSetting {
NONE, PORTRAIT, LANDSCAPE
}
data class ServerConnectionConfig( data class ServerConnectionConfig(
var id:String, var id:String,
var index:Int, var index:Int,
@@ -22,7 +27,8 @@ data class DeviceSettings(
var enableAltView:Boolean, var enableAltView:Boolean,
var jumpBackwardsTime:Int, var jumpBackwardsTime:Int,
var jumpForwardTime:Int, var jumpForwardTime:Int,
var disableShakeToResetSleepTimer:Boolean var disableShakeToResetSleepTimer:Boolean,
var lockOrientation:LockOrientationSetting
) { ) {
companion object { companion object {
// Static method to get default device settings // Static method to get default device settings
@@ -32,7 +38,8 @@ data class DeviceSettings(
enableAltView = false, enableAltView = false,
jumpBackwardsTime = 10, jumpBackwardsTime = 10,
jumpForwardTime = 10, jumpForwardTime = 10,
disableShakeToResetSleepTimer = false disableShakeToResetSleepTimer = false,
lockOrientation = LockOrientationSetting.NONE
) )
} }
} }
@@ -95,7 +102,7 @@ data class LocalFolder(
) )
open class LibraryItemWrapper(var id:String) { open class LibraryItemWrapper(var id:String) {
@JsonIgnore @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) @JsonIgnoreProperties(ignoreUnknown = true)
@@ -1,5 +1,6 @@
package com.audiobookshelf.app.data package com.audiobookshelf.app.data
import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaDescriptionCompat
@@ -55,8 +56,9 @@ class LibraryItem(
} }
@JsonIgnore @JsonIgnore
override fun getMediaDescription(progress:MediaProgressWrapper?): MediaDescriptionCompat { override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat {
val extras = Bundle() val extras = Bundle()
if (progress != null) { if (progress != null) {
if (progress.isFinished) { if (progress.isFinished) {
extras.putInt( extras.putInt(
@@ -1,7 +1,12 @@
package com.audiobookshelf.app.data package com.audiobookshelf.app.data
import android.content.Context
import android.graphics.Bitmap
import android.graphics.ImageDecoder
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.MediaStore
import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaDescriptionCompat
import android.util.Log import android.util.Log
import androidx.media.utils.MediaConstants import androidx.media.utils.MediaConstants
@@ -96,9 +101,21 @@ class LocalLibraryItem(
} }
@JsonIgnore @JsonIgnore
override fun getMediaDescription(progress:MediaProgressWrapper?): MediaDescriptionCompat { override fun getMediaDescription(progress:MediaProgressWrapper?, ctx:Context?): MediaDescriptionCompat {
val coverUri = getCoverUri() 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() val extras = Bundle()
if (progress != null) { if (progress != null) {
if (progress.isFinished) { if (progress.isFinished) {
@@ -122,12 +139,17 @@ class LocalLibraryItem(
) )
} }
return MediaDescriptionCompat.Builder() val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
.setMediaId(id) .setMediaId(id)
.setTitle(title) .setTitle(title)
.setIconUri(coverUri) .setIconUri(coverUri)
.setSubtitle(authorName) .setSubtitle(authorName)
.setExtras(extras) .setExtras(extras)
.build()
bitmap?.let {
mediaDescriptionBuilder.setIconBitmap(bitmap)
}
return mediaDescriptionBuilder.build()
} }
} }
@@ -1,8 +1,11 @@
package com.audiobookshelf.app.data package com.audiobookshelf.app.data
import android.content.Context
import android.graphics.ImageDecoder
import android.net.Uri import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.MediaMetadataCompat
import com.audiobookshelf.app.R
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.player.MediaProgressSyncData import com.audiobookshelf.app.player.MediaProgressSyncData
import com.fasterxml.jackson.annotation.JsonIgnore 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.cast.MediaQueueItem
import com.google.android.gms.common.images.WebImage import com.google.android.gms.common.images.WebImage
// TODO: enum or something in kotlin? // TODO: enum or something in kotlin?
val PLAYMETHOD_DIRECTPLAY = 0 val PLAYMETHOD_DIRECTPLAY = 0
val PLAYMETHOD_DIRECTSTREAM = 1 val PLAYMETHOD_DIRECTSTREAM = 1
@@ -53,6 +57,8 @@ class PlaybackSession(
@get:JsonIgnore @get:JsonIgnore
val isLocal get() = playMethod == PLAYMETHOD_LOCAL val isLocal get() = playMethod == PLAYMETHOD_LOCAL
@get:JsonIgnore @get:JsonIgnore
val isPodcastEpisode get() = mediaType == "podcast"
@get:JsonIgnore
val currentTimeMs get() = (currentTime * 1000L).toLong() val currentTimeMs get() = (currentTime * 1000L).toLong()
@get:JsonIgnore @get:JsonIgnore
val totalDurationMs get() = (getTotalDuration() * 1000L).toLong() val totalDurationMs get() = (getTotalDuration() * 1000L).toLong()
@@ -105,9 +111,9 @@ class PlaybackSession(
@JsonIgnore @JsonIgnore
fun getCoverUri(): Uri { 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}") return Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")
} }
@@ -118,13 +124,33 @@ class PlaybackSession(
} }
@JsonIgnore @JsonIgnore
fun getMediaMetadataCompat(): MediaMetadataCompat { fun getMediaMetadataCompat(ctx: Context): MediaMetadataCompat {
val metadataBuilder = MediaMetadataCompat.Builder() val metadataBuilder = MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, displayTitle) .putString(MediaMetadataCompat.METADATA_KEY_TITLE, displayTitle)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle) .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, displayAuthor) .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, displayAuthor)
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, 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_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() return metadataBuilder.build()
} }
@@ -136,6 +162,9 @@ class PlaybackSession(
.setArtist(displayAuthor) .setArtist(displayAuthor)
.setAlbumArtist(displayAuthor) .setAlbumArtist(displayAuthor)
.setSubtitle(displayAuthor) .setSubtitle(displayAuthor)
.setAlbumTitle(displayAuthor)
.setDescription(displayAuthor)
.setArtworkUri(getCoverUri())
val contentUri = this.getContentUri(audioTrack) val contentUri = this.getContentUri(audioTrack)
metadataBuilder.setMediaUri(contentUri) 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_TITLE, displayTitle ?: "")
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_ARTIST, displayAuthor ?: "") 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.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) castMetadata.putInt(com.google.android.gms.cast.MediaMetadata.KEY_TRACK_NUMBER, audioTrack.index)
return castMetadata 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 -> loadLibraryItem(libraryItemId) { libraryItemWrapper ->
Log.d(tag, "Loaded Podcast library item $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}") Log.d(tag, "Local Podcast Episode ${podcastEpisode.title} | ${podcastEpisode.id}")
val progress = DeviceManager.dbManager.getLocalMediaProgress("${libraryItemWrapper.id}-${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) MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
} }
children?.let { cb(children as MutableList) } ?: cb(mutableListOf()) 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 children = podcast.episodes?.map { podcastEpisode ->
val progress = serverUserMediaProgress.find { it.libraryItemId == libraryItemWrapper.id && it.episodeId == podcastEpisode.id } 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) MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
} }
children?.let { cb(children as MutableList) } ?: cb(mutableListOf()) 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) { fun loadAndroidAutoItems(cb: () -> Unit) {
Log.d(tag, "Load android auto items") Log.d(tag, "Load android auto items")
@@ -2,7 +2,10 @@ package com.audiobookshelf.app.player
import android.app.PendingIntent import android.app.PendingIntent
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.ImageDecoder
import android.net.Uri import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.support.v4.media.session.MediaControllerCompat import android.support.v4.media.session.MediaControllerCompat
import android.util.Log import android.util.Log
import com.audiobookshelf.app.R import com.audiobookshelf.app.R
@@ -42,6 +45,16 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
// `getCurrentLargeIcon` don't cause the bitmap to be recreated. // `getCurrentLargeIcon` don't cause the bitmap to be recreated.
currentIconUri = albumArtUri currentIconUri = albumArtUri
Log.d(tag, "ART $currentIconUri") Log.d(tag, "ART $currentIconUri")
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
} else {
serviceScope.launch { serviceScope.launch {
currentBitmap = albumArtUri?.let { currentBitmap = albumArtUri?.let {
resolveUriAsBitmap(it) resolveUriAsBitmap(it)
@@ -49,6 +62,8 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
currentBitmap?.let { callback.onBitmap(it) } currentBitmap?.let { callback.onBitmap(it) }
} }
null null
}
} else { } else {
currentBitmap currentBitmap
} }
@@ -115,7 +115,19 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
failedSyncs = 0 failedSyncs = 0
cb() 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) { fun syncFromServerProgress(mediaProgress: MediaProgress) {
@@ -181,6 +181,12 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> { KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
playerNotificationService.jumpBackward() playerNotificationService.jumpBackward()
} }
KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
playerNotificationService.jumpForward()
}
KeyEvent.KEYCODE_MEDIA_REWIND -> {
playerNotificationService.jumpBackward()
}
KeyEvent.KEYCODE_MEDIA_STOP -> { KeyEvent.KEYCODE_MEDIA_STOP -> {
playerNotificationService.closePlayback() playerNotificationService.closePlayback()
} }
@@ -52,6 +52,8 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
if (playerNotificationService.currentPlayer.playbackState == Player.STATE_ENDED) { if (playerNotificationService.currentPlayer.playbackState == Player.STATE_ENDED) {
Log.d(tag, "STATE_ENDED") Log.d(tag, "STATE_ENDED")
playerNotificationService.sendClientMetadata(PlayerState.ENDED) playerNotificationService.sendClientMetadata(PlayerState.ENDED)
playerNotificationService.handlePlaybackEnded()
} }
if (playerNotificationService.currentPlayer.playbackState == Player.STATE_IDLE) { if (playerNotificationService.currentPlayer.playbackState == Player.STATE_IDLE) {
Log.d(tag, "STATE_IDLE") Log.d(tag, "STATE_IDLE")
@@ -3,7 +3,9 @@ package com.audiobookshelf.app.player
import android.app.* import android.app.*
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Color import android.graphics.Color
import android.graphics.ImageDecoder
import android.hardware.Sensor import android.hardware.Sensor
import android.hardware.SensorManager import android.hardware.SensorManager
import android.net.ConnectivityManager import android.net.ConnectivityManager
@@ -11,8 +13,10 @@ import android.net.Network
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.net.NetworkRequest import android.net.NetworkRequest
import android.os.* import android.os.*
import android.provider.MediaStore
import android.support.v4.media.MediaBrowserCompat import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaControllerCompat import android.support.v4.media.session.MediaControllerCompat
import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat import android.support.v4.media.session.PlaybackStateCompat
@@ -250,6 +254,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX) playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX)
playerNotificationManager.setUseFastForwardActionInCompactView(true) playerNotificationManager.setUseFastForwardActionInCompactView(true)
playerNotificationManager.setUseRewindActionInCompactView(true) playerNotificationManager.setUseRewindActionInCompactView(true)
playerNotificationManager.setSmallIcon(R.drawable.exo_icon_localaudio)
// Unknown action // Unknown action
playerNotificationManager.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE) playerNotificationManager.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE)
@@ -270,6 +275,17 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
val coverUri = currentPlaybackSession!!.getCoverUri() 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 // Fix for local images crashing on Android 11 for specific devices
// https://stackoverflow.com/questions/64186578/android-11-mediastyle-notification-crash/64232958#64232958 // https://stackoverflow.com/questions/64186578/android-11-mediastyle-notification-crash/64232958#64232958
try { try {
@@ -282,11 +298,19 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
Log.e(tag, "Grant uri permission error $error") Log.e(tag, "Grant uri permission error $error")
} }
return MediaDescriptionCompat.Builder() val extra = Bundle()
.setMediaId(currentPlaybackSession!!.id) extra.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, currentPlaybackSession!!.displayAuthor)
val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
.setExtras(extra)
.setTitle(currentPlaybackSession!!.displayTitle) .setTitle(currentPlaybackSession!!.displayTitle)
.setSubtitle(currentPlaybackSession!!.displayAuthor) .setIconUri(coverUri)
.setIconUri(coverUri).build()
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 isClosed = false
val customActionProviders = mutableListOf( val customActionProviders = mutableListOf(
JumpBackwardCustomActionProvider(), JumpBackwardCustomActionProvider(),
JumpForwardCustomActionProvider(), JumpForwardCustomActionProvider(),
) )
val metadata = playbackSession.getMediaMetadataCompat() val metadata = playbackSession.getMediaMetadataCompat(ctx)
mediaSession.setMetadata(metadata) mediaSession.setMetadata(metadata)
val mediaItems = playbackSession.getMediaItems() val mediaItems = playbackSession.getMediaItems()
val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f
@@ -358,7 +390,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
customActionProviders.addAll(listOf( customActionProviders.addAll(listOf(
SkipBackwardCustomActionProvider(), SkipBackwardCustomActionProvider(),
SkipForwardCustomActionProvider(), SkipForwardCustomActionProvider(),
)); ))
} }
mediaSessionConnector.setCustomActionProviders(*customActionProviders.toTypedArray()); 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() { fun startNewPlaybackSession() {
currentPlaybackSession?.let { playbackSession -> currentPlaybackSession?.let { playbackSession ->
val forceTranscode = playbackSession.isHLS // If already HLS then force 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 // Streaming from server so check if playback session still exists on server
Log.d( Log.d(
tag, 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) { apiHandler.getPlaybackSession(playbackSession.id) {
if (it == null) { if (it == null) {
@@ -694,14 +756,23 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
fun seekPlayer(time: Long) { 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) { if (currentPlayer.mediaItemCount > 1) {
currentPlaybackSession?.currentTime = time / 1000.0 currentPlaybackSession?.currentTime = timeToSeek / 1000.0
val newWindowIndex = currentPlaybackSession?.getCurrentTrackIndex() ?: 0 val newWindowIndex = currentPlaybackSession?.getCurrentTrackIndex() ?: 0
val newTimeOffset = currentPlaybackSession?.getCurrentTrackTimeMs() ?: 0 val newTimeOffset = currentPlaybackSession?.getCurrentTrackTimeMs() ?: 0
currentPlayer.seekTo(newWindowIndex, newTimeOffset) currentPlayer.seekTo(newWindowIndex, newTimeOffset)
} else { } else {
currentPlayer.seekTo(time) currentPlayer.seekTo(timeToSeek)
} }
} }
@@ -856,7 +927,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
if (parentMediaId.startsWith("li_") || parentMediaId.startsWith("local_")) { // Show podcast episodes if (parentMediaId.startsWith("li_") || parentMediaId.startsWith("local_")) { // Show podcast episodes
Log.d(tag, "Loading podcast episodes") Log.d(tag, "Loading podcast episodes")
mediaManager.loadPodcastEpisodeMediaBrowserItems(parentMediaId) { mediaManager.loadPodcastEpisodeMediaBrowserItems(parentMediaId, ctx) {
result.sendResult(it) result.sendResult(it)
} }
} else if (::browseTree.isInitialized && browseTree[parentMediaId] == null && mediaManager.getIsLibrary(parentMediaId)) { // Load library items for library } 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 -> mediaManager.loadLibraryItemsWithAudio(parentMediaId) { libraryItems ->
val children = libraryItems.map { libraryItem -> val children = libraryItems.map { libraryItem ->
if (libraryItem.mediaType == "podcast") { // Podcasts are browseable 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) MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
} else { } else {
val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.id } 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) MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
} }
} }
@@ -882,13 +953,13 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
localBooks.forEach { localLibraryItem -> localBooks.forEach { localLibraryItem ->
val progress = DeviceManager.dbManager.getLocalMediaProgress(localLibraryItem.id) 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) localBrowseItems += MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
} }
localPodcasts.forEach { localLibraryItem -> localPodcasts.forEach { localLibraryItem ->
val mediaDescription = localLibraryItem.getMediaDescription(null) val mediaDescription = localLibraryItem.getMediaDescription(null, ctx)
localBrowseItems += MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) localBrowseItems += MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
} }
@@ -905,14 +976,14 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} else { } else {
progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == itemInProgress.libraryItemWrapper.id && it.episodeId == itemInProgress.episode.id } 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 { } else {
if (itemInProgress.isLocal) { if (itemInProgress.isLocal) {
progress = DeviceManager.dbManager.getLocalMediaProgress(itemInProgress.libraryItemWrapper.id) progress = DeviceManager.dbManager.getLocalMediaProgress(itemInProgress.libraryItemWrapper.id)
} else { } else {
progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == itemInProgress.libraryItemWrapper.id } 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) localBrowseItems += MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
} }
@@ -12,7 +12,6 @@ import com.getcapacitor.annotation.CapacitorPlugin
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.json.JSONObject
@CapacitorPlugin(name = "AbsDatabase") @CapacitorPlugin(name = "AbsDatabase")
class AbsDatabase : Plugin() { class AbsDatabase : Plugin() {
@@ -138,12 +138,16 @@ class ApiHandler(var ctx:Context) {
val mapper = jacksonMapper val mapper = jacksonMapper
getRequest("/api/libraries", null,null) { getRequest("/api/libraries", null,null) {
val libraries = mutableListOf<Library>() val libraries = mutableListOf<Library>()
if (it.has("value")) {
val array = it.getJSONArray("value") var array = JSONArray()
for (i in 0 until array.length()) { if (it.has("libraries")) { // TODO: Server 2.2.9 changed to this
val library = mapper.readValue<Library>(array.get(i).toString()) array = it.getJSONArray("libraries")
libraries.add(library) } 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) cb(libraries)
} }
@@ -317,6 +321,7 @@ class ApiHandler(var ctx:Context) {
} }
fun getPlaybackSession(playbackSessionId:String, cb: (PlaybackSession?) -> Unit) { fun getPlaybackSession(playbackSessionId:String, cb: (PlaybackSession?) -> Unit) {
Log.d(tag, "getPlaybackSession for $playbackSessionId for server ${DeviceManager.serverAddress}")
val endpoint = "/api/session/$playbackSessionId" val endpoint = "/api/session/$playbackSessionId"
getRequest(endpoint, null, null) { getRequest(endpoint, null, null) {
val err = it.getString("error") val err = it.getString("error")
+4
View File
@@ -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"> <widget version="1.0.0" xmlns="http://www.w3.org/ns/widgets" xmlns:cdv="http://cordova.apache.org/ns/1.0">
<access origin="*" /> <access origin="*" />
<feature name="CDVOrientation">
<param name="android-package" value="cordova.plugins.screenorientation.CDVOrientation"/>
</feature>
</widget> </widget>
+1 -1
View File
@@ -1,7 +1,7 @@
ext { ext {
minSdkVersion = 24 minSdkVersion = 24
compileSdkVersion = 31 compileSdkVersion = 31
targetSdkVersion = 30 targetSdkVersion = 31
androidxActivityVersion = '1.2.0' androidxActivityVersion = '1.2.0'
androidxAppCompatVersion = '1.4.1' androidxAppCompatVersion = '1.4.1'
androidxCoordinatorLayoutVersion = '1.2.0' androidxCoordinatorLayoutVersion = '1.2.0'
+16 -1
View File
@@ -13,7 +13,6 @@
.abs-icons { .abs-icons {
/* use !important to prevent issues with browser extensions that change fonts */ /* use !important to prevent issues with browser extensions that change fonts */
font-family: 'absicons' !important; font-family: 'absicons' !important;
speak: never;
font-style: normal; font-style: normal;
font-weight: normal; font-weight: normal;
font-variant: normal; font-variant: normal;
@@ -25,6 +24,18 @@
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
.icon-list:before {
content: "\e907";
}
.icon-home:before {
content: "\e909";
}
.icon-authors:before {
content: "\e90a";
}
.icon-books-1:before { .icon-books-1:before {
content: "\e905"; content: "\e905";
} }
@@ -53,6 +64,10 @@
content: "\e901"; content: "\e901";
} }
.icon-columns:before {
content: "\e90b";
}
.icon-headphones:before { .icon-headphones:before {
content: "\e910"; content: "\e910";
} }
+4 -4
View File
@@ -21,12 +21,12 @@
<widgets-download-progress-indicator /> <widgets-download-progress-indicator />
<!-- Must be connected to a server to cast, only supports media items on server --> <!-- 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"> <div v-show="isCastAvailable && user" class="mx-2 cursor-pointer flex items-center pt-0.5" @click="castClick">
<span class="material-icons text-2xl" :class="isCasting ? 'text-success' : ''" @click="castClick">cast</span> <span class="material-icons" :class="isCasting ? 'text-success' : ''">cast</span>
</div> </div>
<nuxt-link v-if="user" class="h-7 mx-1.5" to="/search"> <nuxt-link v-if="user" class="h-7 mx-1.5" style="padding-top: 3px" to="/search">
<span class="material-icons" style="font-size: 1.75rem">search</span> <span class="material-icons">search</span>
</nuxt-link> </nuxt-link>
<div class="h-7 mx-1.5"> <div class="h-7 mx-1.5">
+63 -43
View File
@@ -1,16 +1,16 @@
<template> <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="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 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> <span class="material-icons text-5xl" @click="collapseFullscreen">expand_more</span>
</div> </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> <span class="material-icons text-3xl" :class="isCasting ? 'text-success' : ''" @click="castClick">cast</span>
</div> </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> <span class="material-icons text-3xl" @click="showMoreMenuDialog = true">more_vert</span>
</div> </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>
<div v-if="useChapterTrack && showFullscreen" class="absolute total-track w-full px-3 z-30"> <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 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 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"> <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 --> <!-- 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> <span v-else class="material-icons text-3xl text-white text-opacity-0">bookmark</span>
@@ -75,13 +75,13 @@
</div> </div>
</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="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="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="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="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="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>
<div id="timestamp-row" class="flex pt-0.5"> <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> <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: () => [] default: () => []
}, },
sleepTimerRunning: Boolean, sleepTimerRunning: Boolean,
sleepTimeRemaining: Number sleepTimeRemaining: Number,
isServerItem: Boolean
}, },
data() { data() {
return { return {
@@ -209,7 +210,7 @@ export default {
}, },
bookCoverWidth() { bookCoverWidth() {
if (this.showFullscreen) return this.fullscreenBookCoverWidth if (this.showFullscreen) return this.fullscreenBookCoverWidth
return 60 return 46 / this.bookCoverAspectRatio
}, },
fullscreenBookCoverWidth() { fullscreenBookCoverWidth() {
var heightScale = (this.windowHeight - 200) / 651 var heightScale = (this.windowHeight - 200) / 651
@@ -324,6 +325,9 @@ export default {
} else { } else {
return secondsRemaining + 's' return secondsRemaining + 's'
} }
},
networkConnected() {
return this.$store.state.networkConnected
} }
}, },
methods: { methods: {
@@ -335,15 +339,18 @@ export default {
this.showFullscreen = false this.showFullscreen = false
} }
}, },
touchstartTrack(e) { async touchstartTrack(e) {
await this.$hapticsImpactMedium()
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen || this.lockUi) return if (!e || !e.touches || !this.$refs.track || !this.showFullscreen || this.lockUi) return
this.touchTrackStart = true this.touchTrackStart = true
}, },
selectChapter(chapter) { async selectChapter(chapter) {
await this.$hapticsImpactMedium()
this.seek(chapter.start) this.seek(chapter.start)
this.showChapterModal = false this.showChapterModal = false
}, },
castClick() { async castClick() {
await this.$hapticsImpactMedium()
if (this.isLocalPlayMethod) { if (this.isLocalPlayMethod) {
this.$eventBus.$emit('cast-local-item') this.$eventBus.$emit('cast-local-item')
return return
@@ -362,12 +369,14 @@ export default {
this.showFullscreen = false this.showFullscreen = false
this.forceCloseDropdownMenu() this.forceCloseDropdownMenu()
}, },
jumpNextChapter() { async jumpNextChapter() {
await this.$hapticsImpactMedium()
if (this.isLoading) return if (this.isLoading) return
if (!this.nextChapter) return if (!this.nextChapter) return
this.seek(this.nextChapter.start) this.seek(this.nextChapter.start)
}, },
jumpChapterStart() { async jumpChapterStart() {
await this.$hapticsImpactMedium()
if (this.isLoading) return if (this.isLoading) return
if (!this.currentChapter) { if (!this.currentChapter) {
return this.restart() return this.restart()
@@ -375,9 +384,9 @@ export default {
// If 4 seconds or less into current chapter, then go to previous // If 4 seconds or less into current chapter, then go to previous
if (this.currentTime - this.currentChapter.start <= 4) { 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) { if (currChapterIndex > 0) {
var prevChapter = this.chapters[currChapterIndex - 1] const prevChapter = this.chapters[currChapterIndex - 1]
this.seek(prevChapter.start) this.seek(prevChapter.start)
} }
} else { } else {
@@ -387,7 +396,8 @@ export default {
showSleepTimerModal() { showSleepTimerModal() {
this.$emit('showSleepTimer') this.$emit('showSleepTimer')
}, },
setPlaybackSpeed(speed) { async setPlaybackSpeed(speed) {
await this.$hapticsImpactMedium()
console.log(`[AudioPlayer] Set Playback Rate: ${speed}`) console.log(`[AudioPlayer] Set Playback Rate: ${speed}`)
this.currentPlaybackRate = speed this.currentPlaybackRate = speed
AbsAudioPlayer.setPlaybackSpeed({ value: speed }) AbsAudioPlayer.setPlaybackSpeed({ value: speed })
@@ -395,11 +405,13 @@ export default {
restart() { restart() {
this.seek(0) this.seek(0)
}, },
jumpBackwards() { async jumpBackwards() {
await this.$hapticsImpactMedium()
if (this.isLoading) return if (this.isLoading) return
AbsAudioPlayer.seekBackward({ value: this.jumpBackwardsTime }) AbsAudioPlayer.seekBackward({ value: this.jumpBackwardsTime })
}, },
jumpForward() { async jumpForward() {
await this.$hapticsImpactMedium()
if (this.isLoading) return if (this.isLoading) return
AbsAudioPlayer.seekForward({ value: this.jumpForwardTime }) AbsAudioPlayer.seekForward({ value: this.jumpForwardTime })
}, },
@@ -408,19 +420,19 @@ export default {
this.updateReadyTrack() this.updateReadyTrack()
}, },
setChunksReady(chunks, numSegments) { setChunksReady(chunks, numSegments) {
var largestSeg = 0 let largestSeg = 0
for (let i = 0; i < chunks.length; i++) { for (let i = 0; i < chunks.length; i++) {
var chunk = chunks[i] const chunk = chunks[i]
if (typeof chunk === 'string') { 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.length < 2) continue
if (chunkRange[1] > largestSeg) largestSeg = chunkRange[1] if (chunkRange[1] > largestSeg) largestSeg = chunkRange[1]
} else if (chunk > largestSeg) { } else if (chunk > largestSeg) {
largestSeg = chunk largestSeg = chunk
} }
} }
var percentageReady = largestSeg / numSegments const percentageReady = largestSeg / numSegments
var widthReady = Math.round(this.trackWidth * percentageReady) const widthReady = Math.round(this.trackWidth * percentageReady)
if (this.readyTrackWidth === widthReady) { if (this.readyTrackWidth === widthReady) {
return return
} }
@@ -472,17 +484,17 @@ export default {
}, },
updateTrack() { updateTrack() {
// Update progress track UI // Update progress track UI
var percentDone = this.currentTime / this.totalDuration let percentDone = this.currentTime / this.totalDuration
var totalPercentDone = percentDone const totalPercentDone = percentDone
var bufferedPercent = this.bufferedTime / this.totalDuration let bufferedPercent = this.bufferedTime / this.totalDuration
var totalBufferedPercent = bufferedPercent const totalBufferedPercent = bufferedPercent
if (this.useChapterTrack && this.currentChapter) { if (this.useChapterTrack && this.currentChapter) {
var currChapTime = this.currentTime - this.currentChapter.start const currChapTime = this.currentTime - this.currentChapter.start
percentDone = currChapTime / this.currentChapterDuration percentDone = currChapTime / this.currentChapterDuration
bufferedPercent = Math.max(0, Math.min(1, (this.bufferedTime - this.currentChapter.start) / 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.playedTrack.style.width = ptWidth + 'px'
this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px' this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px'
@@ -539,6 +551,7 @@ export default {
this.seek(time) this.seek(time)
}, },
async playPauseClick() { async playPauseClick() {
await this.$hapticsImpactMedium()
if (this.isLoading) return if (this.isLoading) return
this.isPlaying = !!((await AbsAudioPlayer.playPause()) || {}).playing this.isPlaying = !!((await AbsAudioPlayer.playPause()) || {}).playing
@@ -641,7 +654,8 @@ export default {
ts.innerText = currTimeStr ts.innerText = currTimeStr
} }
}, },
clickMenuAction(action) { async clickMenuAction(action) {
await this.$hapticsImpactMedium()
this.showMoreMenuDialog = false this.showMoreMenuDialog = false
this.$nextTick(() => { this.$nextTick(() => {
if (action === 'lock') { if (action === 'lock') {
@@ -750,16 +764,22 @@ export default {
this.onProgressSyncSuccess = AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess) this.onProgressSyncSuccess = AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess)
}, },
screenOrientationChange() { screenOrientationChange() {
setTimeout(this.updateScreenSize, 50) setTimeout(() => {
this.updateScreenSize()
if (this.$refs.track) {
this.trackWidth = this.$refs.track.clientWidth
this.updateTrack()
this.updateReadyTrack()
}
}, 50)
}, },
updateScreenSize() { updateScreenSize() {
this.windowHeight = window.innerHeight 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-width', this.fullscreenBookCoverWidth + 'px')
document.documentElement.style.setProperty('--cover-image-height', coverHeight + 'px') document.documentElement.style.setProperty('--cover-image-height', coverHeight + 'px')
}, },
minimizePlayerEvt() { minimizePlayerEvt() {
console.log('Minimize Player Evt')
this.showFullscreen = false this.showFullscreen = false
}, },
showProgressSyncIsFailing() { showProgressSyncIsFailing() {
@@ -821,8 +841,8 @@ export default {
:root { :root {
--cover-image-width: 0px; --cover-image-width: 0px;
--cover-image-height: 0px; --cover-image-height: 0px;
--cover-image-width-collapsed: 60px; --cover-image-width-collapsed: 46px;
--cover-image-height-collapsed: 60px; --cover-image-height-collapsed: 46px;
} }
.bookCoverWrapper { .bookCoverWrapper {
box-shadow: 3px -2px 5px #00000066; box-shadow: 3px -2px 5px #00000066;
@@ -840,10 +860,10 @@ export default {
#playerTrack { #playerTrack {
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1); transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
transition-property: margin; transition-property: margin;
margin-bottom: 4px; bottom: 10px;
} }
.fullscreen #playerTrack { .fullscreen #playerTrack {
margin-bottom: 18px; bottom: 32px;
} }
.ios-player #timestamp-row { .ios-player #timestamp-row {
@@ -852,7 +872,7 @@ export default {
} }
.cover-wrapper { .cover-wrapper {
bottom: 44px; bottom: 48px;
left: 12px; left: 12px;
height: var(--cover-image-height-collapsed); height: var(--cover-image-height-collapsed);
width: var(--cover-image-width-collapsed); width: var(--cover-image-width-collapsed);
@@ -873,8 +893,8 @@ export default {
transform-origin: left bottom; transform-origin: left bottom;
width: 40%; width: 40%;
bottom: 50px; bottom: 56px;
left: 80px; left: 70px;
text-align: left; text-align: left;
} }
.title-author-texts .title-text { .title-author-texts .title-text {
@@ -912,7 +932,7 @@ export default {
width: 140px; width: 140px;
padding-left: 12px; padding-left: 12px;
padding-right: 12px; padding-right: 12px;
bottom: 45px; bottom: 50px;
} }
#playerControls .jump-icon { #playerControls .jump-icon {
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1); transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
@@ -949,7 +969,7 @@ export default {
.fullscreen #playerControls { .fullscreen #playerControls {
width: 100%; width: 100%;
bottom: 100px; bottom: 105px;
} }
.fullscreen #playerControls .jump-icon { .fullscreen #playerControls .jump-icon {
margin: 0px 18px; margin: 0px 18px;
+7 -39
View File
@@ -1,6 +1,6 @@
<template> <template>
<div> <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-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" /> <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 serverEpisodeId: null
} }
}, },
watch: {
socketConnected(newVal) {
if (newVal) {
console.log('Socket Connected set listeners')
this.setListeners()
}
}
},
computed: { computed: {
bookmarks() { bookmarks() {
if (!this.serverLibraryItemId) return [] if (!this.serverLibraryItemId) return []
return this.$store.getters['user/getUserBookmarksForItem'](this.serverLibraryItemId) return this.$store.getters['user/getUserBookmarksForItem'](this.serverLibraryItemId)
},
socketConnected() {
return this.$store.state.socketConnected
} }
}, },
methods: { methods: {
@@ -62,7 +51,7 @@ export default {
selectBookmark(bookmark) { selectBookmark(bookmark) {
this.showBookmarksModal = false this.showBookmarksModal = false
if (!bookmark || isNaN(bookmark.time)) return if (!bookmark || isNaN(bookmark.time)) return
var bookmarkTime = Number(bookmark.time) const bookmarkTime = Number(bookmark.time)
if (this.$refs.audioPlayer) { if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.seek(bookmarkTime) this.$refs.audioPlayer.seek(bookmarkTime)
} }
@@ -71,8 +60,6 @@ export default {
this.isSleepTimerRunning = false this.isSleepTimerRunning = false
if (currentPosition) { if (currentPosition) {
console.log('Sleep Timer Ended Current Position: ' + 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 }) { onSleepTimerSet({ value: sleepTimeRemaining }) {
@@ -160,17 +147,6 @@ export default {
this.notifyOnReady() 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() { closeStreamOnly() {
// If user logs out or disconnects from server and not playing local // If user logs out or disconnects from server and not playing local
if (this.$refs.audioPlayer && !this.$refs.audioPlayer.isLocalPlayMethod) { if (this.$refs.audioPlayer && !this.$refs.audioPlayer.isLocalPlayMethod) {
@@ -206,13 +182,13 @@ export default {
}) })
}, },
async playLibraryItem(payload) { async playLibraryItem(payload) {
var libraryItemId = payload.libraryItemId const libraryItemId = payload.libraryItemId
var episodeId = payload.episodeId const episodeId = payload.episodeId
// When playing local library item and can also play this item from the server // 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 // then store the server library item id so it can be used if a cast is made
var serverLibraryItemId = payload.serverLibraryItemId || null const serverLibraryItemId = payload.serverLibraryItemId || null
var serverEpisodeId = payload.serverEpisodeId || null const serverEpisodeId = payload.serverEpisodeId || null
if (libraryItemId.startsWith('local') && this.$store.state.isCasting) { if (libraryItemId.startsWith('local') && this.$store.state.isCasting) {
const { value } = await Dialog.confirm({ const { value } = await Dialog.confirm({
@@ -227,7 +203,7 @@ export default {
this.serverLibraryItemId = null this.serverLibraryItemId = null
this.serverEpisodeId = null this.serverEpisodeId = null
var playbackRate = 1 let playbackRate = 1
if (this.$refs.audioPlayer) { if (this.$refs.audioPlayer) {
playbackRate = this.$refs.audioPlayer.currentPlaybackRate || 1 playbackRate = this.$refs.audioPlayer.currentPlaybackRate || 1
} }
@@ -292,7 +268,6 @@ export default {
this.playbackSpeed = this.$store.getters['user/getUserSetting']('playbackRate') this.playbackSpeed = this.$store.getters['user/getUserSetting']('playbackRate')
console.log(`[AudioPlayerContainer] Init Playback Speed: ${this.playbackSpeed}`) console.log(`[AudioPlayerContainer] Init Playback Speed: ${this.playbackSpeed}`)
this.setListeners()
this.$eventBus.$on('abs-ui-ready', this.onReady) this.$eventBus.$on('abs-ui-ready', this.onReady)
this.$eventBus.$on('play-item', this.playLibraryItem) this.$eventBus.$on('play-item', this.playLibraryItem)
this.$eventBus.$on('pause-item', this.pauseItem) this.$eventBus.$on('pause-item', this.pauseItem)
@@ -306,13 +281,6 @@ export default {
if (this.onSleepTimerSetListener) this.onSleepTimerSetListener.remove() if (this.onSleepTimerSetListener) this.onSleepTimerSetListener.remove()
if (this.onMediaPlayerChangedListener) this.onMediaPlayerChangedListener.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('abs-ui-ready', this.onReady)
this.$eventBus.$off('play-item', this.playLibraryItem) this.$eventBus.$off('play-item', this.playLibraryItem)
this.$eventBus.$off('pause-item', this.pauseItem) this.$eventBus.$off('pause-item', this.pauseItem)
+1
View File
@@ -137,6 +137,7 @@ export default {
this.show = false this.show = false
}, },
async logout() { async logout() {
await this.$hapticsImpactMedium()
if (this.user) { if (this.user) {
await this.$axios.$post('/logout').catch((error) => { await this.$axios.$post('/logout').catch((error) => {
console.error(error) console.error(error)
+11 -13
View File
@@ -95,16 +95,16 @@ export default {
var coverSize = 100 var coverSize = 100
if (window.innerWidth <= 375) coverSize = 90 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 return coverSize
}, },
bookHeight() { bookHeight() {
if (this.isCoverSquareAspectRatio) return this.bookWidth if (this.isCoverSquareAspectRatio || this.entityName === 'playlists') return this.bookWidth
return this.bookWidth * 1.6 return this.bookWidth * 1.6
}, },
entityWidth() { entityWidth() {
if (this.showBookshelfListView) return this.bookshelfWidth - 16 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 return this.bookWidth * 2
}, },
entityHeight() { entityHeight() {
@@ -134,7 +134,7 @@ export default {
return this.$store.getters['getAltViewEnabled'] return this.$store.getters['getAltViewEnabled']
}, },
sizeMultiplier() { sizeMultiplier() {
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120 const baseSize = this.isCoverSquareAspectRatio ? 192 : 120
return this.entityWidth / baseSize return this.entityWidth / baseSize
} }
}, },
@@ -145,7 +145,7 @@ export default {
}) })
}, },
async fetchEntities(page) { async fetchEntities(page) {
var startIndex = page * this.booksPerFetch const startIndex = page * this.booksPerFetch
this.isFetchingEntities = true this.isFetchingEntities = true
@@ -153,11 +153,11 @@ export default {
this.currentSFQueryString = this.buildSearchParams() this.currentSFQueryString = this.buildSearchParams()
} }
var entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `items` : this.entityName const entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `items` : this.entityName
var sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : '' const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
var fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1` 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) console.error('failed to fetch books', error)
return null return null
}) })
@@ -179,13 +179,13 @@ export default {
} }
for (let i = 0; i < payload.results.length; i++) { for (let i = 0; i < payload.results.length; i++) {
var index = i + startIndex const index = i + startIndex
this.entities[index] = payload.results[i] this.entities[index] = payload.results[i]
if (this.entityComponentRefs[index]) { if (this.entityComponentRefs[index]) {
this.entityComponentRefs[index].setEntity(this.entities[index]) this.entityComponentRefs[index].setEntity(this.entities[index])
if (this.isBookEntity) { 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) { if (localLibraryItem) {
this.entityComponentRefs[index].setLocalLibraryItem(localLibraryItem) this.entityComponentRefs[index].setLocalLibraryItem(localLibraryItem)
} }
@@ -356,8 +356,6 @@ export default {
let searchParams = new URLSearchParams() let searchParams = new URLSearchParams()
if (this.page === 'series-books') { if (this.page === 'series-books') {
searchParams.set('filter', `series.${this.$encode(this.seriesId)}`) searchParams.set('filter', `series.${this.$encode(this.seriesId)}`)
searchParams.set('sort', 'book.volumeNumber')
searchParams.set('desc', 0)
} else { } else {
if (this.filterBy && this.filterBy !== 'all') { if (this.filterBy && this.filterBy !== 'all') {
searchParams.set('filter', this.filterBy) searchParams.set('filter', this.filterBy)
+2 -4
View File
@@ -319,9 +319,6 @@ export default {
userIsRoot() { userIsRoot() {
return this.store.getters['user/getIsRoot'] return this.store.getters['user/getIsRoot']
}, },
_socket() {
return this.$root.socket || this.$nuxt.$root.socket
},
titleFontSize() { titleFontSize() {
return 0.75 * this.sizeMultiplier return 0.75 * this.sizeMultiplier
}, },
@@ -404,12 +401,13 @@ export default {
// Server books may have a local library item // Server books may have a local library item
this.localLibraryItem = localLibraryItem this.localLibraryItem = localLibraryItem
}, },
clickCard(e) { async clickCard(e) {
if (this.isSelectionMode) { if (this.isSelectionMode) {
e.stopPropagation() e.stopPropagation()
e.preventDefault() e.preventDefault()
this.selectBtnClick() this.selectBtnClick()
} else if (this.recentEpisode) { } else if (this.recentEpisode) {
await this.$hapticsImpactMedium()
var eventBus = this.$eventBus || this.$nuxt.$eventBus var eventBus = this.$eventBus || this.$nuxt.$eventBus
if (this.streamIsPlaying) { if (this.streamIsPlaying) {
eventBus.$emit('pause-item') eventBus.$emit('pause-item')
-3
View File
@@ -63,9 +63,6 @@ export default {
var router = this.$router || this.$nuxt.$router var router = this.$router || this.$nuxt.$router
router.push(`/collection/${this.collection.id}`) router.push(`/collection/${this.collection.id}`)
}, },
clickEdit() {
this.$emit('edit', this.collection)
},
destroy() { destroy() {
// destroy the vue listeners, etc // destroy the vue listeners, etc
this.$destroy() this.$destroy()
-3
View File
@@ -278,9 +278,6 @@ export default {
userIsRoot() { userIsRoot() {
return this.store.getters['user/getIsRoot'] return this.store.getters['user/getIsRoot']
}, },
_socket() {
return this.$root.socket || this.$nuxt.$root.socket
},
titleFontSize() { titleFontSize() {
return 0.75 * this.sizeMultiplier return 0.75 * this.sizeMultiplier
}, },
+86
View File
@@ -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>
+4 -1
View File
@@ -132,6 +132,7 @@ export default {
} }
}, },
async connectToServer(config) { async connectToServer(config) {
await this.$hapticsImpactMedium()
console.log('[ServerConnectForm] connectToServer', config.address) console.log('[ServerConnectForm] connectToServer', config.address)
this.processing = true this.processing = true
this.serverConfig = { this.serverConfig = {
@@ -159,6 +160,7 @@ export default {
}, },
async removeServerConfigClick() { async removeServerConfigClick() {
if (!this.serverConfig.id) return if (!this.serverConfig.id) return
await this.$hapticsImpactMedium()
const { value } = await Dialog.confirm({ const { value } = await Dialog.confirm({
title: 'Confirm', title: 'Confirm',
@@ -189,7 +191,8 @@ export default {
this.showForm = true this.showForm = true
this.showAuth = true this.showAuth = true
}, },
newServerConfigClick() { async newServerConfigClick() {
await this.$hapticsImpactMedium()
this.serverConfig = { this.serverConfig = {
address: '', address: '',
userId: '', userId: '',
+3 -2
View File
@@ -47,7 +47,8 @@ export default {
default: 120 default: 120
}, },
bookCoverAspectRatio: Number, bookCoverAspectRatio: Number,
downloadCover: String downloadCover: String,
raw: Boolean
}, },
data() { data() {
return { return {
@@ -115,7 +116,7 @@ export default {
if (this.downloadCover) return this.downloadCover if (this.downloadCover) return this.downloadCover
if (!this.libraryItem) return null if (!this.libraryItem) return null
var store = this.$store || this.$nuxt.$store 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() { cover() {
return this.media.coverPath || this.placeholderUrl return this.media.coverPath || this.placeholderUrl
+51
View File
@@ -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>
+182
View File
@@ -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>
+80 -5
View File
@@ -1,8 +1,10 @@
<template> <template>
<div class="w-full h-9 bg-bg relative"> <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"> <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'"> <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>{{ item.text }}</p> <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> </nuxt-link>
</div> </div>
</div> </div>
@@ -14,43 +16,114 @@ export default {
return {} return {}
}, },
computed: { 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() { items() {
let items = []
if (this.isPodcast) { if (this.isPodcast) {
return [ items = [
{ {
to: '/bookshelf', to: '/bookshelf',
routeName: 'bookshelf', routeName: 'bookshelf',
iconPack: 'abs-icons',
icon: 'home',
iconClass: 'text-xl',
text: 'Home' text: 'Home'
}, },
{
to: '/bookshelf/latest',
routeName: 'bookshelf-latest',
iconPack: 'abs-icons',
icon: 'list',
iconClass: 'text-xl',
text: 'Latest'
},
{ {
to: '/bookshelf/library', to: '/bookshelf/library',
routeName: 'bookshelf-library', routeName: 'bookshelf-library',
iconPack: 'abs-icons',
icon: this.currentLibraryIcon,
iconClass: 'text-lg',
text: 'Library' text: 'Library'
} }
] ]
if (this.userIsAdminOrUp) {
items.push({
to: '/bookshelf/search',
routeName: 'bookshelf-search',
iconPack: 'material-icons',
icon: 'podcasts',
iconClass: 'text-xl',
text: 'Search'
})
} }
return [ } else {
items = [
{ {
to: '/bookshelf', to: '/bookshelf',
routeName: 'bookshelf', routeName: 'bookshelf',
iconPack: 'abs-icons',
icon: 'home',
iconClass: 'text-xl',
text: 'Home' text: 'Home'
}, },
{ {
to: '/bookshelf/library', to: '/bookshelf/library',
routeName: 'bookshelf-library', routeName: 'bookshelf-library',
iconPack: 'abs-icons',
icon: this.currentLibraryIcon,
iconClass: 'text-lg',
text: 'Library' text: 'Library'
}, },
{ {
to: '/bookshelf/series', to: '/bookshelf/series',
routeName: 'bookshelf-series', routeName: 'bookshelf-series',
iconPack: 'abs-icons',
icon: 'columns',
iconClass: 'text-lg pt-px',
text: 'Series' text: 'Series'
}, },
{ {
to: '/bookshelf/collections', to: '/bookshelf/collections',
routeName: 'bookshelf-collections', routeName: 'bookshelf-collections',
iconPack: 'material-icons-outlined',
icon: 'collections_bookmark',
iconClass: 'text-xl',
text: 'Collections' text: 'Collections'
} }
// {
// to: '/bookshelf/authors',
// routeName: 'bookshelf-authors',
// iconPack: 'abs-icons',
// icon: 'authors',
// iconClass: 'text-2xl pb-px',
// text: 'Authors'
// }
] ]
}
if (this.userHasPlaylists) {
items.push({
to: '/bookshelf/playlists',
routeName: 'bookshelf-playlists',
iconPack: 'material-icons',
icon: 'queue_music',
text: 'Playlists'
})
}
return items
}, },
routeName() { routeName() {
return this.$route.name return this.$route.name
@@ -62,7 +135,9 @@ export default {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] return this.$store.getters['libraries/getCurrentLibraryMediaType']
} }
}, },
methods: {}, methods: {
isSelected(item) {}
},
mounted() {} mounted() {}
} }
</script> </script>
+7 -1
View File
@@ -8,7 +8,7 @@
<p v-show="!selectedSeriesName" class="font-book pt-1">{{ totalEntities }} {{ entityTitle }}</p> <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> <p v-show="selectedSeriesName" class="ml-2 font-book pt-1">{{ selectedSeriesName }} ({{ totalEntities }})</p>
<div class="flex-grow" /> <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'"> <template v-if="page === 'library'">
<div class="relative flex items-center px-2"> <div class="relative flex items-center px-2">
<span class="material-icons" @click="showFilterModal = true">filter_alt</span> <span class="material-icons" @click="showFilterModal = true">filter_alt</span>
@@ -64,6 +64,8 @@ export default {
return 'Series' return 'Series'
} else if (this.page === 'collections') { } else if (this.page === 'collections') {
return 'Collections' return 'Collections'
} else if (this.page === 'playlists') {
return 'Playlists'
} }
return '' return ''
}, },
@@ -100,6 +102,10 @@ export default {
}, },
setTotalEntities(total) { setTotalEntities(total) {
this.totalEntities = total this.totalEntities = total
},
async changeView() {
this.bookshelfListView = !this.bookshelfListView
await this.$hapticsImpactMedium()
} }
}, },
mounted() { mounted() {
+6 -2
View File
@@ -46,6 +46,7 @@
<script> <script>
import { Dialog } from '@capacitor/dialog' import { Dialog } from '@capacitor/dialog'
export default { export default {
props: { props: {
value: Boolean, value: Boolean,
@@ -94,6 +95,7 @@ export default {
this.showBookmarkTitleInput = true this.showBookmarkTitleInput = true
}, },
async deleteBookmark(bm) { async deleteBookmark(bm) {
await this.$hapticsImpactMedium()
const { value } = await Dialog.confirm({ const { value } = await Dialog.confirm({
title: 'Remove Bookmark', title: 'Remove Bookmark',
message: `Are you sure you want to remove bookmark?` message: `Are you sure you want to remove bookmark?`
@@ -111,7 +113,8 @@ export default {
}) })
this.show = false this.show = false
}, },
clickBookmark(bm) { async clickBookmark(bm) {
await this.$hapticsImpactMedium()
this.$emit('select', bm) this.$emit('select', bm)
}, },
submitUpdateBookmark(updatedBookmark) { submitUpdateBookmark(updatedBookmark) {
@@ -155,7 +158,8 @@ export default {
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm') this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
this.showBookmarkTitleInput = true this.showBookmarkTitleInput = true
}, },
submitBookmark() { async submitBookmark() {
await this.$hapticsImpactMedium()
if (this.selectedBookmark) { if (this.selectedBookmark) {
var updatePayload = { var updatePayload = {
...this.selectedBookmark, ...this.selectedBookmark,
+3 -3
View File
@@ -1,5 +1,5 @@
<template> <template>
<modals-modal v-model="show" :width="300" height="100%"> <modals-modal v-model="show" :width="350" height="100%">
<template #outer> <template #outer>
<div v-if="currentChapter" class="absolute top-7 left-4 z-40" style="max-width: 80%"> <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> <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"> <ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="chapter in chapters"> <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)"> <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> <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"> <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>
</div> </div>
+22 -4
View File
@@ -3,7 +3,6 @@
<template #outer> <template #outer>
<div v-show="selected !== 'all'" class="absolute top-4 left-4 z-40"> <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> <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> </div>
</template> </template>
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false"> <div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
@@ -174,7 +173,24 @@ export default {
return this.filterData.languages || [] return this.filterData.languages || []
}, },
progress() { 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() { sublistItems() {
return (this[this.sublist] || []).map((item) => { return (this[this.sublist] || []).map((item) => {
@@ -196,7 +212,8 @@ export default {
} }
}, },
methods: { methods: {
clearSelected() { async clearSelected() {
await this.$hapticsImpactMedium()
this.selected = 'all' this.selected = 'all'
this.show = false this.show = false
this.$nextTick(() => this.$emit('change', 'all')) this.$nextTick(() => this.$emit('change', 'all'))
@@ -204,7 +221,7 @@ export default {
clickedSublistOption(item) { clickedSublistOption(item) {
this.clickedOption({ value: `${this.sublist}.${item}` }) this.clickedOption({ value: `${this.sublist}.${item}` })
}, },
clickedOption(option) { async clickedOption(option) {
if (option.sublist) { if (option.sublist) {
this.sublist = option.value this.sublist = option.value
return return
@@ -215,6 +232,7 @@ export default {
this.show = false this.show = false
return return
} }
await this.$hapticsImpactMedium()
this.selected = val this.selected = val
this.show = false this.show = false
this.$nextTick(() => this.$emit('change', val)) this.$nextTick(() => this.$emit('change', val))
+1 -1
View File
@@ -1,7 +1,7 @@
<template> <template>
<modals-modal v-model="show" width="100%" height="100%" max-width="100%"> <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"> <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> </div>
</modals-modal> </modals-modal>
</template> </template>
+1
View File
@@ -49,6 +49,7 @@ export default {
}, },
methods: { methods: {
async clickedOption(lib) { async clickedOption(lib) {
await this.$hapticsImpactMedium()
this.show = false this.show = false
if (lib.id === this.currentLibraryId) return if (lib.id === this.currentLibraryId) return
await this.$store.dispatch('libraries/fetch', lib.id) await this.$store.dispatch('libraries/fetch', lib.id)
+2 -1
View File
@@ -126,7 +126,8 @@ export default {
} }
}, },
methods: { methods: {
clickedOption(val) { async clickedOption(val) {
await this.$hapticsImpactMedium()
if (this.selected === val) { if (this.selected === val) {
this.selectedDesc = !this.selectedDesc this.selectedDesc = !this.selectedDesc
} else { } else {
@@ -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>
+14 -7
View File
@@ -89,30 +89,37 @@ export default {
} }
}, },
methods: { methods: {
clickedChapterOption() { async clickedChapterOption() {
await this.$hapticsImpactMedium()
this.show = false this.show = false
this.$nextTick(() => this.$emit('change', { time: this.currentEndOfChapterTime * 1000, isChapterTime: true })) this.$nextTick(() => this.$emit('change', { time: this.currentEndOfChapterTime * 1000, isChapterTime: true }))
}, },
clickedOption(timeoutMin) { async clickedOption(timeoutMin) {
await this.$hapticsImpactMedium()
var timeout = timeoutMin * 1000 * 60 var timeout = timeoutMin * 1000 * 60
this.show = false this.show = false
this.manualTimerModal = false this.manualTimerModal = false
this.$nextTick(() => this.$emit('change', { time: timeout, isChapterTime: false })) this.$nextTick(() => this.$emit('change', { time: timeout, isChapterTime: false }))
}, },
cancelSleepTimer() { async cancelSleepTimer() {
await this.$hapticsImpactMedium()
this.$emit('cancel') this.$emit('cancel')
this.show = false this.show = false
}, },
increaseSleepTime() { async increaseSleepTime() {
await this.$hapticsImpactMedium()
this.$emit('increase') this.$emit('increase')
}, },
decreaseSleepTime() { async decreaseSleepTime() {
await this.$hapticsImpactMedium()
this.$emit('decrease') this.$emit('decrease')
}, },
increaseManualTimeout() { async increaseManualTimeout() {
await this.$hapticsImpactMedium()
this.manualTimeoutMin++ this.manualTimeoutMin++
}, },
decreaseManualTimeout() { async decreaseManualTimeout() {
await this.$hapticsImpactMedium()
if (this.manualTimeoutMin > 1) this.manualTimeoutMin-- 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>
+26 -4
View File
@@ -1,7 +1,7 @@
<template> <template>
<div id="epub-frame" class="w-full"> <div id="epub-frame" class="w-full">
<div id="viewer" class="border border-gray-100 bg-white shadow-md h-full w-full"></div> <div id="viewer" class="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> <p class="text-xs">epub</p>
<div class="flex-grow" /> <div class="flex-grow" />
@@ -29,8 +29,25 @@ export default {
hasPrev: false hasPrev: false
} }
}, },
computed: {}, watch: {
playerLibraryItemId() {
this.updateHeight()
}
},
computed: {
playerLibraryItemId() {
return this.$store.state.playerLibraryItemId
},
readerHeightOffset() {
return this.playerLibraryItemId ? 164 : 64
}
},
methods: { methods: {
updateHeight() {
if (this.rendition && this.rendition.resize) {
this.rendition.resize(window.innerWidth, window.innerHeight - this.readerHeightOffset)
}
},
prev() { prev() {
if (this.rendition) { if (this.rendition) {
this.rendition.prev() this.rendition.prev()
@@ -54,12 +71,12 @@ export default {
this.rendition = book.renderTo('viewer', { this.rendition = book.renderTo('viewer', {
width: window.innerWidth, width: window.innerWidth,
height: window.innerHeight - 64, height: window.innerHeight - this.readerHeightOffset,
snap: true, snap: true,
manager: 'continuous', manager: 'continuous',
flow: 'paginated' flow: 'paginated'
}) })
var displayed = this.rendition.display() const displayed = this.rendition.display()
book.ready book.ready
.then(() => { .then(() => {
@@ -127,4 +144,9 @@ export default {
max-height: calc(100% - 32px); max-height: calc(100% - 32px);
overflow: hidden; overflow: hidden;
} }
.reader-player-open #epub-frame {
height: calc(100% - 132px);
max-height: calc(100% - 132px);
overflow: hidden;
}
</style> </style>
+20 -6
View File
@@ -1,9 +1,9 @@
<template> <template>
<div class="ebook-viewer w-full h-full"> <div class="mobi-ebook-viewer w-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"> <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-screen"> Loading </iframe> <iframe title="html-viewer" class="w-full overflow-hidden"> Loading </iframe>
</div> </div>
<div class="fixed bottom-0 left-0 h-8 w-full bg-bg px-2 flex items-center z-20"> <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> <p class="text-xs">mobi</p>
<div class="flex-grow" /> <div class="flex-grow" />
</div> </div>
@@ -22,7 +22,14 @@ export default {
data() { data() {
return {} return {}
}, },
computed: {}, computed: {
playerLibraryItemId() {
return this.$store.state.playerLibraryItemId
},
readerHeightOffset() {
return this.playerLibraryItemId ? 164 : 64
}
},
methods: { methods: {
addHtmlCss() { addHtmlCss() {
let iframe = document.getElementsByTagName('iframe')[0] let iframe = document.getElementsByTagName('iframe')[0]
@@ -114,7 +121,14 @@ export default {
</script> </script>
<style> <style>
.ebook-viewer { .mobi-ebook-viewer {
height: calc(100% - 32px); 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> </style>
+79
View File
@@ -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>
+33 -9
View File
@@ -1,5 +1,5 @@
<template> <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"> <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> <p class="w-5/6 truncate">{{ title }}</p>
<div class="flex-grow" /> <div class="flex-grow" />
@@ -14,7 +14,10 @@ export default {
data() { data() {
return { return {
touchstartX: 0, touchstartX: 0,
touchendX: 0 touchstartY: 0,
touchendX: 0,
touchendY: 0,
touchstartTime: 0
} }
}, },
watch: { watch: {
@@ -53,6 +56,7 @@ export default {
if (this.ebookType === 'epub') return 'readers-epub-reader' if (this.ebookType === 'epub') return 'readers-epub-reader'
else if (this.ebookType === 'mobi') return 'readers-mobi-reader' else if (this.ebookType === 'mobi') return 'readers-mobi-reader'
else if (this.ebookType === 'comic') return 'readers-comic-reader' else if (this.ebookType === 'comic') return 'readers-comic-reader'
else if (this.ebookType === 'pdf') return 'readers-pdf-reader'
return null return null
}, },
folderId() { folderId() {
@@ -89,15 +93,22 @@ export default {
}, },
ebookUrl() { ebookUrl() {
if (!this.ebookFile) return null if (!this.ebookFile) return null
let filepath = ''
var itemRelPath = this.selectedLibraryItem.relPath if (this.selectedLibraryItem.isFile) {
filepath = this.$encodeUriPath(this.ebookFile.metadata.filename)
} else {
const itemRelPath = this.selectedLibraryItem.relPath
if (itemRelPath.startsWith('/')) itemRelPath = itemRelPath.slice(1) if (itemRelPath.startsWith('/')) itemRelPath = itemRelPath.slice(1)
const relPath = this.ebookFile.metadata.relPath
var relPath = this.ebookFile.metadata.relPath
if (relPath.startsWith('/')) relPath = relPath.slice(1) if (relPath.startsWith('/')) relPath = relPath.slice(1)
var serverAddress = this.$store.getters['user/getServerAddress'] filepath = this.$encodeUriPath(`${itemRelPath}/${relPath}`)
return `${serverAddress}/ebook/${this.libraryId}/${this.folderId}/${itemRelPath}/${relPath}` }
const serverAddress = this.$store.getters['user/getServerAddress']
return `${serverAddress}/ebook/${this.libraryId}/${this.folderId}/${filepath}`
},
playerLibraryItemId() {
return this.$store.state.playerLibraryItemId
} }
}, },
methods: { methods: {
@@ -112,9 +123,19 @@ export default {
} }
}, },
handleGesture() { 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) { if (this.touchendX < this.touchstartX) {
console.log('swiped left') console.log('swiped left')
this.next() this.next()
} }
if (this.touchendX > this.touchstartX) { if (this.touchendX > this.touchstartX) {
@@ -124,9 +145,12 @@ export default {
}, },
touchstart(e) { touchstart(e) {
this.touchstartX = e.changedTouches[0].screenX this.touchstartX = e.changedTouches[0].screenX
this.touchstartY = e.changedTouches[0].screenY
this.touchstartTime = Date.now()
}, },
touchend(e) { touchend(e) {
this.touchendX = e.changedTouches[0].screenX this.touchendX = e.changedTouches[0].screenX
this.touchendY = e.changedTouches[0].screenY
this.handleGesture() this.handleGesture()
}, },
registerListeners() { registerListeners() {
+47
View File
@@ -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>
+103
View File
@@ -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>
+52 -5
View File
@@ -1,5 +1,5 @@
<template> <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"> <div v-if="episode" class="w-full px-1">
<!-- Help debug for testing --> <!-- Help debug for testing -->
<!-- <template> <!-- <template>
@@ -26,14 +26,28 @@
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" /> <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"> <div v-if="userCanDownload">
<span v-if="isLocal" class="material-icons-outlined px-2 text-success text-lg">audio_file</span> <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> <span v-else class="material-icons px-2 text-success text-xl">download_done</span>
</div> </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> </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 v-if="!userIsFinished" class="absolute bottom-0 left-0 h-0.5 bg-warning" :style="{ width: itemProgressPercent * 100 + '%' }" />
</div> </div>
</template> </template>
@@ -58,10 +72,14 @@ export default {
}, },
data() { data() {
return { return {
isProcessingReadUpdate: false isProcessingReadUpdate: false,
processing: false
} }
}, },
computed: { computed: {
isAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
isIos() { isIos() {
return this.$platform === 'ios' return this.$platform === 'ios'
}, },
@@ -134,6 +152,32 @@ export default {
} }
}, },
methods: { 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() { async selectFolder() {
var folderObj = await AbsFileSystem.selectFolder({ mediaType: this.mediaType }) var folderObj = await AbsFileSystem.selectFolder({ mediaType: this.mediaType })
if (folderObj.error) { if (folderObj.error) {
@@ -141,8 +185,9 @@ export default {
} }
return folderObj return folderObj
}, },
downloadClick() { async downloadClick() {
if (this.downloadItem) return if (this.downloadItem) return
await this.$hapticsImpactMedium()
if (this.isIos) { if (this.isIos) {
// no local folders on iOS // no local folders on iOS
this.startDownload() this.startDownload()
@@ -202,7 +247,8 @@ export default {
this.$toast.error(errorMsg) this.$toast.error(errorMsg)
} }
}, },
playClick() { async playClick() {
await this.$hapticsImpactMedium()
if (this.streamIsPlaying) { if (this.streamIsPlaying) {
this.$eventBus.$emit('pause-item') this.$eventBus.$emit('pause-item')
} else { } else {
@@ -224,6 +270,7 @@ export default {
} }
}, },
async toggleFinished() { async toggleFinished() {
await this.$hapticsImpactMedium()
this.isProcessingReadUpdate = true this.isProcessingReadUpdate = true
if (this.isLocal || this.localEpisode) { if (this.isLocal || this.localEpisode) {
var isFinished = !this.userIsFinished var isFinished = !this.userIsFinished
+132 -6
View File
@@ -1,8 +1,32 @@
<template> <template>
<div class="w-full"> <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"> <div class="flex items-center">
<p class="text-lg mb-1 font-semibold">Episodes ({{ episodesFiltered.length }})</p> <p class="text-lg mb-1 font-semibold">Episodes ({{ episodesFiltered.length }})</p>
<div class="flex-grow" /> <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"> <button class="outline:none mx-3 pt-0.5 relative" @click="showFilters">
<span class="material-icons text-xl text-gray-200">filter_alt</span> <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" /> <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> </div>
<template v-for="episode in episodesSorted"> <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> </template>
<!-- What in tarnation is going on here? <!-- Huhhh?
Without anything below the template it will not re-render --> Without anything below the template it will not re-render -->
<p>&nbsp;</p> <p>&nbsp;</p>
<modals-dialog v-model="showFiltersModal" title="Episode Filter" :items="filterItems" :selected="filterKey" @action="setFilter" /> <modals-dialog v-model="showFiltersModal" title="Episode Filter" :items="filterItems" :selected="filterKey" @action="setFilter" />
<modals-podcast-episodes-feed-modal v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" />
</div> </div>
</template> </template>
<script> <script>
import { Dialog } from '@capacitor/dialog'
export default { export default {
props: { props: {
libraryItemId: String, libraryItem: {
type: Object,
default: () => {}
},
episodes: { episodes: {
type: Array, type: Array,
default: () => [] default: () => []
@@ -83,7 +114,12 @@ export default {
text: 'Complete', text: 'Complete',
value: 'complete' value: 'complete'
} }
] ],
fetchingRSSFeed: false,
podcastFeedEpisodes: [],
showPodcastEpisodeFeed: false,
episodesDownloading: [],
episodeDownloadsQueued: []
} }
}, },
watch: { watch: {
@@ -95,6 +131,21 @@ export default {
} }
}, },
computed: { 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() { episodesAreFiltered() {
return this.episodesFiltered.length !== this.episodesCopy.length return this.episodesFiltered.length !== this.episodesCopy.length
}, },
@@ -140,9 +191,58 @@ export default {
} }
}, },
methods: { 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) { setFilter(filter) {
this.filterKey = filter this.filterKey = filter
console.log('Set filter', this.filterKey)
this.showFiltersModal = false this.showFiltersModal = false
}, },
showFilters() { showFilters() {
@@ -159,8 +259,34 @@ export default {
this.episodesCopy = this.episodes.map((ep) => { this.episodesCopy = this.episodes.map((ep) => {
return { ...ep } return { ...ep }
}) })
},
episodeDownloadQueued(episodeDownload) {
if (episodeDownload.libraryItemId === this.libraryItemId) {
this.episodeDownloadsQueued.push(episodeDownload)
} }
}, },
mounted() {} 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() {
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> </script>
+4 -9
View File
@@ -1,7 +1,7 @@
<template> <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> <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="flex items-center" :class="!selectedText ? 'text-gray-300' : 'text-white'">
<span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText || placeholder || '' }}</span> <span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText || placeholder || '' }}</span>
</span> </span>
@@ -11,9 +11,9 @@
</button> </button>
<transition name="menu"> <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"> <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"> <div class="flex items-center">
<span class="font-normal ml-3 block truncate font-sans text-sm">{{ item.text }}</span> <span class="font-normal ml-3 block truncate font-sans text-sm">{{ item.text }}</span>
</div> </div>
@@ -42,11 +42,6 @@ export default {
}, },
data() { data() {
return { return {
clickOutsideObj: {
handler: this.clickedOutside,
events: ['mousedown'],
isActive: true
},
showMenu: false showMenu: false
} }
}, },
+1 -1
View File
@@ -4,7 +4,7 @@
<slot /> <slot />
</div> </div>
<transition name="menu"> <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"> <template v-for="item in items">
<nuxt-link :key="item.value" v-if="item.to" :to="item.to"> <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)"> <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)">
+261
View File
@@ -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>
+2 -6
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="relative"> <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"> <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> <span class="material-icons text-lg">{{ prependIcon }}</span>
</div> </div>
@@ -26,10 +26,6 @@ export default {
prependIcon: { prependIcon: {
type: String, type: String,
default: null default: null
},
textSize: {
type: String,
default: 'lg'
} }
}, },
data() { data() {
@@ -45,7 +41,7 @@ export default {
} }
}, },
inputClass() { 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') if (this.disabled) classes.push('text-gray-300')
else classes.push('text-white') else classes.push('text-white')
+53
View File
@@ -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>
+41
View File
@@ -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>
+10
View File
@@ -147,6 +147,16 @@ export default {
margin-top: -2px; margin-top: -2px;
margin-left: -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 { .la-ball-spin-clockwise.la-2x {
width: 64px; width: 64px;
height: 64px; height: 64px;
+4 -4
View File
@@ -720,12 +720,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = Icons; ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 16; CURRENT_PROJECT_VERSION = 17;
DEVELOPMENT_TEAM = 7UFJ7D8V6A; DEVELOPMENT_TEAM = 7UFJ7D8V6A;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0; IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.9.59; MARKETING_VERSION = 0.9.60;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev; PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -744,12 +744,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = Icons; ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 16; CURRENT_PROJECT_VERSION = 17;
DEVELOPMENT_TEAM = 7UFJ7D8V6A; DEVELOPMENT_TEAM = 7UFJ7D8V6A;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0; IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.9.59; MARKETING_VERSION = 0.9.60;
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app; PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
+7 -1
View File
@@ -14,7 +14,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
// Override point for customization after application launch. // Override point for customization after application launch.
let configuration = Realm.Configuration( let configuration = Realm.Configuration(
schemaVersion: 4, schemaVersion: 5,
migrationBlock: { [weak self] migration, oldSchemaVersion in migrationBlock: { [weak self] migration, oldSchemaVersion in
if (oldSchemaVersion < 1) { if (oldSchemaVersion < 1) {
self?.logger.log("Realm schema version was \(oldSchemaVersion)") self?.logger.log("Realm schema version was \(oldSchemaVersion)")
@@ -30,6 +30,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
indexCounter += 1 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 Realm.Configuration.defaultConfiguration = configuration
+4
View File
@@ -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"> <widget version="1.0.0" xmlns="http://www.w3.org/ns/widgets" xmlns:cdv="http://cordova.apache.org/ns/1.0">
<access origin="*" /> <access origin="*" />
<feature name="CDVOrientation">
<param name="ios-package" value="CDVOrientation"/>
</feature>
</widget> </widget>
+1
View File
@@ -26,6 +26,7 @@ public class AbsAudioPlayer: CAPPlugin {
NotificationCenter.default.addObserver(self, selector: #selector(onLocalMediaProgressUpdate), name: NSNotification.Name(PlayerEvents.localProgress.rawValue), object: nil) NotificationCenter.default.addObserver(self, selector: #selector(onLocalMediaProgressUpdate), name: NSNotification.Name(PlayerEvents.localProgress.rawValue), object: nil)
self.bridge?.webView?.allowsBackForwardNavigationGestures = true; self.bridge?.webView?.allowsBackForwardNavigationGestures = true;
self.bridge?.webView?.scrollView.alwaysBounceVertical = false;
} }
+2
View File
@@ -236,11 +236,13 @@ public class AbsDatabase: CAPPlugin {
let enableAltView = call.getBool("enableAltView") ?? false let enableAltView = call.getBool("enableAltView") ?? false
let jumpBackwardsTime = call.getInt("jumpBackwardsTime") ?? 10 let jumpBackwardsTime = call.getInt("jumpBackwardsTime") ?? 10
let jumpForwardTime = call.getInt("jumpForwardTime") ?? 10 let jumpForwardTime = call.getInt("jumpForwardTime") ?? 10
let lockOrientation = call.getString("lockOrientation") ?? "NONE"
let settings = DeviceSettings() let settings = DeviceSettings()
settings.disableAutoRewind = disableAutoRewind settings.disableAutoRewind = disableAutoRewind
settings.enableAltView = enableAltView settings.enableAltView = enableAltView
settings.jumpBackwardsTime = jumpBackwardsTime settings.jumpBackwardsTime = jumpBackwardsTime
settings.jumpForwardTime = jumpForwardTime settings.jumpForwardTime = jumpForwardTime
settings.lockOrientation = lockOrientation
Database.shared.setDeviceSettings(deviceSettings: settings) Database.shared.setDeviceSettings(deviceSettings: settings)
+1
View File
@@ -15,6 +15,7 @@ def capacitor_pods
pod 'CapacitorNetwork', :path => '../../node_modules/@capacitor/network' pod 'CapacitorNetwork', :path => '../../node_modules/@capacitor/network'
pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar' pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar'
pod 'CapacitorStorage', :path => '../../node_modules/@capacitor/storage' pod 'CapacitorStorage', :path => '../../node_modules/@capacitor/storage'
pod 'CordovaPlugins', :path => '../capacitor-cordova-ios-plugins'
end end
target 'Audiobookshelf' do target 'Audiobookshelf' do
+3 -1
View File
@@ -13,6 +13,7 @@ class DeviceSettings: Object {
@Persisted var enableAltView: Bool = false @Persisted var enableAltView: Bool = false
@Persisted var jumpBackwardsTime: Int = 10 @Persisted var jumpBackwardsTime: Int = 10
@Persisted var jumpForwardTime: Int = 10 @Persisted var jumpForwardTime: Int = 10
@Persisted var lockOrientation: String = "NONE"
} }
func getDefaultDeviceSettings() -> DeviceSettings { func getDefaultDeviceSettings() -> DeviceSettings {
@@ -24,6 +25,7 @@ func deviceSettingsToJSON(settings: DeviceSettings) -> Dictionary<String, Any> {
"disableAutoRewind": settings.disableAutoRewind, "disableAutoRewind": settings.disableAutoRewind,
"enableAltView": settings.enableAltView, "enableAltView": settings.enableAltView,
"jumpBackwardsTime": settings.jumpBackwardsTime, "jumpBackwardsTime": settings.jumpBackwardsTime,
"jumpForwardTime": settings.jumpForwardTime "jumpForwardTime": settings.jumpForwardTime,
"lockOrientation": settings.lockOrientation
] ]
} }
@@ -151,6 +151,14 @@ class PlayerProgress {
} else { } else {
let playbackReport = PlaybackReport(currentTime: session.currentTime, duration: session.duration, timeListened: session.timeListening) let playbackReport = PlaybackReport(currentTime: session.currentTime, duration: session.duration, timeListened: session.timeListening)
success = await ApiClient.reportPlaybackProgress(report: playbackReport, sessionId: session.id) 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
View File
@@ -1,11 +1,12 @@
<template> <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 /> <app-appbar />
<div id="content" class="overflow-hidden relative" :class="playerIsOpen ? 'playerOpen' : ''"> <div id="content" class="overflow-hidden relative" :class="playerIsOpen ? 'playerOpen' : ''">
<Nuxt /> <Nuxt />
</div> </div>
<app-audio-player-container ref="streamContainer" /> <app-audio-player-container ref="streamContainer" />
<modals-libraries-modal /> <modals-libraries-modal />
<modals-playlists-add-create-modal />
<app-side-drawer /> <app-side-drawer />
<readers-reader /> <readers-reader />
</div> </div>
@@ -174,7 +175,7 @@ export default {
} }
console.log('[default] Calling syncLocalMediaProgress') console.log('[default] Calling syncLocalMediaProgress')
var response = await this.$db.syncLocalMediaProgressWithServer() const response = await this.$db.syncLocalMediaProgressWithServer()
if (!response) { if (!response) {
if (this.$platform != 'web') this.$toast.error('Failed to sync local media with server') if (this.$platform != 'web') this.$toast.error('Failed to sync local media with server')
this.$store.commit('setLastLocalMediaSyncResults', null) this.$store.commit('setLastLocalMediaSyncResults', null)
@@ -261,6 +262,7 @@ export default {
const deviceData = await this.$db.getDeviceData() const deviceData = await this.$db.getDeviceData()
this.$store.commit('setDeviceData', deviceData) this.$store.commit('setDeviceData', deviceData)
this.$setOrientationLock(this.$store.getters['getOrientationLockSetting'])
await this.$store.dispatch('setupNetworkListener') await this.$store.dispatch('setupNetworkListener')
+2
View File
@@ -3,6 +3,7 @@ import LazyBookCard from '@/components/cards/LazyBookCard'
import LazyListBookCard from '@/components/cards/LazyListBookCard' import LazyListBookCard from '@/components/cards/LazyListBookCard'
import LazySeriesCard from '@/components/cards/LazySeriesCard' import LazySeriesCard from '@/components/cards/LazySeriesCard'
import LazyCollectionCard from '@/components/cards/LazyCollectionCard' import LazyCollectionCard from '@/components/cards/LazyCollectionCard'
import LazyPlaylistCard from '@/components/cards/LazyPlaylistCard'
export default { export default {
data() { data() {
@@ -16,6 +17,7 @@ export default {
getComponentClass() { getComponentClass() {
if (this.entityName === 'series') return Vue.extend(LazySeriesCard) if (this.entityName === 'series') return Vue.extend(LazySeriesCard)
if (this.entityName === 'collections') return Vue.extend(LazyCollectionCard) if (this.entityName === 'collections') return Vue.extend(LazyCollectionCard)
if (this.entityName === 'playlists') return Vue.extend(LazyPlaylistCard)
if (this.showBookshelfListView) return Vue.extend(LazyListBookCard) if (this.showBookshelfListView) return Vue.extend(LazyListBookCard)
return Vue.extend(LazyBookCard) return Vue.extend(LazyBookCard)
}, },
+238 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf-app", "name": "audiobookshelf-app",
"version": "0.9.59-beta", "version": "0.9.60-beta",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf-app", "name": "audiobookshelf-app",
"version": "0.9.59-beta", "version": "0.9.60-beta",
"dependencies": { "dependencies": {
"@capacitor/android": "^3.4.3", "@capacitor/android": "^3.4.3",
"@capacitor/app": "^1.1.1", "@capacitor/app": "^1.1.1",
@@ -19,12 +19,14 @@
"@capacitor/status-bar": "^1.0.8", "@capacitor/status-bar": "^1.0.8",
"@capacitor/storage": "^1.2.5", "@capacitor/storage": "^1.2.5",
"@nuxtjs/axios": "^5.13.6", "@nuxtjs/axios": "^5.13.6",
"cordova-plugin-screen-orientation": "^3.0.2",
"core-js": "^3.15.1", "core-js": "^3.15.1",
"date-fns": "^2.25.0", "date-fns": "^2.25.0",
"epubjs": "^0.3.88", "epubjs": "^0.3.88",
"libarchive.js": "^1.3.0", "libarchive.js": "^1.3.0",
"nuxt": "^2.15.7", "nuxt": "^2.15.7",
"socket.io-client": "^4.1.3", "socket.io-client": "^4.1.3",
"vue-pdf": "^4.2.0",
"vue-toastification": "^1.7.11", "vue-toastification": "^1.7.11",
"vuedraggable": "^2.24.3" "vuedraggable": "^2.24.3"
}, },
@@ -4545,6 +4547,11 @@
"@babel/core": "^7.0.0-0" "@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": { "node_modules/backo2": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz", "resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz",
@@ -5739,6 +5746,18 @@
"node": ">=0.10.0" "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": { "node_modules/core-js": {
"version": "3.21.1", "version": "3.21.1",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.21.1.tgz", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.21.1.tgz",
@@ -10945,6 +10964,11 @@
"node": ">=0.12" "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": { "node_modules/pend": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
@@ -14438,6 +14462,25 @@
"node": ">= 0.6" "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": { "node_modules/rc9": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/rc9/-/rc9-1.2.0.tgz", "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", "resolved": "https://registry.npmjs.org/vue-no-ssr/-/vue-no-ssr-1.1.1.tgz",
"integrity": "sha512-ZMjqRpWabMPqPc7gIrG0Nw6vRf1+itwf0Itft7LbMXs2g3Zs/NFmevjZGN1x7K3Q95GmIjWbQZTVerxiBxI+0g==" "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": { "node_modules/vue-router": {
"version": "3.5.3", "version": "3.5.3",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-3.5.3.tgz", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-3.5.3.tgz",
@@ -18479,6 +18564,57 @@
"errno": "~0.1.7" "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": { "node_modules/wrap-ansi": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "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/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": { "backo2": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz", "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", "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz",
"integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=" "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": { "core-js": {
"version": "3.21.1", "version": "3.21.1",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.21.1.tgz", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.21.1.tgz",
@@ -27073,6 +27219,11 @@
"sha.js": "^2.4.8" "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": { "pend": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "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", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" "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": { "rc9": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/rc9/-/rc9-1.2.0.tgz", "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", "resolved": "https://registry.npmjs.org/vue-no-ssr/-/vue-no-ssr-1.1.1.tgz",
"integrity": "sha512-ZMjqRpWabMPqPc7gIrG0Nw6vRf1+itwf0Itft7LbMXs2g3Zs/NFmevjZGN1x7K3Q95GmIjWbQZTVerxiBxI+0g==" "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": { "vue-router": {
"version": "3.5.3", "version": "3.5.3",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-3.5.3.tgz", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-3.5.3.tgz",
@@ -32802,6 +33000,44 @@
"errno": "~0.1.7" "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": { "wrap-ansi": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-app", "name": "audiobookshelf-app",
"version": "0.9.59-beta", "version": "0.9.60-beta",
"author": "advplyr", "author": "advplyr",
"scripts": { "scripts": {
"dev": "nuxt --hostname 0.0.0.0 --port 1337", "dev": "nuxt --hostname 0.0.0.0 --port 1337",
@@ -23,12 +23,14 @@
"@capacitor/status-bar": "^1.0.8", "@capacitor/status-bar": "^1.0.8",
"@capacitor/storage": "^1.2.5", "@capacitor/storage": "^1.2.5",
"@nuxtjs/axios": "^5.13.6", "@nuxtjs/axios": "^5.13.6",
"cordova-plugin-screen-orientation": "^3.0.2",
"core-js": "^3.15.1", "core-js": "^3.15.1",
"date-fns": "^2.25.0", "date-fns": "^2.25.0",
"epubjs": "^0.3.88", "epubjs": "^0.3.88",
"libarchive.js": "^1.3.0", "libarchive.js": "^1.3.0",
"nuxt": "^2.15.7", "nuxt": "^2.15.7",
"socket.io-client": "^4.1.3", "socket.io-client": "^4.1.3",
"vue-pdf": "^4.2.0",
"vue-toastification": "^1.7.11", "vue-toastification": "^1.7.11",
"vuedraggable": "^2.24.3" "vuedraggable": "^2.24.3"
}, },
+1
View File
@@ -53,6 +53,7 @@ export default {
}, },
methods: { methods: {
async logout() { async logout() {
await this.$hapticsImpactMedium()
if (this.user) { if (this.user) {
await this.$axios.$post('/logout').catch((error) => { await this.$axios.$post('/logout').catch((error) => {
console.error(error) console.error(error)
+12 -3
View File
@@ -1,8 +1,8 @@
<template> <template>
<div class="w-full h-full"> <div class="w-full h-full">
<home-bookshelf-nav-bar /> <home-bookshelf-nav-bar />
<home-bookshelf-toolbar v-show="!isHome" /> <home-bookshelf-toolbar v-show="!hideToolbar" />
<div id="bookshelf-wrapper" class="main-content overflow-y-auto overflow-x-hidden relative" :class="isHome ? 'home-page' : ''"> <div id="bookshelf-wrapper" class="main-content overflow-y-auto overflow-x-hidden relative" :class="hideToolbar ? 'no-toolbar' : ''">
<nuxt-child /> <nuxt-child />
</div> </div>
</div> </div>
@@ -14,8 +14,17 @@ export default {
return {} return {}
}, },
computed: { computed: {
hideToolbar() {
return this.isHome || this.isLatest || this.isPodcastSearch
},
isHome() { isHome() {
return this.$route.name === 'bookshelf' 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); min-height: calc(100% - 72px);
max-width: 100vw; max-width: 100vw;
} }
.main-content.home-page { .main-content.no-toolbar {
height: calc(100% - 36px); height: calc(100% - 36px);
max-height: calc(100% - 36px); max-height: calc(100% - 36px);
min-height: calc(100% - 36px); min-height: calc(100% - 36px);
+14
View File
@@ -0,0 +1,14 @@
<template>
<div>Authors</div>
</template>
<script>
export default {
data() {
return {}
},
watch: {},
computed: {},
methods: {}
}
</script>
+24 -22
View File
@@ -41,12 +41,19 @@ export default {
localLibraryItems: [] localLibraryItems: []
} }
}, },
watch: {
networkConnected(newVal) {
// Update shelves when network connect status changes
console.log(`Network changed to ${newVal} - fetch categories`)
this.fetchCategories()
}
},
computed: { computed: {
user() { user() {
return this.$store.state.user.user return this.$store.state.user.user
}, },
isSocketConnected() { networkConnected() {
return this.$store.state.socketConnected return this.$store.state.networkConnected
}, },
currentLibraryName() { currentLibraryName() {
return this.$store.getters['libraries/getCurrentLibraryName'] return this.$store.getters['libraries/getCurrentLibraryName']
@@ -54,6 +61,9 @@ export default {
currentLibraryId() { currentLibraryId() {
return this.$store.state.libraries.currentLibraryId return this.$store.state.libraries.currentLibraryId
}, },
currentLibraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
altViewEnabled() { altViewEnabled() {
return this.$store.getters['getAltViewEnabled'] return this.$store.getters['getAltViewEnabled']
}, },
@@ -111,21 +121,19 @@ export default {
this.shelves = [] this.shelves = []
this.localLibraryItems = await this.$db.getLocalLibraryItems() this.localLibraryItems = await this.$db.getLocalLibraryItems()
const localCategories = await this.getLocalMediaItemCategories()
var localCategories = await this.getLocalMediaItemCategories()
this.shelves = this.shelves.concat(localCategories)
if (this.user && this.currentLibraryId) { 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) console.error('Failed to fetch categories', error)
return [] return []
}) })
categories = categories.map((cat) => { this.shelves = categories.map((cat) => {
console.log('[breadcrumb] Personalized category from server', cat.type) console.log('[breadcrumb] Personalized category from server', cat.type)
if (cat.type == 'book' || cat.type == 'podcast' || cat.type == 'episode') { if (cat.type == 'book' || cat.type == 'podcast' || cat.type == 'episode') {
// Map localLibraryItem to entities // Map localLibraryItem to entities
cat.entities = cat.entities.map((entity) => { cat.entities = cat.entities.map((entity) => {
var localLibraryItem = this.localLibraryItems.find((lli) => { const localLibraryItem = this.localLibraryItems.find((lli) => {
return lli.libraryItemId == entity.id return lli.libraryItemId == entity.id
}) })
if (localLibraryItem) { if (localLibraryItem) {
@@ -136,14 +144,15 @@ export default {
} }
return cat return cat
}) })
// Put continue listening shelf first
var continueListeningShelf = categories.find((c) => c.id == 'continue-listening') // Only add the local shelf with the same media type
if (continueListeningShelf) { const localShelves = localCategories.filter((cat) => cat.type === this.currentLibraryMediaType)
this.shelves = [continueListeningShelf, ...this.shelves] this.shelves.push(...localShelves)
console.log(this.shelves) } else {
} // Offline only local
this.shelves = this.shelves.concat(categories.filter((c) => c.id != 'continue-listening')) this.shelves = localCategories
} }
this.loading = false this.loading = false
}, },
async libraryChanged() { async libraryChanged() {
@@ -195,21 +204,14 @@ export default {
}, },
initListeners() { initListeners() {
this.$eventBus.$on('library-changed', this.libraryChanged) this.$eventBus.$on('library-changed', this.libraryChanged)
// this.$eventBus.$on('downloads-loaded', this.downloadsLoaded)
}, },
removeListeners() { removeListeners() {
this.$eventBus.$off('library-changed', this.libraryChanged) this.$eventBus.$off('library-changed', this.libraryChanged)
// this.$eventBus.$off('downloads-loaded', this.downloadsLoaded)
} }
}, },
mounted() { mounted() {
this.initListeners() this.initListeners()
this.fetchCategories() this.fetchCategories()
// if (this.$server.initialized && this.currentLibraryId) {
// this.fetchCategories()
// } else {
// this.shelves = this.downloadOnlyShelves
// }
}, },
beforeDestroy() { beforeDestroy() {
this.removeListeners() this.removeListeners()
+59
View File
@@ -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>
+14
View File
@@ -0,0 +1,14 @@
<template>
<bookshelf-lazy-bookshelf page="playlists" />
</template>
<script>
export default {
data() {
return {}
},
watch: {},
computed: {},
methods: {}
}
</script>
+145
View File
@@ -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>
+1 -1
View File
@@ -12,7 +12,7 @@
{{ collectionName }} {{ collectionName }}
</h1> </h1>
<div class="flex-grow" /> <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> <span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">play_arrow</span>
{{ streaming ? 'Streaming' : 'Play' }} {{ streaming ? 'Streaming' : 'Play' }}
</ui-btn> </ui-btn>
+83 -47
View File
@@ -1,50 +1,71 @@
<template> <template>
<div class="w-full h-full px-3 py-4 overflow-y-auto"> <div class="w-full h-full px-3 py-4 overflow-y-auto">
<div class="flex"> <div class="w-full flex justify-center relative mb-2">
<div class="w-16">
<div class="relative" @click="showFullscreenCover = true"> <div class="relative" @click="showFullscreenCover = true">
<covers-book-cover :library-item="libraryItem" :width="64" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <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: 64 * progressPercent + 'px' }"></div> <div v-if="!isPodcast" class="absolute bottom-0 left-0 h-1 shadow-sm z-10" :class="userIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: 175 * progressPercent + 'px' }"></div>
</div> </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"> <button class="absolute top-0 right-0 px-1 outline-none" @click="moreButtonPress">
<span class="material-icons text-xl">more_vert</span> <span class="material-icons text-xl">more_vert</span>
</button> </button>
</div> </div>
<p v-if="subtitle" class="text-gray-100 text-sm py-0.5">{{ subtitle }}</p>
<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"> <p v-if="seriesList && seriesList.length" class="text-sm text-gray-300 py-0.5">
<template v-for="(series, index) in seriesList" <template v-for="(series, index) in seriesList"
><nuxt-link :key="series.id" :to="`/bookshelf/series/${series.id}`">{{ series.text }}</nuxt-link ><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">,&nbsp;</span></template ><span :key="`${series.id}-comma`" v-if="index < seriesList.length - 1">,&nbsp;</span></template
> >
</p> </p>
<p v-if="podcastAuthor" class="text-sm text-gray-300 py-0.5">By {{ podcastAuthor }}</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"> <p v-else-if="bookAuthors && bookAuthors.length" class="text-sm text-gray-300 py-0.5">
By by
<template v-for="(author, index) in bookAuthors" <template v-for="(author, index) in bookAuthors"
><nuxt-link :key="author.id" :to="`/bookshelf/library?filter=authors.${$encode(author.id)}`">{{ author.name }}</nuxt-link ><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">,&nbsp;</span></template ><span :key="`${author.id}-comma`" v-if="index < bookAuthors.length - 1">,&nbsp;</span></template
> >
</p> </p>
</div>
</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">,&nbsp;</span></template
>
</p>
<!-- Show an indicator for local library items whether they are linked to a server item and if that server item is connected --> <!-- Show an indicator for local library items whether they are linked to a server item and if that server item is connected -->
<p v-if="isLocal && serverLibraryItemId" style="font-size: 10px" class="text-success py-1 uppercase tracking-widest">connected</p> <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> <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">,&nbsp;</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">,&nbsp;</span>
</template>
</div>
</div>
<div v-if="numTracks" class="flex text-gray-100 text-xs my-2 -mx-0.5"> <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"> <div class="bg-primary bg-opacity-80 px-3 py-0.5 rounded-full mx-0.5">
<p>{{ $elapsedPretty(duration) }}</p> <p>{{ $elapsedPretty(duration) }}</p>
@@ -106,7 +127,7 @@
<p class="text-sm">{{ description }}</p> <p class="text-sm">{{ description }}</p>
</div> </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" /> <modals-select-local-folder-modal v-model="showSelectLocalFolder" :media-type="mediaType" @select="selectedLocalFolder" />
@@ -222,6 +243,12 @@ export default {
subtitle() { subtitle() {
return this.mediaMetadata.subtitle return this.mediaMetadata.subtitle
}, },
genres() {
return this.mediaMetadata.genres || []
},
publishedYear() {
return this.mediaMetadata.publishedYear
},
podcastAuthor() { podcastAuthor() {
if (!this.isPodcast) return null if (!this.isPodcast) return null
return this.mediaMetadata.author || '' return this.mediaMetadata.author || ''
@@ -308,7 +335,7 @@ export default {
return !this.isMissing && !this.isIncomplete && (this.numTracks || this.episodes.length) return !this.isMissing && !this.isIncomplete && (this.numTracks || this.episodes.length)
}, },
showRead() { showRead() {
return this.ebookFile && this.ebookFormat !== 'pdf' return this.ebookFile
}, },
showDownload() { showDownload() {
if (this.isPodcast) return false if (this.isPodcast) return false
@@ -331,34 +358,39 @@ export default {
return this.$store.state.isCasting return this.$store.state.isCasting
}, },
moreMenuItems() { moreMenuItems() {
if (this.isLocal) { const items = []
return [ if (this.localLibraryItemId) {
{ items.push({
text: 'Manage Local Files', text: 'Manage Local Files',
value: 'manageLocal' value: 'manageLocal'
}, })
{ }
items.push({
text: 'View Details', text: 'View Details',
value: 'details' value: 'details'
})
if (!this.isPodcast && this.serverLibraryItemId) {
items.push({
text: 'Add to Playlist',
value: 'playlist'
})
} }
]
} else { return items
return [
{
text: 'View Details',
value: 'details'
}
]
}
} }
}, },
methods: { methods: {
moreMenuAction(action) { moreMenuAction(action) {
this.showMoreMenu = false this.showMoreMenu = false
if (action === 'manageLocal') { if (action === 'manageLocal') {
this.$router.push(`/localMedia/item/${this.libraryItemId}`) this.$router.push(`/localMedia/item/${this.localLibraryItemId}`)
} else if (action === 'details') { } else if (action === 'details') {
this.showDetailsModal = true this.showDetailsModal = true
} else if (action === 'playlist') {
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: null }])
this.$store.commit('globals/setShowPlaylistsAddCreateModal', true)
} }
}, },
moreButtonPress() { moreButtonPress() {
@@ -367,15 +399,16 @@ export default {
readBook() { readBook() {
this.$store.commit('openReader', this.libraryItem) this.$store.commit('openReader', this.libraryItem)
}, },
playClick() { async playClick() {
var episodeId = null let episodeId = null
await this.$hapticsImpactMedium()
if (this.isPodcast) { if (this.isPodcast) {
this.episodes.sort((a, b) => { this.episodes.sort((a, b) => {
return String(b.publishedAt).localeCompare(String(a.publishedAt), undefined, { numeric: true, sensitivity: 'base' }) return String(b.publishedAt).localeCompare(String(a.publishedAt), undefined, { numeric: true, sensitivity: 'base' })
}) })
var episode = this.episodes.find((ep) => { let episode = this.episodes.find((ep) => {
var podcastProgress = null var podcastProgress = null
if (!this.isLocal) { if (!this.isLocal) {
podcastProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, ep.id) podcastProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, ep.id)
@@ -389,13 +422,13 @@ export default {
episodeId = episode.id episodeId = episode.id
var localEpisode = null let localEpisode = null
if (this.hasLocal && !this.isLocal) { if (this.hasLocal && !this.isLocal) {
localEpisode = this.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId == episodeId) localEpisode = this.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId == episodeId)
} else if (this.isLocal) { } else if (this.isLocal) {
localEpisode = episode 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 (serverEpisodeId && this.serverLibraryItemId && this.isCasting) {
// If casting and connected to server for local library item then send server library item id // 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 }) this.$eventBus.$emit('play-item', { libraryItemId: this.libraryItemId, episodeId })
}, },
async clearProgressClick() { async clearProgressClick() {
await this.$hapticsImpactMedium()
const { value } = await Dialog.confirm({ const { value } = await Dialog.confirm({
title: 'Confirm', title: 'Confirm',
message: 'Are you sure you want to reset your progress?' message: 'Are you sure you want to reset your progress?'
@@ -467,13 +501,14 @@ export default {
this.showSelectLocalFolder = false this.showSelectLocalFolder = false
this.download(localFolder) this.download(localFolder)
}, },
downloadClick() { async downloadClick() {
if (this.downloadItem) { if (this.downloadItem) {
return return
} }
if (!this.numTracks) { if (!this.numTracks) {
return return
} }
await this.$hapticsImpactMedium()
if (this.isIos) { if (this.isIos) {
// no local folders on iOS // no local folders on iOS
this.startDownload() this.startDownload()
@@ -541,6 +576,7 @@ export default {
} }
}, },
async toggleFinished() { async toggleFinished() {
await this.$hapticsImpactMedium()
this.isProcessingReadUpdate = true this.isProcessingReadUpdate = true
if (this.isLocal) { if (this.isLocal) {
var isFinished = !this.userIsFinished var isFinished = !this.userIsFinished
@@ -593,12 +629,12 @@ export default {
mounted() { mounted() {
this.$eventBus.$on('library-changed', this.libraryChanged) this.$eventBus.$on('library-changed', this.libraryChanged)
this.$eventBus.$on('new-local-library-item', this.newLocalLibraryItem) this.$eventBus.$on('new-local-library-item', this.newLocalLibraryItem)
this.$socket.on('item_updated', this.itemUpdated) this.$socket.$on('item_updated', this.itemUpdated)
}, },
beforeDestroy() { beforeDestroy() {
this.$eventBus.$off('library-changed', this.libraryChanged) this.$eventBus.$off('library-changed', this.libraryChanged)
this.$eventBus.$off('new-local-library-item', this.newLocalLibraryItem) this.$eventBus.$off('new-local-library-item', this.newLocalLibraryItem)
this.$socket.off('item_updated', this.itemUpdated) this.$socket.$off('item_updated', this.itemUpdated)
} }
} }
</script> </script>
+48 -5
View File
@@ -1,5 +1,10 @@
<template> <template>
<div class="w-full h-full py-6"> <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 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="w-full pl-2 pr-2 py-2 bg-black bg-opacity-25 rounded-lg relative">
<div class="flex items-center mb-1"> <div class="flex items-center mb-1">
@@ -23,9 +28,9 @@
</div> </div>
</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"> <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"> <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> <span class="material-icons text-xl text-yellow-400">folder</span>
@@ -55,6 +60,7 @@ export default {
data() { data() {
return { return {
localFolders: [], localFolders: [],
localLibraryItems: [],
newFolderMediaType: null, newFolderMediaType: null,
mediaTypeItems: [ mediaTypeItems: [
{ {
@@ -65,7 +71,8 @@ export default {
value: 'podcast', value: 'podcast',
text: 'Podcasts' text: 'Podcasts'
} }
] ],
syncing: false
} }
}, },
computed: { computed: {
@@ -75,6 +82,14 @@ export default {
lastLocalMediaSyncResults() { lastLocalMediaSyncResults() {
return this.$store.state.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() { numLocalMediaSynced() {
if (!this.lastLocalMediaSyncResults) return 0 if (!this.lastLocalMediaSyncResults) return 0
return this.lastLocalMediaSyncResults.numLocalMediaProgressForServer || 0 return this.lastLocalMediaSyncResults.numLocalMediaProgressForServer || 0
@@ -97,6 +112,33 @@ export default {
} }
}, },
methods: { 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() { async selectFolder() {
if (!this.newFolderMediaType) { if (!this.newFolderMediaType) {
return this.$toast.error('Must select a media type') return this.$toast.error('Must select a media type')
@@ -107,14 +149,14 @@ export default {
return this.$toast.error(`Error: ${folderObj.error || 'Unknown Error'}`) 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) { if (indexOfExisting >= 0) {
this.localFolders.splice(indexOfExisting, 1, folderObj) this.localFolders.splice(indexOfExisting, 1, folderObj)
} else { } else {
this.localFolders.push(folderObj) this.localFolders.push(folderObj)
} }
var permissionsGood = await AbsFileSystem.checkFolderPermissions({ folderUrl: folderObj.contentUrl }) const permissionsGood = await AbsFileSystem.checkFolderPermissions({ folderUrl: folderObj.contentUrl })
if (!permissionsGood) { if (!permissionsGood) {
this.$toast.error('Folder permissions failed') this.$toast.error('Folder permissions failed')
@@ -127,6 +169,7 @@ export default {
}, },
async init() { async init() {
this.localFolders = (await this.$db.getLocalFolders()) || [] this.localFolders = (await this.$db.getLocalFolders()) || []
this.localLibraryItems = await this.$db.getLocalLibraryItems()
} }
}, },
mounted() { mounted() {
+8 -3
View File
@@ -90,7 +90,7 @@
<p class="text-lg text-center px-8">{{ failed ? 'Failed to get local library item ' + localLibraryItemId : 'Loading..' }}</p> <p class="text-lg text-center px-8">{{ failed ? 'Failed to get local library item ' + localLibraryItemId : 'Loading..' }}</p>
</div> </div>
<div v-if="orderChanged" class="fixed 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" /> <div class="flex-grow" />
<ui-btn small color="success" @click="saveTrackOrder">Save Order</ui-btn> <ui-btn small color="success" @click="saveTrackOrder">Save Order</ui-btn>
</div> </div>
@@ -138,6 +138,9 @@ export default {
} }
}, },
computed: { computed: {
playerLibraryItemId() {
return this.$store.state.playerLibraryItemId
},
isIos() { isIos() {
return this.$platform === 'ios' return this.$platform === 'ios'
}, },
@@ -252,14 +255,16 @@ export default {
} }
this.showDialog = true this.showDialog = true
}, },
play() { async play() {
await this.$hapticsImpactMedium()
this.$eventBus.$emit('play-item', { libraryItemId: this.localLibraryItemId }) this.$eventBus.$emit('play-item', { libraryItemId: this.localLibraryItemId })
}, },
getCapImageSrc(contentUrl) { getCapImageSrc(contentUrl) {
return Capacitor.convertFileSrc(contentUrl) return Capacitor.convertFileSrc(contentUrl)
}, },
dialogAction(action) { async dialogAction(action) {
console.log('Dialog action', action) console.log('Dialog action', action)
await this.$hapticsImpactMedium()
if (action == 'scan') { if (action == 'scan') {
this.scanItem() this.scanItem()
} else if (action == 'rescan') { } else if (action == 'rescan') {
+95
View File
@@ -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
View File
@@ -89,9 +89,9 @@ export default {
return return
} }
this.isFetching = true 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) console.error('Search error', error)
return [] return null
}) })
if (value !== this.lastSearch) { if (value !== this.lastSearch) {
console.log(`runSearch: New search was made for ${this.lastSearch} - results are from ${value}`) console.log(`runSearch: New search was made for ${this.lastSearch} - results are from ${value}`)
+37 -2
View File
@@ -7,6 +7,12 @@
</div> </div>
<p class="pl-4">Alternative bookshelf view</p> <p class="pl-4">Alternative bookshelf view</p>
</div> </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> <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"> <div v-if="!isiOS" class="flex items-center py-3" @click="toggleDisableAutoRewind">
@@ -51,14 +57,16 @@ export default {
enableAltView: false, enableAltView: false,
jumpForwardTime: 10, jumpForwardTime: 10,
jumpBackwardsTime: 10, jumpBackwardsTime: 10,
disableShakeToResetSleepTimer: false disableShakeToResetSleepTimer: false,
lockOrientation: 0
}, },
settingInfo: { settingInfo: {
disableShakeToResetSleepTimer: { disableShakeToResetSleepTimer: {
name: 'Disable shake to reset sleep timer', 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.' 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: { computed: {
@@ -107,6 +115,28 @@ export default {
this.settings.enableAltView = !this.settings.enableAltView this.settings.enableAltView = !this.settings.enableAltView
this.saveSettings() 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() { toggleJumpForward() {
var next = (this.currentJumpForwardTimeIndex + 1) % 3 var next = (this.currentJumpForwardTimeIndex + 1) % 3
this.settings.jumpForwardTime = this.jumpForwardItems[next].value this.settings.jumpForwardTime = this.jumpForwardItems[next].value
@@ -119,6 +149,7 @@ export default {
this.saveSettings() this.saveSettings()
}, },
async saveSettings() { async saveSettings() {
await this.$hapticsImpactMedium()
const updatedDeviceData = await this.$db.updateDeviceSettings({ ...this.settings }) const updatedDeviceData = await this.$db.updateDeviceSettings({ ...this.settings })
console.log('Saved device data', updatedDeviceData) console.log('Saved device data', updatedDeviceData)
if (updatedDeviceData) { if (updatedDeviceData) {
@@ -136,6 +167,10 @@ export default {
this.settings.jumpForwardTime = deviceSettings.jumpForwardTime || 10 this.settings.jumpForwardTime = deviceSettings.jumpForwardTime || 10
this.settings.jumpBackwardsTime = deviceSettings.jumpBackwardsTime || 10 this.settings.jumpBackwardsTime = deviceSettings.jumpBackwardsTime || 10
this.settings.disableShakeToResetSleepTimer = !!deviceSettings.disableShakeToResetSleepTimer 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() { mounted() {
+3 -3
View File
@@ -5,21 +5,21 @@ export default function ({ $axios, store }) {
return return
} }
var customHeaders = store.getters['user/getCustomHeaders'] const customHeaders = store.getters['user/getCustomHeaders']
if (customHeaders) { if (customHeaders) {
for (const key in customHeaders) { for (const key in customHeaders) {
config.headers.common[key] = customHeaders[key] config.headers.common[key] = customHeaders[key]
} }
} }
var bearerToken = store.getters['user/getToken'] const bearerToken = store.getters['user/getToken']
if (bearerToken) { if (bearerToken) {
config.headers.common['Authorization'] = `Bearer ${bearerToken}` config.headers.common['Authorization'] = `Bearer ${bearerToken}`
} else { } else {
console.warn('[Axios] No Bearer Token for request') console.warn('[Axios] No Bearer Token for request')
} }
var serverUrl = store.getters['user/getServerAddress'] const serverUrl = store.getters['user/getServerAddress']
if (serverUrl) { if (serverUrl) {
config.url = `${serverUrl}${config.url}` config.url = `${serverUrl}${config.url}`
} }
+22 -22
View File
@@ -1,52 +1,52 @@
import Vue from 'vue' import Vue from "vue";
import { Haptics, ImpactStyle, NotificationType } from '@capacitor/haptics' import { Haptics, ImpactStyle, NotificationType } from "@capacitor/haptics"
const hapticsImpactHeavy = async () => { 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 () => { const hapticsImpactMedium = async () => {
await Haptics.impact({ style: ImpactStyle.Medium }); await Haptics.impact({ style: ImpactStyle.Medium })
} }
Vue.prototype.$hapticsImpactMedium = hapticsImpactMedium Vue.prototype.$hapticsImpactMedium = hapticsImpactMedium
const hapticsImpactLight = async () => { const hapticsImpactLight = async () => {
await Haptics.impact({ style: ImpactStyle.Light }); await Haptics.impact({ style: ImpactStyle.Light })
}; }
Vue.prototype.$hapticsImpactLight = hapticsImpactLight Vue.prototype.$hapticsImpactLight = hapticsImpactLight
const hapticsVibrate = async () => { const hapticsVibrate = async () => {
await Haptics.vibrate(); await Haptics.vibrate()
}; }
Vue.prototype.$hapticsVibrate = hapticsVibrate Vue.prototype.$hapticsVibrate = hapticsVibrate;
const hapticsNotificationSuccess = async () => { const hapticsNotificationSuccess = async () => {
await Haptics.notification({ type: NotificationType.Success }); await Haptics.notification({ type: NotificationType.Success })
}; }
Vue.prototype.$hapticsNotificationSuccess = hapticsNotificationSuccess Vue.prototype.$hapticsNotificationSuccess = hapticsNotificationSuccess
const hapticsNotificationWarning = async () => { const hapticsNotificationWarning = async () => {
await Haptics.notification({ type: NotificationType.Warning }); await Haptics.notification({ type: NotificationType.Warning })
}; }
Vue.prototype.$hapticsNotificationWarning = hapticsNotificationWarning Vue.prototype.$hapticsNotificationWarning = hapticsNotificationWarning
const hapticsNotificationError = async () => { const hapticsNotificationError = async () => {
await Haptics.notification({ type: NotificationType.Error }); await Haptics.notification({ type: NotificationType.Error })
}; }
Vue.prototype.$hapticsNotificationError = hapticsNotificationError Vue.prototype.$hapticsNotificationError = hapticsNotificationError
const hapticsSelectionStart = async () => { const hapticsSelectionStart = async () => {
await Haptics.selectionStart(); await Haptics.selectionStart()
}; }
Vue.prototype.$hapticsSelectionStart = hapticsSelectionStart Vue.prototype.$hapticsSelectionStart = hapticsSelectionStart
const hapticsSelectionChanged = async () => { const hapticsSelectionChanged = async () => {
await Haptics.selectionChanged(); await Haptics.selectionChanged()
}; }
Vue.prototype.$hapticsSelectionChanged = hapticsSelectionChanged Vue.prototype.$hapticsSelectionChanged = hapticsSelectionChanged
const hapticsSelectionEnd = async () => { const hapticsSelectionEnd = async () => {
await Haptics.selectionEnd(); await Haptics.selectionEnd()
}; }
Vue.prototype.$hapticsSelectionEnd = hapticsSelectionEnd Vue.prototype.$hapticsSelectionEnd = hapticsSelectionEnd
+50
View File
@@ -16,6 +16,9 @@ if (Capacitor.getPlatform() != 'web') {
Vue.prototype.$isDev = process.env.NODE_ENV !== 'production' 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) => { Vue.prototype.$dateDistanceFromNow = (unixms) => {
if (!unixms) return '' if (!unixms) return ''
return formatDistance(unixms, Date.now(), { addSuffix: true }) 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')}` 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 = []) { function isClickedOutsideEl(clickEvent, elToCheckOutside, ignoreSelectors = [], ignoreElems = []) {
const isDOMElement = (element) => { const isDOMElement = (element) => {
return element instanceof Element || element instanceof HTMLDocument return element instanceof Element || element instanceof HTMLDocument
@@ -156,6 +196,16 @@ Vue.prototype.$encode = encode
const decode = (text) => Buffer.from(decodeURIComponent(text), 'base64').toString() const decode = (text) => Buffer.from(decodeURIComponent(text), 'base64').toString()
Vue.prototype.$decode = decode 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 }) => { export default ({ store, app }) => {
// iOS Only // iOS Only
// backButton event does not work with iOS swipe navigation so use this workaround // backButton event does not work with iOS swipe navigation so use this workaround
Binary file not shown.
+6 -2
View File
@@ -13,12 +13,16 @@
<glyph unicode="&#xe903;" 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="&#xe903;" 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="&#xe904;" 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="&#xe904;" 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="&#xe905;" 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="&#xe905;" 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="&#xe906;" 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="&#xe906;" 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="&#xe907;" 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="&#xe909;" 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="&#xe90a;" 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="&#xe90b;" 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="&#xe910;" 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="&#xe910;" 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="&#xe911;" 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="&#xe911;" 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="&#xe914;" 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="&#xe914;" 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="&#xe91e;" 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="&#xe91e;" 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="&#xe91f;" 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="&#xe91f;" 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="&#xe920;" 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="&#xe920;" 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="&#xe927;" 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="&#xe927;" 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="&#xe964;" 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" /> <glyph unicode="&#xe964;" 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
View File
@@ -33,7 +33,9 @@ export const state = () => ({
value: 30 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 = { export const getters = {
@@ -43,26 +45,26 @@ export const getters = {
return i.libraryItemId == libraryItemId 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 if (!libraryItem) return placeholder
var media = libraryItem.media const media = libraryItem.media
if (!media || !media.coverPath || media.coverPath === placeholder) return placeholder if (!media || !media.coverPath || media.coverPath === placeholder) return placeholder
// Absolute URL covers (should no longer be used) // Absolute URL covers (should no longer be used)
if (media.coverPath.startsWith('http:') || media.coverPath.startsWith('https:')) return media.coverPath if (media.coverPath.startsWith('http:') || media.coverPath.startsWith('https:')) return media.coverPath
var userToken = rootGetters['user/getToken'] const userToken = rootGetters['user/getToken']
var serverAddress = rootGetters['user/getServerAddress'] const serverAddress = rootGetters['user/getServerAddress']
if (!userToken || !serverAddress) return placeholder if (!userToken || !serverAddress) return placeholder
var lastUpdate = libraryItem.updatedAt || Date.now() const lastUpdate = libraryItem.updatedAt || Date.now()
if (process.env.NODE_ENV !== 'production') { // Testing if (process.env.NODE_ENV !== 'production') { // Testing
// return `http://localhost:3333/api/items/${libraryItem.id}/cover?token=${userToken}&ts=${lastUpdate}` // return `http://localhost:3333/api/items/${libraryItem.id}/cover?token=${userToken}&ts=${lastUpdate}`
} }
var url = new URL(`/api/items/${libraryItem.id}/cover`, serverAddress) const url = new URL(`/api/items/${libraryItem.id}/cover`, serverAddress)
return `${url}?token=${userToken}&ts=${lastUpdate}` return `${url}?token=${userToken}&ts=${lastUpdate}${raw ? '&raw=1' : ''}`
}, },
getLocalMediaProgressById: (state) => (localLibraryItemId, episodeId = null) => { getLocalMediaProgressById: (state) => (localLibraryItemId, episodeId = null) => {
return state.localMediaProgress.find(lmp => { return state.localMediaProgress.find(lmp => {
@@ -89,7 +91,6 @@ export const getters = {
export const actions = { export const actions = {
async loadLocalMediaProgress({ state, commit }) { async loadLocalMediaProgress({ state, commit }) {
var mediaProgress = await this.$db.getAllLocalMediaProgress() var mediaProgress = await this.$db.getAllLocalMediaProgress()
console.log('Got all local media progress', JSON.stringify(mediaProgress))
commit('setLocalMediaProgress', mediaProgress) commit('setLocalMediaProgress', mediaProgress)
} }
} }
@@ -124,10 +125,8 @@ export const mutations = {
} }
var index = state.localMediaProgress.findIndex(lmp => lmp.id == prog.id) var index = state.localMediaProgress.findIndex(lmp => lmp.id == prog.id)
if (index >= 0) { if (index >= 0) {
console.log('UpdateLocalMediaProgress updating', prog.id, prog.progress)
state.localMediaProgress.splice(index, 1, prog) state.localMediaProgress.splice(index, 1, prog)
} else { } else {
console.log('updateLocalMediaProgress inserting new progress', prog.id, prog.progress)
state.localMediaProgress.push(prog) state.localMediaProgress.push(prog)
} }
}, },
@@ -139,5 +138,11 @@ export const mutations = {
}, },
setLastSearch(state, val) { setLastSearch(state, val) {
state.lastSearch = val state.lastSearch = val
},
setSelectedPlaylistItems(state, items) {
state.selectedPlaylistItems = items
},
setShowPlaylistsAddCreateModal(state, val) {
state.showPlaylistsAddCreateModal = val
} }
} }
+9
View File
@@ -26,6 +26,11 @@ export const state = () => ({
}) })
export const getters = { 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 => { getIsItemStreaming: state => libraryItemId => {
return state.playerLibraryItemId == libraryItemId return state.playerLibraryItemId == libraryItemId
}, },
@@ -47,6 +52,10 @@ export const getters = {
getAltViewEnabled: state => { getAltViewEnabled: state => {
if (!state.deviceData || !state.deviceData.deviceSettings) return false if (!state.deviceData || !state.deviceData.deviceSettings) return false
return state.deviceData.deviceSettings.enableAltView return state.deviceData.deviceSettings.enableAltView
},
getOrientationLockSetting: state => {
if (!state.deviceData || !state.deviceData.deviceSettings) return false
return state.deviceData.deviceSettings.lockOrientation
} }
} }
+16 -7
View File
@@ -6,7 +6,8 @@ export const state = () => ({
currentLibraryId: '', currentLibraryId: '',
showModal: false, showModal: false,
issues: 0, issues: 0,
filterData: null filterData: null,
numUserPlaylists: 0
}) })
export const getters = { export const getters = {
@@ -41,15 +42,17 @@ export const actions = {
return this.$axios return this.$axios
.$get(`/api/libraries/${libraryId}?include=filterdata`) .$get(`/api/libraries/${libraryId}?include=filterdata`)
.then((data) => { .then((data) => {
var library = data.library const library = data.library
var filterData = data.filterdata const filterData = data.filterdata
var issues = data.issues || 0 const issues = data.issues || 0
const numUserPlaylists = data.numUserPlaylists || 0
dispatch('user/checkUpdateLibrarySortFilter', library.mediaType, { root: true }) dispatch('user/checkUpdateLibrarySortFilter', library.mediaType, { root: true })
commit('addUpdate', library) commit('addUpdate', library)
commit('setLibraryIssues', issues) commit('setLibraryIssues', issues)
commit('setLibraryFilterData', filterData) commit('setLibraryFilterData', filterData)
commit('setNumUserPlaylists', numUserPlaylists)
commit('setCurrentLibrary', libraryId) commit('setCurrentLibrary', libraryId)
return data return data
}) })
@@ -75,12 +78,15 @@ export const actions = {
return this.$axios return this.$axios
.$get(`/api/libraries`) .$get(`/api/libraries`)
.then((data) => { .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 // 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))) { if (libraries.length && (!state.currentLibraryId || !libraries.find(li => li.id == state.currentLibraryId))) {
commit('setCurrentLibrary', data[0].id) commit('setCurrentLibrary', libraries[0].id)
} }
commit('set', data) commit('set', libraries)
commit('setLastLoad', Date.now()) commit('setLastLoad', Date.now())
return true return true
}) })
@@ -125,6 +131,9 @@ export const mutations = {
setLibraryIssues(state, val) { setLibraryIssues(state, val) {
state.issues = val state.issues = val
}, },
setNumUserPlaylists(state, numUserPlaylists) {
state.numUserPlaylists = numUserPlaylists
},
setLibraryFilterData(state, filterData) { setLibraryFilterData(state, filterData) {
state.filterData = filterData state.filterData = filterData
}, },
+4
View File
@@ -16,9 +16,13 @@ export const state = () => ({
export const getters = { export const getters = {
getIsRoot: (state) => state.user && state.user.type === 'root', getIsRoot: (state) => state.user && state.user.type === 'root',
getIsAdminOrUp: (state) => state.user && (state.user.type === 'admin' || state.user.type === 'root'),
getToken: (state) => { getToken: (state) => {
return state.user ? state.user.token : null return state.user ? state.user.token : null
}, },
getServerConnectionConfigId: (state) => {
return state.serverConnectionConfig ? state.serverConnectionConfig.id : null
},
getServerAddress: (state) => { getServerAddress: (state) => {
return state.serverConnectionConfig ? state.serverConnectionConfig.address : null return state.serverConnectionConfig ? state.serverConnectionConfig.address : null
}, },
+2
View File
@@ -42,6 +42,8 @@ module.exports = {
'24': '6rem' '24': '6rem'
}, },
minWidth: { minWidth: {
'4': '1rem',
'8': '2rem',
'12': '3rem' '12': '3rem'
}, },
minHeight: { minHeight: {