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
advplyrandGitHub 99217fee48 Merge pull request #459 from benonymity/hapticFeedback
Add haptic feedback
2022-12-09 17:08:17 -06:00
advplyrandGitHub f12e64990c Update components/home/BookshelfToolbar.vue 2022-12-09 17:08:00 -06:00
advplyrandGitHub ef7bb9a9d8 Update pages/settings.vue 2022-12-09 17:07:50 -06:00
advplyrandGitHub 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
advplyrandGitHub 8365e06762 Merge pull request #458 from benonymity/noTextSelect
Prevent selecting of text in the app
2022-12-07 16:37:47 -06:00
advplyrandGitHub 139a0b3412 Merge pull request #456 from benonymity/listeningStatsFix
Fix Listening Stats
2022-12-07 16:28:37 -06:00
advplyrandGitHub 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
advplyr 6bbcd300e4 iOS version bump 0.9.59 2022-11-05 16:41:45 -05:00
advplyr c54d9b3c5f Version bump 0.9.59 2022-11-05 16:29:30 -05:00
advplyr 9ab03fbcd4 fix & update comic reader for comics with subfolders and better sorting 2022-11-05 15:13:52 -05:00
advplyr a852114e9c Update:Use cover aspect ratio from library setting 2022-10-22 08:59:10 -05:00
advplyr 3c9b8c1c6f Fix read btn color 2022-10-21 16:29:46 -05:00
advplyr 4735b51dc0 Update:New library icons 2022-10-19 10:56:31 -05:00
advplyrandGitHub 23927a1d59 Merge pull request #413 from wnhrt/master
Colored status and navigationbar
2022-10-16 12:08:55 -05:00
Niklas Weinhart 40b592f7c1 Colored status and navigationbar 2022-10-16 05:37:35 +02:00
advplyrandGitHub 4da31f5850 Merge pull request #391 from ronaldheft/fix-ios-pause-during-seek
fix: Player sometimes pauses during seek on iOS
2022-09-23 15:37:50 -05:00
advplyrandGitHub f79e96cbfe Merge pull request #397 from ronaldheft/ios-player-seek-back-time-fixes
feat: Groundwork for testable iOS swift code
2022-09-23 15:36:59 -05:00
ronaldheft 2dbce1e3bb fix: Negative currentTime after seek back 2022-09-21 20:16:29 -04:00
ronaldheft cdfbbcbf8a refactor: Use refactored seek back time class 2022-09-20 18:34:43 -04:00
ronaldheft 890852bef5 refactor: Pull out seek back calculation into testable class 2022-09-20 18:32:28 -04:00
advplyrandGitHub 3b285db21a Merge pull request #393 from ronaldheft/fix-on-ready-multiple-calls
fix: onReady called multiple times
2022-09-19 16:46:00 -05:00
advplyrandGitHub d37be88977 Merge pull request #392 from ronaldheft/fix-ios-chapter-sleep-timer
fix: iOS chapter sleep timer issues
2022-09-19 16:45:09 -05:00
ronaldheft e6d27a1487 fix: onReady called multiple times 2022-09-18 14:38:32 -04:00
ronaldheft 5d23c17d30 fix: iOS chapter sleep timer issues 2022-09-18 14:21:41 -04:00
ronaldheft 9c9c1c3b94 fix: Player sometimes pauses during seek 2022-09-18 13:38:16 -04:00
advplyrandGitHub 4e94fd6ad0 Merge pull request #390 from ronaldheft/fix-ios-seek-back-time
fix: iOS seek back time not winding back
2022-09-18 11:58:50 -05:00
advplyrandGitHub 81a8203cca Merge pull request #389 from ronaldheft/ios-bug-artwork
Fix local cover images not used on iOS
2022-09-18 11:57:12 -05:00
ronaldheft b6b206c288 fix: Seek back time not winding back 2022-09-18 12:44:48 -04:00
advplyrandGitHub ed2310a2e7 Merge pull request #387 from ronaldheft/ios-realm-crash
fix: iOS 16 realm crash
2022-09-18 11:29:52 -05:00
ronaldheft bae5d97087 fix: Local artwork not loading 2022-09-18 12:24:19 -04:00
ronaldheft 3e12ff139b fix: Local artwork not used 2022-09-18 11:43:38 -04:00
ronaldheft 7ccd8db085 fix: iOS 16 realm crash 2022-09-17 17:47:18 -04:00
advplyrandGitHub fdc829d06c Merge pull request #386 from ronaldheft/ios-general-fixes
Fix various iOS app startup issues
2022-09-16 16:14:44 -05:00
ronaldheft e7f61e34e8 Fix player automatically starting after WebKit reload 2022-09-15 20:49:25 -04:00
ronaldheft 141877f111 Fix Pods with iOS target change 2022-09-14 20:24:27 -04:00
ronaldheft b71a976e61 Merge remote-tracking branch 'upstream/master' into ios-general-fixes 2022-09-14 19:55:42 -04:00
advplyrandGitHub 68c95ed6d5 Merge pull request #371 from ronaldheft/ios-logging
Improve iOS Logging
2022-09-14 17:54:31 -05:00
ronaldheft 7a68cc99af Convert remain NSLog statement to new logging framework 2022-09-13 21:16:55 -04:00
ronaldheft 7294bc7033 Audio player modifications should be on main queue 2022-09-13 20:47:07 -04:00
ronaldheft f8836be147 fix: Debug logging level does not persist to disk 2022-09-08 20:09:35 -04:00
ronaldheft 713999eb83 Fix deprecated and early window access warnings 2022-09-08 19:51:35 -04:00
ronaldheft cc4c9787c0 Rename ABS to Audiobookshelf 2022-09-06 21:56:10 -04:00
ronaldheft ecc5cf3e1b Rename app to provide better logging 2022-09-06 21:42:12 -04:00
ronaldheft ba7d2be03e Fix incorrect method call 2022-09-06 21:33:24 -04:00
ronaldheft 368f349c78 Use logging framework for AudioPlayer 2022-09-06 21:32:32 -04:00
ronaldheft 639d641c07 Fix usage of this 2022-09-06 21:32:10 -04:00
ronaldheft d9313a1654 Use os logging framework, so logs can be retrieved later 2022-09-06 21:26:48 -04:00
advplyrandGitHub 9dd532285c Merge pull request #365 from vangorra/vangorra/aa-button-cleanup
Cleanup Android Auto buttons
2022-09-04 11:51:11 -05:00
Robert Van GorkomandGitHub 92f10fdb5c Merge branch 'advplyr:master' into vangorra/aa-button-cleanup 2022-09-04 09:23:51 -07:00
advplyrandGitHub 9d0f43a664 Merge pull request #363 from vangorra/vangorra/debug-build
Add debug build to to allow prod and dev apps to work side by side.
2022-09-04 09:46:03 -05:00
Robbie Van Gorkom 453365fcd6 Fix buttons for Android Auto (and probably wear too). 2022-09-03 18:16:12 -07:00
Robbie Van Gorkom 6231c5fcc4 Add debug build to to allow prod and dev apps to work side by side. 2022-09-03 15:06:28 -07:00
156 changed files with 4132 additions and 890 deletions
+8 -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 88 versionCode 91
versionName "0.9.57-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.
@@ -43,6 +43,12 @@ android {
} }
} }
buildTypes { buildTypes {
debug {
applicationIdSuffix ".debug"
versionNameSuffix "-debug"
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
release { release {
minifyEnabled false minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 879 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 685 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+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,8 +252,8 @@ 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 {
(libraryItem as LibraryItem).getCoverUri() (libraryItem as LibraryItem).getCoverUri()
@@ -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
) )
} }
} }
@@ -40,7 +47,7 @@ data class DeviceSettings(
@get:JsonIgnore @get:JsonIgnore
val jumpBackwardsTimeMs get() = (jumpBackwardsTime ?: default().jumpBackwardsTime) * 1000L val jumpBackwardsTimeMs get() = (jumpBackwardsTime ?: default().jumpBackwardsTime) * 1000L
@get:JsonIgnore @get:JsonIgnore
val jumpForwardTimeMs get() = (jumpForwardTime ?: default().jumpBackwardsTime) * 1000L val jumpForwardTimeMs get() = (jumpForwardTime ?: default().jumpForwardTime) * 1000L
} }
data class DeviceData( data class DeviceData(
@@ -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,13 +45,25 @@ 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")
serviceScope.launch {
currentBitmap = albumArtUri?.let { if (currentIconUri.toString().startsWith("content://")) {
resolveUriAsBitmap(it) currentBitmap = if (Build.VERSION.SDK_INT < 28) {
MediaStore.Images.Media.getBitmap(playerNotificationService.contentResolver, currentIconUri)
} else {
val source: ImageDecoder.Source = ImageDecoder.createSource(playerNotificationService.contentResolver, currentIconUri!!)
ImageDecoder.decodeBitmap(source)
} }
currentBitmap?.let { callback.onBitmap(it) } currentBitmap
} else {
serviceScope.launch {
currentBitmap = albumArtUri?.let {
resolveUriAsBitmap(it)
}
currentBitmap?.let { callback.onBitmap(it) }
}
null
} }
null
} else { } 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()
} }
@@ -239,6 +245,8 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
when (action) { when (action) {
CUSTOM_ACTION_JUMP_FORWARD -> onFastForward() CUSTOM_ACTION_JUMP_FORWARD -> onFastForward()
CUSTOM_ACTION_JUMP_BACKWARD -> onRewind() CUSTOM_ACTION_JUMP_BACKWARD -> onRewind()
CUSTOM_ACTION_SKIP_FORWARD -> onSkipToNext()
CUSTOM_ACTION_SKIP_BACKWARD -> onSkipToPrevious()
} }
} }
} }
@@ -2,3 +2,5 @@ package com.audiobookshelf.app.player
const val CUSTOM_ACTION_JUMP_FORWARD = "com.audiobookshelf.customAction.jump_forward"; const val CUSTOM_ACTION_JUMP_FORWARD = "com.audiobookshelf.customAction.jump_forward";
const val CUSTOM_ACTION_JUMP_BACKWARD = "com.audiobookshelf.customAction.jump_backward"; const val CUSTOM_ACTION_JUMP_BACKWARD = "com.audiobookshelf.customAction.jump_backward";
const val CUSTOM_ACTION_SKIP_FORWARD = "com.audiobookshelf.customAction.skip_forward";
const val CUSTOM_ACTION_SKIP_BACKWARD = "com.audiobookshelf.customAction.skip_backward";
@@ -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
@@ -45,6 +49,8 @@ import kotlin.concurrent.schedule
const val SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m const val SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m
const val PLAYER_CAST = "cast-player";
const val PLAYER_EXO = "exo-player";
class PlayerNotificationService : MediaBrowserServiceCompat() { class PlayerNotificationService : MediaBrowserServiceCompat() {
@@ -248,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)
@@ -256,6 +263,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
mediaSessionConnector = MediaSessionConnector(mediaSession) mediaSessionConnector = MediaSessionConnector(mediaSession)
val queueNavigator: TimelineQueueNavigator = object : TimelineQueueNavigator(mediaSession) { val queueNavigator: TimelineQueueNavigator = object : TimelineQueueNavigator(mediaSession) {
override fun getSupportedQueueNavigatorActions(player: Player): Long {
return PlaybackStateCompat.ACTION_PLAY_PAUSE or PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PAUSE
}
override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat { override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat {
if (currentPlaybackSession == null) { if (currentPlaybackSession == null) {
Log.e(tag,"Playback session is not set - returning blank MediaDescriptionCompat") Log.e(tag,"Playback session is not set - returning blank MediaDescriptionCompat")
@@ -264,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 {
@@ -276,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()
} }
} }
@@ -296,11 +326,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
mediaSessionConnector.setQueueNavigator(queueNavigator) mediaSessionConnector.setQueueNavigator(queueNavigator)
mediaSessionConnector.setPlaybackPreparer(MediaSessionPlaybackPreparer(this)) mediaSessionConnector.setPlaybackPreparer(MediaSessionPlaybackPreparer(this))
mediaSessionConnector.setCustomActionProviders(
JumpForwardCustomActionProvider(),
JumpBackwardCustomActionProvider(),
)
mediaSession.setCallback(MediaSessionCallback(this)) mediaSession.setCallback(MediaSessionCallback(this))
initializeMPlayer() initializeMPlayer()
@@ -342,20 +367,43 @@ 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(
JumpBackwardCustomActionProvider(),
JumpForwardCustomActionProvider(),
)
val metadata = playbackSession.getMediaMetadataCompat(ctx)
mediaSession.setMetadata(metadata)
val mediaItems = playbackSession.getMediaItems()
val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f
initialPlaybackRate = playbackRate initialPlaybackRate = playbackRate
if (playbackSession.mediaPlayer != PLAYER_CAST && mediaItems.size > 1) {
customActionProviders.addAll(listOf(
SkipBackwardCustomActionProvider(),
SkipForwardCustomActionProvider(),
))
}
mediaSessionConnector.setCustomActionProviders(*customActionProviders.toTypedArray());
playbackSession.mediaPlayer = getMediaPlayer() playbackSession.mediaPlayer = getMediaPlayer()
if (playbackSession.mediaPlayer == "cast-player" && playbackSession.isLocal) { if (playbackSession.mediaPlayer == PLAYER_CAST && playbackSession.isLocal) {
Log.w(tag, "Cannot cast local media item - switching player") Log.w(tag, "Cannot cast local media item - switching player")
currentPlaybackSession = null currentPlaybackSession = null
switchToPlayer(false) switchToPlayer(false)
playbackSession.mediaPlayer = getMediaPlayer() playbackSession.mediaPlayer = getMediaPlayer()
} }
if (playbackSession.mediaPlayer == "cast-player") { if (playbackSession.mediaPlayer == PLAYER_CAST) {
// If cast-player is the first player to be used // If cast-player is the first player to be used
mediaSessionConnector.setPlayer(castPlayer) mediaSessionConnector.setPlayer(castPlayer)
playerNotificationManager.setPlayer(castPlayer) playerNotificationManager.setPlayer(castPlayer)
@@ -366,10 +414,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
clientEventEmitter?.onPlaybackSession(playbackSession) clientEventEmitter?.onPlaybackSession(playbackSession)
val metadata = playbackSession.getMediaMetadataCompat()
mediaSession.setMetadata(metadata)
val mediaItems = playbackSession.getMediaItems()
if (mediaItems.isEmpty()) { if (mediaItems.isEmpty()) {
Log.e(tag, "Invalid playback session no media items to play") Log.e(tag, "Invalid playback session no media items to play")
currentPlaybackSession = null currentPlaybackSession = null
@@ -452,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
@@ -627,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) {
@@ -682,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)
} }
} }
@@ -751,7 +834,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
fun getMediaPlayer():String { fun getMediaPlayer():String {
return if(currentPlayer == castPlayer) "cast-player" else "exo-player" return if(currentPlayer == castPlayer) PLAYER_CAST else PLAYER_EXO
} }
fun getDeviceInfo(): DeviceInfo { fun getDeviceInfo(): DeviceInfo {
@@ -844,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
@@ -852,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)
} }
} }
@@ -870,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)
} }
@@ -893,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)
} }
@@ -1024,5 +1107,39 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
).build() ).build()
} }
} }
inner class SkipForwardCustomActionProvider : CustomActionProvider {
override fun onCustomAction(player: Player, action: String, extras: Bundle?) {
/*
This does not appear to ever get called. Instead, MediaSessionCallback.onCustomAction() is
responsible to reacting to a custom action.
*/
}
override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? {
return PlaybackStateCompat.CustomAction.Builder(
CUSTOM_ACTION_SKIP_FORWARD,
getContext().getString(R.string.action_skip_forward),
R.drawable.skip_next_24
).build()
}
}
inner class SkipBackwardCustomActionProvider : CustomActionProvider {
override fun onCustomAction(player: Player, action: String, extras: Bundle?) {
/*
This does not appear to ever get called. Instead, MediaSessionCallback.onCustomAction() is
responsible to reacting to a custom action.
*/
}
override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? {
return PlaybackStateCompat.CustomAction.Builder(
CUSTOM_ACTION_SKIP_BACKWARD,
getContext().getString(R.string.action_skip_backward),
R.drawable.skip_previous_24
).build()
}
}
} }
@@ -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")
@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<group android:scaleX="1.127451"
android:scaleY="1.127451"
android:translateX="-1.5294118"
android:translateY="-1.5294118">
<path
android:fillColor="@android:color/white"
android:pathData="M6,18l8.5,-6L6,6v12zM16,6v12h2V6h-2z"/>
</group>
</vector>
@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<group android:scaleX="1.127451"
android:scaleY="1.127451"
android:translateX="-1.5294118"
android:translateY="-1.5294118">
<path
android:fillColor="@android:color/white"
android:pathData="M6,6h2v12L6,18zM9.5,12l8.5,6L18,6z"/>
</group>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path
android:fillColor="@android:color/white"
android:pathData="M6,18l8.5,-6L6,6v12zM16,6v12h2V6h-2z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path
android:fillColor="@android:color/white"
android:pathData="M6,6h2v12L6,18zM9.5,12l8.5,6L18,6z"/>
</vector>
@@ -11,6 +11,8 @@
<item name="windowActionBar">false</item> <item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item> <item name="windowNoTitle">true</item>
<item name="android:background">@null</item> <item name="android:background">@null</item>
<item name="android:statusBarColor">@color/background_dark</item>
<item name="android:navigationBarColor">@color/background_dark</item>
</style> </style>
@@ -3,4 +3,5 @@
<color name="light_blue_200">#FF81D4FA</color> <color name="light_blue_200">#FF81D4FA</color>
<color name="light_blue_600">#FF039BE5</color> <color name="light_blue_600">#FF039BE5</color>
<color name="light_blue_900">#FF01579B</color> <color name="light_blue_900">#FF01579B</color>
<color name="background_dark">#262626</color>
</resources> </resources>
@@ -8,4 +8,6 @@
<string name="app_widget_description">Simple widget for audiobookshelf playback</string> <string name="app_widget_description">Simple widget for audiobookshelf playback</string>
<string name="action_jump_forward">Jump Forward</string> <string name="action_jump_forward">Jump Forward</string>
<string name="action_jump_backward">Jump Backward</string> <string name="action_jump_backward">Jump Backward</string>
<string name="action_skip_forward">Skip Forward</string>
<string name="action_skip_backward">Skip Backward</string>
</resources> </resources>
@@ -13,6 +13,8 @@
<item name="windowActionBar">false</item> <item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item> <item name="windowNoTitle">true</item>
<item name="android:background">@null</item> <item name="android:background">@null</item>
<item name="android:statusBarColor">@color/background_dark</item>
<item name="android:navigationBarColor">@color/background_dark</item>
</style> </style>
+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'
+121
View File
@@ -0,0 +1,121 @@
@font-face {
font-family: 'absicons';
src: url('~static/fonts/absicons/absicons.eot?2jfq33');
src: url('~static/fonts/absicons/absicons.eot?2jfq33#iefix') format('embedded-opentype'),
url('~static/fonts/absicons/absicons.ttf?2jfq33') format('truetype'),
url('~static/fonts/absicons/absicons.woff?2jfq33') format('woff'),
url('~static/fonts/absicons/absicons.svg?2jfq33#absicons') format('svg');
font-weight: normal;
font-style: normal;
font-display: block;
}
.abs-icons {
/* use !important to prevent issues with browser extensions that change fonts */
font-family: 'absicons' !important;
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
/* Better Font Rendering =========== */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-list:before {
content: "\e907";
}
.icon-home:before {
content: "\e909";
}
.icon-authors:before {
content: "\e90a";
}
.icon-books-1:before {
content: "\e905";
}
.icon-microphone-1:before {
content: "\e902";
}
.icon-radio:before {
content: "\e903";
}
.icon-podcast:before {
content: "\e904";
}
.icon-audiobookshelf:before {
content: "\e900";
}
.icon-database:before {
content: "\e906";
}
.icon-microphone-2:before {
content: "\e901";
}
.icon-columns:before {
content: "\e90b";
}
.icon-headphones:before {
content: "\e910";
}
.icon-music:before {
content: "\e911";
}
.icon-video:before {
content: "\e914";
}
.icon-microphone-3:before {
content: "\e91e";
}
.icon-book-1:before {
content: "\e91f";
}
.icon-books-2:before {
content: "\e920";
}
.icon-file-picture:before {
content: "\e927";
}
.icon-database-1:before {
content: "\e964";
}
.icon-rocket:before {
content: "\e9a5";
}
.icon-power:before {
content: "\e9b5";
}
.icon-star:before {
content: "\e9d9";
}
.icon-heart:before {
content: "\e9da";
}
.icon-rss:before {
content: "\ea9b";
}
+1
View File
@@ -1,5 +1,6 @@
@import "./fonts.css"; @import "./fonts.css";
@import './defaultStyles.css'; @import './defaultStyles.css';
@import './absicons.css';
body { body {
background-color: #262626; background-color: #262626;
+5 -5
View File
@@ -9,7 +9,7 @@
</a> </a>
<div v-if="user && currentLibrary"> <div v-if="user && currentLibrary">
<div class="pl-1.5 pr-2.5 py-2 bg-bg bg-opacity-30 rounded-md flex items-center" @click="clickShowLibraryModal"> <div class="pl-1.5 pr-2.5 py-2 bg-bg bg-opacity-30 rounded-md flex items-center" @click="clickShowLibraryModal">
<widgets-library-icon :icon="currentLibraryIcon" :size="4" /> <ui-library-icon :icon="currentLibraryIcon" :size="4" font-size="base" />
<p class="text-sm font-book leading-4 ml-2 mt-0.5 max-w-24 truncate">{{ currentLibraryName }}</p> <p class="text-sm font-book leading-4 ml-2 mt-0.5 max-w-24 truncate">{{ currentLibraryName }}</p>
</div> </div>
</div> </div>
@@ -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">
+64 -44
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 {
@@ -205,11 +206,11 @@ export default {
return this.$store.getters['getJumpBackwardsTime'] return this.$store.getters['getJumpBackwardsTime']
}, },
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio'] return this.$store.getters['libraries/getBookCoverAspectRatio']
}, },
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;
+12 -42
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 }) {
@@ -155,19 +142,10 @@ export default {
} }
// Settings have been loaded (at least once, so it's safe to kickoff onReady) // Settings have been loaded (at least once, so it's safe to kickoff onReady)
this.settingsLoaded = true if (!this.settingsLoaded) {
this.notifyOnReady() this.settingsLoaded = true
}, 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
@@ -204,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({
@@ -225,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
} }
@@ -276,7 +254,7 @@ export default {
notifyOnReady() { notifyOnReady() {
// If settings aren't loaded yet, native player will receive incorrect settings // If settings aren't loaded yet, native player will receive incorrect settings
console.log('Notify on ready... settingsLoaded:', this.settingsLoaded, 'isReady:', this.isReady) console.log('Notify on ready... settingsLoaded:', this.settingsLoaded, 'isReady:', this.isReady)
if ( this.settingsLoaded && this.isReady ) { if (this.settingsLoaded && this.isReady) {
AbsAudioPlayer.onReady() AbsAudioPlayer.onReady()
} }
} }
@@ -290,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)
@@ -304,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)
+12 -14
View File
@@ -89,22 +89,22 @@ export default {
return this.bookCoverAspectRatio === 1 return this.bookCoverAspectRatio === 1
}, },
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio'] return this.$store.getters['libraries/getBookCoverAspectRatio']
}, },
bookWidth() { bookWidth() {
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)
+1 -1
View File
@@ -69,7 +69,7 @@ export default {
return this.bookCoverAspectRatio === 1 return this.bookCoverAspectRatio === 1
}, },
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio'] return this.$store.getters['libraries/getBookCoverAspectRatio']
}, },
altViewEnabled() { altViewEnabled() {
return this.$store.getters['getAltViewEnabled'] return this.$store.getters['getAltViewEnabled']
+1 -1
View File
@@ -31,7 +31,7 @@ export default {
}, },
computed: { computed: {
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio'] return this.$store.getters['libraries/getBookCoverAspectRatio']
}, },
coverWidth() { coverWidth() {
if (this.bookCoverAspectRatio === 1) return 50 * 1.2 if (this.bookCoverAspectRatio === 1) return 50 * 1.2
+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>
+1 -1
View File
@@ -24,7 +24,7 @@ export default {
}, },
computed: { computed: {
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio'] return this.$store.getters['libraries/getBookCoverAspectRatio']
}, },
name() { name() {
return this.series.name return this.series.name
+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>
+101 -26
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'
},
{
to: '/bookshelf/latest',
routeName: 'bookshelf-latest',
iconPack: 'abs-icons',
icon: 'list',
iconClass: 'text-xl',
text: 'Latest'
},
{
to: '/bookshelf/library',
routeName: 'bookshelf-library',
iconPack: 'abs-icons',
icon: this.currentLibraryIcon,
iconClass: 'text-lg',
text: 'Library'
}
]
if (this.userIsAdminOrUp) {
items.push({
to: '/bookshelf/search',
routeName: 'bookshelf-search',
iconPack: 'material-icons',
icon: 'podcasts',
iconClass: 'text-xl',
text: 'Search'
})
}
} else {
items = [
{
to: '/bookshelf',
routeName: 'bookshelf',
iconPack: 'abs-icons',
icon: 'home',
iconClass: 'text-xl',
text: 'Home' 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',
routeName: 'bookshelf-series',
iconPack: 'abs-icons',
icon: 'columns',
iconClass: 'text-lg pt-px',
text: 'Series'
},
{
to: '/bookshelf/collections',
routeName: 'bookshelf-collections',
iconPack: 'material-icons-outlined',
icon: 'collections_bookmark',
iconClass: 'text-xl',
text: 'Collections'
} }
// {
// to: '/bookshelf/authors',
// routeName: 'bookshelf-authors',
// iconPack: 'abs-icons',
// icon: 'authors',
// iconClass: 'text-2xl pb-px',
// text: 'Authors'
// }
] ]
} }
return [
{ if (this.userHasPlaylists) {
to: '/bookshelf', items.push({
routeName: 'bookshelf', to: '/bookshelf/playlists',
text: 'Home' routeName: 'bookshelf-playlists',
}, iconPack: 'material-icons',
{ icon: 'queue_music',
to: '/bookshelf/library', text: 'Playlists'
routeName: 'bookshelf-library', })
text: 'Library' }
},
{ return items
to: '/bookshelf/series',
routeName: 'bookshelf-series',
text: 'Series'
},
{
to: '/bookshelf/collections',
routeName: 'bookshelf-collections',
text: 'Collections'
}
]
}, },
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() {
-23
View File
@@ -1,23 +0,0 @@
<template>
<svg fill="currentColor" class="h-full w-full p-px" viewBox="0 0 1978.03 2349.44">
<path
d="M2519.5,1438.39c-12.13-10.1-31-25-56.57-42.62V1197.31c0-505.94-410.15-916.09-916.1-916.09h0c-505.94,0-916.09,410.15-916.09,916.09v198.46c-25.57,17.66-44.44,32.52-56.57,42.62a45.45,45.45,0,0,0-16.35,34.95v237.74a45.45,45.45,0,0,0,16.35,35c28.28,23.54,93.18,72.92,194.22,123.55v23.11c0,62.32,40.21,112.85,89.8,112.85h0c49.59,0,89.8-50.53,89.8-112.85V1322.51c0-62.33-40.21-112.86-89.8-112.86h0c-47.51,0-86.4,46.38-89.58,105.07l-.22.11V1197.31c0-429.92,348.52-778.43,778.44-778.43h0c429.92,0,778.44,348.51,778.44,778.43v117.52l-.22-.11c-3.18-58.69-42.06-105.07-89.58-105.07h0c-49.59,0-89.79,50.53-89.79,112.86v570.18c0,62.32,40.2,112.85,89.79,112.85h0c49.6,0,89.8-50.53,89.8-112.85v-23.11c101.05-50.63,165.95-100,194.23-123.55a45.48,45.48,0,0,0,16.35-35V1473.34A45.48,45.48,0,0,0,2519.5,1438.39Z"
transform="translate(-557.82 -281.22)"
/>
<path d="M1227.4,2429.63a108.47,108.47,0,0,0,108.47-108.47V1106.56A108.47,108.47,0,0,0,1227.4,998.08H1115.33a108.48,108.48,0,0,0-108.48,108.48v1214.6a108.47,108.47,0,0,0,108.48,108.47ZM1047.75,1289.38H1295v25.83H1047.75Z" transform="translate(-557.82 -281.22)" />
<path d="M1602.87,2429.63a108.47,108.47,0,0,0,108.47-108.47V1106.56a108.47,108.47,0,0,0-108.47-108.48H1490.8a108.48,108.48,0,0,0-108.48,108.48v1214.6a108.47,108.47,0,0,0,108.48,108.47ZM1423.22,1289.38h247.22v25.83H1423.22Z" transform="translate(-557.82 -281.22)" />
<path d="M1978.34,2429.63a108.47,108.47,0,0,0,108.47-108.47V1106.56a108.47,108.47,0,0,0-108.47-108.48H1866.27a108.48,108.48,0,0,0-108.48,108.48v1214.6a108.47,108.47,0,0,0,108.48,108.47ZM1798.69,1289.38h247.22v25.83H1798.69Z" transform="translate(-557.82 -281.22)" />
<rect x="180.05" y="2185.95" width="1617.93" height="163.49" rx="81.74" />
</svg>
</template>
<script>
export default {
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
-16
View File
@@ -1,16 +0,0 @@
<template>
<svg fill="currentColor" viewBox="0 0 24 24">
<path fill="currentColor" d="M9 3V18H12V3H9M12 5L16 18L19 17L15 4L12 5M5 5V18H8V5H5M3 19V21H21V19H3Z" />
</svg>
</template>
<script>
export default {
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
-16
View File
@@ -1,16 +0,0 @@
<template>
<svg viewBox="0 0 24 24">
<path fill="currentColor" d="M6,19L9,15.14L11.14,17.72L14.14,13.86L18,19H6M6,4H11V12L8.5,10.5L6,12M18,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V4A2,2 0 0,0 18,2Z" />
</svg>
</template>
<script>
export default {
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
-16
View File
@@ -1,16 +0,0 @@
<template>
<svg class="p-px" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
</template>
<script>
export default {
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
-19
View File
@@ -1,19 +0,0 @@
<template>
<svg class="p-px" viewBox="0 0 122.877 120.596">
<path
fill="currentColor"
d="M68.925,69.906v50.689H53.953V69.906c-4.918-2.662-8.259-7.867-8.259-13.854 c0-8.694,7.05-15.744,15.745-15.744c8.694,0,15.745,7.05,15.745,15.744C77.184,62.039,73.843,67.244,68.925,69.906L68.925,69.906z M39.32,11.165c2.916-1.438,4.111-4.969,2.673-7.882c-1.438-2.914-4.966-4.111-7.88-2.674C22.213,6.479,12.958,16.19,7.11,27.625 c-4.32,8.445-6.783,17.842-7.08,27.325c-0.299,9.563,1.587,19.223,5.973,28.114c5.401,10.953,14.558,20.695,28.039,27.592 c2.889,1.477,6.429,0.33,7.905-2.559c1.477-2.889,0.331-6.428-2.558-7.904c-11.037-5.645-18.486-13.525-22.833-22.334 c-3.506-7.111-5.014-14.857-4.774-22.539c0.243-7.757,2.256-15.442,5.79-22.348C22.304,23.721,29.76,15.879,39.32,11.165 L39.32,11.165z M88.765,0.608c-2.914-1.438-6.443-0.24-7.881,2.674c-1.438,2.914-0.242,6.445,2.674,7.882 c9.561,4.715,17.017,12.556,21.747,21.808c3.533,6.905,5.547,14.59,5.789,22.348c0.24,7.682-1.268,15.428-4.773,22.539 c-4.347,8.809-11.796,16.689-22.833,22.334c-2.889,1.477-4.034,5.016-2.558,7.904c1.476,2.889,5.016,4.035,7.905,2.559 c13.48-6.896,22.638-16.639,28.039-27.592c4.386-8.891,6.272-18.551,5.973-28.114c-0.297-9.483-2.76-18.88-7.079-27.325 C109.919,16.19,100.665,6.479,88.765,0.608L88.765,0.608z M82.791,26.505c-2.195-1.581-5.256-1.082-6.837,1.113 c-1.58,2.195-1.082,5.256,1.113,6.837c0.885,0.637,1.753,1.352,2.604,2.134c4.971,4.583,7.919,10.694,8.538,17.16 c0.626,6.524-1.111,13.437-5.518,19.552c-0.748,1.039-1.61,2.092-2.585,3.15c-1.835,1.992-1.708,5.098,0.287,6.932 c1.994,1.834,5.099,1.705,6.933-0.287c1.18-1.279,2.286-2.641,3.315-4.072c5.862-8.139,8.166-17.4,7.322-26.197 c-0.848-8.853-4.871-17.208-11.648-23.457C85.249,28.387,84.074,27.431,82.791,26.505L82.791,26.505z M45.81,34.458 c2.195-1.581,2.694-4.642,1.113-6.837c-1.581-2.195-4.642-2.694-6.837-1.114c-1.284,0.926-2.458,1.882-3.524,2.864 c-6.778,6.25-10.801,14.604-11.649,23.457c-0.844,8.796,1.46,18.06,7.323,26.199c1.031,1.43,2.136,2.791,3.315,4.07 c1.834,1.992,4.939,2.121,6.932,0.287c1.996-1.834,2.123-4.939,0.288-6.932c-0.975-1.059-1.837-2.111-2.585-3.15 c-4.406-6.115-6.144-13.027-5.518-19.551c0.619-6.465,3.567-12.577,8.538-17.16C44.058,35.81,44.926,35.095,45.81,34.458 L45.81,34.458z"
/>
</svg>
</template>
<script>
export default {
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
+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))
+2 -2
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>
@@ -40,7 +40,7 @@ export default {
} }
}, },
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio'] return this.$store.getters['libraries/getBookCoverAspectRatio']
} }
}, },
methods: { methods: {
+2 -1
View File
@@ -13,7 +13,7 @@
<li :key="library.id" class="text-gray-50 select-none relative py-3 cursor-pointer hover:bg-black-400" :class="currentLibraryId === library.id ? 'bg-bg bg-opacity-80' : ''" role="option" @click="clickedOption(library)"> <li :key="library.id" class="text-gray-50 select-none relative py-3 cursor-pointer hover:bg-black-400" :class="currentLibraryId === library.id ? 'bg-bg bg-opacity-80' : ''" role="option" @click="clickedOption(library)">
<div v-show="currentLibraryId === library.id" class="absolute top-0 left-0 w-0.5 bg-warning h-full" /> <div v-show="currentLibraryId === library.id" class="absolute top-0 left-0 w-0.5 bg-warning h-full" />
<div class="flex items-center px-3"> <div class="flex items-center px-3">
<widgets-library-icon :icon="library.icon" /> <ui-library-icon :icon="library.icon" />
<span class="font-normal block truncate text-lg ml-4">{{ library.name }}</span> <span class="font-normal block truncate text-lg ml-4">{{ library.name }}</span>
</div> </div>
</li> </li>
@@ -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>
+27 -2
View File
@@ -39,6 +39,7 @@
<script> <script>
import Path from 'path' import Path from 'path'
import { Archive } from 'libarchive.js/main.js' import { Archive } from 'libarchive.js/main.js'
import { CompressedFile } from 'libarchive.js/src/compressed-file'
Archive.init({ Archive.init({
workerUrl: '/libarchive/worker-bundle.js' workerUrl: '/libarchive/worker-bundle.js'
@@ -136,7 +137,11 @@ export default {
responseType: 'blob' responseType: 'blob'
}) })
const archive = await Archive.open(buff) const archive = await Archive.open(buff)
this.filesObject = await archive.getFilesObject() const originalFilesObject = await archive.getFilesObject()
// to support images in subfolders we need to flatten the object
// ref: https://github.com/advplyr/audiobookshelf/issues/811
this.filesObject = this.flattenFilesObject(originalFilesObject)
console.log('Extracted files object', this.filesObject)
var filenames = Object.keys(this.filesObject) var filenames = Object.keys(this.filesObject)
this.parseFilenames(filenames) this.parseFilenames(filenames)
@@ -154,6 +159,26 @@ export default {
this.loading = false this.loading = false
} }
}, },
flattenFilesObject(filesObject) {
const flattenObject = (obj, prefix = '') => {
var _obj = {}
for (const key in obj) {
const newKey = prefix ? prefix + '/' + key : key
if (obj[key] instanceof CompressedFile) {
_obj[newKey] = obj[key]
} else if (!key.startsWith('_') && typeof obj[key] === 'object' && !Array.isArray(obj[key])) {
_obj = {
..._obj,
...flattenObject(obj[key], newKey)
}
} else {
_obj[newKey] = obj[key]
}
}
return _obj
}
return flattenObject(filesObject)
},
async extractXmlFile(filename) { async extractXmlFile(filename) {
console.log('extracting xml filename', filename) console.log('extracting xml filename', filename)
try { try {
@@ -173,7 +198,7 @@ export default {
}, },
parseImageFilename(filename) { parseImageFilename(filename) {
var basename = Path.basename(filename, Path.extname(filename)) var basename = Path.basename(filename, Path.extname(filename))
var numbersinpath = basename.match(/\d{1,4}/g) var numbersinpath = basename.match(/\d{1,5}/g)
if (!numbersinpath || !numbersinpath.length) { if (!numbersinpath || !numbersinpath.length) {
return { return {
index: -1, index: -1,
+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>
+35 -11
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 = ''
if (this.selectedLibraryItem.isFile) {
filepath = this.$encodeUriPath(this.ebookFile.metadata.filename)
} else {
const itemRelPath = this.selectedLibraryItem.relPath
if (itemRelPath.startsWith('/')) itemRelPath = itemRelPath.slice(1)
const relPath = this.ebookFile.metadata.relPath
if (relPath.startsWith('/')) relPath = relPath.slice(1)
var itemRelPath = this.selectedLibraryItem.relPath filepath = this.$encodeUriPath(`${itemRelPath}/${relPath}`)
if (itemRelPath.startsWith('/')) itemRelPath = itemRelPath.slice(1) }
const serverAddress = this.$store.getters['user/getServerAddress']
var relPath = this.ebookFile.metadata.relPath return `${serverAddress}/ebook/${this.libraryId}/${this.folderId}/${filepath}`
if (relPath.startsWith('/')) relPath = relPath.slice(1) },
playerLibraryItemId() {
var serverAddress = this.$store.getters['user/getServerAddress'] return this.$store.state.playerLibraryItemId
return `${serverAddress}/ebook/${this.libraryId}/${this.folderId}/${itemRelPath}/${relPath}`
} }
}, },
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>
@@ -50,7 +50,7 @@ export default {
return this.$elapsedPretty(this.media.duration) return this.$elapsedPretty(this.media.duration)
}, },
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio'] return this.$store.getters['libraries/getBookCoverAspectRatio']
}, },
bookWidth() { bookWidth() {
if (this.bookCoverAspectRatio === 1) return 50 if (this.bookCoverAspectRatio === 1) return 50
+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)
}
},
episodeDownloadStarted(episodeDownload) {
if (episodeDownload.libraryItemId === this.libraryItemId) {
this.episodeDownloadsQueued = this.episodeDownloadsQueued.filter((d) => d.id !== episodeDownload.id)
this.episodesDownloading.push(episodeDownload)
}
},
episodeDownloadFinished(episodeDownload) {
if (episodeDownload.libraryItemId === this.libraryItemId) {
this.episodeDownloadsQueued = this.episodeDownloadsQueued.filter((d) => d.id !== episodeDownload.id)
this.episodesDownloading = this.episodesDownloading.filter((d) => d.id !== episodeDownload.id)
}
} }
}, },
mounted() {} mounted() {
this.$socket.$on('episode_download_queued', this.episodeDownloadQueued)
this.$socket.$on('episode_download_started', this.episodeDownloadStarted)
this.$socket.$on('episode_download_finished', this.episodeDownloadFinished)
},
beforeDestroy() {
this.$socket.$off('episode_download_queued', this.episodeDownloadQueued)
this.$socket.$off('episode_download_started', this.episodeDownloadStarted)
this.$socket.$off('episode_download_finished', this.episodeDownloadFinished)
}
} }
</script> </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)">
+37
View File
@@ -0,0 +1,37 @@
<template>
<div :class="`h-${size} w-${size} min-w-${size} text-${fontSize}`" class="flex items-center justify-center">
<span class="abs-icons" :class="`icon-${iconToUse}`"></span>
</div>
</template>
<script>
export default {
props: {
icon: {
type: String,
default: 'audiobookshelf'
},
fontSize: {
type: String,
default: 'lg'
},
size: {
type: Number,
default: 5
}
},
data() {
return {}
},
computed: {
iconToUse() {
return this.icons.includes(this.icon) ? this.icon : 'audiobookshelf'
},
icons() {
return this.$store.state.globals.libraryIcons
}
},
methods: {},
mounted() {}
}
</script>
+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>

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