Compare commits

...
Author SHA1 Message Date
advplyr cdbbd56a58 Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2023-06-25 14:29:09 -05:00
advplyr d6a1eba657 Fix:Android file provider to use application id 2023-06-25 14:29:02 -05:00
advplyr da899bfddd iOS version bump 0.9.65 2023-06-24 16:16:56 -05:00
advplyr b7b746fb2a Version bump 0.9.65-beta 2023-06-24 15:38:56 -05:00
advplyr c8053355df Add:Open/close RSS feed for library items 2023-06-24 14:45:25 -05:00
advplyr 1ddb6eca00 Fix:iOS local podcast episode progress #719 2023-06-24 10:37:56 -05:00
advplyr f42c624cba Update:iOS saving progress for offline ebooks 2023-06-24 09:41:14 -05:00
advplyr f8fbeef9b7 Fix:Add podcast form cutoff by open player #750 2023-06-21 17:23:51 -05:00
advplyr 46b82a81ce Update:iOS deleting downloaded item redirects to downloads page 2023-06-20 16:34:44 -05:00
advplyr 48342e5bd3 Fix:iOS offline reading and initialization 2023-06-20 16:29:56 -05:00
advplyr 147f89f870 Add:iOS offline ebooks 2023-06-19 17:42:15 -05:00
advplyr 8b39ba9c92 Fix dependency issue 2023-06-19 15:28:39 -05:00
advplyr 13d32dc1f7 Fix:Android internal file paths using file provider 2023-06-19 15:09:12 -05:00
advplyr 0d1312e497 Fix:Android auto filter out ebook only from downloaded tab 2023-06-19 14:03:29 -05:00
advplyr ff4f8324e7 Update:Syncing playback time when media item is open in player 2023-06-19 12:37:44 -05:00
advplyr b4bf10d409 Set epubjs version to 0.3.88 to work on ios, fix epub reader height 2023-06-18 17:11:22 -05:00
advplyr ae37861f7d Add:iOS support offline episode chapters #675 2023-06-18 15:34:56 -05:00
advplyr 1da03cdd65 Add:Podcast episode chapters for local eps #675 2023-06-18 15:12:07 -05:00
advplyr a7443d0992 Fix:Persist Total Track setting in audio player #663 2023-06-18 14:53:01 -05:00
advplyr 5a953464ab Update:Remove toast for mark as finished #718 2023-06-18 14:26:04 -05:00
advplyr 953bde5a42 Update:Add epub ereader settings and table of contents 2023-06-18 13:31:38 -05:00
advplyr 481d0d0285 Update:Show status bar when ereader toolbar is shown 2023-06-18 08:59:35 -05:00
advplyr 7b3a005ad6 Update:Ereader fullscreen #731 2023-06-17 17:34:08 -05:00
advplyr 4f9d341f69 Update:Delete local item/episode options in context menu #690, android: remove scan options for local server items & remove option 2023-06-17 12:49:24 -05:00
advplyr 9e7d1d1617 Add:Android setting to use mp3 index seeking #638 2023-06-17 11:27:44 -05:00
advplyr 4f03c2a35d Update:Cleanup chapters modal & increase width 2023-06-13 17:15:28 -05:00
advplyr db3c5396e9 Update:UI/UX improvements for playlists 2023-06-11 17:03:03 -05:00
advplyr 455af239a1 Update:Increasing touch size for seek bar cursor #713 2023-06-11 14:20:33 -05:00
advplyr d8bc26f5f8 Add:Ebook files table and supplementary ereader 2023-06-11 13:36:19 -05:00
advplyr 543ac209e4 Add:Progress for comics #738 2023-06-11 11:12:52 -05:00
advplyr c8b5cefeb5 Fix:Playlist items playing from server and not using local copy #734 2023-06-09 17:05:29 -05:00
advplyr f1411011e6 Update:Android include DeviceInfo with local playback session 2023-06-08 17:19:31 -05:00
advplyr f3964dda17 Update:Home screen server loading to not block local item access #708 2023-06-04 17:14:26 -05:00
advplyr d207e88e18 Update:Save last scroll position for item pages #717 2023-06-04 15:52:36 -05:00
advplyr 2643e98fc0 Fix:Back button closes ebook #709 2023-06-04 15:38:26 -05:00
advplyr 410648e316 Fix:Android auto filter out ebook only items 2023-06-04 15:12:56 -05:00
advplyr 373221703d Update:Android download to internal storage option #635 2023-06-04 14:59:55 -05:00
advplyr fbcb8620f9 Update:iOS stream and download URLs to use new API endpoints 2023-06-04 12:21:40 -05:00
advplyr a8ac4970cc Update:Android downloads to use new API endpoint 2023-06-04 11:34:34 -05:00
advplyr 195e33ae32 Update:EReaders to use new ebook API routes 2023-06-04 11:13:05 -05:00
advplyr 9acf695802 Remove file copy 2023-06-03 17:28:37 -05:00
advplyr e6aaccfc74 Update:Android support storing downloads to internal app storage #635 2023-06-03 17:24:32 -05:00
advplyr 87c74fe78b Fix:Playback speed not persisting #532 2023-06-02 16:57:17 -05:00
advplyr 7dc319c1bd Fix:Android app widget updating #658 2023-06-02 16:29:52 -05:00
advplyr c093548b71 Update:Scale chapter times with playback rate #726 2023-06-01 17:36:52 -05:00
advplyr b067a2d3d5 Fix:Libraries modal background colors #723 2023-06-01 17:22:28 -05:00
advplyr a9ae7bfdb8 Update:Fix epub3 background color, increase padding on top of ereader 2023-06-01 17:19:16 -05:00
advplyr 9dcf22614c Update:Android remove stop action when notification cancelled 2023-06-01 16:28:25 -05:00
advplyr c7564d439e Update:Android exoplayer version, target SDK to 33, remove stop button from notification 2023-05-31 17:43:54 -05:00
advplyr 157dc01673 Fix:Android close playback session on server when streaming 2023-05-27 17:20:01 -05:00
advplyr cce6e1d0ab Update:Android local media page to show ebook file 2023-05-25 17:59:24 -05:00
advplyr b935cb5cf1 Fix:Android remove local sessions after sync 2023-05-25 17:23:35 -05:00
advplyr 9ab37ac525 Add:Saving progress for PDF ebooks #712 2023-05-25 17:09:28 -05:00
advplyr 7031c895b5 iOS version bump 0.9.64 2023-05-21 18:41:18 -05:00
advplyr 7c739bf3bf Version bump 0.9.64-beta 2023-05-21 18:01:28 -05:00
advplyr facf56930e iOS add deviceId 2023-05-21 17:19:21 -05:00
advplyr 2c3dff3544 Add:EBook download and offline reading #187 #243 2023-05-21 15:02:49 -05:00
advplyr b1bf68b8cd Add:Series progress to series cards #410 2023-05-21 10:25:10 -05:00
advplyr 78aade7694 Fix:Android delete empty author folder when removing item #701 2023-05-20 18:13:19 -05:00
advplyr 83a4474375 Add:Podcast episode table sort options #376 2023-05-20 17:12:36 -05:00
advplyr 1b47c38efa Fix:Android sleep timer getting set to time left in media #659 2023-05-20 16:51:16 -05:00
advplyr 922d9f22ce Fix chapters button clickable when no chapters 2023-05-20 16:31:51 -05:00
advplyr d7feb52dd8 Fix:Discard progress for local and server media progress #646 2023-05-20 16:29:54 -05:00
advplyr fafaad8b5f Update:Play buttons on item pages show Play/Pause instead of disabled and showing Playing/Streaming #697 2023-05-20 14:49:55 -05:00
advplyr 928f33005a Update:Add play button to collections and playlist table #666 2023-05-20 14:22:53 -05:00
advplyr 0448d20583 Update:Android disable check for updated progress when playing 2023-05-20 13:22:04 -05:00
advplyrandGitHub f9d408b33f Merge pull request #692 from HStep20/master
Added Auto Rewind on Sleep Timer setting to Android
2023-05-20 12:18:17 -05:00
advplyr 9386ae95a3 Fix set sleepTimerSessionId 2023-05-20 12:17:41 -05:00
advplyr c530e0f7e3 Remove test log 2023-05-20 12:00:19 -05:00
advplyr e39a5e1ba3 Fix saveSettings function call 2023-05-20 11:58:27 -05:00
advplyr cb9e317e74 Initialize auto rewind time and update description 2023-05-20 11:56:36 -05:00
advplyr 8061ac0ced Fix auto rewind when check resetting sleep timer 2023-05-20 11:41:39 -05:00
advplyr daff8355d7 Merge branch 'master' into HStep20/master 2023-05-20 10:18:16 -05:00
advplyr 231289e9ac Update:Add podcast type, episode type, episode number, season and support html episode description #677 2023-05-19 17:57:36 -05:00
advplyr 87df022af0 Add:Narrators in search results 2023-05-19 17:18:31 -05:00
advplyr 07fdac9746 Update:Podcast episodes on home page dont show an Episode number if none is set 2023-05-19 17:03:54 -05:00
advplyr 0d482aaa42 Update:Android remove auto-open most recent listened item 2023-05-19 16:59:10 -05:00
advplyr d48d1fdde3 Update:Audio player play method color for visibility #706 2023-05-17 17:59:47 -05:00
hstephens 65b69cc020 fixed if statement syntax 2023-05-12 20:19:18 -06:00
hstephens a3794a6a6a added check before setting state 2023-04-29 17:20:04 -06:00
hstephens 695ab84940 changed behavior for first timer set 2023-04-29 16:48:43 -06:00
hstephens 5399ec67d7 Added Auto Rewind on Sleep Timer setting 2023-04-29 15:38:51 -06:00
advplyr d9a6ec65df Add deviceId to DeviceInfo for Android 2023-04-22 16:48:22 -05:00
advplyr e842de78dd Fix:Audiobook tracks table for local only books 2023-03-28 17:30:54 -05:00
advplyr 260363bf4f Fix:Android media widget initialize DB 2023-03-28 17:30:20 -05:00
advplyrandGitHub 6d7dffd4de Merge pull request #649 from maltejur/largeHeap
Add `android:largeHeap="true"` to `AndroidManifest.xml`
2023-03-27 17:02:09 -05:00
advplyr ef1d8a8eb4 Update:iOS HLS stream buffer set to 50s 2023-03-27 16:54:19 -05:00
Malte Jürgens cdd7a1a636 add android:largeHeap to AndroidManifest.xml 2023-03-27 21:00:52 +02:00
advplyr 6584cd1522 Fix:Android auto sleep timer window #648 2023-03-26 16:43:24 -05:00
advplyr d01b602306 Update epub reader media progress update 2023-03-26 13:50:21 -05:00
advplyr c90f2c136a Add:Support epub progress saving 2023-03-25 17:40:46 -05:00
advplyr 15ccc7192e Fix:Android sleep timer reset volume when shake #626 2023-03-25 16:51:24 -05:00
advplyr e4345a4438 Update:Raw cover on library item page, Android download raw cover #634 2023-03-19 17:13:33 -05:00
advplyr 3f4d564bad Fix:Add try/catch to initCastManager #480 2023-03-10 16:51:45 -06:00
advplyr 3311f66da8 Update readme screenshot 2023-03-10 16:13:04 -06:00
advplyr e711ada0b3 Fix:Center play button when locking ui #624 2023-03-10 15:37:42 -06:00
advplyr 840979d635 Fix play at start time 2023-03-06 16:59:49 -06:00
advplyr 53c9109534 iOS fix local episode duration and playback session title, version bump 0.9.63 2023-03-06 16:47:38 -06:00
advplyr 87ca6018d3 Version bump 0.9.63 2023-03-06 16:11:20 -06:00
advplyr c590087578 Fix description line clamp show more 2023-03-06 15:51:11 -06:00
advplyr 3963ffa0e2 iOS implement start time override 2023-03-06 15:46:07 -06:00
advplyrandGitHub eaa0c9914a Merge pull request #571 from lkiesow/center-title
Center title on book details
2023-03-06 15:30:15 -06:00
advplyr d9b0b8c33d Add chapters & tracks table. Clamp description to 4 lines. Move size to more info modal 2023-03-06 15:27:12 -06:00
advplyr fb4e7e6b55 Merge branch 'master' into center-title 2023-03-06 13:48:30 -06:00
advplyr 78a063a669 Fix:iOS download books with CD subfolders #620 2023-03-06 13:27:57 -06:00
advplyr cb5d32bbb9 Fix epub reader 2023-03-06 13:09:09 -06:00
advplyr f594b734c0 Adding more top padding to ereader 2023-03-06 11:40:00 -06:00
advplyr bf62b82037 Fix ereader padding and swiping 2023-03-06 10:58:08 -06:00
advplyr 39fbb0902e Removing padding above cover on item page 2023-03-05 16:47:04 -06:00
advplyr 7818500f0f Fix:Podcast latest and book authors page refresh/redirect on library change 2023-03-05 11:12:17 -06:00
advplyr 1ea984b915 Update iOS disable touch callout and user select 2023-03-04 17:15:48 -06:00
advplyr f28405081a Update:Audio player controls max width 2023-03-04 16:42:06 -06:00
advplyr 317f00e1b8 Update:Audio player drag cursor to seek #457 2023-03-04 16:26:37 -06:00
advplyr f8ce0b8355 Fix collapse sub series show series name 2023-03-04 14:15:08 -06:00
advplyr 9fd6ff9fd2 Fix sub series navigation not refreshing entities 2023-03-04 14:12:16 -06:00
advplyr 3931020c83 Update:Audio player cover rounded corners and rounded track 2023-03-04 13:59:37 -06:00
advplyr 8bdb6b0b66 Update:Fullscreen audio player track padding 2023-03-04 13:49:58 -06:00
advplyr 33bdee66a3 Add:Collapse series and collapse sub-series settings #99 2023-03-03 17:05:23 -06:00
advplyr a81f50878e Add:Authors page #618 2023-03-02 17:59:11 -06:00
advplyr 322f7d75dd Fix:Show episode title on home page shelves #616 2023-03-02 17:17:49 -06:00
advplyr 2f34da4e99 Fix:Android playback rate set on initial load #532 2023-03-01 17:47:07 -06:00
advplyr 5a9e30b08c Update:Show raw cover image in audio player #614 2023-02-27 16:30:18 -06:00
advplyr 04e233816d Android fix for playing items with no cover in development 2023-02-26 17:21:03 -06:00
advplyr 3ad1042b13 Fix:Android bluetooth controls for fast forward/rewind 2023-02-26 15:56:39 -06:00
advplyr 4fb9201823 Update:Additional padding in local library items folder page #592 2023-02-26 14:57:04 -06:00
advplyr 02089e1489 Update:Increased padding on modals 2023-02-26 14:41:18 -06:00
advplyr da442f4a17 Fix:Clicking author card on home page filter/sort #607 2023-02-26 14:26:41 -06:00
advplyr a0f8e6fb93 Fix:Initializing haptic feedback setting on app launch #603 2023-02-26 12:54:56 -06:00
advplyrandGitHub 20cf5a6132 Merge pull request #597 from KeyboardHammer/widget
Android Home Screen Widget
2023-02-26 11:30:16 -06:00
advplyr f5ca30620f Update android widget sizing, hide buttons on app close, and initialize when added 2023-02-26 11:26:34 -06:00
Lars Kiesow 72ec9d81b1 Singular & Plural 2023-02-26 13:19:23 +01:00
Lars Kiesow 29568412e6 four columns 2023-02-26 13:19:23 +01:00
Lars Kiesow f94140ec6b expand 2023-02-26 13:19:23 +01:00
Lars Kiesow 51062e7403 2023-02-26 13:19:22 +01:00
Lars Kiesow 0942bd2548 move title down 2023-02-26 13:19:22 +01:00
Lars Kiesow 5939fee66f one-big-table 2023-02-26 13:19:22 +01:00
Lars Kiesow 68560fd5f5 move series down 2023-02-26 13:19:22 +01:00
Lars Kiesow a38b826898 Center title on book details
This patch centers the title, author and series metadata on the book
details page below the cover. This makes this view look similar to the
places where the metadata are also places in the center.
2023-02-26 13:19:22 +01:00
advplyr 0158ccc4c8 Hide widget action buttons when app closes 2023-02-25 18:13:03 -06:00
Alex a5ba6c64c9 redo widget layout, hide buttons on force close 2023-02-25 18:02:20 -06:00
advplyr d67454db9a Fix:Update bookshelf on screen orientation change #602 2023-02-25 16:53:48 -06:00
advplyr fb35d8b1a0 Add:Podcast episode page and update episode lists to not show description #588 2023-02-25 16:33:06 -06:00
advplyr 960f75bfba Fix:Android cancel sleep timer resets volume #605 2023-02-25 09:28:22 -06:00
advplyr 83027a5787 Fix:Android local podcasts creating playback sessions with bad duration and displayTitle 2023-02-24 17:42:34 -06:00
Alex bc6ef96465 Android Home Screen Widget 2023-02-21 22:20:49 -06:00
165 changed files with 18917 additions and 13989 deletions
+2 -9
View File
@@ -21,8 +21,6 @@ kotlin {
} }
android { android {
namespace 'com.audiobookshelf.app' namespace 'com.audiobookshelf.app'
buildFeatures { buildFeatures {
viewBinding true viewBinding true
@@ -35,8 +33,8 @@ android {
applicationId "com.audiobookshelf.app" applicationId "com.audiobookshelf.app"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 93 versionCode 96
versionName "0.9.62-beta" versionName "0.9.65-beta"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -74,8 +72,6 @@ configurations.all {
force("androidx.media:media:$androidx_media_version") force("androidx.media:media:$androidx_media_version")
force("org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version") force("org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version")
force("org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version") force("org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version")
// failOnVersionConflict()
} }
} }
@@ -88,9 +84,6 @@ dependencies {
implementation 'androidx.constraintlayout:constraintlayout:2.1.3' implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
// testImplementation "junit:junit:$junitVersion"
// androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
// androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins') implementation project(':capacitor-cordova-android-plugins')
implementation "androidx.core:core-ktx:$androidx_core_ktx_version" implementation "androidx.core:core-ktx:$androidx_core_ktx_version"
+1
View File
@@ -10,6 +10,7 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies { dependencies {
implementation project(':capacitor-app') implementation project(':capacitor-app')
implementation project(':capacitor-clipboard')
implementation project(':capacitor-dialog') implementation project(':capacitor-dialog')
implementation project(':capacitor-haptics') implementation project(':capacitor-haptics')
implementation project(':capacitor-network') implementation project(':capacitor-network')
+23 -12
View File
@@ -21,20 +21,21 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/AppTheme" android:theme="@style/AppTheme"
android:usesCleartextTraffic="true" > android:usesCleartextTraffic="true"
android:largeHeap="true" >
<!-- TODO: Incomplete desktop widget --> <!-- Android app widget -->
<!-- <receiver--> <receiver
<!-- android:name=".NewAppWidget"--> android:name="MediaPlayerWidget"
<!-- android:exported="true" >--> android:exported="false">
<!-- <intent-filter>--> <intent-filter>
<!-- <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />--> <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<!-- </intent-filter>--> </intent-filter>
<!-- <meta-data--> <meta-data
<!-- android:name="android.appwidget.provider"--> android:name="android.appwidget.provider"
<!-- android:resource="@xml/new_app_widget_info" />--> android:resource="@xml/media_player_widget_info" />
<!-- </receiver>--> </receiver>
<!-- Used by Android Auto --> <!-- Used by Android Auto -->
<meta-data <meta-data
@@ -91,6 +92,16 @@
<action android:name="android.media.browse.MediaBrowserService" /> <action android:name="android.media.browse.MediaBrowserService" />
</intent-filter> </intent-filter>
</service> </service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:grantUriPermissions="true"
android:exported="false">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application> </application>
</manifest> </manifest>
@@ -3,6 +3,10 @@
"pkg": "@capacitor/app", "pkg": "@capacitor/app",
"classpath": "com.capacitorjs.plugins.app.AppPlugin" "classpath": "com.capacitorjs.plugins.app.AppPlugin"
}, },
{
"pkg": "@capacitor/clipboard",
"classpath": "com.capacitorjs.plugins.clipboard.ClipboardPlugin"
},
{ {
"pkg": "@capacitor/dialog", "pkg": "@capacitor/dialog",
"classpath": "com.capacitorjs.plugins.dialog.DialogPlugin" "classpath": "com.capacitorjs.plugins.dialog.DialogPlugin"
@@ -12,10 +12,10 @@ import android.util.Log
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import com.anggrayudi.storage.SimpleStorage import com.anggrayudi.storage.SimpleStorage
import com.anggrayudi.storage.SimpleStorageHelper import com.anggrayudi.storage.SimpleStorageHelper
import com.audiobookshelf.app.plugins.AbsDatabase
import com.audiobookshelf.app.managers.DbManager import com.audiobookshelf.app.managers.DbManager
import com.audiobookshelf.app.player.PlayerNotificationService import com.audiobookshelf.app.player.PlayerNotificationService
import com.audiobookshelf.app.plugins.AbsAudioPlayer import com.audiobookshelf.app.plugins.AbsAudioPlayer
import com.audiobookshelf.app.plugins.AbsDatabase
import com.audiobookshelf.app.plugins.AbsDownloader import com.audiobookshelf.app.plugins.AbsDownloader
import com.audiobookshelf.app.plugins.AbsFileSystem import com.audiobookshelf.app.plugins.AbsFileSystem
import com.getcapacitor.BridgeActivity import com.getcapacitor.BridgeActivity
@@ -0,0 +1,103 @@
package com.audiobookshelf.app
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.support.v4.media.session.PlaybackStateCompat
import android.util.Log
import android.view.View
import android.widget.RemoteViews
import androidx.media.session.MediaButtonReceiver
import com.audiobookshelf.app.data.PlaybackSession
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.managers.DbManager
import com.bumptech.glide.Glide
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.request.target.AppWidgetTarget
import com.bumptech.glide.request.transition.Transition
/**
* Implementation of App Widget functionality.
*/
class MediaPlayerWidget : AppWidgetProvider() {
val tag = "MediaPlayerWidget"
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
Log.d(tag, "onUpdate $appWidgetIds")
}
override fun onEnabled(context: Context) {
Log.i(tag, "onEnabled check context ${context.packageName}")
DbManager.initialize(context)
DeviceManager.deviceData.lastPlaybackSession?.let {
val appWidgetManager = AppWidgetManager.getInstance(context)
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
val ids = appWidgetManager.getAppWidgetIds(componentName)
Log.d(tag, "Setting initial widget state with last playback session ${it.displayTitle}")
for (widgetId in ids) {
updateAppWidget(context, appWidgetManager, widgetId, it, false, true)
}
}
// Enter relevant functionality for when the first widget is created
DeviceManager.initializeWidgetUpdater(context)
}
}
internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, playbackSession: PlaybackSession?, isPlaying:Boolean, isAppClosed:Boolean) {
val tag = "MediaPlayerWidget"
val views = RemoteViews(context.packageName, R.layout.media_player_widget)
Log.i(tag, "updateAppWidget ${playbackSession?.displayTitle ?: "No Title"} isPlaying=$isPlaying isAppClosed=$isAppClosed")
val wholeWidgetClickI = Intent(context, MainActivity::class.java)
wholeWidgetClickI.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
val wholeWidgetClickPI = PendingIntent.getActivity(
context,
System.currentTimeMillis().toInt(),
wholeWidgetClickI,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val playPausePI = MediaButtonReceiver.buildMediaButtonPendingIntent(context, PlaybackStateCompat.ACTION_PLAY_PAUSE)
views.setOnClickPendingIntent(R.id.widgetPlayPauseButton, playPausePI)
val fastForwardPI = MediaButtonReceiver.buildMediaButtonPendingIntent(context, PlaybackStateCompat.ACTION_FAST_FORWARD)
views.setOnClickPendingIntent(R.id.widgetFastForwardButton, fastForwardPI)
val rewindPI = MediaButtonReceiver.buildMediaButtonPendingIntent(context, PlaybackStateCompat.ACTION_REWIND)
views.setOnClickPendingIntent(R.id.widgetRewindButton, rewindPI)
// Show/Hide button container
views.setViewVisibility(R.id.widgetButtonContainer, if (isAppClosed) View.GONE else View.VISIBLE)
views.setOnClickPendingIntent(R.id.widgetBackground, wholeWidgetClickPI)
val imageUri = playbackSession?.getCoverUri(context) ?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
val awt: AppWidgetTarget = object : AppWidgetTarget(context.applicationContext, R.id.widgetAlbumArt, views, appWidgetId) {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
super.onResourceReady(resource, transition)
}
}
val artist = playbackSession?.displayAuthor ?: "Unknown"
views.setTextViewText(R.id.widgetArtistText, artist)
val title = playbackSession?.displayTitle ?: "Unknown"
views.setTextViewText(R.id.widgetMediaTitle, title)
val options = RequestOptions().override(300, 300).placeholder(R.drawable.icon).error(R.drawable.icon)
Glide.with(context.applicationContext).asBitmap().load(imageUri).apply(options).into(awt)
Log.i(tag, "Update App Widget | Is Playing=$isPlaying | isAppClosed=$isAppClosed")
val playPauseResource = if (isPlaying) R.drawable.ic_media_pause_dark else R.drawable.ic_media_play_dark
views.setImageViewResource(R.id.widgetPlayPauseButton, playPauseResource)
// Instruct the widget manager to update the widget
appWidgetManager.updateAppWidget(appWidgetId, views)
}
@@ -1,96 +0,0 @@
package com.audiobookshelf.app
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.support.v4.media.session.PlaybackStateCompat.ACTION_PLAY_PAUSE
import android.util.Log
import android.widget.RemoteViews
import androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.device.WidgetEventEmitter
import com.audiobookshelf.app.player.PlayerNotificationService
import com.bumptech.glide.Glide
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.request.target.AppWidgetTarget
import com.bumptech.glide.request.transition.Transition
/**
* Implementation of App Widget functionality.
*/
class NewAppWidget : AppWidgetProvider() {
val tag = "NewAppWidget"
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
// There may be multiple widgets active, so update all of them
for (appWidgetId in appWidgetIds) {
updateAppWidget(context, appWidgetManager, appWidgetId, null,false)
}
}
override fun onEnabled(context: Context) {
Log.w(tag, "onEnabled check context ${context.packageName}")
// Enter relevant functionality for when the first widget is created
DeviceManager.widgetUpdater = (object : WidgetEventEmitter {
override fun onPlayerChanged(pns:PlayerNotificationService) {
val isPlaying = pns.currentPlayer.isPlaying
Log.i(tag, "onPlayerChanged | Is Playing? $isPlaying")
val appWidgetManager = AppWidgetManager.getInstance(context)
val componentName = ComponentName(context, NewAppWidget::class.java)
val ids = appWidgetManager.getAppWidgetIds(componentName)
val playbackSession = pns.getCurrentPlaybackSessionCopy()
val cover = playbackSession?.getCoverUri()
for (widgetId in ids) {
updateAppWidget(context, appWidgetManager, widgetId, cover, isPlaying)
}
}
})
}
override fun onDisabled(context: Context) {
// Enter relevant functionality for when the last widget is disabled
}
}
internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, coverUri:Uri?, isPlaying:Boolean) {
val views = RemoteViews(context.packageName, R.layout.new_app_widget)
val playPausePI = buildMediaButtonPendingIntent(context, ACTION_PLAY_PAUSE)
views.setOnClickPendingIntent(R.id.playPauseIcon, playPausePI)
val wholeWidgetClickI = Intent(context, MainActivity::class.java)
wholeWidgetClickI.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
val wholeWidgetClickPI = PendingIntent.getActivity(
context,
System.currentTimeMillis().toInt(),
wholeWidgetClickI,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
views.setOnClickPendingIntent(R.id.appWidget, wholeWidgetClickPI)
val imageUri = coverUri ?: Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
val awt: AppWidgetTarget = object : AppWidgetTarget(context.applicationContext, R.id.imageView, views, appWidgetId) {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
super.onResourceReady(resource, transition)
}
}
val options = RequestOptions().override(300, 300).placeholder(R.drawable.icon).error(R.drawable.icon)
Glide.with(context.applicationContext).asBitmap().load(imageUri).apply(options).into(awt)
val playPauseResource = if (isPlaying) R.drawable.ic_media_pause_dark else R.drawable.ic_media_play_dark
views.setImageViewResource(R.id.playPauseIcon, playPauseResource)
// Instruct the widget manager to update the widget
appWidgetManager.updateAppWidget(appWidgetId, views)
}
@@ -53,7 +53,7 @@ class Podcast(
audioTracks.forEach { at -> audioTracks.forEach { at ->
if (episodes?.find{ it.audioTrack?.localFileId == at.localFileId } == null) { if (episodes?.find{ it.audioTrack?.localFileId == at.localFileId } == null) {
val localEpisodeId = "local_ep_" + at.localFileId val localEpisodeId = "local_ep_" + at.localFileId
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,null,null,at.title,null,null,null, null, null, at,at.duration,0, null, localEpisodeId) val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,null,null,at.title,null,null,null, null, null, at,null,at.duration,0, null, localEpisodeId)
episodes?.add(newEpisode) episodes?.add(newEpisode)
} }
} }
@@ -67,7 +67,7 @@ class Podcast(
@JsonIgnore @JsonIgnore
override fun addAudioTrack(audioTrack:AudioTrack) { override fun addAudioTrack(audioTrack:AudioTrack) {
val localEpisodeId = "local_ep_" + audioTrack.localFileId val localEpisodeId = "local_ep_" + audioTrack.localFileId
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,null,null,audioTrack.title,null,null,null, null, null,audioTrack,audioTrack.duration,0, null, localEpisodeId) val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,null,null,audioTrack.title,null,null,null, null, null,audioTrack,null,audioTrack.duration,0, null, localEpisodeId)
episodes?.add(newEpisode) episodes?.add(newEpisode)
var index = 1 var index = 1
@@ -96,7 +96,7 @@ class Podcast(
@JsonIgnore @JsonIgnore
fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode { fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode {
val localEpisodeId = "local_ep_" + episode.id val localEpisodeId = "local_ep_" + episode.id
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,null,null,audioTrack,audioTrack.duration,0, episode.id, localEpisodeId) val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,null,null,audioTrack,episode.chapters,audioTrack.duration,0, episode.id, localEpisodeId)
episodes?.add(newEpisode) episodes?.add(newEpisode)
var index = 1 var index = 1
@@ -126,6 +126,7 @@ class Book(
var audioFiles:List<AudioFile>?, var audioFiles:List<AudioFile>?,
var chapters:List<BookChapter>?, var chapters:List<BookChapter>?,
var tracks:MutableList<AudioTrack>?, var tracks:MutableList<AudioTrack>?,
var ebookFile: EBookFile?,
var size:Long?, var size:Long?,
var duration:Double?, var duration:Double?,
var numTracks:Int? var numTracks:Int?
@@ -179,7 +180,7 @@ class Book(
} }
@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(), ebookFile, null,null, 0)
} }
} }
@@ -250,15 +251,16 @@ data class PodcastEpisode(
var publishedAt:Long?, var publishedAt:Long?,
var audioFile:AudioFile?, var audioFile:AudioFile?,
var audioTrack:AudioTrack?, var audioTrack:AudioTrack?,
var chapters:List<BookChapter>?,
var duration:Double?, var duration:Double?,
var size:Long?, var size:Long?,
var serverEpisodeId:String?, // For local podcasts to match with server podcasts var serverEpisodeId:String?, // For local podcasts to match with server podcasts
var localEpisodeId:String? // For Android Auto server episodes with local copy var localEpisodeId:String? // For Android Auto server episodes with local copy
) { ) {
@JsonIgnore @JsonIgnore
fun getMediaDescription(libraryItem:LibraryItemWrapper, progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat { fun getMediaDescription(libraryItem:LibraryItemWrapper, progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat {
val coverUri = if (libraryItem is LocalLibraryItem) { val coverUri = if (libraryItem is LocalLibraryItem) {
libraryItem.getCoverUri() libraryItem.getCoverUri(ctx)
} else { } else {
(libraryItem as LibraryItem).getCoverUri() (libraryItem as LibraryItem).getCoverUri()
} }
@@ -48,6 +48,20 @@ data class LocalFile(
if (mimeType == "video/mp4") return true if (mimeType == "video/mp4") return true
return mimeType?.startsWith("audio") == true return mimeType?.startsWith("audio") == true
} }
@JsonIgnore
fun isEBookFile():Boolean {
return getEBookFormat() != null
}
@JsonIgnore
fun getEBookFormat():String? {
if (mimeType == "application/epub+zip") return "epub"
if (mimeType == "application/pdf") return "pdf"
if (mimeType == "application/x-mobipocket-ebook") return "mobi"
if (mimeType == "application/vnd.comicbook+zip") return "cbz"
if (mimeType == "application/vnd.comicbook-rar") return "cbr"
if (mimeType == "application/vnd.amazon.mobi8-ebook") return "azw3"
return null
}
} }
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
@@ -69,14 +83,14 @@ data class LocalFolder(
) )
open class LibraryItemWrapper(var id:String) { open class LibraryItemWrapper(var id:String) {
@JsonIgnore @JsonIgnore
open fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat { return MediaDescriptionCompat.Builder().build() } open fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat { return MediaDescriptionCompat.Builder().build() }
} }
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class DeviceInfo( data class DeviceInfo(
var deviceId:String,
var manufacturer:String, var manufacturer:String,
var model:String, var model:String,
var brand:String,
var sdkVersion:Int, var sdkVersion:Int,
var clientVersion: String var clientVersion: String
) )
@@ -95,6 +109,7 @@ data class DeviceSettings(
var enableAltView:Boolean, var enableAltView:Boolean,
var jumpBackwardsTime:Int, var jumpBackwardsTime:Int,
var jumpForwardTime:Int, var jumpForwardTime:Int,
var enableMp3IndexSeeking:Boolean,
var disableShakeToResetSleepTimer:Boolean, var disableShakeToResetSleepTimer:Boolean,
var shakeSensitivity: ShakeSensitivitySetting, var shakeSensitivity: ShakeSensitivitySetting,
var lockOrientation: LockOrientationSetting, var lockOrientation: LockOrientationSetting,
@@ -102,6 +117,8 @@ data class DeviceSettings(
var autoSleepTimer: Boolean, var autoSleepTimer: Boolean,
var autoSleepTimerStartTime: String, var autoSleepTimerStartTime: String,
var autoSleepTimerEndTime: String, var autoSleepTimerEndTime: String,
var autoSleepTimerAutoRewind: Boolean,
var autoSleepTimerAutoRewindTime: Long, //Time in milliseconds
var sleepTimerLength: Long, // Time in milliseconds var sleepTimerLength: Long, // Time in milliseconds
var disableSleepTimerFadeOut: Boolean, var disableSleepTimerFadeOut: Boolean,
var disableSleepTimerResetFeedback: Boolean var disableSleepTimerResetFeedback: Boolean
@@ -114,6 +131,7 @@ data class DeviceSettings(
enableAltView = true, enableAltView = true,
jumpBackwardsTime = 10, jumpBackwardsTime = 10,
jumpForwardTime = 10, jumpForwardTime = 10,
enableMp3IndexSeeking = false,
disableShakeToResetSleepTimer = false, disableShakeToResetSleepTimer = false,
shakeSensitivity = ShakeSensitivitySetting.MEDIUM, shakeSensitivity = ShakeSensitivitySetting.MEDIUM,
lockOrientation = LockOrientationSetting.NONE, lockOrientation = LockOrientationSetting.NONE,
@@ -122,6 +140,8 @@ data class DeviceSettings(
autoSleepTimerStartTime = "22:00", autoSleepTimerStartTime = "22:00",
autoSleepTimerEndTime = "06:00", autoSleepTimerEndTime = "06:00",
sleepTimerLength = 900000L, // 15 minutes sleepTimerLength = 900000L, // 15 minutes
autoSleepTimerAutoRewind = false,
autoSleepTimerAutoRewindTime = 300000L, // 5 minutes
disableSleepTimerFadeOut = false, disableSleepTimerFadeOut = false,
disableSleepTimerResetFeedback = false disableSleepTimerResetFeedback = false
) )
@@ -141,6 +161,7 @@ data class DeviceSettings(
@get:JsonIgnore @get:JsonIgnore
val autoSleepTimerEndMinute get() = autoSleepTimerEndTime.split(":")[1].toInt() val autoSleepTimerEndMinute get() = autoSleepTimerEndTime.split(":")[1].toInt()
@JsonIgnore @JsonIgnore
fun getShakeThresholdGravity() : Float { // Used in ShakeDetector fun getShakeThresholdGravity() : Float { // Used in ShakeDetector
return if (shakeSensitivity == ShakeSensitivitySetting.VERY_HIGH) 1.2f return if (shakeSensitivity == ShakeSensitivitySetting.VERY_HIGH) 1.2f
@@ -0,0 +1,13 @@
package com.audiobookshelf.app.data
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true)
data class EBookFile(
var ino:String,
var metadata:FileMetadata?,
var ebookFormat:String,
var isLocal:Boolean,
var localFileId:String?,
var contentUrl:String?
)
@@ -5,6 +5,7 @@ import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaDescriptionCompat
import androidx.media.utils.MediaConstants import androidx.media.utils.MediaConstants
import com.audiobookshelf.app.BuildConfig
import com.audiobookshelf.app.R import com.audiobookshelf.app.R
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonIgnore
@@ -41,7 +42,7 @@ class LibraryItem(
@JsonIgnore @JsonIgnore
fun getCoverUri(): Uri { fun getCoverUri(): Uri {
if (media.coverPath == null) { if (media.coverPath == null) {
return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon) return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
} }
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}") return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}")
@@ -57,7 +58,7 @@ class LibraryItem(
} }
@JsonIgnore @JsonIgnore
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat { override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat {
val extras = Bundle() val extras = Bundle()
if (localLibraryItemId != null) { if (localLibraryItemId != null) {
@@ -9,7 +9,10 @@ import android.os.Bundle
import android.provider.MediaStore import android.provider.MediaStore
import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaDescriptionCompat
import android.util.Log import android.util.Log
import androidx.core.content.FileProvider
import androidx.core.net.toFile
import androidx.media.utils.MediaConstants import androidx.media.utils.MediaConstants
import com.audiobookshelf.app.BuildConfig
import com.audiobookshelf.app.R import com.audiobookshelf.app.R
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonIgnore
@@ -45,8 +48,11 @@ class LocalLibraryItem(
val isPodcast get() = mediaType == "podcast" val isPodcast get() = mediaType == "podcast"
@JsonIgnore @JsonIgnore
fun getCoverUri(): Uri { fun getCoverUri(ctx:Context): Uri {
return if (coverContentUrl != null) Uri.parse(coverContentUrl) else Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon) if (coverContentUrl?.startsWith("file:") == true) {
return FileProvider.getUriForFile(ctx, "${BuildConfig.APPLICATION_ID}.fileprovider", Uri.parse(coverContentUrl).toFile())
}
return if (coverContentUrl != null) Uri.parse(coverContentUrl) else Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
} }
@JsonIgnore @JsonIgnore
@@ -73,7 +79,7 @@ class LocalLibraryItem(
} }
@JsonIgnore @JsonIgnore
fun getPlaybackSession(episode:PodcastEpisode?):PlaybackSession { fun getPlaybackSession(episode:PodcastEpisode?, deviceInfo:DeviceInfo):PlaybackSession {
val localEpisodeId = episode?.id val localEpisodeId = episode?.id
val sessionId = "play_local_${UUID.randomUUID()}" val sessionId = "play_local_${UUID.randomUUID()}"
@@ -82,18 +88,22 @@ class LocalLibraryItem(
val mediaProgress = DeviceManager.dbManager.getLocalMediaProgress(mediaProgressId) val mediaProgress = DeviceManager.dbManager.getLocalMediaProgress(mediaProgressId)
val currentTime = mediaProgress?.currentTime ?: 0.0 val currentTime = mediaProgress?.currentTime ?: 0.0
// TODO: Clean up add mediaType methods for displayTitle and displayAuthor
val mediaMetadata = media.metadata val mediaMetadata = media.metadata
val chapters = if (mediaType == "book") (media as Book).chapters else mutableListOf() var chapters = if (mediaType == "book") (media as Book).chapters else mutableListOf()
var audioTracks = media.getAudioTracks() as MutableList<AudioTrack> var audioTracks = media.getAudioTracks() as MutableList<AudioTrack>
val authorName = mediaMetadata.getAuthorDisplayName() val authorName = mediaMetadata.getAuthorDisplayName()
val displayTitle = episode?.title ?: mediaMetadata.title
var duration = getDuration()
if (episode != null) { // Get podcast episode audio track if (episode != null) { // Get podcast episode audio track
episode.audioTrack?.let { at -> mutableListOf(at) }?.let { tracks -> audioTracks = tracks } episode.audioTrack?.let { at -> mutableListOf(at) }?.let { tracks -> audioTracks = tracks }
chapters = episode.chapters
duration = episode.audioTrack?.duration ?: 0.0
Log.d("LocalLibraryItem", "getPlaybackSession: Got podcast episode audio track ${audioTracks.size}") Log.d("LocalLibraryItem", "getPlaybackSession: Got podcast episode audio track ${audioTracks.size}")
} }
val dateNow = System.currentTimeMillis() val dateNow = System.currentTimeMillis()
return PlaybackSession(sessionId,serverUserId,libraryItemId,episode?.serverEpisodeId, mediaType, mediaMetadata, chapters ?: mutableListOf(), mediaMetadata.title, authorName,null,getDuration(),PLAYMETHOD_LOCAL,dateNow,0L,0L, audioTracks,currentTime,null,this,localEpisodeId,serverConnectionConfigId, serverAddress, "exo-player") return PlaybackSession(sessionId,serverUserId,libraryItemId,episode?.serverEpisodeId, mediaType, mediaMetadata, deviceInfo,chapters ?: mutableListOf(), displayTitle, authorName,null,duration,PLAYMETHOD_LOCAL,dateNow,0L,0L, audioTracks,currentTime,null,this,localEpisodeId,serverConnectionConfigId, serverAddress, "exo-player")
} }
@JsonIgnore @JsonIgnore
@@ -102,18 +112,16 @@ class LocalLibraryItem(
} }
@JsonIgnore @JsonIgnore
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx:Context?): MediaDescriptionCompat { override fun getMediaDescription(progress:MediaProgressWrapper?, ctx:Context): MediaDescriptionCompat {
val coverUri = getCoverUri() val coverUri = getCoverUri(ctx)
var bitmap:Bitmap? = null var bitmap:Bitmap? = null
if (coverContentUrl != null) { if (coverContentUrl != null) {
ctx?.let { bitmap = if (Build.VERSION.SDK_INT < 28) {
bitmap = if (Build.VERSION.SDK_INT < 28) { MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
MediaStore.Images.Media.getBitmap(it.contentResolver, coverUri) } else {
} else { val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, coverUri)
val source: ImageDecoder.Source = ImageDecoder.createSource(it.contentResolver, coverUri) ImageDecoder.decodeBitmap(source)
ImageDecoder.decodeBitmap(source)
}
} }
} }
@@ -18,6 +18,7 @@ data class LocalMediaItem(
var basePath:String, var basePath:String,
var absolutePath:String, var absolutePath:String,
var audioTracks:MutableList<AudioTrack>, var audioTracks:MutableList<AudioTrack>,
var ebookFile:EBookFile?,
var localFiles:MutableList<LocalFile>, var localFiles:MutableList<LocalFile>,
var coverContentUrl:String?, var coverContentUrl:String?,
var coverAbsolutePath:String? var coverAbsolutePath:String?
@@ -61,7 +62,7 @@ data class LocalMediaItem(
val mediaMetadata = getMediaMetadata() val mediaMetadata = getMediaMetadata()
if (mediaType == "book") { if (mediaType == "book") {
val chapters = getAudiobookChapters() val chapters = getAudiobookChapters()
val book = Book(mediaMetadata as BookMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), chapters,audioTracks,getTotalSize(),getDuration(),audioTracks.size) val book = Book(mediaMetadata as BookMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), chapters,audioTracks,ebookFile,getTotalSize(),getDuration(),audioTracks.size)
return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false,mediaType, book, localFiles, coverContentUrl, coverAbsolutePath,true,null,null,null,null) return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false,mediaType, book, localFiles, coverContentUrl, coverAbsolutePath,true,null,null,null,null)
} else { } else {
val podcast = Podcast(mediaMetadata as PodcastMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), false, 0) val podcast = Podcast(mediaMetadata as PodcastMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), false, 0)
@@ -13,6 +13,8 @@ class LocalMediaProgress(
progress:Double, // 0 to 1 progress:Double, // 0 to 1
currentTime:Double, currentTime:Double,
isFinished:Boolean, isFinished:Boolean,
var ebookLocation:String?, // cfi tag
var ebookProgress:Double?, // 0 to 1
var lastUpdate:Long, var lastUpdate:Long,
var startedAt:Long, var startedAt:Long,
var finishedAt:Long?, var finishedAt:Long?,
@@ -58,11 +60,20 @@ class LocalMediaProgress(
finishedAt = if (isFinished) lastUpdate else null finishedAt = if (isFinished) lastUpdate else null
} }
@JsonIgnore
fun updateEbookProgress(ebookLocation:String, ebookProgress:Double) {
lastUpdate = System.currentTimeMillis()
this.ebookProgress = ebookProgress
this.ebookLocation = ebookLocation
}
@JsonIgnore @JsonIgnore
fun updateFromServerMediaProgress(serverMediaProgress:MediaProgress) { fun updateFromServerMediaProgress(serverMediaProgress:MediaProgress) {
isFinished = serverMediaProgress.isFinished isFinished = serverMediaProgress.isFinished
progress = serverMediaProgress.progress progress = serverMediaProgress.progress
currentTime = serverMediaProgress.currentTime currentTime = serverMediaProgress.currentTime
ebookProgress = serverMediaProgress.ebookProgress
ebookLocation = serverMediaProgress.ebookLocation
duration = serverMediaProgress.duration duration = serverMediaProgress.duration
lastUpdate = serverMediaProgress.lastUpdate lastUpdate = serverMediaProgress.lastUpdate
finishedAt = serverMediaProgress.finishedAt finishedAt = serverMediaProgress.finishedAt
@@ -12,6 +12,8 @@ class MediaProgress(
progress:Double, // 0 to 1 progress:Double, // 0 to 1
currentTime:Double, currentTime:Double,
isFinished:Boolean, isFinished:Boolean,
var ebookLocation:String?, // cfi tag
var ebookProgress:Double?, // 0 to 1
var lastUpdate:Long, var lastUpdate:Long,
var startedAt:Long, var startedAt:Long,
var finishedAt:Long? var finishedAt:Long?
@@ -6,6 +6,10 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.MediaStore import android.provider.MediaStore
import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.MediaMetadataCompat
import androidx.core.content.FileProvider
import androidx.core.net.toFile
import com.audiobookshelf.app.BuildConfig
import com.audiobookshelf.app.R
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.media.MediaProgressSyncData import com.audiobookshelf.app.media.MediaProgressSyncData
import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonIgnore
@@ -25,6 +29,7 @@ class PlaybackSession(
var episodeId:String?, var episodeId:String?,
var mediaType:String, var mediaType:String,
var mediaMetadata:MediaTypeMetadata, var mediaMetadata:MediaTypeMetadata,
var deviceInfo:DeviceInfo,
var chapters:List<BookChapter>, var chapters:List<BookChapter>,
var displayTitle: String?, var displayTitle: String?,
var displayAuthor: String?, var displayAuthor: String?,
@@ -135,10 +140,17 @@ class PlaybackSession(
} }
@JsonIgnore @JsonIgnore
fun getCoverUri(): Uri { fun getCoverUri(ctx:Context): Uri {
if (localLibraryItem?.coverContentUrl != null) return Uri.parse(localLibraryItem?.coverContentUrl) ?: Uri.parse("android.resource://com.audiobookshelf.app/" + com.audiobookshelf.app.R.drawable.icon) if (localLibraryItem?.coverContentUrl != null) {
var coverUri = Uri.parse(localLibraryItem?.coverContentUrl.toString())
if (coverUri.toString().startsWith("file:")) {
coverUri = FileProvider.getUriForFile(ctx, "${BuildConfig.APPLICATION_ID}.fileprovider", coverUri.toFile())
}
if (coverPath == null) return Uri.parse("android.resource://com.audiobookshelf.app/" + com.audiobookshelf.app.R.drawable.icon) return coverUri ?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
}
if (coverPath == null) return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
return Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}") return Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")
} }
@@ -150,6 +162,8 @@ class PlaybackSession(
@JsonIgnore @JsonIgnore
fun getMediaMetadataCompat(ctx: Context): MediaMetadataCompat { fun getMediaMetadataCompat(ctx: Context): MediaMetadataCompat {
val coverUri = getCoverUri(ctx)
val metadataBuilder = MediaMetadataCompat.Builder() val metadataBuilder = MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, displayTitle) .putString(MediaMetadataCompat.METADATA_KEY_TITLE, displayTitle)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle) .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle)
@@ -160,16 +174,16 @@ class PlaybackSession(
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, displayAuthor) .putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, displayAuthor)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, displayAuthor) .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, displayAuthor)
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id) .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getCoverUri().toString()) .putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString())
.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, getCoverUri().toString()) .putString(MediaMetadataCompat.METADATA_KEY_ART_URI, coverUri.toString())
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, getCoverUri().toString()) .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString())
// Local covers get bitmap // Local covers get bitmap
if (localLibraryItem?.coverContentUrl != null) { if (localLibraryItem?.coverContentUrl != null) {
val bitmap = if (Build.VERSION.SDK_INT < 28) { val bitmap = if (Build.VERSION.SDK_INT < 28) {
MediaStore.Images.Media.getBitmap(ctx.contentResolver, getCoverUri()) MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
} else { } else {
val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, getCoverUri()) val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, coverUri)
ImageDecoder.decodeBitmap(source) ImageDecoder.decodeBitmap(source)
} }
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap) metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
@@ -180,7 +194,9 @@ class PlaybackSession(
} }
@JsonIgnore @JsonIgnore
fun getExoMediaMetadata(audioTrack:AudioTrack): MediaMetadata { fun getExoMediaMetadata(ctx:Context): MediaMetadata {
val coverUri = getCoverUri(ctx)
val metadataBuilder = MediaMetadata.Builder() val metadataBuilder = MediaMetadata.Builder()
.setTitle(displayTitle) .setTitle(displayTitle)
.setDisplayTitle(displayTitle) .setDisplayTitle(displayTitle)
@@ -189,20 +205,18 @@ class PlaybackSession(
.setSubtitle(displayAuthor) .setSubtitle(displayAuthor)
.setAlbumTitle(displayAuthor) .setAlbumTitle(displayAuthor)
.setDescription(displayAuthor) .setDescription(displayAuthor)
.setArtworkUri(getCoverUri()) .setArtworkUri(coverUri)
.setMediaType(MediaMetadata.MEDIA_TYPE_AUDIO_BOOK)
val contentUri = this.getContentUri(audioTrack)
metadataBuilder.setMediaUri(contentUri)
return metadataBuilder.build() return metadataBuilder.build()
} }
@JsonIgnore @JsonIgnore
fun getMediaItems():List<MediaItem> { fun getMediaItems(ctx:Context):List<MediaItem> {
val mediaItems:MutableList<MediaItem> = mutableListOf() val mediaItems:MutableList<MediaItem> = mutableListOf()
for (audioTrack in audioTracks) { for (audioTrack in audioTracks) {
val mediaMetadata = this.getExoMediaMetadata(audioTrack) val mediaMetadata = this.getExoMediaMetadata(ctx)
val mediaUri = this.getContentUri(audioTrack) val mediaUri = this.getContentUri(audioTrack)
val mimeType = audioTrack.mimeType val mimeType = audioTrack.mimeType
@@ -250,7 +264,7 @@ class PlaybackSession(
@JsonIgnore @JsonIgnore
fun clone():PlaybackSession { fun clone():PlaybackSession {
return PlaybackSession(id,userId,libraryItemId,episodeId,mediaType,mediaMetadata,chapters,displayTitle,displayAuthor,coverPath,duration,playMethod,startedAt,updatedAt,timeListening,audioTracks,currentTime,libraryItem,localLibraryItem,localEpisodeId,serverConnectionConfigId,serverAddress, mediaPlayer) return PlaybackSession(id,userId,libraryItemId,episodeId,mediaType,mediaMetadata,deviceInfo,chapters,displayTitle,displayAuthor,coverPath,duration,playMethod,startedAt,updatedAt,timeListening,audioTracks,currentTime,libraryItem,localLibraryItem,localEpisodeId,serverConnectionConfigId,serverAddress, mediaPlayer)
} }
@JsonIgnore @JsonIgnore
@@ -262,6 +276,6 @@ class PlaybackSession(
@JsonIgnore @JsonIgnore
fun getNewLocalMediaProgress():LocalMediaProgress { fun getNewLocalMediaProgress():LocalMediaProgress {
return LocalMediaProgress(localMediaProgressId,localLibraryItemId,localEpisodeId,getTotalDuration(),progress,currentTime,false,updatedAt,startedAt,null,serverConnectionConfigId,serverAddress,userId,libraryItemId,episodeId) return LocalMediaProgress(localMediaProgressId,localLibraryItemId,localEpisodeId,getTotalDuration(),progress,currentTime,false,null,null,updatedAt,startedAt,null,serverConnectionConfigId,serverAddress,userId,libraryItemId,episodeId)
} }
} }
@@ -1,15 +1,20 @@
package com.audiobookshelf.app.device package com.audiobookshelf.app.device
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context import android.content.Context
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.util.Log import android.util.Log
import com.audiobookshelf.app.MediaPlayerWidget
import com.audiobookshelf.app.data.* import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.managers.DbManager import com.audiobookshelf.app.managers.DbManager
import com.audiobookshelf.app.player.PlayerNotificationService import com.audiobookshelf.app.player.PlayerNotificationService
import com.audiobookshelf.app.updateAppWidget
interface WidgetEventEmitter { interface WidgetEventEmitter {
fun onPlayerChanged(pns:PlayerNotificationService) fun onPlayerChanged(pns:PlayerNotificationService)
fun onPlayerClosed()
} }
object DeviceManager { object DeviceManager {
@@ -39,6 +44,10 @@ object DeviceManager {
if (deviceData.deviceSettings?.shakeSensitivity == null) { if (deviceData.deviceSettings?.shakeSensitivity == null) {
deviceData.deviceSettings?.shakeSensitivity = ShakeSensitivitySetting.MEDIUM deviceData.deviceSettings?.shakeSensitivity = ShakeSensitivitySetting.MEDIUM
} }
// Initialize auto sleep timer auto rewind added in v0.9.64
if (deviceData.deviceSettings?.autoSleepTimerAutoRewindTime == null) {
deviceData.deviceSettings?.autoSleepTimerAutoRewindTime = 300000L // 5 minutes
}
} }
fun getBase64Id(id:String):String { fun getBase64Id(id:String):String {
@@ -72,4 +81,31 @@ object DeviceManager {
deviceData.lastPlaybackSession = playbackSession deviceData.lastPlaybackSession = playbackSession
dbManager.saveDeviceData(deviceData) dbManager.saveDeviceData(deviceData)
} }
fun initializeWidgetUpdater(context:Context) {
Log.d(tag, "Initializing widget updater")
widgetUpdater = (object : WidgetEventEmitter {
override fun onPlayerChanged(pns: PlayerNotificationService) {
val isPlaying = pns.currentPlayer.isPlaying
val appWidgetManager = AppWidgetManager.getInstance(context)
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
val ids = appWidgetManager.getAppWidgetIds(componentName)
val playbackSession = pns.getCurrentPlaybackSessionCopy()
for (widgetId in ids) {
updateAppWidget(context, appWidgetManager, widgetId, playbackSession, isPlaying, PlayerNotificationService.isClosed)
}
}
override fun onPlayerClosed() {
val appWidgetManager = AppWidgetManager.getInstance(context)
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
val ids = appWidgetManager.getAppWidgetIds(componentName)
for (widgetId in ids) {
updateAppWidget(context, appWidgetManager, widgetId, deviceData.lastPlaybackSession, false, PlayerNotificationService.isClosed)
}
}
})
}
} }
@@ -15,6 +15,7 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue import com.fasterxml.jackson.module.kotlin.readValue
import com.getcapacitor.JSObject import com.getcapacitor.JSObject
import org.json.JSONException import org.json.JSONException
import java.io.File
class FolderScanner(var ctx: Context) { class FolderScanner(var ctx: Context) {
private val tag = "FolderScanner" private val tag = "FolderScanner"
@@ -72,7 +73,7 @@ class FolderScanner(var ctx: Context) {
Log.d(tag, "Iterating over Folder Found ${itemFolder.name} | ${itemFolder.getSimplePath(ctx)} | URI: ${itemFolder.uri}") Log.d(tag, "Iterating over Folder Found ${itemFolder.name} | ${itemFolder.getSimplePath(ctx)} | URI: ${itemFolder.uri}")
val existingItem = existingLocalLibraryItems.find { emi -> emi.id == getLocalLibraryItemId(itemFolder.id) } val existingItem = existingLocalLibraryItems.find { emi -> emi.id == getLocalLibraryItemId(itemFolder.id) }
val filesInFolder = itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream")) val filesInFolder = itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/*"))
// Do not scan folders that have no media items and not an existing item already // Do not scan folders that have no media items and not an existing item already
if (existingItem != null || filesInFolder.isNotEmpty()) { if (existingItem != null || filesInFolder.isNotEmpty()) {
@@ -110,6 +111,8 @@ class FolderScanner(var ctx: Context) {
var startOffset = 0.0 var startOffset = 0.0
var coverContentUrl:String? = null var coverContentUrl:String? = null
var coverAbsolutePath:String? = null var coverAbsolutePath:String? = null
var hasEBookFile = false
var newEBookFile:EBookFile? = null
val existingLocalFilesRemoved = existingLocalFiles.filter { elf -> val existingLocalFilesRemoved = existingLocalFiles.filter { elf ->
filesInFolder.find { fif -> DeviceManager.getBase64Id(fif.id) == elf.id } == null // File was not found in media item folder filesInFolder.find { fif -> DeviceManager.getBase64Id(fif.id) == elf.id } == null // File was not found in media item folder
@@ -122,7 +125,6 @@ class FolderScanner(var ctx: Context) {
filesInFolder.forEach { file -> filesInFolder.forEach { file ->
val mimeType = file.mimeType ?: "" val mimeType = file.mimeType ?: ""
val filename = file.name ?: "" val filename = file.name ?: ""
val isAudio = mimeType.startsWith("audio") || mimeType == "video/mp4"
Log.d(tag, "Found $mimeType file $filename in folder $itemFolderName") Log.d(tag, "Found $mimeType file $filename in folder $itemFolderName")
val localFileId = DeviceManager.getBase64Id(file.id) val localFileId = DeviceManager.getBase64Id(file.id)
@@ -132,7 +134,7 @@ class FolderScanner(var ctx: Context) {
Log.d(tag, "File attributes Id:${localFileId}|ContentUrl:${localFile.contentUrl}|isDownloadsDocument:${file.isDownloadsDocument}") Log.d(tag, "File attributes Id:${localFileId}|ContentUrl:${localFile.contentUrl}|isDownloadsDocument:${file.isDownloadsDocument}")
if (isAudio) { if (localFile.isAudioFile()) {
val audioTrackToAdd:AudioTrack? val audioTrackToAdd:AudioTrack?
val existingAudioTrack = existingAudioTracks.find { eat -> eat.localFileId == localFileId } val existingAudioTrack = existingAudioTracks.find { eat -> eat.localFileId == localFileId }
@@ -174,6 +176,15 @@ class FolderScanner(var ctx: Context) {
startOffset += audioTrackToAdd.duration startOffset += audioTrackToAdd.duration
index++ index++
audioTracks.add(audioTrackToAdd) audioTracks.add(audioTrackToAdd)
} else if (localFile.isEBookFile()) {
val existingLocalFile = existingLocalFiles.find { elf -> elf.id == localFileId }
if (localFolder.mediaType == "book") {
hasEBookFile = true
if (existingLocalFile == null) {
newEBookFile = EBookFile(localFileId, null, localFile.getEBookFormat() ?: "", true, localFileId, localFile.contentUrl)
}
}
} else { } else {
val existingLocalFile = existingLocalFiles.find { elf -> elf.id == localFileId } val existingLocalFile = existingLocalFiles.find { elf -> elf.id == localFileId }
@@ -198,7 +209,7 @@ class FolderScanner(var ctx: Context) {
} }
} }
if (existingItem != null && audioTracks.isEmpty()) { if (existingItem != null && audioTracks.isEmpty() && !hasEBookFile) {
Log.d(tag, "Local library item ${existingItem.media.metadata.title} no longer has audio tracks - removing item") Log.d(tag, "Local library item ${existingItem.media.metadata.title} no longer has audio tracks - removing item")
DeviceManager.dbManager.removeLocalLibraryItem(existingItem.id) DeviceManager.dbManager.removeLocalLibraryItem(existingItem.id)
return ItemScanResult.REMOVED return ItemScanResult.REMOVED
@@ -210,9 +221,9 @@ class FolderScanner(var ctx: Context) {
existingItem.updateFromScan(audioTracks,localFiles) existingItem.updateFromScan(audioTracks,localFiles)
DeviceManager.dbManager.saveLocalLibraryItem(existingItem) DeviceManager.dbManager.saveLocalLibraryItem(existingItem)
return ItemScanResult.UPDATED return ItemScanResult.UPDATED
} else if (audioTracks.isNotEmpty()) { } else if (audioTracks.isNotEmpty() || newEBookFile != null) {
Log.d(tag, "Found local media item named $itemFolderName with ${audioTracks.size} tracks and ${localFiles.size} local files") Log.d(tag, "Found local media item named $itemFolderName with ${audioTracks.size} tracks and ${localFiles.size} local files")
val localMediaItem = LocalMediaItem(itemId, itemFolderName, localFolder.mediaType, localFolder.id, itemFolder.uri.toString(), itemFolder.getSimplePath(ctx), itemFolder.getBasePath(ctx), itemFolder.getAbsolutePath(ctx),audioTracks,localFiles,coverContentUrl,coverAbsolutePath) val localMediaItem = LocalMediaItem(itemId, itemFolderName, localFolder.mediaType, localFolder.id, itemFolder.uri.toString(), itemFolder.getSimplePath(ctx), itemFolder.getBasePath(ctx), itemFolder.getAbsolutePath(ctx),audioTracks,newEBookFile,localFiles,coverContentUrl,coverAbsolutePath)
val localLibraryItem = localMediaItem.getLocalLibraryItem() val localLibraryItem = localMediaItem.getLocalLibraryItem()
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem) DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
return ItemScanResult.ADDED return ItemScanResult.ADDED
@@ -221,101 +232,126 @@ class FolderScanner(var ctx: Context) {
} }
} }
// Scan item after download and create local library item private fun scanInternalDownloadItem(downloadItem:DownloadItem, cb: (DownloadItemScanResult?) -> Unit) {
fun scanDownloadItem(downloadItem: DownloadItem, cb: (DownloadItemScanResult?) -> Unit) { val localLibraryItemId = "local_${downloadItem.libraryItemId}"
val folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl))
val foldersFound = folderDf?.search(true, DocumentFileType.FOLDER) ?: mutableListOf()
var itemFolderId = ""
var itemFolderUrl = ""
var itemFolderBasePath = ""
var itemFolderAbsolutePath = ""
foldersFound.forEach {
// e.g. absolute path is "storage/emulated/0/Audiobooks/Orson Scott Card/Enders Game"
// and itemSubfolder is "Orson Scott Card/Enders Game"
if (it.getAbsolutePath(ctx).endsWith(downloadItem.itemSubfolder)) {
itemFolderId = it.id
itemFolderUrl = it.uri.toString()
itemFolderBasePath = it.getBasePath(ctx)
itemFolderAbsolutePath = it.getAbsolutePath(ctx)
}
}
if (itemFolderUrl == "") {
Log.d(tag, "scanDownloadItem failed to find media folder")
return cb(null)
}
val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(itemFolderUrl))
if (df == null) {
Log.e(tag, "Folder Doc File Invalid ${downloadItem.itemFolderPath}")
return cb(null)
}
val localLibraryItemId = getLocalLibraryItemId(itemFolderId)
Log.d(tag, "scanDownloadItem starting for ${downloadItem.itemFolderPath} | ${df.uri} | Item Folder Id:$itemFolderId | LLI Id:$localLibraryItemId")
// Search for files in media item folder
// m4b files showing as mimeType application/octet-stream on Android 10 and earlier see #154
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream"))
Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}")
var localEpisodeId:String? = null var localEpisodeId:String? = null
var localLibraryItem:LocalLibraryItem? var localLibraryItem:LocalLibraryItem?
if (downloadItem.mediaType == "book") { if (downloadItem.mediaType == "book") {
localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, itemFolderBasePath, itemFolderAbsolutePath, itemFolderUrl, false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true, downloadItem.serverConnectionConfigId, downloadItem.serverAddress, downloadItem.serverUserId, downloadItem.libraryItemId) localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, downloadItem.itemFolderPath, downloadItem.itemFolderPath, "", false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true, downloadItem.serverConnectionConfigId, downloadItem.serverAddress, downloadItem.serverUserId, downloadItem.libraryItemId)
} else { } else {
// Lookup or create podcast local library item // Lookup or create podcast local library item
localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId) localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
if (localLibraryItem == null) { if (localLibraryItem == null) {
Log.d(tag, "[FolderScanner] Podcast local library item not created yet for ${downloadItem.media.metadata.title}") Log.d(tag, "[FolderScanner] Podcast local library item not created yet for ${downloadItem.media.metadata.title}")
localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, itemFolderBasePath, itemFolderAbsolutePath, itemFolderUrl, false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true,downloadItem.serverConnectionConfigId,downloadItem.serverAddress,downloadItem.serverUserId,downloadItem.libraryItemId) localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, downloadItem.itemFolderPath, downloadItem.itemFolderPath, "", false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true,downloadItem.serverConnectionConfigId,downloadItem.serverAddress,downloadItem.serverUserId,downloadItem.libraryItemId)
} }
} }
val audioTracks:MutableList<AudioTrack> = mutableListOf() val audioTracks:MutableList<AudioTrack> = mutableListOf()
var foundEBookFile = false
filesFound.forEach { docFile -> downloadItem.downloadItemParts.forEach { downloadItemPart ->
val itemPart = downloadItem.downloadItemParts.find { itemPart -> Log.d(tag, "Scan internal storage item with finalDestinationUri=${downloadItemPart.finalDestinationUri}")
itemPart.filename == docFile.name
} val file = File(downloadItemPart.finalDestinationPath)
if (itemPart == null) { Log.d(tag, "Scan internal storage item created file ${file.name}")
if (downloadItem.mediaType == "book") { // for books every download item should be a file found
Log.e(tag, "scanDownloadItem: Item part not found for doc file ${docFile.name} | ${docFile.getAbsolutePath(ctx)} | ${docFile.uri}") if (file == null) {
Log.e(tag, "scanInternalDownloadItem: Null docFile for path ${downloadItemPart.finalDestinationPath}")
} else {
if (downloadItemPart.audioTrack != null) {
val audioTrackFromServer = downloadItemPart.audioTrack
Log.d(
tag,
"scanInternalDownloadItem: Audio Track from Server index = ${audioTrackFromServer.index}"
)
val localFileId = DeviceManager.getBase64Id(file.name)
Log.d(tag, "Scan internal file localFileId=$localFileId")
val localFile = LocalFile(
localFileId,
file.name,
downloadItemPart.finalDestinationUri.toString(),
file.getBasePath(ctx),
file.absolutePath,
file.getSimplePath(ctx),
file.mimeType,
file.length()
)
localLibraryItem.localFiles.add(localFile)
// Create new audio track
val track = AudioTrack(
audioTrackFromServer.index,
audioTrackFromServer.startOffset,
audioTrackFromServer.duration,
localFile.filename ?: "",
localFile.contentUrl,
localFile.mimeType ?: "",
null,
true,
localFileId,
null,
audioTrackFromServer.index
)
audioTracks.add(track)
Log.d(
tag,
"scanInternalDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}"
)
// Add podcast episodes to library
downloadItemPart.episode?.let { podcastEpisode ->
val podcast = localLibraryItem.media as Podcast
val newEpisode = podcast.addEpisode(track, podcastEpisode)
localEpisodeId = newEpisode.id
Log.d(
tag,
"scanInternalDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}"
)
}
} else if (downloadItemPart.ebookFile != null) {
foundEBookFile = true
Log.d(tag, "scanInternalDownloadItem: Ebook file found with mimetype=${file.mimeType}")
val localFileId = DeviceManager.getBase64Id(file.name)
val localFile = LocalFile(
localFileId,
file.name,
Uri.fromFile(file).toString(),
file.getBasePath(ctx),
file.absolutePath,
file.getSimplePath(ctx),
file.mimeType,
file.length()
)
localLibraryItem.localFiles.add(localFile)
val ebookFile = EBookFile(
downloadItemPart.ebookFile.ino,
downloadItemPart.ebookFile.metadata,
downloadItemPart.ebookFile.ebookFormat,
true,
localFileId,
localFile.contentUrl
)
(localLibraryItem.media as Book).ebookFile = ebookFile
Log.d(tag, "scanInternalDownloadItem: Ebook file added to lli ${localFile.contentUrl}")
} else {
val localFileId = DeviceManager.getBase64Id(file.name)
val localFile = LocalFile(localFileId,file.name,Uri.fromFile(file).toString(),file.getBasePath(ctx),file.absolutePath,file.getSimplePath(ctx),file.mimeType,file.length())
localLibraryItem.coverAbsolutePath = localFile.absolutePath
localLibraryItem.coverContentUrl = localFile.contentUrl
localLibraryItem.localFiles.add(localFile)
} }
} else if (itemPart.audioTrack != null) { // Is audio track
val audioTrackFromServer = itemPart.audioTrack
Log.d(tag, "scanDownloadItem: Audio Track from Server index = ${audioTrackFromServer.index}")
val localFileId = DeviceManager.getBase64Id(docFile.id)
val localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
localLibraryItem.localFiles.add(localFile)
// Create new audio track
val track = AudioTrack(audioTrackFromServer.index, audioTrackFromServer.startOffset, audioTrackFromServer.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, null, audioTrackFromServer.index)
audioTracks.add(track)
Log.d(tag, "scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}")
// Add podcast episodes to library
itemPart.episode?.let { podcastEpisode ->
val podcast = localLibraryItem.media as Podcast
val newEpisode = podcast.addEpisode(track, podcastEpisode)
localEpisodeId = newEpisode.id
Log.d(tag, "scanDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}")
}
} else { // Cover image
val localFileId = DeviceManager.getBase64Id(docFile.id)
val localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
localLibraryItem.coverAbsolutePath = localFile.absolutePath
localLibraryItem.coverContentUrl = localFile.contentUrl
localLibraryItem.localFiles.add(localFile)
} }
} }
if (audioTracks.isEmpty()) { if (audioTracks.isEmpty() && !foundEBookFile) {
Log.d(tag, "scanDownloadItem did not find any audio tracks in folder for ${downloadItem.itemFolderPath}") Log.d(tag, "scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}")
return cb(null) return cb(null)
} }
@@ -350,6 +386,175 @@ class FolderScanner(var ctx: Context) {
progress = mediaProgress.progress, progress = mediaProgress.progress,
currentTime = mediaProgress.currentTime, currentTime = mediaProgress.currentTime,
isFinished = false, isFinished = false,
ebookLocation = mediaProgress.ebookLocation,
ebookProgress = mediaProgress.ebookProgress,
lastUpdate = mediaProgress.lastUpdate,
startedAt = mediaProgress.startedAt,
finishedAt = mediaProgress.finishedAt,
serverConnectionConfigId = downloadItem.serverConnectionConfigId,
serverAddress = downloadItem.serverAddress,
serverUserId = downloadItem.serverUserId,
libraryItemId = downloadItem.libraryItemId,
episodeId = downloadItem.episodeId)
Log.d(tag, "scanLibraryItemFolder: Saving local media progress ${newLocalMediaProgress.id} at progress ${newLocalMediaProgress.progress}")
DeviceManager.dbManager.saveLocalMediaProgress(newLocalMediaProgress)
downloadItemScanResult.localMediaProgress = newLocalMediaProgress
}
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
cb(downloadItemScanResult)
}
// Scan item after download and create local library item
fun scanDownloadItem(downloadItem: DownloadItem, cb: (DownloadItemScanResult?) -> Unit) {
// If downloading to internal storage handle separately
if (downloadItem.isInternalStorage) {
scanInternalDownloadItem(downloadItem, cb)
return
}
val folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl))
val foldersFound = folderDf?.search(true, DocumentFileType.FOLDER) ?: mutableListOf()
var itemFolderId = ""
var itemFolderUrl = ""
var itemFolderBasePath = ""
var itemFolderAbsolutePath = ""
foldersFound.forEach {
// e.g. absolute path is "storage/emulated/0/Audiobooks/Orson Scott Card/Enders Game"
// and itemSubfolder is "Orson Scott Card/Enders Game"
if (it.getAbsolutePath(ctx).endsWith(downloadItem.itemSubfolder)) {
itemFolderId = it.id
itemFolderUrl = it.uri.toString()
itemFolderBasePath = it.getBasePath(ctx)
itemFolderAbsolutePath = it.getAbsolutePath(ctx)
}
}
if (itemFolderUrl == "") {
Log.d(tag, "scanDownloadItem failed to find media folder")
return cb(null)
}
val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(itemFolderUrl))
if (df == null) {
Log.e(tag, "Folder Doc File Invalid ${downloadItem.itemFolderPath}")
return cb(null)
}
val localLibraryItemId = getLocalLibraryItemId(itemFolderId)
Log.d(tag, "scanDownloadItem starting for ${downloadItem.itemFolderPath} | ${df.uri} | Item Folder Id:$itemFolderId | LLI Id:$localLibraryItemId")
// Search for files in media item folder
// m4b files showing as mimeType application/octet-stream on Android 10 and earlier see #154
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/*"))
Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}")
var localEpisodeId:String? = null
var localLibraryItem:LocalLibraryItem?
if (downloadItem.mediaType == "book") {
localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, itemFolderBasePath, itemFolderAbsolutePath, itemFolderUrl, false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true, downloadItem.serverConnectionConfigId, downloadItem.serverAddress, downloadItem.serverUserId, downloadItem.libraryItemId)
} else {
// Lookup or create podcast local library item
localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
if (localLibraryItem == null) {
Log.d(tag, "[FolderScanner] Podcast local library item not created yet for ${downloadItem.media.metadata.title}")
localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, itemFolderBasePath, itemFolderAbsolutePath, itemFolderUrl, false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true,downloadItem.serverConnectionConfigId,downloadItem.serverAddress,downloadItem.serverUserId,downloadItem.libraryItemId)
}
}
val audioTracks:MutableList<AudioTrack> = mutableListOf()
var foundEBookFile = false
filesFound.forEach { docFile ->
val itemPart = downloadItem.downloadItemParts.find { itemPart ->
itemPart.filename == docFile.name
}
if (itemPart == null) {
if (downloadItem.mediaType == "book") { // for books every download item should be a file found
Log.e(tag, "scanDownloadItem: Item part not found for doc file ${docFile.name} | ${docFile.getAbsolutePath(ctx)} | ${docFile.uri}")
}
} else if (itemPart.audioTrack != null) { // Is audio track
val audioTrackFromServer = itemPart.audioTrack
Log.d(tag, "scanDownloadItem: Audio Track from Server index = ${audioTrackFromServer.index}")
val localFileId = DeviceManager.getBase64Id(docFile.id)
val localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
localLibraryItem.localFiles.add(localFile)
// Create new audio track
val track = AudioTrack(audioTrackFromServer.index, audioTrackFromServer.startOffset, audioTrackFromServer.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, null, audioTrackFromServer.index)
audioTracks.add(track)
Log.d(tag, "scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}")
// Add podcast episodes to library
itemPart.episode?.let { podcastEpisode ->
val podcast = localLibraryItem.media as Podcast
val newEpisode = podcast.addEpisode(track, podcastEpisode)
localEpisodeId = newEpisode.id
Log.d(tag, "scanDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}")
}
} else if (itemPart.ebookFile != null) { // Ebook
foundEBookFile = true
Log.d(tag, "scanDownloadItem: Ebook file found with mimetype=${docFile.mimeType}")
val localFileId = DeviceManager.getBase64Id(docFile.id)
val localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
localLibraryItem.localFiles.add(localFile)
val ebookFile = EBookFile(itemPart.ebookFile.ino, itemPart.ebookFile.metadata, itemPart.ebookFile.ebookFormat, true, localFileId, localFile.contentUrl)
(localLibraryItem.media as Book).ebookFile = ebookFile
Log.d(tag, "scanDownloadItem: Ebook file added to lli ${localFile.contentUrl}")
} else { // Cover image
val localFileId = DeviceManager.getBase64Id(docFile.id)
val localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
localLibraryItem.coverAbsolutePath = localFile.absolutePath
localLibraryItem.coverContentUrl = localFile.contentUrl
localLibraryItem.localFiles.add(localFile)
}
}
if (audioTracks.isEmpty() && !foundEBookFile) {
Log.d(tag, "scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}")
return cb(null)
}
// For books sort audio tracks then set
if (downloadItem.mediaType == "book") {
audioTracks.sortBy { it.index }
var indexCheck = 1
var startOffset = 0.0
audioTracks.forEach { audioTrack ->
if (audioTrack.index != indexCheck || audioTrack.startOffset != startOffset) {
audioTrack.index = indexCheck
audioTrack.startOffset = startOffset
}
indexCheck++
startOffset += audioTrack.duration
}
localLibraryItem.media.setAudioTracks(audioTracks)
}
val downloadItemScanResult = DownloadItemScanResult(localLibraryItem,null)
// If library item had media progress then make local media progress and save
downloadItem.userMediaProgress?.let { mediaProgress ->
val localMediaProgressId = if (downloadItem.episodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
val newLocalMediaProgress = LocalMediaProgress(
id = localMediaProgressId,
localLibraryItemId = localLibraryItemId,
localEpisodeId = localEpisodeId,
duration = mediaProgress.duration,
progress = mediaProgress.progress,
currentTime = mediaProgress.currentTime,
isFinished = false,
ebookLocation = mediaProgress.ebookLocation,
ebookProgress = mediaProgress.ebookProgress,
lastUpdate = mediaProgress.lastUpdate, lastUpdate = mediaProgress.lastUpdate,
startedAt = mediaProgress.startedAt, startedAt = mediaProgress.startedAt,
finishedAt = mediaProgress.finishedAt, finishedAt = mediaProgress.finishedAt,
@@ -381,7 +586,7 @@ class FolderScanner(var ctx: Context) {
var wasUpdated = false var wasUpdated = false
// Search for files in media item folder // Search for files in media item folder
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream")) val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/*"))
Log.d(tag, "scanLocalLibraryItem ${filesFound.size} files found in ${localLibraryItem.absolutePath}") Log.d(tag, "scanLocalLibraryItem ${filesFound.size} files found in ${localLibraryItem.absolutePath}")
filesFound.forEach { filesFound.forEach {
@@ -427,7 +632,7 @@ class FolderScanner(var ctx: Context) {
val audioProbeResult = probeAudioFile(localFile.absolutePath) val audioProbeResult = probeAudioFile(localFile.absolutePath)
val existingTrack = existingAudioTracks.find { audioTrack -> val existingTrack = existingAudioTracks.find { audioTrack ->
audioTrack.localFileId == localFile.id audioTrack.localFileId == localFileId
} }
if (existingTrack == null) { if (existingTrack == null) {
@@ -446,6 +651,16 @@ class FolderScanner(var ctx: Context) {
wasUpdated = true wasUpdated = true
} }
} else if (localFile.isEBookFile()) {
if (localLibraryItem.mediaType == "book") {
val existingEbookFile = (localLibraryItem.media as Book).ebookFile
if (existingEbookFile == null || existingEbookFile.localFileId != localFileId) {
val ebookFile = EBookFile(localFileId, null, localFile.getEBookFormat() ?: "", true, localFileId, localFile.contentUrl)
(localLibraryItem.media as Book).ebookFile = ebookFile
Log.d(tag, "scanLocalLibraryItem: Ebook file added to lli ${localFile.contentUrl}")
wasUpdated = true
}
}
} else { // Check if cover is empty } else { // Check if cover is empty
if (localLibraryItem.coverContentUrl == null) { if (localLibraryItem.coverContentUrl == null) {
Log.d(tag, "scanLocalLibraryItem setting cover for ${localLibraryItem.media.metadata.title}") Log.d(tag, "scanLocalLibraryItem setting cover for ${localLibraryItem.media.metadata.title}")
@@ -22,6 +22,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File
import java.io.FileOutputStream
import java.util.* import java.util.*
class DownloadItemManager(var downloadManager:DownloadManager, private var folderScanner: FolderScanner, var mainActivity: MainActivity, private var clientEventEmitter:DownloadEventEmitter) { class DownloadItemManager(var downloadManager:DownloadManager, private var folderScanner: FolderScanner, var mainActivity: MainActivity, private var clientEventEmitter:DownloadEventEmitter) {
@@ -44,6 +46,11 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
fun onDownloadItemComplete(jsobj:JSObject) fun onDownloadItemComplete(jsobj:JSObject)
} }
interface InternalProgressCallback {
fun onProgress(totalBytesWritten:Long, progress: Long)
fun onComplete(failed: Boolean)
}
companion object { companion object {
var isDownloading:Boolean = false var isDownloading:Boolean = false
} }
@@ -65,11 +72,32 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
if (nextDownloadItemParts.size > 0) { if (nextDownloadItemParts.size > 0) {
nextDownloadItemParts.forEach { nextDownloadItemParts.forEach {
val dlRequest = it.getDownloadRequest() if (it.isInternalStorage) {
val downloadId = downloadManager.enqueue(dlRequest) val file = File(it.finalDestinationPath)
it.downloadId = downloadId file.parentFile?.mkdirs()
Log.d(tag, "checkUpdateDownloadQueue: Starting download item part, downloadId=$downloadId")
currentDownloadItemParts.add(it) val fileOutputStream = FileOutputStream(it.finalDestinationPath)
val internalProgressCallback = (object : InternalProgressCallback {
override fun onProgress(totalBytesWritten:Long, progress: Long) {
it.bytesDownloaded = totalBytesWritten
it.progress = progress
}
override fun onComplete(failed:Boolean) {
it.failed = failed
it.completed = true
}
})
Log.d(tag, "Start internal download to destination path ${it.finalDestinationPath} from ${it.serverUrl}")
InternalDownloadManager(fileOutputStream, internalProgressCallback).download(it.serverUrl)
currentDownloadItemParts.add(it)
} else {
val dlRequest = it.getDownloadRequest()
val downloadId = downloadManager.enqueue(dlRequest)
it.downloadId = downloadId
Log.d(tag, "checkUpdateDownloadQueue: Starting download item part, downloadId=$downloadId")
currentDownloadItemParts.add(it)
}
} }
} }
@@ -89,14 +117,25 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
isDownloading = true isDownloading = true
while (currentDownloadItemParts.size > 0) { while (currentDownloadItemParts.size > 0) {
val itemParts = currentDownloadItemParts.filter { !it.isMoving }.map { it } val itemParts = currentDownloadItemParts.filter { !it.isMoving }.map { it }
for (downloadItemPart in itemParts) { for (downloadItemPart in itemParts) {
val downloadCheckStatus = checkDownloadItemPart(downloadItemPart) if (downloadItemPart.isInternalStorage) {
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart) clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
// Will move to final destination, remove current item parts, and check if download item is finished if (downloadItemPart.completed) {
handleDownloadItemPartCheck(downloadCheckStatus, downloadItemPart) val downloadItem = downloadItemQueue.find { it.id == downloadItemPart.downloadItemId }
downloadItem?.let {
checkDownloadItemFinished(it)
}
currentDownloadItemParts.remove(downloadItemPart)
}
} else {
val downloadCheckStatus = checkDownloadItemPart(downloadItemPart)
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
// Will move to final destination, remove current item parts, and check if download item is finished
handleDownloadItemPartCheck(downloadCheckStatus, downloadItemPart)
}
} }
delay(500) delay(500)
@@ -166,7 +205,6 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
Log.e(tag, "Download item part finished but download item not found ${downloadItemPart.filename}") Log.e(tag, "Download item part finished but download item not found ${downloadItemPart.filename}")
currentDownloadItemParts.remove(downloadItemPart) currentDownloadItemParts.remove(downloadItemPart)
} else if (downloadCheckStatus == DownloadCheckStatus.Successful) { } else if (downloadCheckStatus == DownloadCheckStatus.Successful) {
val file = DocumentFileCompat.fromUri(mainActivity, downloadItemPart.destinationUri) val file = DocumentFileCompat.fromUri(mainActivity, downloadItemPart.destinationUri)
Log.d(tag, "DOWNLOAD: DESTINATION URI ${downloadItemPart.destinationUri}") Log.d(tag, "DOWNLOAD: DESTINATION URI ${downloadItemPart.destinationUri}")
@@ -0,0 +1,74 @@
package com.audiobookshelf.app.managers
import android.util.Log
import com.google.common.net.HttpHeaders.CONTENT_LENGTH
import okhttp3.*
import java.io.*
import java.util.*
class InternalDownloadManager(outputStream:FileOutputStream, private val progressCallback: DownloadItemManager.InternalProgressCallback) : AutoCloseable {
private val tag = "InternalDownloadManager"
private val client: OkHttpClient = OkHttpClient()
private val writer = BinaryFileWriter(outputStream, progressCallback)
@Throws(IOException::class)
fun download(url:String) {
val request: Request = Request.Builder().url(url).build()
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
Log.e(tag, "download URL $url FAILED")
progressCallback.onComplete(true)
}
override fun onResponse(call: Call, response: Response) {
val responseBody: ResponseBody = response.body
?: throw IllegalStateException("Response doesn't contain a file")
val length: Long = (response.header(CONTENT_LENGTH, "1") ?: "0").toLong()
writer.write(responseBody.byteStream(), length)
}
})
}
@Throws(Exception::class)
override fun close() {
writer.close()
}
}
class BinaryFileWriter(outputStream: OutputStream, progressCallback: DownloadItemManager.InternalProgressCallback) :
AutoCloseable {
private val outputStream: OutputStream
private val progressCallback: DownloadItemManager.InternalProgressCallback
init {
this.outputStream = outputStream
this.progressCallback = progressCallback
}
@Throws(IOException::class)
fun write(inputStream: InputStream?, length: Long): Long {
BufferedInputStream(inputStream).use { input ->
val dataBuffer = ByteArray(CHUNK_SIZE)
var readBytes: Int
var totalBytes: Long = 0
while (input.read(dataBuffer).also { readBytes = it } != -1) {
totalBytes += readBytes.toLong()
outputStream.write(dataBuffer, 0, readBytes)
progressCallback.onProgress(totalBytes, (totalBytes * 100L) / length)
}
progressCallback.onComplete(false)
return totalBytes
}
}
@Throws(IOException::class)
override fun close() {
outputStream.close()
}
companion object {
private const val CHUNK_SIZE = 1024
}
}
@@ -1,6 +1,7 @@
package com.audiobookshelf.app.managers package com.audiobookshelf.app.managers
import android.content.Context import android.content.Context
import android.media.metrics.PlaybackSession
import android.os.* import android.os.*
import android.util.Log import android.util.Log
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
@@ -21,6 +22,8 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
private var sleepTimerElapsed:Long = 0L private var sleepTimerElapsed:Long = 0L
private var sleepTimerFinishedAt:Long = 0L private var sleepTimerFinishedAt:Long = 0L
private var isAutoSleepTimer:Boolean = false // When timer was auto-set private var isAutoSleepTimer:Boolean = false // When timer was auto-set
private var isFirstAutoSleepTimer: Boolean = true
private var sleepTimerSessionId:String = ""
private fun getCurrentTime():Long { private fun getCurrentTime():Long {
return playerNotificationService.getCurrentTime() return playerNotificationService.getCurrentTime()
@@ -61,6 +64,7 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
sleepTimerRunning = true sleepTimerRunning = true
sleepTimerFinishedAt = 0L sleepTimerFinishedAt = 0L
sleepTimerElapsed = 0L sleepTimerElapsed = 0L
setVolume(1f)
// Register shake sensor // Register shake sensor
playerNotificationService.registerSensor() playerNotificationService.registerSensor()
@@ -80,10 +84,6 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
} else { } else {
sleepTimerLength = time sleepTimerLength = time
sleepTimerEndTime = 0L sleepTimerEndTime = 0L
if (sleepTimerLength + getCurrentTime() > getDuration()) {
sleepTimerLength = getDuration() - getCurrentTime()
}
} }
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds(), isAutoSleepTimer) playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds(), isAutoSleepTimer)
@@ -107,17 +107,14 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
playerNotificationService.clientEventEmitter?.onSleepTimerEnded(getCurrentTime()) playerNotificationService.clientEventEmitter?.onSleepTimerEnded(getCurrentTime())
clearSleepTimer() clearSleepTimer()
sleepTimerFinishedAt = System.currentTimeMillis() sleepTimerFinishedAt = System.currentTimeMillis()
} else if (sleepTimeSecondsRemaining <= 60) { } else if (sleepTimeSecondsRemaining <= 60 && DeviceManager.deviceData.deviceSettings?.disableSleepTimerFadeOut != true) {
if (DeviceManager.deviceData.deviceSettings?.disableSleepTimerFadeOut == true) { // Start fading out audio down to 10% volume
// Set volume to 1 in case setting was enabled while fading val percentToReduce = 1 - (sleepTimeSecondsRemaining / 60F)
setVolume(1f) val volume = 1f - (percentToReduce * 0.9f)
} else { Log.d(tag, "SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining")
// Start fading out audio down to 10% volume setVolume(volume)
val percentToReduce = 1 - (sleepTimeSecondsRemaining / 60F) } else {
val volume = 1f - (percentToReduce * 0.9f) setVolume(1f)
Log.d(tag, "SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining")
setVolume(volume)
}
} }
} }
} }
@@ -125,7 +122,8 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
return true return true
} }
fun setManualSleepTimer(time: Long, isChapterTime:Boolean):Boolean { fun setManualSleepTimer(playbackSessionId:String, time: Long, isChapterTime:Boolean):Boolean {
sleepTimerSessionId = playbackSessionId
isAutoSleepTimer = false isAutoSleepTimer = false
return setSleepTimer(time, isChapterTime) return setSleepTimer(time, isChapterTime)
} }
@@ -135,8 +133,9 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
sleepTimerTask = null sleepTimerTask = null
sleepTimerEndTime = 0 sleepTimerEndTime = 0
sleepTimerRunning = false sleepTimerRunning = false
isAutoSleepTimer = false
playerNotificationService.unregisterSensor() playerNotificationService.unregisterSensor()
setVolume(1f)
} }
fun getSleepTimerTime():Long { fun getSleepTimerTime():Long {
@@ -227,6 +226,17 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
return return
} }
// Automatically Rewind in the book if settings is enabled
if (isAutoSleepTimer) {
DeviceManager.deviceData.deviceSettings?.let { deviceSettings ->
if (deviceSettings.autoSleepTimerAutoRewind && !isFirstAutoSleepTimer) {
Log.i(tag, "Auto sleep timer auto rewind seeking back ${deviceSettings.autoSleepTimerAutoRewindTime}ms")
playerNotificationService.seekBackward(deviceSettings.autoSleepTimerAutoRewindTime)
}
isFirstAutoSleepTimer = false
}
}
// Set sleep timer // Set sleep timer
// When sleepTimerLength is 0 then use end of chapter/track time // When sleepTimerLength is 0 then use end of chapter/track time
if (sleepTimerLength == 0L) { if (sleepTimerLength == 0L) {
@@ -325,18 +335,31 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
endCalendar.set(Calendar.HOUR_OF_DAY, deviceSettings.autoSleepTimerEndHour) endCalendar.set(Calendar.HOUR_OF_DAY, deviceSettings.autoSleepTimerEndHour)
endCalendar.set(Calendar.MINUTE, deviceSettings.autoSleepTimerEndMinute) endCalendar.set(Calendar.MINUTE, deviceSettings.autoSleepTimerEndMinute)
// In cases where end time is earlier then start time then we add a day to end time val currentCalendar = Calendar.getInstance()
// e.g. start time 22:00 and end time 06:00. End time will be treated as 6am the next day.
// e.g. start time 08:00 and end time 22:00. Start and end time will be the same day. // In cases where end time is before start time then we shift the time window forward or backward based on the current time.
// e.g. start time 22:00 and end time 06:00.
// If current time is less than start time (e.g. 00:30) then start time will be the previous day.
// If current time is greater than start time (e.g. 23:00) then end time will be the next day.
if (endCalendar.before(startCalendar)) { if (endCalendar.before(startCalendar)) {
endCalendar.add(Calendar.DAY_OF_MONTH, 1) if (currentCalendar.before(startCalendar)) { // Shift start back a day
startCalendar.add(Calendar.DAY_OF_MONTH, -1)
} else { // Shift end forward a day
endCalendar.add(Calendar.DAY_OF_MONTH, 1)
}
} }
val currentCalendar = Calendar.getInstance()
val currentHour = SimpleDateFormat("HH:mm", Locale.getDefault()).format(currentCalendar.time) val currentHour = SimpleDateFormat("HH:mm", Locale.getDefault()).format(currentCalendar.time)
if (currentCalendar.after(startCalendar) && currentCalendar.before(endCalendar)) { if (currentCalendar.after(startCalendar) && currentCalendar.before(endCalendar)) {
Log.i(tag, "Current hour $currentHour is between ${deviceSettings.autoSleepTimerStartTime} and ${deviceSettings.autoSleepTimerEndTime} - starting sleep timer") Log.i(tag, "Current hour $currentHour is between ${deviceSettings.autoSleepTimerStartTime} and ${deviceSettings.autoSleepTimerEndTime} - starting sleep timer")
// Automatically Rewind in the book if settings is enabled
if (deviceSettings.autoSleepTimerAutoRewind && !isFirstAutoSleepTimer) {
Log.i(tag, "Auto sleep timer auto rewind seeking back ${deviceSettings.autoSleepTimerAutoRewindTime}ms")
playerNotificationService.seekBackward(deviceSettings.autoSleepTimerAutoRewindTime)
}
isFirstAutoSleepTimer = false
// Set sleep timer // Set sleep timer
// When sleepTimerLength is 0 then use end of chapter/track time // When sleepTimerLength is 0 then use end of chapter/track time
if (deviceSettings.sleepTimerLength == 0L) { if (deviceSettings.sleepTimerLength == 0L) {
@@ -352,13 +375,23 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
setSleepTimer(deviceSettings.sleepTimerLength, false) setSleepTimer(deviceSettings.sleepTimerLength, false)
} }
} else { } else {
isFirstAutoSleepTimer = true
Log.d(tag, "Current hour $currentHour is NOT between ${deviceSettings.autoSleepTimerStartTime} and ${deviceSettings.autoSleepTimerEndTime}") Log.d(tag, "Current hour $currentHour is NOT between ${deviceSettings.autoSleepTimerStartTime} and ${deviceSettings.autoSleepTimerEndTime}")
} }
} }
} }
fun handleMediaPlayEvent() { fun handleMediaPlayEvent(playbackSessionId:String) {
checkShouldResetSleepTimer() // Check if the playback session has changed
// If it hasn't changed OR the sleep timer is running then check reset the timer
// e.g. You set a manual sleep timer for 10 mins, then decide to change books, the sleep timer will stay on and reset to 10 mins
if (sleepTimerSessionId == playbackSessionId || sleepTimerRunning) {
checkShouldResetSleepTimer()
} else {
isFirstAutoSleepTimer = true
}
sleepTimerSessionId = playbackSessionId
checkAutoSleepTimer() checkAutoSleepTimer()
} }
} }
@@ -111,7 +111,10 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
cb(serverItemsInProgress) cb(serverItemsInProgress)
} else { } else {
apiHandler.getAllItemsInProgress { itemsInProgress -> apiHandler.getAllItemsInProgress { itemsInProgress ->
serverItemsInProgress = itemsInProgress serverItemsInProgress = itemsInProgress.filter {
val libraryItem = it.libraryItemWrapper as LibraryItem
libraryItem.checkHasTracks()
}
cb(serverItemsInProgress) cb(serverItemsInProgress)
} }
} }
@@ -394,7 +397,7 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
fun play(libraryItemWrapper:LibraryItemWrapper, episode:PodcastEpisode?, playItemRequestPayload:PlayItemRequestPayload, cb: (PlaybackSession?) -> Unit) { fun play(libraryItemWrapper:LibraryItemWrapper, episode:PodcastEpisode?, playItemRequestPayload:PlayItemRequestPayload, cb: (PlaybackSession?) -> Unit) {
if (libraryItemWrapper is LocalLibraryItem) { if (libraryItemWrapper is LocalLibraryItem) {
cb(libraryItemWrapper.getPlaybackSession(episode)) cb(libraryItemWrapper.getPlaybackSession(episode, playItemRequestPayload.deviceInfo))
} else { } else {
val libraryItem = libraryItemWrapper as LibraryItem val libraryItem = libraryItemWrapper as LibraryItem
apiHandler.playLibraryItem(libraryItem.id,episode?.id ?: "", playItemRequestPayload) { apiHandler.playLibraryItem(libraryItem.id,episode?.id ?: "", playItemRequestPayload) {
@@ -38,8 +38,8 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
var currentLocalMediaProgress: LocalMediaProgress? = null var currentLocalMediaProgress: LocalMediaProgress? = null
private val currentDisplayTitle get() = currentPlaybackSession?.displayTitle ?: "Unset" private val currentDisplayTitle get() = currentPlaybackSession?.displayTitle ?: "Unset"
private val currentIsLocal get() = currentPlaybackSession?.isLocal == true val currentIsLocal get() = currentPlaybackSession?.isLocal == true
private val currentSessionId get() = currentPlaybackSession?.id ?: "" val currentSessionId get() = currentPlaybackSession?.id ?: ""
private val currentPlaybackDuration get() = currentPlaybackSession?.duration ?: 0.0 private val currentPlaybackDuration get() = currentPlaybackSession?.duration ?: 0.0
fun start(playbackSession:PlaybackSession) { fun start(playbackSession:PlaybackSession) {
@@ -215,7 +215,6 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
val listeningTimeToAdd = diffSinceLastSync / 1000L val listeningTimeToAdd = diffSinceLastSync / 1000L
val syncData = MediaProgressSyncData(listeningTimeToAdd,currentPlaybackDuration,currentTime) val syncData = MediaProgressSyncData(listeningTimeToAdd,currentPlaybackDuration,currentTime)
currentPlaybackSession?.syncData(syncData) currentPlaybackSession?.syncData(syncData)
if (currentPlaybackSession?.progress?.isNaN() == true) { if (currentPlaybackSession?.progress?.isNaN() == true) {
@@ -243,11 +242,6 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
// Send sync to server also if connected to this server and local item belongs to this server // Send sync to server also if connected to this server and local item belongs to this server
if (hasNetworkConnection && shouldSyncServer && !it.libraryItemId.isNullOrEmpty() && it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) { if (hasNetworkConnection && shouldSyncServer && !it.libraryItemId.isNullOrEmpty() && it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
apiHandler.sendLocalProgressSync(it) { syncSuccess, errorMsg -> apiHandler.sendLocalProgressSync(it) { syncSuccess, errorMsg ->
Log.d(
tag,
"Local progress sync data sent to server $currentDisplayTitle for time $currentTime"
)
if (syncSuccess) { if (syncSuccess) {
failedSyncs = 0 failedSyncs = 0
playerNotificationService.alertSyncSuccess() playerNotificationService.alertSyncSuccess()
@@ -21,6 +21,9 @@ data class DownloadItem(
val media: MediaType, val media: MediaType,
val downloadItemParts: MutableList<DownloadItemPart> val downloadItemParts: MutableList<DownloadItemPart>
) { ) {
@get:JsonIgnore
val isInternalStorage get() = localFolder.id.startsWith("internal-")
@get:JsonIgnore @get:JsonIgnore
val isDownloadFinished get() = !downloadItemParts.any { !it.completed || it.isMoving } val isDownloadFinished get() = !downloadItemParts.any { !it.completed || it.isMoving }
@@ -4,6 +4,7 @@ import android.app.DownloadManager
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
import com.audiobookshelf.app.data.AudioTrack import com.audiobookshelf.app.data.AudioTrack
import com.audiobookshelf.app.data.EBookFile
import com.audiobookshelf.app.data.LocalFolder import com.audiobookshelf.app.data.LocalFolder
import com.audiobookshelf.app.data.PodcastEpisode import com.audiobookshelf.app.data.PodcastEpisode
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
@@ -20,6 +21,7 @@ data class DownloadItemPart(
val localFolderName: String, val localFolderName: String,
val localFolderUrl: String, val localFolderUrl: String,
val localFolderId: String, val localFolderId: String,
val ebookFile: EBookFile?,
val audioTrack: AudioTrack?, val audioTrack: AudioTrack?,
val episode: PodcastEpisode?, val episode: PodcastEpisode?,
var completed:Boolean, var completed:Boolean,
@@ -35,12 +37,12 @@ data class DownloadItemPart(
var bytesDownloaded: Long var bytesDownloaded: Long
) { ) {
companion object { companion object {
fun make(downloadItemId:String, filename:String, fileSize: Long, destinationFile: File, finalDestinationFile: File, subfolder:String, serverPath:String, localFolder: LocalFolder, audioTrack: AudioTrack?, episode: PodcastEpisode?) :DownloadItemPart { fun make(downloadItemId:String, filename:String, fileSize: Long, destinationFile: File, finalDestinationFile: File, subfolder:String, serverPath:String, localFolder: LocalFolder, ebookFile: EBookFile?, audioTrack: AudioTrack?, episode: PodcastEpisode?) :DownloadItemPart {
val destinationUri = Uri.fromFile(destinationFile) val destinationUri = Uri.fromFile(destinationFile)
val finalDestinationUri = Uri.fromFile(finalDestinationFile) val finalDestinationUri = Uri.fromFile(finalDestinationFile)
var downloadUrl = "${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}" var downloadUrl = "${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}"
if (serverPath.endsWith("/cover")) downloadUrl += "&format=jpeg" // For cover images force to jpeg if (serverPath.endsWith("/cover")) downloadUrl += "&format=jpeg&raw=1" // For cover images force to jpeg
val downloadUri = Uri.parse(downloadUrl) val downloadUri = Uri.parse(downloadUrl)
Log.d("DownloadItemPart", "Audio File Destination Uri: $destinationUri | Final Destination Uri: $finalDestinationUri | Download URI $downloadUri") Log.d("DownloadItemPart", "Audio File Destination Uri: $destinationUri | Final Destination Uri: $finalDestinationUri | Download URI $downloadUri")
return DownloadItemPart( return DownloadItemPart(
@@ -53,6 +55,7 @@ data class DownloadItemPart(
localFolderName = localFolder.name, localFolderName = localFolder.name,
localFolderUrl = localFolder.contentUrl, localFolderUrl = localFolder.contentUrl,
localFolderId = localFolder.id, localFolderId = localFolder.id,
ebookFile = ebookFile,
audioTrack = audioTrack, audioTrack = audioTrack,
episode = episode, episode = episode,
completed = false, completed = false,
@@ -70,6 +73,12 @@ data class DownloadItemPart(
} }
} }
@get:JsonIgnore
val isInternalStorage get() = localFolderId.startsWith("internal-")
@get:JsonIgnore
val serverUrl get() = "${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}"
@JsonIgnore @JsonIgnore
fun getDownloadRequest(): DownloadManager.Request { fun getDownloadRequest(): DownloadManager.Request {
val dlRequest = DownloadManager.Request(uri) val dlRequest = DownloadManager.Request(uri)
@@ -8,6 +8,7 @@ import android.os.Build
import android.provider.MediaStore 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.BuildConfig
import com.audiobookshelf.app.R import com.audiobookshelf.app.R
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.Player
@@ -83,7 +84,7 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
Glide.with(playerNotificationService) Glide.with(playerNotificationService)
.asBitmap() .asBitmap()
.load(Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)) .load(Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon))
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE) .submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
.get() .get()
} }
@@ -123,7 +123,7 @@ class CastManager constructor(val mainActivity:Activity) {
} }
fun startRouteScan(connListener:ChromecastListener) { fun startRouteScan(connListener:ChromecastListener) {
var callback = object : ScanCallback() { val callback = object : ScanCallback() {
override fun onRouteUpdate(routes: List<MediaRouter.RouteInfo>?) { override fun onRouteUpdate(routes: List<MediaRouter.RouteInfo>?) {
Log.d(tag, "CAST On ROUTE UPDATED ${routes?.size} | ${getContext().castState}") Log.d(tag, "CAST On ROUTE UPDATED ${routes?.size} | ${getContext().castState}")
// if the routes have changed, we may have an available device // if the routes have changed, we may have an available device
@@ -136,7 +136,7 @@ class CastManager constructor(val mainActivity:Activity) {
// Let the client know a receiver is available // Let the client know a receiver is available
connListener.onReceiverAvailableUpdate(true) connListener.onReceiverAvailableUpdate(true)
// Since we have a receiver we may also have an active session // Since we have a receiver we may also have an active session
var session = getSessionManager()?.currentCastSession val session = getSessionManager()?.currentCastSession
// If we do have a session // If we do have a session
if (session != null) { if (session != null) {
// Let the client know // Let the client know
@@ -246,15 +246,15 @@ class CastManager constructor(val mainActivity:Activity) {
onRouteUpdate(outRoutes) onRouteUpdate(outRoutes)
} }
override fun onRouteAdded(router: MediaRouter?, route: MediaRouter.RouteInfo?) { override fun onRouteAdded(router: MediaRouter, route: MediaRouter.RouteInfo) {
onFilteredRouteUpdate() onFilteredRouteUpdate()
} }
override fun onRouteChanged(router: MediaRouter?, route: MediaRouter.RouteInfo?) { override fun onRouteChanged(router: MediaRouter, route: MediaRouter.RouteInfo) {
onFilteredRouteUpdate() onFilteredRouteUpdate()
} }
override fun onRouteRemoved(router: MediaRouter?, route: MediaRouter.RouteInfo?) { override fun onRouteRemoved(router: MediaRouter, route: MediaRouter.RouteInfo) {
onFilteredRouteUpdate() onFilteredRouteUpdate()
} }
} }
@@ -11,12 +11,12 @@ import com.google.android.exoplayer2.*
import com.google.android.exoplayer2.C.TrackType import com.google.android.exoplayer2.C.TrackType
import com.google.android.exoplayer2.MediaMetadata import com.google.android.exoplayer2.MediaMetadata
import com.google.android.exoplayer2.Player.* import com.google.android.exoplayer2.Player.*
import com.google.android.exoplayer2.TracksInfo.TrackGroupInfo import com.google.android.exoplayer2.Tracks.Group
import com.google.android.exoplayer2.audio.AudioAttributes import com.google.android.exoplayer2.audio.AudioAttributes
import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener
import com.google.android.exoplayer2.source.TrackGroup import com.google.android.exoplayer2.source.TrackGroup
import com.google.android.exoplayer2.source.TrackGroupArray import com.google.android.exoplayer2.source.TrackGroupArray
import com.google.android.exoplayer2.text.Cue import com.google.android.exoplayer2.text.CueGroup
import com.google.android.exoplayer2.trackselection.TrackSelection import com.google.android.exoplayer2.trackselection.TrackSelection
import com.google.android.exoplayer2.trackselection.TrackSelectionArray import com.google.android.exoplayer2.trackselection.TrackSelectionArray
import com.google.android.exoplayer2.trackselection.TrackSelectionParameters import com.google.android.exoplayer2.trackselection.TrackSelectionParameters
@@ -49,7 +49,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
private var myPlayWhenReady:Boolean = false private var myPlayWhenReady:Boolean = false
private var playbackParameters:PlaybackParameters = PlaybackParameters.DEFAULT private var playbackParameters:PlaybackParameters = PlaybackParameters.DEFAULT
private var myAvailableCommands: Commands private var myAvailableCommands: Commands
private var myCurrentTracksInfo: TracksInfo private var myCurrentTracksInfo: Tracks
private var myCurrentTrackGroups: TrackGroupArray private var myCurrentTrackGroups: TrackGroupArray
private var myCurrentTrackSelections: TrackSelectionArray private var myCurrentTrackSelections: TrackSelectionArray
@@ -76,7 +76,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
COMMAND_GET_MEDIA_ITEMS_METADATA, COMMAND_GET_MEDIA_ITEMS_METADATA,
COMMAND_SET_MEDIA_ITEMS_METADATA, COMMAND_SET_MEDIA_ITEMS_METADATA,
COMMAND_CHANGE_MEDIA_ITEMS, COMMAND_CHANGE_MEDIA_ITEMS,
COMMAND_GET_TRACK_INFOS) COMMAND_GET_TRACKS)
.build() .build()
var currentPlaybackState = Player.STATE_IDLE var currentPlaybackState = Player.STATE_IDLE
@@ -95,7 +95,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
Log.d(tag, "Init CastPlayer") Log.d(tag, "Init CastPlayer")
myCurrentTrackGroups = TrackGroupArray.EMPTY myCurrentTrackGroups = TrackGroupArray.EMPTY
myCurrentTrackSelections = EMPTY_TRACK_SELECTION_ARRAY myCurrentTrackSelections = EMPTY_TRACK_SELECTION_ARRAY
myCurrentTracksInfo = TracksInfo.EMPTY myCurrentTracksInfo = Tracks.EMPTY
statusListener = StatusListener() statusListener = StatusListener()
timelineTracker = CastTimelineTracker() timelineTracker = CastTimelineTracker()
myCurrentTimeline = CastTimeline.EMPTY_CAST_TIMELINE myCurrentTimeline = CastTimeline.EMPTY_CAST_TIMELINE
@@ -385,7 +385,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
} }
if (updateTracksAndSelectionsAndNotifyIfChanged()) { if (updateTracksAndSelectionsAndNotifyIfChanged()) {
listeners.queueEvent( listeners.queueEvent(
EVENT_TRACKS_CHANGED) { listener: Listener -> listener.onTracksInfoChanged(currentTracksInfo) } EVENT_TRACKS_CHANGED) { listener: Listener -> listener.onTracksChanged(currentTracks) }
} }
updateAvailableCommandsAndNotifyIfChanged() updateAvailableCommandsAndNotifyIfChanged()
listeners.flushEvents() listeners.flushEvents()
@@ -405,7 +405,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
val hasChanged = !myCurrentTrackGroups.isEmpty val hasChanged = !myCurrentTrackGroups.isEmpty
myCurrentTrackGroups = TrackGroupArray.EMPTY myCurrentTrackGroups = TrackGroupArray.EMPTY
myCurrentTrackSelections = EMPTY_TRACK_SELECTION_ARRAY myCurrentTrackSelections = EMPTY_TRACK_SELECTION_ARRAY
myCurrentTracksInfo = TracksInfo.EMPTY myCurrentTracksInfo = Tracks.EMPTY
return hasChanged return hasChanged
} }
var activeTrackIds = mediaStatus.activeTrackIds var activeTrackIds = mediaStatus.activeTrackIds
@@ -414,7 +414,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
} }
val trackGroups = arrayOfNulls<TrackGroup>(castMediaTracks.size) val trackGroups = arrayOfNulls<TrackGroup>(castMediaTracks.size)
val trackSelections = arrayOfNulls<TrackSelection>(RENDERER_COUNT) val trackSelections = arrayOfNulls<TrackSelection>(RENDERER_COUNT)
val trackGroupInfos = arrayOfNulls<TrackGroupInfo>(castMediaTracks.size) val trackGroupInfos = arrayOfNulls<Group>(castMediaTracks.size)
for (i in castMediaTracks.indices) { for (i in castMediaTracks.indices) {
val mediaTrack = castMediaTracks[i] val mediaTrack = castMediaTracks[i]
trackGroups[i] = TrackGroup( /* id= */i.toString(), mediaTrackToFormat(mediaTrack)) trackGroups[i] = TrackGroup( /* id= */i.toString(), mediaTrackToFormat(mediaTrack))
@@ -428,18 +428,16 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
} }
val trackSupport = intArrayOf(if (supported) C.FORMAT_HANDLED else C.FORMAT_UNSUPPORTED_TYPE) val trackSupport = intArrayOf(if (supported) C.FORMAT_HANDLED else C.FORMAT_UNSUPPORTED_TYPE)
val trackSelected = booleanArrayOf(selected) val trackSelected = booleanArrayOf(selected)
trackGroupInfos[i] = TrackGroupInfo(trackGroups[i]!!, trackSupport, trackType, trackSelected) trackGroupInfos[i] = Group(trackGroups[i]!!, false, trackSupport, trackSelected)
} }
val tg = trackGroups.filterNotNull().toTypedArray() val tg = trackGroups.filterNotNull().toTypedArray()
var ts = trackSelections.filterNotNull().toTypedArray() val ts = trackSelections.filterNotNull().toTypedArray()
var tgi = trackGroupInfos.filterNotNull().toMutableList() val tgi = trackGroupInfos.filterNotNull().toMutableList()
val newTrackGroups = TrackGroupArray(*tg) val newTrackGroups = TrackGroupArray(*tg)
val newTrackSelections = TrackSelectionArray(*ts) val newTrackSelections = TrackSelectionArray(*ts)
val newTracksInfo = TracksInfo(tgi) val newTracksInfo = Tracks(tgi)
if (newTrackGroups != currentTrackGroups if (newTracksInfo != currentTracks) {
|| newTrackSelections != currentTrackSelections
|| newTracksInfo != currentTracksInfo) {
myCurrentTrackSelections = newTrackSelections myCurrentTrackSelections = newTrackSelections
myCurrentTrackGroups = newTrackGroups myCurrentTrackGroups = newTrackGroups
myCurrentTracksInfo = newTracksInfo myCurrentTracksInfo = newTracksInfo
@@ -593,7 +591,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
listeners.flushEvents() listeners.flushEvents()
val pendingResult: PendingResult<RemoteMediaClient.MediaChannelResult> = if (playWhenReady) remoteMediaClient!!.play() else remoteMediaClient!!.pause() val pendingResult: PendingResult<RemoteMediaClient.MediaChannelResult> = if (playWhenReady) remoteMediaClient!!.play() else remoteMediaClient!!.pause()
var resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> { val resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
updatePlayerStateAndNotifyIfChanged() updatePlayerStateAndNotifyIfChanged()
} }
@@ -622,10 +620,10 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
return false return false
} }
override fun seekTo(mediaItemIndex: Int, positionMs: Long) { override fun seekTo(mediaItemIndex: Int, positionMs: Long, seekCommand: Int, isRepeatingCurrentItem: Boolean) {
Log.d(tag, "seekTo $mediaItemIndex position $positionMs") Log.d(tag, "seekTo $mediaItemIndex position $positionMs")
var resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> { val resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
val statusCode: Int = it.status.statusCode val statusCode: Int = it.status.statusCode
if (statusCode != CastStatusCodes.SUCCESS && statusCode != CastStatusCodes.REPLACED) { if (statusCode != CastStatusCodes.SUCCESS && statusCode != CastStatusCodes.REPLACED) {
Log.e(tag, "Seek failed. Error code $statusCode") Log.e(tag, "Seek failed. Error code $statusCode")
@@ -645,14 +643,14 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
// We assume the default position is 0. There is no support for seeking to the default position // We assume the default position is 0. There is no support for seeking to the default position
// in RemoteMediaClient. // in RemoteMediaClient.
var positionMsFinal = if (positionMs != C.TIME_UNSET) positionMs else 0 val positionMsFinal = if (positionMs != C.TIME_UNSET) positionMs else 0
if (mediaStatus != null) { if (mediaStatus != null) {
if (currentMediaItemIndex != mediaItemIndex) { if (currentMediaItemIndex != mediaItemIndex) {
Log.d(tag, "seekTo: Changing media item index from $currentMediaItemIndex to $mediaItemIndex") Log.d(tag, "seekTo: Changing media item index from $currentMediaItemIndex to $mediaItemIndex")
remoteMediaClient?.queueJumpToItem(myCurrentTimeline.getPeriod(mediaItemIndex, period).uid as Int, positionMsFinal, JSONObject())?.setResultCallback(resultCb) remoteMediaClient?.queueJumpToItem(myCurrentTimeline.getPeriod(mediaItemIndex, period).uid as Int, positionMsFinal, JSONObject())?.setResultCallback(resultCb)
} else { } else {
Log.d(tag, "seekTo: Same media index seek to position $positionMsFinal") Log.d(tag, "seekTo: Same media index seek to position $positionMsFinal")
var mediaSeekOptions = MediaSeekOptions.Builder().setPosition(positionMsFinal).build() val mediaSeekOptions = MediaSeekOptions.Builder().setPosition(positionMsFinal).build()
remoteMediaClient?.seek(mediaSeekOptions)?.setResultCallback(resultCb) remoteMediaClient?.seek(mediaSeekOptions)?.setResultCallback(resultCb)
} }
val oldPosition = getCurrentPositionInfo() val oldPosition = getCurrentPositionInfo()
@@ -709,7 +707,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
val pendingResult: PendingResult<RemoteMediaClient.MediaChannelResult>? = remoteMediaClient?.setPlaybackRate(actualPlaybackParameters.speed.toDouble(), /* customData= */null) val pendingResult: PendingResult<RemoteMediaClient.MediaChannelResult>? = remoteMediaClient?.setPlaybackRate(actualPlaybackParameters.speed.toDouble(), /* customData= */null)
var resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> { val resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
updatePlaybackRateAndNotifyIfChanged() updatePlaybackRateAndNotifyIfChanged()
listeners.flushEvents() listeners.flushEvents()
} }
@@ -736,15 +734,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
sessionManager.endCurrentSession(false) sessionManager.endCurrentSession(false)
} }
override fun getCurrentTrackGroups(): TrackGroupArray { override fun getCurrentTracks(): Tracks {
return this.myCurrentTrackGroups
}
override fun getCurrentTrackSelections(): TrackSelectionArray {
return this.myCurrentTrackSelections
}
override fun getCurrentTracksInfo(): TracksInfo {
return this.myCurrentTracksInfo return this.myCurrentTracksInfo
} }
@@ -858,8 +848,12 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
return VideoSize.UNKNOWN return VideoSize.UNKNOWN
} }
override fun getCurrentCues(): MutableList<Cue> { override fun getSurfaceSize(): Size {
return mutableListOf() return Size.UNKNOWN
}
override fun getCurrentCues(): CueGroup {
return CueGroup.EMPTY_TIME_ZERO
} }
override fun getDeviceInfo(): DeviceInfo { override fun getDeviceInfo(): DeviceInfo {
@@ -190,6 +190,14 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
handleMediaButtonClickCount() handleMediaButtonClickCount()
} }
} }
KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
Log.d(tag, "handleCallMediaButton: Media Fast Forward")
playerNotificationService.jumpForward()
}
KeyEvent.KEYCODE_MEDIA_REWIND -> {
Log.d(tag, "handleCallMediaButton: Media Rewind")
playerNotificationService.jumpBackward()
}
} }
} }
@@ -224,12 +232,6 @@ 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()
} }
@@ -7,7 +7,7 @@ import com.audiobookshelf.app.device.DeviceManager
import com.google.android.exoplayer2.PlaybackException import com.google.android.exoplayer2.PlaybackException
import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.Player
const val PAUSE_LEN_BEFORE_RECHECK = 30000 // 30 seconds //const val PAUSE_LEN_BEFORE_RECHECK = 30000 // 30 seconds
class PlayerListener(var playerNotificationService:PlayerNotificationService) : Player.Listener { class PlayerListener(var playerNotificationService:PlayerNotificationService) : Player.Listener {
var tag = "PlayerListener" var tag = "PlayerListener"
@@ -56,6 +56,9 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
lazyIsPlaying = isPlaying lazyIsPlaying = isPlaying
// Update widget
DeviceManager.widgetUpdater?.onPlayerChanged(playerNotificationService)
if (isPlaying) { if (isPlaying) {
Log.d(tag, "SeekBackTime: Player is playing") Log.d(tag, "SeekBackTime: Player is playing")
if (lastPauseTime > 0 && DeviceManager.deviceData.deviceSettings?.disableAutoRewind != true) { if (lastPauseTime > 0 && DeviceManager.deviceData.deviceSettings?.disableAutoRewind != true) {
@@ -74,12 +77,13 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
Log.d(tag, "SeekBackTime $seekBackTime") Log.d(tag, "SeekBackTime $seekBackTime")
} }
// TODO: this needs to be reworked so that the audio doesn't start playing before it checks for updated progress
// Check if playback session still exists or sync media progress if updated // Check if playback session still exists or sync media progress if updated
val pauseLength: Long = System.currentTimeMillis() - lastPauseTime // val pauseLength: Long = System.currentTimeMillis() - lastPauseTime
if (pauseLength > PAUSE_LEN_BEFORE_RECHECK) { // if (pauseLength > PAUSE_LEN_BEFORE_RECHECK) {
val shouldCarryOn = playerNotificationService.checkCurrentSessionProgress(seekBackTime) // val shouldCarryOn = playerNotificationService.checkCurrentSessionProgress(seekBackTime)
if (!shouldCarryOn) return // if (!shouldCarryOn) return
} // }
if (seekBackTime > 0L) { if (seekBackTime > 0L) {
playerNotificationService.seekBackward(seekBackTime) playerNotificationService.seekBackward(seekBackTime)
@@ -92,12 +96,15 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
// Start/stop progress sync interval // Start/stop progress sync interval
if (isPlaying) { if (isPlaying) {
// Handles auto-starting sleep timer and resetting sleep timer
playerNotificationService.sleepTimerManager.handleMediaPlayEvent()
player.volume = 1F // Volume on sleep timer might have decreased this
val playbackSession: PlaybackSession? = playerNotificationService.mediaProgressSyncer.currentPlaybackSession ?: playerNotificationService.currentPlaybackSession val playbackSession: PlaybackSession? = playerNotificationService.mediaProgressSyncer.currentPlaybackSession ?: playerNotificationService.currentPlaybackSession
playbackSession?.let { playerNotificationService.mediaProgressSyncer.play(it) } playbackSession?.let {
// Handles auto-starting sleep timer and resetting sleep timer
playerNotificationService.sleepTimerManager.handleMediaPlayEvent(it.id)
player.volume = 1F // Volume on sleep timer might have decreased this
playerNotificationService.mediaProgressSyncer.play(it)
}
} else { } else {
playerNotificationService.mediaProgressSyncer.pause { playerNotificationService.mediaProgressSyncer.pause {
Log.d(tag, "Media Progress Syncer paused and synced") Log.d(tag, "Media Progress Syncer paused and synced")
@@ -105,8 +112,6 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
} }
playerNotificationService.clientEventEmitter?.onPlayingUpdate(isPlaying) playerNotificationService.clientEventEmitter?.onPlayingUpdate(isPlaying)
DeviceManager.widgetUpdater?.onPlayerChanged(playerNotificationService)
} }
override fun onEvents(player: Player, events: Player.Events) { override fun onEvents(player: Player, events: Player.Events) {
@@ -44,11 +44,7 @@ class PlayerNotificationListener(var playerNotificationService:PlayerNotificatio
} else { } else {
Log.d(tag, "onNotificationCancelled not dismissed by user") Log.d(tag, "onNotificationCancelled not dismissed by user")
// When stop button is pressed on the notification I guess it isn't considered "dismissedByUser" so we need to close playback ourselves if (PlayerNotificationService.isSwitchingPlayer) {
if (!PlayerNotificationService.isClosed && !PlayerNotificationService.isSwitchingPlayer) {
Log.d(tag, "PNS is not closed - closing it now")
playerNotificationService.closePlayback()
} else if (PlayerNotificationService.isSwitchingPlayer) {
// When switching from cast player to exo player and vice versa the notification is cancelled and posted again // When switching from cast player to exo player and vice versa the notification is cancelled and posted again
// so we don't want to cancel the playback during this switch // so we don't want to cancel the playback during this switch
Log.d(tag, "PNS is switching player") Log.d(tag, "PNS is switching player")
@@ -1,5 +1,6 @@
package com.audiobookshelf.app.player package com.audiobookshelf.app.player
import android.annotation.SuppressLint
import android.app.* import android.app.*
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@@ -8,12 +9,10 @@ import android.graphics.Color
import android.graphics.ImageDecoder import android.graphics.ImageDecoder
import android.hardware.Sensor import android.hardware.Sensor
import android.hardware.SensorManager import android.hardware.SensorManager
import android.net.ConnectivityManager import android.net.*
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.* import android.os.*
import android.provider.MediaStore import android.provider.MediaStore
import android.provider.Settings
import android.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.MediaMetadataCompat
@@ -41,6 +40,8 @@ import com.google.android.exoplayer2.audio.AudioAttributes
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.CustomActionProvider import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.CustomActionProvider
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator
import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory
import com.google.android.exoplayer2.extractor.mp3.Mp3Extractor
import com.google.android.exoplayer2.source.MediaSource import com.google.android.exoplayer2.source.MediaSource
import com.google.android.exoplayer2.source.ProgressiveMediaSource import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.source.hls.HlsMediaSource import com.google.android.exoplayer2.source.hls.HlsMediaSource
@@ -170,12 +171,16 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
Log.d(tag, "onDestroy") Log.d(tag, "onDestroy")
isStarted = false
isClosed = true
DeviceManager.widgetUpdater?.onPlayerChanged(this)
playerNotificationManager.setPlayer(null) playerNotificationManager.setPlayer(null)
mPlayer.release() mPlayer.release()
castPlayer?.release() castPlayer?.release()
mediaSession.release() mediaSession.release()
mediaProgressSyncer.reset() mediaProgressSyncer.reset()
isStarted = false
super.onDestroy() super.onDestroy()
} }
@@ -184,6 +189,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
override fun onTaskRemoved(rootIntent: Intent?) { override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent) super.onTaskRemoved(rootIntent)
Log.d(tag, "onTaskRemoved") Log.d(tag, "onTaskRemoved")
stopSelf() stopSelf()
} }
@@ -192,6 +198,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
super.onCreate() super.onCreate()
ctx = this ctx = this
// Initialize Paper
DbManager.initialize(ctx)
// Initialize widget
DeviceManager.initializeWidgetUpdater(ctx)
// To listen for network change from metered to unmetered // To listen for network change from metered to unmetered
val networkRequest = NetworkRequest.Builder() val networkRequest = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
@@ -253,12 +265,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
playerNotificationManager.setUseNextAction(false) playerNotificationManager.setUseNextAction(false)
playerNotificationManager.setUsePreviousAction(false) playerNotificationManager.setUsePreviousAction(false)
playerNotificationManager.setUseChronometer(false) playerNotificationManager.setUseChronometer(false)
playerNotificationManager.setUseStopAction(true) playerNotificationManager.setUseStopAction(false)
playerNotificationManager.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) playerNotificationManager.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX) playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX)
playerNotificationManager.setUseFastForwardActionInCompactView(true) playerNotificationManager.setUseFastForwardActionInCompactView(true)
playerNotificationManager.setUseRewindActionInCompactView(true) playerNotificationManager.setUseRewindActionInCompactView(true)
playerNotificationManager.setSmallIcon(R.drawable.exo_icon_localaudio) playerNotificationManager.setSmallIcon(R.drawable.icon_monochrome)
// Unknown action // Unknown action
playerNotificationManager.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE) playerNotificationManager.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE)
@@ -277,10 +289,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
return MediaDescriptionCompat.Builder().build() return MediaDescriptionCompat.Builder().build()
} }
val coverUri = currentPlaybackSession!!.getCoverUri() val coverUri = currentPlaybackSession!!.getCoverUri(ctx)
var bitmap:Bitmap? = null var bitmap: Bitmap? = null
// Local covers get bitmap // Local covers get bitmap
if (currentPlaybackSession!!.localLibraryItem?.coverContentUrl != null) { if (currentPlaybackSession!!.localLibraryItem?.coverContentUrl != null) {
bitmap = if (Build.VERSION.SDK_INT < 28) { bitmap = if (Build.VERSION.SDK_INT < 28) {
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri) MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
@@ -351,7 +363,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
.build() .build()
mPlayer.setHandleAudioBecomingNoisy(true) mPlayer.setHandleAudioBecomingNoisy(true)
mPlayer.addListener(PlayerListener(this)) mPlayer.addListener(PlayerListener(this))
val audioAttributes:AudioAttributes = AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.CONTENT_TYPE_SPEECH).build() val audioAttributes:AudioAttributes = AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.AUDIO_CONTENT_TYPE_SPEECH).build()
mPlayer.setAudioAttributes(audioAttributes, true) mPlayer.setAudioAttributes(audioAttributes, true)
//attach player to playerNotificationManager //attach player to playerNotificationManager
@@ -383,7 +395,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
val metadata = playbackSession.getMediaMetadataCompat(ctx) val metadata = playbackSession.getMediaMetadataCompat(ctx)
mediaSession.setMetadata(metadata) mediaSession.setMetadata(metadata)
val mediaItems = playbackSession.getMediaItems() val mediaItems = playbackSession.getMediaItems(ctx)
val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f
initialPlaybackRate = playbackRate initialPlaybackRate = playbackRate
@@ -409,9 +421,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
DeviceManager.setLastPlaybackSession(playbackSession) // Save playback session to use when app is closed DeviceManager.setLastPlaybackSession(playbackSession) // Save playback session to use when app is closed
Log.d(tag, "Set CurrentPlaybackSession MediaPlayer ${currentPlaybackSession?.mediaPlayer}") Log.d(tag, "Set CurrentPlaybackSession MediaPlayer ${currentPlaybackSession?.mediaPlayer}")
// Notify client
clientEventEmitter?.onPlaybackSession(playbackSession) clientEventEmitter?.onPlaybackSession(playbackSession)
// Update widget
DeviceManager.widgetUpdater?.onPlayerChanged(this)
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
@@ -424,12 +439,26 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
if (playbackSession.isLocal) { if (playbackSession.isLocal) {
Log.d(tag, "Playing Local Item") Log.d(tag, "Playing Local Item")
val dataSourceFactory = DefaultDataSource.Factory(ctx) val dataSourceFactory = DefaultDataSource.Factory(ctx)
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0])
val extractorsFactory = DefaultExtractorsFactory()
if (DeviceManager.deviceData.deviceSettings?.enableMp3IndexSeeking == true) {
// @see https://exoplayer.dev/troubleshooting.html#why-is-seeking-inaccurate-in-some-mp3-files
extractorsFactory.setMp3ExtractorFlags(Mp3Extractor.FLAG_ENABLE_INDEX_SEEKING)
}
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory).createMediaSource(mediaItems[0])
} else if (!playbackSession.isHLS) { } else if (!playbackSession.isHLS) {
Log.d(tag, "Direct Playing Item") Log.d(tag, "Direct Playing Item")
val dataSourceFactory = DefaultHttpDataSource.Factory() val dataSourceFactory = DefaultHttpDataSource.Factory()
val extractorsFactory = DefaultExtractorsFactory()
if (DeviceManager.deviceData.deviceSettings?.enableMp3IndexSeeking == true) {
// @see https://exoplayer.dev/troubleshooting.html#why-is-seeking-inaccurate-in-some-mp3-files
extractorsFactory.setMp3ExtractorFlags(Mp3Extractor.FLAG_ENABLE_INDEX_SEEKING)
}
dataSourceFactory.setUserAgent(channelId) dataSourceFactory.setUserAgent(channelId)
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0]) mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory).createMediaSource(mediaItems[0])
} else { } else {
Log.d(tag, "Playing HLS Item") Log.d(tag, "Playing HLS Item")
val dataSourceFactory = DefaultHttpDataSource.Factory() val dataSourceFactory = DefaultHttpDataSource.Factory()
@@ -469,7 +498,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
private fun setMediaSessionConnectorCustomActions(playbackSession:PlaybackSession) { private fun setMediaSessionConnectorCustomActions(playbackSession:PlaybackSession) {
val mediaItems = playbackSession.getMediaItems() val mediaItems = playbackSession.getMediaItems(ctx)
val customActionProviders = mutableListOf( val customActionProviders = mutableListOf(
JumpBackwardCustomActionProvider(), JumpBackwardCustomActionProvider(),
JumpForwardCustomActionProvider(), JumpForwardCustomActionProvider(),
@@ -849,10 +878,26 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
fun closePlayback(calledOnError:Boolean? = false) { fun closePlayback(calledOnError:Boolean? = false) {
Log.d(tag, "closePlayback") Log.d(tag, "closePlayback")
val isLocal = mediaProgressSyncer.currentIsLocal
val currentSessionId = mediaProgressSyncer.currentSessionId
if (mediaProgressSyncer.listeningTimerRunning) { if (mediaProgressSyncer.listeningTimerRunning) {
Log.i(tag, "About to close playback so stopping media progress syncer first") Log.i(tag, "About to close playback so stopping media progress syncer first")
mediaProgressSyncer.stop(calledOnError == false) { // If closing on error then do not sync progress (causes exception) mediaProgressSyncer.stop(calledOnError == false) { // If closing on error then do not sync progress (causes exception)
Log.d(tag, "Media Progress syncer stopped") Log.d(tag, "Media Progress syncer stopped")
// If not local session then close on server
if (!isLocal && currentSessionId != "") {
apiHandler.closePlaybackSession(currentSessionId) {
Log.d(tag, "Closed playback session $currentSessionId")
}
}
}
} else {
// If not local session then close on server
if (!isLocal && currentSessionId != "") {
apiHandler.closePlaybackSession(currentSessionId) {
Log.d(tag, "Closed playback session $currentSessionId")
}
} }
} }
@@ -866,8 +911,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
currentPlaybackSession = null currentPlaybackSession = null
mediaProgressSyncer.reset() mediaProgressSyncer.reset()
clientEventEmitter?.onPlaybackClosed() clientEventEmitter?.onPlaybackClosed()
PlayerListener.lastPauseTime = 0 PlayerListener.lastPauseTime = 0
isClosed = true isClosed = true
DeviceManager.widgetUpdater?.onPlayerClosed()
stopForeground(Service.STOP_FOREGROUND_REMOVE) stopForeground(Service.STOP_FOREGROUND_REMOVE)
stopSelf() stopSelf()
} }
@@ -881,7 +928,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
return if(currentPlayer == castPlayer) PLAYER_CAST else PLAYER_EXO return if(currentPlayer == castPlayer) PLAYER_CAST else PLAYER_EXO
} }
private fun getDeviceInfo(): DeviceInfo { @SuppressLint("HardwareIds")
fun getDeviceInfo(): DeviceInfo {
/* EXAMPLE /* EXAMPLE
manufacturer: Google manufacturer: Google
model: Pixel 6 model: Pixel 6
@@ -889,7 +937,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
sdkVersion: 32 sdkVersion: 32
appVersion: 0.9.46-beta appVersion: 0.9.46-beta
*/ */
return DeviceInfo(Build.MANUFACTURER, Build.MODEL, Build.BRAND, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME) val deviceId = Settings.Secure.getString(ctx.contentResolver, Settings.Secure.ANDROID_ID)
return DeviceInfo(deviceId, Build.MANUFACTURER, Build.MODEL, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
} }
private val deviceSettings get() = DeviceManager.deviceData.deviceSettings ?: DeviceSettings.default() private val deviceSettings get() = DeviceManager.deviceData.deviceSettings ?: DeviceSettings.default()
@@ -992,10 +1041,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
val localBrowseItems:MutableList<MediaBrowserCompat.MediaItem> = mutableListOf() val localBrowseItems:MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
localBooks.forEach { localLibraryItem -> localBooks.forEach { localLibraryItem ->
val progress = DeviceManager.dbManager.getLocalMediaProgress(localLibraryItem.id) if (localLibraryItem.media.getAudioTracks().isNotEmpty()) {
val description = localLibraryItem.getMediaDescription(progress, ctx) val progress = DeviceManager.dbManager.getLocalMediaProgress(localLibraryItem.id)
val description = localLibraryItem.getMediaDescription(progress, ctx)
localBrowseItems += MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) localBrowseItems += MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
}
} }
localPodcasts.forEach { localLibraryItem -> localPodcasts.forEach { localLibraryItem ->
@@ -37,7 +37,11 @@ class AbsAudioPlayer : Plugin() {
mainActivity = (activity as MainActivity) mainActivity = (activity as MainActivity)
apiHandler = ApiHandler(mainActivity) apiHandler = ApiHandler(mainActivity)
initCastManager() try {
initCastManager()
} catch(e:Exception) {
Log.e(tag, "initCastManager exception ${e.printStackTrace()}")
}
val foregroundServiceReady : () -> Unit = { val foregroundServiceReady : () -> Unit = {
playerNotificationService = mainActivity.foregroundService playerNotificationService = mainActivity.foregroundService
@@ -176,7 +180,7 @@ class AbsAudioPlayer : Plugin() {
val playWhenReady = call.getBoolean("playWhenReady") == true val playWhenReady = call.getBoolean("playWhenReady") == true
val playbackRate = call.getFloat("playbackRate",1f) ?: 1f val playbackRate = call.getFloat("playbackRate",1f) ?: 1f
val startTimeOverride = call.getDouble("startTime") val startTimeOverride = call.getDouble("startTime")
Log.d(tag, "prepareLibraryItem lid=$libraryItemId, startTimeOverride=$startTimeOverride") Log.d(tag, "prepareLibraryItem lid=$libraryItemId, startTimeOverride=$startTimeOverride, playbackRate=$playbackRate")
if (libraryItemId.isEmpty()) { if (libraryItemId.isEmpty()) {
Log.e(tag, "Invalid call to play library item no library item id") Log.e(tag, "Invalid call to play library item no library item id")
@@ -197,7 +201,7 @@ class AbsAudioPlayer : Plugin() {
Handler(Looper.getMainLooper()).post { Handler(Looper.getMainLooper()).post {
Log.d(tag, "prepareLibraryItem: Preparing Local Media item ${jacksonMapper.writeValueAsString(it)}") Log.d(tag, "prepareLibraryItem: Preparing Local Media item ${jacksonMapper.writeValueAsString(it)}")
val playbackSession = it.getPlaybackSession(episode) val playbackSession = it.getPlaybackSession(episode, playerNotificationService.getDeviceInfo())
if (startTimeOverride != null) { if (startTimeOverride != null) {
Log.d(tag, "prepareLibraryItem: Using start time override $startTimeOverride") Log.d(tag, "prepareLibraryItem: Using start time override $startTimeOverride")
playbackSession.currentTime = startTimeOverride playbackSession.currentTime = startTimeOverride
@@ -337,7 +341,8 @@ class AbsAudioPlayer : Plugin() {
val isChapterTime:Boolean = call.getBoolean("isChapterTime", false) == true val isChapterTime:Boolean = call.getBoolean("isChapterTime", false) == true
Handler(Looper.getMainLooper()).post { Handler(Looper.getMainLooper()).post {
val success:Boolean = playerNotificationService.sleepTimerManager.setManualSleepTimer(time, isChapterTime) val playbackSession: PlaybackSession? = playerNotificationService.mediaProgressSyncer.currentPlaybackSession ?: playerNotificationService.currentPlaybackSession
val success:Boolean = playerNotificationService.sleepTimerManager.setManualSleepTimer(playbackSession?.id ?: "", time, isChapterTime)
val ret = JSObject() val ret = JSObject()
ret.put("success", success) ret.put("success", success)
call.resolve(ret) call.resolve(ret)
@@ -378,7 +383,9 @@ class AbsAudioPlayer : Plugin() {
@PluginMethod @PluginMethod
fun cancelSleepTimer(call: PluginCall) { fun cancelSleepTimer(call: PluginCall) {
playerNotificationService.sleepTimerManager.cancelSleepTimer() Handler(Looper.getMainLooper()).post {
playerNotificationService.sleepTimerManager.cancelSleepTimer()
}
call.resolve() call.resolve()
} }
@@ -234,6 +234,8 @@ class AbsDatabase : Plugin() {
call.resolve() call.resolve()
} }
} }
} else {
call.resolve()
} }
} }
} }
@@ -265,6 +267,8 @@ class AbsDatabase : Plugin() {
progress = mediaProgress.progress, progress = mediaProgress.progress,
currentTime = mediaProgress.currentTime, currentTime = mediaProgress.currentTime,
isFinished = mediaProgress.isFinished, isFinished = mediaProgress.isFinished,
ebookLocation = mediaProgress.ebookLocation,
ebookProgress = mediaProgress.ebookProgress,
lastUpdate = mediaProgress.lastUpdate, lastUpdate = mediaProgress.lastUpdate,
startedAt = mediaProgress.startedAt, startedAt = mediaProgress.startedAt,
finishedAt = mediaProgress.finishedAt, finishedAt = mediaProgress.finishedAt,
@@ -345,6 +349,8 @@ class AbsDatabase : Plugin() {
progress = if (isFinished) 1.0 else 0.0, progress = if (isFinished) 1.0 else 0.0,
currentTime = 0.0, currentTime = 0.0,
isFinished = isFinished, isFinished = isFinished,
ebookLocation = null,
ebookProgress = null,
lastUpdate = currentTime, lastUpdate = currentTime,
startedAt = if (isFinished) currentTime else 0L, startedAt = if (isFinished) currentTime else 0L,
finishedAt = if (isFinished) currentTime else null, finishedAt = if (isFinished) currentTime else null,
@@ -389,6 +395,55 @@ class AbsDatabase : Plugin() {
} }
} }
@PluginMethod
fun updateLocalEbookProgress(call:PluginCall) {
val localLibraryItemId = call.getString("localLibraryItemId", "").toString()
val ebookLocation = call.getString("ebookLocation", "").toString()
val ebookProgress = call.getDouble("ebookProgress") ?: 0.0
val localMediaProgressId = localLibraryItemId
var localMediaProgress = DeviceManager.dbManager.getLocalMediaProgress(localMediaProgressId)
if (localMediaProgress == null) {
Log.d(tag, "updateLocalEbookProgress Local Media Progress not found $localMediaProgressId - Creating new")
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
?: return call.resolve(JSObject("{\"error\":\"Library Item not found\"}"))
val book = localLibraryItem.media as Book
localMediaProgress = LocalMediaProgress(
id = localMediaProgressId,
localLibraryItemId = localLibraryItemId,
localEpisodeId = null,
duration = book.duration ?: 0.0,
progress = 0.0,
currentTime = 0.0,
isFinished = false,
ebookLocation = ebookLocation,
ebookProgress = ebookProgress,
lastUpdate = System.currentTimeMillis(),
startedAt = 0L,
finishedAt = null,
serverConnectionConfigId = localLibraryItem.serverConnectionConfigId,
serverAddress = localLibraryItem.serverAddress,
serverUserId = localLibraryItem.serverUserId,
libraryItemId = localLibraryItem.libraryItemId,
episodeId = null)
} else {
localMediaProgress.updateEbookProgress(ebookLocation, ebookProgress)
}
// Save local media progress locally
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
val lmpstring = jacksonMapper.writeValueAsString(localMediaProgress)
Log.d(tag, "updateLocalEbookProgress: Local Media Progress String $lmpstring")
val jsobj = JSObject()
jsobj.put("localMediaProgress", JSObject(lmpstring))
call.resolve(jsobj)
}
@PluginMethod @PluginMethod
fun updateLocalTrackOrder(call:PluginCall) { fun updateLocalTrackOrder(call:PluginCall) {
val localLibraryItemId = call.getString("localLibraryItemId", "") ?: "" val localLibraryItemId = call.getString("localLibraryItemId", "") ?: ""
@@ -2,7 +2,6 @@ package com.audiobookshelf.app.plugins
import android.app.DownloadManager import android.app.DownloadManager
import android.content.Context import android.content.Context
import android.os.Build
import android.os.Environment import android.os.Environment
import android.util.Log import android.util.Log
import com.audiobookshelf.app.MainActivity import com.audiobookshelf.app.MainActivity
@@ -58,7 +57,7 @@ class AbsDownloader : Plugin() {
val libraryItemId = call.data.getString("libraryItemId").toString() val libraryItemId = call.data.getString("libraryItemId").toString()
var episodeId = call.data.getString("episodeId").toString() var episodeId = call.data.getString("episodeId").toString()
if (episodeId == "null") episodeId = "" if (episodeId == "null") episodeId = ""
val localFolderId = call.data.getString("localFolderId").toString() var localFolderId = call.data.getString("localFolderId", "").toString()
Log.d(tag, "Download library item $libraryItemId to folder $localFolderId / episode: $episodeId") Log.d(tag, "Download library item $libraryItemId to folder $localFolderId / episode: $episodeId")
val downloadId = if (episodeId.isEmpty()) libraryItemId else "$libraryItemId-$episodeId" val downloadId = if (episodeId.isEmpty()) libraryItemId else "$libraryItemId-$episodeId"
@@ -73,9 +72,18 @@ class AbsDownloader : Plugin() {
} else { } else {
Log.d(tag, "Got library item from server ${libraryItem.id}") Log.d(tag, "Got library item from server ${libraryItem.id}")
val localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId) if (localFolderId == "") {
if (localFolder != null) { localFolderId = "internal-${libraryItem.mediaType}"
}
var localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
if (localFolder == null && localFolderId.startsWith("internal-")) {
Log.d(tag, "Creating new App Storage internal LocalFolder $localFolderId")
localFolder = LocalFolder(localFolderId, "Internal App Storage", "", "", "", "", "internal", libraryItem.mediaType)
DeviceManager.dbManager.saveLocalFolder(localFolder)
}
if (localFolder != null) {
if (episodeId.isNotEmpty() && libraryItem.mediaType != "podcast") { if (episodeId.isNotEmpty() && libraryItem.mediaType != "podcast") {
Log.e(tag, "Library item is not a podcast but episode was requested") Log.e(tag, "Library item is not a podcast but episode was requested")
call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}")) call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
@@ -101,12 +109,6 @@ class AbsDownloader : Plugin() {
} }
} }
// Clean folder path so it can be used in URL
private fun cleanRelPath(relPath: String): String {
val cleanedRelPath = relPath.replace("\\", "/").replace("%", "%25").replace("#", "%23")
return if (cleanedRelPath.startsWith("/")) cleanedRelPath.substring(1) else cleanedRelPath
}
// Item filenames could be the same if they are in sub-folders, this will make them unique // Item filenames could be the same if they are in sub-folders, this will make them unique
private fun getFilenameFromRelPath(relPath: String): String { private fun getFilenameFromRelPath(relPath: String): String {
var cleanedRelPath = relPath.replace("\\", "_").replace("/", "_") var cleanedRelPath = relPath.replace("\\", "_").replace("/", "_")
@@ -128,7 +130,9 @@ class AbsDownloader : Plugin() {
} }
private fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) { private fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) {
val tempFolderPath = mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) val isInternal = localFolder.id.startsWith("internal-")
val tempFolderPath = if (isInternal) "${mainActivity.filesDir}/downloads/${libraryItem.id}" else mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
Log.d(tag, "downloadCacheDirectory=$tempFolderPath") Log.d(tag, "downloadCacheDirectory=$tempFolderPath")
@@ -139,13 +143,40 @@ class AbsDownloader : Plugin() {
val tracks = libraryItem.media.getAudioTracks() val tracks = libraryItem.media.getAudioTracks()
Log.d(tag, "Starting library item download with ${tracks.size} tracks") Log.d(tag, "Starting library item download with ${tracks.size} tracks")
val itemSubfolder = "$bookAuthor/$bookTitle" val itemSubfolder = "$bookAuthor/$bookTitle"
val itemFolderPath = "${localFolder.absolutePath}/$itemSubfolder" val itemFolderPath = if (isInternal) "$tempFolderPath" else "${localFolder.absolutePath}/$itemSubfolder"
val downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null, libraryItem.userMediaProgress,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, itemSubfolder, libraryItem.media, mutableListOf()) val downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null, libraryItem.userMediaProgress,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, itemSubfolder, libraryItem.media, mutableListOf())
val book = libraryItem.media as Book
book.ebookFile?.let { ebookFile ->
val fileSize = ebookFile.metadata?.size ?: 0
val serverPath = "/api/items/${libraryItem.id}/file/${ebookFile.ino}/download"
val destinationFilename = getFilenameFromRelPath(ebookFile.metadata?.relPath ?: "")
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
val destinationFile = File("$tempFolderPath/$destinationFilename")
if (destinationFile.exists()) {
Log.d(tag, "TEMP ebook file already exists, removing it from ${destinationFile.absolutePath}")
destinationFile.delete()
}
if (finalDestinationFile.exists()) {
Log.d(tag, "ebook file already exists, removing it from ${finalDestinationFile.absolutePath}")
finalDestinationFile.delete()
}
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,ebookFile,null,null)
downloadItem.downloadItemParts.add(downloadItemPart)
}
// Create download item part for each audio track // Create download item part for each audio track
val audioFiles = (libraryItem.media as Book).audioFiles ?: mutableListOf()
tracks.forEach { audioTrack -> tracks.forEach { audioTrack ->
val fileSize = audioTrack.metadata?.size ?: 0 val fileSize = audioTrack.metadata?.size ?: 0
val serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioTrack.relPath)}"
// TODO: Currently file ino is only stored on AudioFile. This should be updated server side to be in FileMetadata or on the AudioTrack
val audioFileIno = audioFiles.find { it.metadata.path == audioTrack.metadata?.path }?.ino
val serverPath = "/api/items/${libraryItem.id}/file/${audioFileIno}/download"
val destinationFilename = getFilenameFromRelPath(audioTrack.relPath) val destinationFilename = getFilenameFromRelPath(audioTrack.relPath)
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename") Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
@@ -162,7 +193,7 @@ class AbsDownloader : Plugin() {
finalDestinationFile.delete() finalDestinationFile.delete()
} }
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,audioTrack,null) val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,audioTrack,null)
downloadItem.downloadItemParts.add(downloadItemPart) downloadItem.downloadItemParts.add(downloadItemPart)
} }
@@ -187,7 +218,7 @@ class AbsDownloader : Plugin() {
finalDestinationFile.delete() finalDestinationFile.delete()
} }
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, coverFileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,null) val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, coverFileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,null,null)
downloadItem.downloadItemParts.add(downloadItemPart) downloadItem.downloadItemParts.add(downloadItemPart)
} }
@@ -198,14 +229,15 @@ class AbsDownloader : Plugin() {
val podcastTitle = cleanStringForFileSystem(libraryItem.media.metadata.title) val podcastTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
val audioTrack = episode?.audioTrack val audioTrack = episode?.audioTrack
val audioFileIno = episode?.audioFile?.ino
val fileSize = audioTrack?.metadata?.size ?: 0 val fileSize = audioTrack?.metadata?.size ?: 0
Log.d(tag, "Starting podcast episode download") Log.d(tag, "Starting podcast episode download")
val itemFolderPath = localFolder.absolutePath + "/" + podcastTitle val itemFolderPath = if (isInternal) "$tempFolderPath" else "${localFolder.absolutePath}/$podcastTitle"
val downloadItemId = "${libraryItem.id}-${episode?.id}" val downloadItemId = "${libraryItem.id}-${episode?.id}"
val downloadItem = DownloadItem(downloadItemId, libraryItem.id, episode?.id, libraryItem.userMediaProgress, DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, podcastTitle, podcastTitle, libraryItem.media, mutableListOf()) val downloadItem = DownloadItem(downloadItemId, libraryItem.id, episode?.id, libraryItem.userMediaProgress, DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, podcastTitle, podcastTitle, libraryItem.media, mutableListOf())
var serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioTrack?.relPath ?: "")}" var serverPath = "/api/items/${libraryItem.id}/file/${audioFileIno}/download"
var destinationFilename = getFilenameFromRelPath(audioTrack?.relPath ?: "") var destinationFilename = getFilenameFromRelPath(audioTrack?.relPath ?: "")
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack?.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename") Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack?.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
@@ -216,7 +248,7 @@ class AbsDownloader : Plugin() {
finalDestinationFile.delete() finalDestinationFile.delete()
} }
var downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,fileSize, destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,audioTrack,episode) var downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,fileSize, destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,audioTrack,episode)
downloadItem.downloadItemParts.add(downloadItemPart) downloadItem.downloadItemParts.add(downloadItemPart)
if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) { if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) {
@@ -232,7 +264,7 @@ class AbsDownloader : Plugin() {
if (finalDestinationFile.exists()) { if (finalDestinationFile.exists()) {
Log.d(tag, "Podcast cover already exists - not downloading cover again") Log.d(tag, "Podcast cover already exists - not downloading cover again")
} else { } else {
downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null) downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null,null)
downloadItem.downloadItemParts.add(downloadItemPart) downloadItem.downloadItemParts.add(downloadItemPart)
} }
} }
@@ -1,7 +1,6 @@
package com.audiobookshelf.app.plugins package com.audiobookshelf.app.plugins
import android.app.AlertDialog import android.app.AlertDialog
import android.database.Cursor
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
@@ -23,6 +22,7 @@ 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 java.io.File
@CapacitorPlugin(name = "AbsFileSystem") @CapacitorPlugin(name = "AbsFileSystem")
class AbsFileSystem : Plugin() { class AbsFileSystem : Plugin() {
@@ -164,6 +164,13 @@ class AbsFileSystem : Plugin() {
call.resolve(jsobj) call.resolve(jsobj)
} }
@PluginMethod
fun getSDKVersion(call: PluginCall) {
val jsObject = JSObject()
jsObject.put("version", Build.VERSION.SDK_INT)
call.resolve(jsObject)
}
@PluginMethod @PluginMethod
fun scanFolder(call: PluginCall) { fun scanFolder(call: PluginCall) {
val folderId = call.data.getString("folderId", "").toString() val folderId = call.data.getString("folderId", "").toString()
@@ -226,8 +233,46 @@ class AbsFileSystem : Plugin() {
val contentUrl = call.data.getString("contentUrl", "").toString() val contentUrl = call.data.getString("contentUrl", "").toString()
Log.d(tag, "deleteItem $absolutePath | $contentUrl") Log.d(tag, "deleteItem $absolutePath | $contentUrl")
val docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl)) // Check if should delete subfolder
val success = docfile?.delete() == true val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
val success: Boolean
// If internal library item use File to delete
if (localLibraryItem?.folderId?.startsWith("internal-") == true) {
Log.d(tag, "Deleting internal library item at absolutePath $absolutePath")
val file = File(absolutePath)
success = file.deleteRecursively()
} else {
var subfolderPathToDelete = ""
localLibraryItem?.folderId?.let { folderId ->
val folder = DeviceManager.dbManager.getLocalFolder(folderId)
folder?.absolutePath?.let { folderPath ->
val splitAbsolutePath = absolutePath.split("/")
val fullSubDir = splitAbsolutePath.subList(0, splitAbsolutePath.size - 1).joinToString("/")
if (fullSubDir != folderPath) {
val subdirHasAnItem = DeviceManager.dbManager.getLocalLibraryItems().any { _localLibraryItem ->
if (_localLibraryItem.id == localLibraryItemId) {
false
} else {
_localLibraryItem.absolutePath.startsWith(fullSubDir)
}
}
subfolderPathToDelete = if (subdirHasAnItem) "" else fullSubDir
}
}
}
val docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
success = docfile?.delete() == true
if (subfolderPathToDelete != "") {
Log.d(tag, "Deleting empty subfolder at $subfolderPathToDelete")
val docfilesub = DocumentFileCompat.fromFullPath(mainActivity, subfolderPathToDelete)
docfilesub?.delete()
}
}
if (success) { if (success) {
DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId) DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId)
} }
@@ -17,6 +17,7 @@ import com.getcapacitor.JSObject
import okhttp3.* import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.internal.EMPTY_REQUEST
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONException import org.json.JSONException
import org.json.JSONObject import org.json.JSONObject
@@ -45,11 +46,11 @@ class ApiHandler(var ctx:Context) {
makeRequest(request, httpClient, cb) makeRequest(request, httpClient, cb)
} }
private fun postRequest(endpoint:String, payload: JSObject, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) { private fun postRequest(endpoint:String, payload: JSObject?, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
val address = config?.address ?: DeviceManager.serverAddress val address = config?.address ?: DeviceManager.serverAddress
val token = config?.token ?: DeviceManager.token val token = config?.token ?: DeviceManager.token
val mediaType = "application/json; charset=utf-8".toMediaType() val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = payload.toString().toRequestBody(mediaType) val requestBody = payload?.toString()?.toRequestBody(mediaType) ?: EMPTY_REQUEST
val requestUrl = "${address}$endpoint" val requestUrl = "${address}$endpoint"
Log.d(tag, "postRequest to $requestUrl") Log.d(tag, "postRequest to $requestUrl")
val request = Request.Builder().post(requestBody) val request = Request.Builder().post(requestBody)
@@ -292,6 +293,13 @@ class ApiHandler(var ctx:Context) {
} }
} }
fun closePlaybackSession(playbackSessionId:String, cb: (Boolean) -> Unit) {
Log.d(tag, "closePlaybackSession: playbackSessionId=$playbackSessionId")
postRequest("/api/session/$playbackSessionId/close", null, null) {
cb(true)
}
}
fun authorize(config:ServerConnectionConfig, cb: (MutableList<MediaProgress>?) -> Unit) { fun authorize(config:ServerConnectionConfig, cb: (MutableList<MediaProgress>?) -> Unit) {
Log.d(tag, "authorize: Authorizing ${config.address}") Log.d(tag, "authorize: Authorizing ${config.address}")
postRequest("/api/authorize", JSObject(), config) { postRequest("/api/authorize", JSObject(), config) {
@@ -326,11 +334,11 @@ class ApiHandler(var ctx:Context) {
if (localSessionSyncResult.progressSynced == true) { if (localSessionSyncResult.progressSynced == true) {
val syncResult = SyncResult(true, true, "Progress synced on server") val syncResult = SyncResult(true, true, "Progress synced on server")
MediaEventManager.saveEvent(session, syncResult) MediaEventManager.saveEvent(session, syncResult)
DeviceManager.dbManager.removePlaybackSession(session.id)
Log.i(tag, "Successfully synced session ${session.displayTitle} with server") Log.i(tag, "Successfully synced session ${session.displayTitle} with server")
} else if (!localSessionSyncResult.success) { } else if (!localSessionSyncResult.success) {
Log.e(tag, "Failed to sync session ${session.displayTitle} with server. Error: ${localSessionSyncResult.error}") Log.e(tag, "Failed to sync session ${session.displayTitle} with server. Error: ${localSessionSyncResult.error}")
} }
DeviceManager.dbManager.removePlaybackSession(session.id)
} }
} }
cb(true, null) cb(true, null)
@@ -356,6 +364,16 @@ class ApiHandler(var ctx:Context) {
Log.d(tag, "Server progress for media item id=\"${mediaProgress.mediaItemId}\" is more recent then local. Updating local current time ${localMediaProgress.currentTime} to ${mediaProgress.currentTime}") Log.d(tag, "Server progress for media item id=\"${mediaProgress.mediaItemId}\" is more recent then local. Updating local current time ${localMediaProgress.currentTime} to ${mediaProgress.currentTime}")
localMediaProgress.updateFromServerMediaProgress(mediaProgress) localMediaProgress.updateFromServerMediaProgress(mediaProgress)
MediaEventManager.syncEvent(mediaProgress, "Sync on server connection") MediaEventManager.syncEvent(mediaProgress, "Sync on server connection")
} else if (localMediaProgress.lastUpdate > mediaProgress.lastUpdate && localMediaProgress.ebookLocation != null && localMediaProgress.ebookLocation != mediaProgress.ebookLocation) {
// Patch ebook progress to server
val endpoint = "/api/me/progress/${localMediaProgress.libraryItemId}"
val updatePayload = JSObject()
updatePayload.put("ebookLocation", localMediaProgress.ebookLocation)
updatePayload.put("ebookProgress", localMediaProgress.ebookProgress)
updatePayload.put("lastUpdate", localMediaProgress.lastUpdate)
patchRequest(endpoint,updatePayload) {
Log.d(tag, "syncLocalMediaProgressForUser patched ebook progress")
}
} }
} }
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="862dp"
android:height="1024dp"
android:viewportWidth="862"
android:viewportHeight="1024">
<path
android:fillColor="#FFFFFF"
android:pathData="M855,504.4c-5.3,-4.4 -13.5,-10.9 -24.7,-18.6v-86.5c0,-220.5 -178.8,-399.3 -399.3,-399.3v0c-220.5,0 -399.3,178.8 -399.3,399.3v86.5c-11.1,7.7 -19.4,14.2 -24.7,18.6 -4.4,3.7 -7.1,9.1 -7.1,15.2 0,0 0,0 0,0v-0,103.6c0,0 0,0 0,0 0,6.1 2.8,11.6 7.1,15.2l0,0c12.3,10.3 40.6,31.8 84.7,53.8v10.1c0,27.2 17.5,49.2 39.1,49.2v0c21.6,0 39.1,-22 39.1,-49.2v-248.5c0,-27.2 -17.5,-49.2 -39.1,-49.2v0c-20.7,0 -37.7,20.2 -39,45.8l-0.1,0v-51.2c0,-187.4 151.9,-339.3 339.3,-339.3v0c187.4,0 339.3,151.9 339.3,339.3v51.2l-0.1,-0c-1.4,-25.6 -18.3,-45.8 -39,-45.8v0c-21.6,0 -39.1,22 -39.1,49.2v248.5c0,27.2 17.5,49.2 39.1,49.2v0c21.6,0 39.1,-22 39.1,-49.2v-10.1c44,-22.1 72.3,-43.6 84.7,-53.8 4.4,-3.7 7.1,-9.1 7.1,-15.2 0,-0 0,-0 0,-0v0,-103.6c0,-0 0,-0 0,-0 0,-6.1 -2.8,-11.6 -7.1,-15.2l-0,-0zM291.8,936.4c26.1,0 47.3,-21.2 47.3,-47.3v0,-529.4c0,-0 0,-0 0,-0 0,-26.1 -21.2,-47.3 -47.3,-47.3h-48.8c-26.1,0 -47.3,21.2 -47.3,47.3v0,529.4c0,26.1 21.2,47.3 47.3,47.3 0,0 0,0 0,0v0zM213.5,439.4h107.8v11.3h-107.8zM455.5,936.4c26.1,0 47.3,-21.2 47.3,-47.3v0,-529.4c0,-0 0,-0 0,-0 0,-26.1 -21.2,-47.3 -47.3,-47.3h-48.8c-26.1,0 -47.3,21.2 -47.3,47.3v0,529.4c0,26.1 21.2,47.3 47.3,47.3 0,0 0,0 0,0v0zM377.2,439.4h107.8v11.3h-107.8zM619.1,936.4c26.1,0 47.3,-21.2 47.3,-47.3v0,-529.4c0,-0 0,-0 0,-0 0,-26.1 -21.2,-47.3 -47.3,-47.3h-48.8c-26.1,0 -47.3,21.2 -47.3,47.3v0,529.4c0,26.1 21.2,47.3 47.3,47.3 0,0 0,0 0,0v0zM540.8,439.4h107.8v11.3h-107.8zM114.1,952.7h633.9c19.7,0 35.6,15.9 35.6,35.6v0c0,19.7 -15.9,35.6 -35.6,35.6h-633.9c-19.7,0 -35.6,-15.9 -35.6,-35.6v-0c0,-19.7 15.9,-35.6 35.6,-35.6z"/>
</vector>
@@ -0,0 +1,108 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/widgetBackground"
android:layout_width="fill_parent"
android:layout_height="fill_parent"
android:orientation="horizontal"
android:theme="@style/AppTheme.AppWidgetContainer"
style="@style/Widget.Android.AppWidget.Container">
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="3"
android:background="?android:attr/colorAccent"
android:theme="@style/MediaPlayerWidgetTheme"
android:orientation="horizontal">
<ImageView
android:id="@+id/widgetAlbumArt"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="2"
android:scaleType="centerCrop"
android:src="@drawable/icon" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="3"
android:layout_marginVertical="8dp"
android:orientation="vertical"
android:gravity="center_vertical">
<TextView
android:id="@+id/widgetArtistText"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="2"
android:layout_marginLeft="8dp"
android:gravity="bottom"
android:ellipsize="end"
android:maxLines="1"
android:text="Artist"/>
<TextView
android:id="@+id/widgetMediaTitle"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="2"
android:layout_marginLeft="8dp"
android:gravity="top"
android:ellipsize="end"
android:maxLines="1"
android:text="Title"/>
<LinearLayout
android:id="@+id/widgetButtonContainer"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="3"
android:orientation="horizontal"
android:background="?android:attr/colorAccent"
android:theme="@style/MediaPlayerWidgetTheme">
<ImageView
android:id="@+id/widgetRewindButton"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:layout_margin="8dp"
android:src="@drawable/exo_icon_rewind"
style="@style/Widget.Android.AppWidget.InnerView"/>
<ImageView
android:id="@+id/widgetPlayPauseButton"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:layout_margin="6dp"
android:src="@drawable/ic_media_play_dark" />
<ImageView
android:id="@+id/widgetFastForwardButton"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:layout_margin="8dp"
android:src="@drawable/exo_icon_fastforward" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="36dp"
android:layout_height="match_parent"
android:orientation="vertical">
<ImageView
android:id="@+id/tinyCornerIcon"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_margin="6dp"
android:adjustViewBounds ="true"
android:src="@drawable/icon_monochrome" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
@@ -1,26 +0,0 @@
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
style="@style/Widget.Android.AppWidget.Container"
android:id="@+id/appWidget"
android:theme="@style/AppTheme.AppWidgetContainer"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/imageView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_centerInParent="true"
android:adjustViewBounds="true"
android:alpha="0.75"
android:contentDescription="Cover image"
android:visibility="visible" />
<ImageView
android:id="@+id/playPauseIcon"
android:layout_width="71dp"
android:layout_height="55dp"
android:layout_centerInParent="true"
app:srcCompat="@drawable/cast_ic_expanded_controller_play" />
</RelativeLayout>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--
Having themes.xml for night-v31 because of the priority order of the resource qualifiers.
-->
<style name="AppTheme.AppWidgetContainerParent" parent="@android:style/Theme.DeviceDefault.DayNight">
<item name="appWidgetRadius">@android:dimen/system_app_widget_background_radius</item>
<item name="appWidgetInnerRadius">@android:dimen/system_app_widget_inner_radius</item>
</style>
</resources>
@@ -0,0 +1,16 @@
<resources>
<style name="Widget.Android.AppWidget.Container" parent="android:Widget">
<item name="android:id">@android:id/background</item>
<item name="android:padding">?attr/appWidgetPadding</item>
<item name="android:background">@drawable/app_widget_background</item>
<item name="android:clipToOutline">true</item>
</style>
<style name="Widget.Android.AppWidget.InnerView" parent="android:Widget">
<item name="android:padding">?attr/appWidgetPadding</item>
<!-- <item name="android:background">@drawable/app_widget_inner_view_background</item>-->
<!-- <item name="android:textColor">?android:attr/textColorPrimary</item>-->
<item name="android:clipToOutline">true</item>
</style>
</resources>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--
Having themes.xml for v31 variant because @android:dimen/system_app_widget_background_radius
and @android:dimen/system_app_widget_internal_padding requires API level 31
-->
<style name="AppTheme.AppWidgetContainerParent" parent="@android:style/Theme.DeviceDefault.DayNight">
<item name="appWidgetRadius">@android:dimen/system_app_widget_background_radius</item>
<item name="appWidgetInnerRadius">@android:dimen/system_app_widget_inner_radius</item>
</style>
</resources>
+7 -5
View File
@@ -1,7 +1,9 @@
<resources> <resources>
<declare-styleable name="AppWidgetAttrs"> <declare-styleable name="AppWidgetAttrs">
<attr name="appWidgetPadding" format="dimension" /> <attr name="appWidgetPadding" format="dimension" />
<attr name="appWidgetInnerRadius" format="dimension" /> <attr name="appWidgetInnerRadius" format="dimension" />
<attr name="appWidgetRadius" format="dimension" /> <attr name="appWidgetRadius" format="dimension" />
</declare-styleable> <attr name="backgroundRadius" format="dimension" />
<attr name="appWidgetViewPadding" format="dimension" />
</declare-styleable>
</resources> </resources>
+34 -25
View File
@@ -1,33 +1,42 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<!-- Base application theme. --> <!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar"> <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. --> <!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item> <item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item> <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item> <item name="colorAccent">@color/colorAccent</item>
</style> </style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar"> <style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<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:statusBarColor">@color/background_dark</item>
<item name="android:navigationBarColor">@color/background_dark</item> <item name="android:navigationBarColor">@color/background_dark</item>
</style> </style>
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen"> <style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<!-- <item name="android:background">@drawable/screen</item>--> <!-- <item name="android:background">@drawable/screen</item>-->
</style> </style>
<style name="Widget.Android.AppWidget.Container" parent="android:Widget">
<item name="android:id">@android:id/background</item>
<item name="android:background">?android:attr/colorBackground</item>
</style>
<style name="Widget.Android.AppWidget.InnerView" parent="android:Widget"> <style name="MediaPlayerWidgetTheme" parent="Theme.AppCompat.DayNight">
<item name="android:background">?android:attr/colorBackground</item> <!-- These colors match the vue pages -->
<item name="android:textColor">?android:attr/textColorPrimary</item> <item name="android:colorBackground">#232323</item>
</style> <item name="android:colorAccent">#373838</item>
<item name="android:textColor">#ffffff</item>
</style>
<style name="Widget.Android.AppWidget.Container" parent="android:Widget">
<item name="android:id">@android:id/background</item>
<item name="android:background">?android:attr/colorBackground</item>
</style>
<style name="Widget.Android.AppWidget.InnerView" parent="android:Widget">
<!-- <item name="android:background">?android:attr/colorBackground</item>-->
<!-- <item name="android:textColor">?android:attr/textColorPrimary</item>-->
<item name="android:padding">0dp</item>
</style>
</resources> </resources>
+11 -11
View File
@@ -1,17 +1,17 @@
<resources> <resources>
<style name="AppTheme.AppWidgetContainerParent" parent="@android:style/Theme.DeviceDefault">
<!-- Radius of the outer bound of widgets to make the rounded corners --> <style name="AppTheme.AppWidgetContainerParent" parent="@android:style/Theme.DeviceDefault">
<item name="appWidgetRadius">16dp</item> <!-- Radius of the outer bound of widgets to make the rounded corners -->
<!-- <item name="appWidgetRadius">0dp</item>
<!--
Radius of the inner view's bound of widgets to make the rounded corners. Radius of the inner view's bound of widgets to make the rounded corners.
It needs to be 8dp or less than the value of appWidgetRadius It needs to be 8dp or less than the value of appWidgetRadius
--> -->
<item name="appWidgetInnerRadius">8dp</item> <item name="appWidgetInnerRadius">0dp</item>
</style> </style>
<style name="AppTheme.AppWidgetContainer" <style name="AppTheme.AppWidgetContainer" parent="AppTheme.AppWidgetContainerParent">
parent="AppTheme.AppWidgetContainerParent"> <!-- Apply padding to avoid the content of the widget colliding with the rounded corners -->
<!-- Apply padding to avoid the content of the widget colliding with the rounded corners --> <item name="appWidgetPadding">0dp</item>
<item name="appWidgetPadding">16dp</item> </style>
</style>
</resources> </resources>
+2 -3
View File
@@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android"> <paths>
<external-path name="my_images" path="." /> <files-path name="downloads" path="downloads/" />
<cache-path name="my_cache_images" path="." />
</paths> </paths>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/app_widget_description"
android:initialKeyguardLayout="@layout/media_player_widget"
android:initialLayout="@layout/media_player_widget"
android:minWidth="275dp"
android:minHeight="50dp"
android:previewImage="@drawable/media_player_widget_preview"
android:previewLayout="@layout/media_player_widget"
android:resizeMode="horizontal"
android:targetCellWidth="4"
android:targetCellHeight="1"
android:updatePeriodMillis="86400000"
android:widgetCategory="home_screen" />
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="40dp"
android:updatePeriodMillis="86400000"
android:previewImage="@drawable/example_appwidget_preview"
android:initialLayout="@layout/new_app_widget"
android:description="@string/app_widget_description"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:initialKeyguardLayout="@layout/new_app_widget"
/>
+3
View File
@@ -5,6 +5,9 @@ project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/
include ':capacitor-app' include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android') project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
include ':capacitor-clipboard'
project(':capacitor-clipboard').projectDir = new File('../node_modules/@capacitor/clipboard/android')
include ':capacitor-dialog' include ':capacitor-dialog'
project(':capacitor-dialog').projectDir = new File('../node_modules/@capacitor/dialog/android') project(':capacitor-dialog').projectDir = new File('../node_modules/@capacitor/dialog/android')
+2 -2
View File
@@ -1,7 +1,7 @@
ext { ext {
minSdkVersion = 24 minSdkVersion = 24
compileSdkVersion = 33 compileSdkVersion = 33
targetSdkVersion = 32 targetSdkVersion = 33
androidxActivityVersion = '1.4.0' androidxActivityVersion = '1.4.0'
androidxAppCompatVersion = '1.4.2' androidxAppCompatVersion = '1.4.2'
androidxCoordinatorLayoutVersion = '1.2.0' androidxCoordinatorLayoutVersion = '1.2.0'
@@ -20,7 +20,7 @@ ext {
arch_lifecycle_version = '2.2.0' arch_lifecycle_version = '2.2.0'
constraint_layout_version = '2.0.1' constraint_layout_version = '2.0.1'
espresso_version = '3.3.0' espresso_version = '3.3.0'
exoplayer_version = '2.17.0' exoplayer_version = '2.18.7'
fragment_version = '1.2.5' fragment_version = '1.2.5'
glide_version = '4.11.0' glide_version = '4.11.0'
gms_strict_version_matcher_version = '1.0.3' gms_strict_version_matcher_version = '1.0.3'
+64
View File
@@ -2,6 +2,19 @@
@import './defaultStyles.css'; @import './defaultStyles.css';
@import './absicons.css'; @import './absicons.css';
* {
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
input,
textarea {
-webkit-touch-callout: auto;
-webkit-user-select: auto;
user-select: auto;
}
body { body {
background-color: #262626; background-color: #262626;
} }
@@ -103,3 +116,54 @@ Bookshelf Label
opacity: 1; opacity: 1;
filter: blur(20px); filter: blur(20px);
} }
.episode-subtitle {
word-break: break-word;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
line-height: 16px;
/* fallback */
max-height: 32px;
/* fallback */
-webkit-line-clamp: 2;
/* number of lines to show */
-webkit-box-orient: vertical;
}
.line-clamp-2 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.line-clamp-4 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
}
.tracksTable {
border-collapse: collapse;
width: 100%;
border: 1px solid #474747;
}
.tracksTable tr:nth-child(even) {
background-color: #2e2e2e;
}
.tracksTable tr {
background-color: #373838;
}
.tracksTable td {
padding: 8px 8px;
}
.tracksTable th {
padding: 4px 8px;
font-size: 0.75rem;
}
+1 -1
View File
@@ -79,7 +79,7 @@ export default {
}, },
methods: { methods: {
castClick() { castClick() {
if (this.$store.state.playerIsLocal) { if (this.$store.getters['getIsCurrentSessionLocal']) {
this.$eventBus.$emit('cast-local-item') this.$eventBus.$emit('cast-local-item')
return return
} }
+97 -120
View File
@@ -12,27 +12,27 @@
<div class="top-6 right-4 absolute cursor-pointer"> <div class="top-6 right-4 absolute cursor-pointer">
<span class="material-icons text-3xl" :class="{ 'text-black text-opacity-75': coverBgIsLight }" @click="showMoreMenuDialog = true">more_vert</span> <span class="material-icons text-3xl" :class="{ 'text-black text-opacity-75': coverBgIsLight }" @click="showMoreMenuDialog = true">more_vert</span>
</div> </div>
<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> <p class="top-4 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" :class="{ 'text-black text-opacity-75': coverBgIsLight }" style="font-size: 10px">{{ isDirectPlayMethod ? 'Direct' : isLocalPlayMethod ? 'Local' : 'Transcode' }}</p>
</div> </div>
<div v-if="useChapterTrack && useTotalTrack && showFullscreen" class="absolute total-track w-full px-3 z-30"> <div v-if="useChapterTrack && useTotalTrack && showFullscreen" class="absolute total-track w-full z-30 px-6">
<div class="flex"> <div class="flex">
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem">{{ currentTimePretty }}</p> <p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem">{{ currentTimePretty }}</p>
<div class="flex-grow" /> <div class="flex-grow" />
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem">{{ totalTimeRemainingPretty }}</p> <p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem">{{ totalTimeRemainingPretty }}</p>
</div> </div>
<div class="w-full"> <div class="w-full">
<div class="h-1 w-full bg-gray-500 bg-opacity-50 relative"> <div class="h-1 w-full bg-gray-500 bg-opacity-50 relative rounded-full">
<div ref="totalReadyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" /> <div ref="totalReadyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none rounded-full" />
<div ref="totalBufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 pointer-events-none" /> <div ref="totalBufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 pointer-events-none rounded-full" />
<div ref="totalPlayedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" /> <div ref="totalPlayedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none rounded-full" />
</div> </div>
</div> </div>
</div> </div>
<div class="cover-wrapper absolute z-30 pointer-events-auto" @click="clickContainer"> <div class="cover-wrapper absolute z-30 pointer-events-auto" @click="clickContainer">
<div class="w-full h-full flex justify-center"> <div class="w-full h-full flex justify-center">
<covers-book-cover v-if="libraryItem || localLibraryItemCoverSrc" ref="cover" :library-item="libraryItem" :download-cover="localLibraryItemCoverSrc" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" @imageLoaded="coverImageLoaded" /> <covers-book-cover v-if="libraryItem || localLibraryItemCoverSrc" ref="cover" :library-item="libraryItem" :download-cover="localLibraryItemCoverSrc" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" raw @imageLoaded="coverImageLoaded" />
</div> </div>
<div v-if="syncStatus === $constants.SyncStatus.FAILED" class="absolute top-0 left-0 w-full h-full flex items-center justify-center z-30"> <div v-if="syncStatus === $constants.SyncStatus.FAILED" class="absolute top-0 left-0 w-full h-full flex items-center justify-center z-30">
@@ -46,7 +46,7 @@
</div> </div>
<div id="playerContent" class="playerContainer w-full z-20 absolute bottom-0 left-0 right-0 p-2 pointer-events-auto transition-all" :style="{ backgroundColor: showFullscreen ? '' : coverRgb }" @click="clickContainer"> <div id="playerContent" class="playerContainer w-full z-20 absolute bottom-0 left-0 right-0 p-2 pointer-events-auto transition-all" :style="{ backgroundColor: showFullscreen ? '' : coverRgb }" @click="clickContainer">
<div v-if="showFullscreen" class="absolute bottom-4 left-0 right-0 w-full py-3 mx-auto px-3" style="max-width: 380px"> <div v-if="showFullscreen" class="absolute bottom-4 left-0 right-0 w-full pb-4 pt-2 mx-auto px-6" style="max-width: 414px">
<div class="flex items-center justify-between pointer-events-auto"> <div class="flex items-center justify-between pointer-events-auto">
<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> <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 -->
@@ -60,13 +60,13 @@
<p class="text-xl font-mono text-success">{{ sleepTimeRemainingPretty }}</p> <p class="text-xl font-mono text-success">{{ sleepTimeRemainingPretty }}</p>
</div> </div>
<span class="material-icons text-3xl text-white cursor-pointer" :class="chapters.length ? 'text-opacity-75' : 'text-opacity-10'" @click="showChapterModal = true">format_list_bulleted</span> <span class="material-icons text-3xl text-white cursor-pointer" :class="chapters.length ? 'text-opacity-75' : 'text-opacity-10'" @click="clickChaptersBtn">format_list_bulleted</span>
</div> </div>
</div> </div>
<div v-else class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: linear-gradient(145deg, rgba(38, 38, 38, 0.5) 0%, rgba(38, 38, 38, 0.9) 20%, rgb(38, 38, 38) 60%)" /> <div v-else class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: linear-gradient(145deg, rgba(38, 38, 38, 0.5) 0%, rgba(38, 38, 38, 0.9) 20%, rgb(38, 38, 38) 60%)" />
<div id="playerControls" class="absolute right-0 bottom-0 py-2"> <div id="playerControls" class="absolute right-0 bottom-0 mx-auto" style="max-width: 414px">
<div class="flex items-center justify-center"> <div class="flex items-center max-w-full" :class="lockUi ? 'justify-center' : 'justify-between'">
<span v-show="showFullscreen && !lockUi" class="material-icons next-icon text-white text-opacity-75 cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span> <span v-show="showFullscreen && !lockUi" class="material-icons next-icon text-white text-opacity-75 cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
<span v-show="!lockUi" class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span> <span v-show="!lockUi" class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span>
<div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4 relative overflow-hidden" :style="{ backgroundColor: coverRgb }" :class="{ 'animate-spin': seekLoading }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick"> <div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4 relative overflow-hidden" :style="{ backgroundColor: coverRgb }" :class="{ 'animate-spin': seekLoading }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
@@ -80,25 +80,24 @@
</div> </div>
</div> </div>
<div id="playerTrack" class="absolute left-0 w-full px-3"> <div id="playerTrack" class="absolute left-0 w-full px-6">
<div class="flex pb-0.5"> <div class="flex pointer-events-none">
<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>
<div class="flex-grow" /> <div class="flex-grow" />
<p v-show="showFullscreen" class="text-sm truncate text-white text-opacity-75" style="max-width: 65%">{{ currentChapterTitle }}</p>
<div class="flex-grow" />
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem">{{ timeRemainingPretty }}</p> <p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem">{{ timeRemainingPretty }}</p>
</div> </div>
<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 rounded-full" :class="{ 'animate-pulse': isLoading }" @click.stop>
<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 rounded-full 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 rounded-full 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 rounded-full 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-7 w-7 rounded-full absolute pointer-events-auto flex items-center justify-center" :style="{ top: '-11px' }" :class="{ 'opacity-0': lockUi || !showFullscreen }" @touchstart="touchstartCursor">
<div 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 class="bg-gray-200 rounded-full w-3.5 h-3.5 pointer-events-none" />
</div>
</div> </div>
</div> </div>
</div> </div>
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" /> <modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" :playback-rate="currentPlaybackRate" @select="selectChapter" />
<modals-dialog v-model="showMoreMenuDialog" :items="menuItems" @action="clickMenuAction" /> <modals-dialog v-model="showMoreMenuDialog" :items="menuItems" @action="clickMenuAction" />
</div> </div>
</template> </template>
@@ -151,8 +150,10 @@ export default {
useTotalTrack: true, useTotalTrack: true,
lockUi: false, lockUi: false,
isLoading: false, isLoading: false,
touchTrackStart: false, isDraggingCursor: false,
dragPercent: 0, draggingTouchStartX: 0,
draggingTouchStartTime: 0,
draggingCurrentTime: 0,
syncStatus: 0, syncStatus: 0,
showMoreMenuDialog: false, showMoreMenuDialog: false,
coverRgb: 'rgb(55, 56, 56)', coverRgb: 'rgb(55, 56, 56)',
@@ -273,7 +274,7 @@ export default {
return this.playbackSession ? this.playbackSession.localLibraryItem || null : null return this.playbackSession ? this.playbackSession.localLibraryItem || null : null
}, },
localLibraryItemCoverSrc() { localLibraryItemCoverSrc() {
var localItemCover = this.localLibraryItem ? this.localLibraryItem.coverContentUrl : null var localItemCover = this.localLibraryItem?.coverContentUrl || null
if (localItemCover) return Capacitor.convertFileSrc(localItemCover) if (localItemCover) return Capacitor.convertFileSrc(localItemCover)
return null return null
}, },
@@ -287,6 +288,7 @@ export default {
return this.playMethod == this.$constants.PlayMethod.DIRECTPLAY return this.playMethod == this.$constants.PlayMethod.DIRECTPLAY
}, },
title() { title() {
if (this.currentChapterTitle && this.showFullscreen) return this.currentChapterTitle
if (this.playbackSession) return this.playbackSession.displayTitle if (this.playbackSession) return this.playbackSession.displayTitle
return this.mediaMetadata ? this.mediaMetadata.title : 'Title' return this.mediaMetadata ? this.mediaMetadata.title : 'Title'
}, },
@@ -318,17 +320,20 @@ export default {
return this.$secondsToTimestamp(this.totalDuration) return this.$secondsToTimestamp(this.totalDuration)
}, },
currentTimePretty() { currentTimePretty() {
return this.$secondsToTimestamp(this.currentTime / this.currentPlaybackRate) let currentTimeToUse = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
return this.$secondsToTimestamp(currentTimeToUse / this.currentPlaybackRate)
}, },
timeRemaining() { timeRemaining() {
let currentTimeToUse = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
if (this.useChapterTrack && this.currentChapter) { if (this.useChapterTrack && this.currentChapter) {
var currChapTime = this.currentTime - this.currentChapter.start var currChapTime = currentTimeToUse - this.currentChapter.start
return (this.currentChapterDuration - currChapTime) / this.currentPlaybackRate return (this.currentChapterDuration - currChapTime) / this.currentPlaybackRate
} }
return this.totalTimeRemaining return this.totalTimeRemaining
}, },
totalTimeRemaining() { totalTimeRemaining() {
return (this.totalDuration - this.currentTime) / this.currentPlaybackRate let currentTimeToUse = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
return (this.totalDuration - currentTimeToUse) / this.currentPlaybackRate
}, },
totalTimeRemainingPretty() { totalTimeRemainingPretty() {
if (this.totalTimeRemaining < 0) { if (this.totalTimeRemaining < 0) {
@@ -342,10 +347,6 @@ export default {
} }
return '-' + this.$secondsToTimestamp(this.timeRemaining) return '-' + this.$secondsToTimestamp(this.timeRemaining)
}, },
timeLeftInChapter() {
if (!this.currentChapter) return 0
return this.currentChapter.end - this.currentTime
},
sleepTimeRemainingPretty() { sleepTimeRemainingPretty() {
if (!this.sleepTimeRemaining) return '0s' if (!this.sleepTimeRemaining) return '0s'
var secondsRemaining = Math.round(this.sleepTimeRemaining) var secondsRemaining = Math.round(this.sleepTimeRemaining)
@@ -370,6 +371,10 @@ export default {
} }
}, },
methods: { methods: {
clickChaptersBtn() {
if (!this.chapters.length) return
this.showChapterModal = true
},
async coverImageLoaded(fullCoverUrl) { async coverImageLoaded(fullCoverUrl) {
if (!fullCoverUrl) return if (!fullCoverUrl) return
@@ -392,11 +397,6 @@ export default {
this.showFullscreen = false this.showFullscreen = false
} }
}, },
async touchstartTrack(e) {
await this.$hapticsImpact()
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen || this.lockUi) return
this.touchTrackStart = true
},
async selectChapter(chapter) { async selectChapter(chapter) {
await this.$hapticsImpact() await this.$hapticsImpact()
this.seek(chapter.start) this.seek(chapter.start)
@@ -450,7 +450,6 @@ export default {
this.$emit('showSleepTimer') this.$emit('showSleepTimer')
}, },
async setPlaybackSpeed(speed) { async setPlaybackSpeed(speed) {
await this.$hapticsImpact()
console.log(`[AudioPlayer] Set Playback Rate: ${speed}`) console.log(`[AudioPlayer] Set Playback Rate: ${speed}`)
this.currentPlaybackRate = speed this.currentPlaybackRate = speed
this.updateTimestamp() this.updateTimestamp()
@@ -509,12 +508,13 @@ export default {
console.error('No timestamp el') console.error('No timestamp el')
return return
} }
let currentTime = this.currentTime / this.currentPlaybackRate
let currentTime = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
if (this.useChapterTrack && this.currentChapter) { if (this.useChapterTrack && this.currentChapter) {
const currChapTime = Math.max(0, this.currentTime - this.currentChapter.start) currentTime = Math.max(0, currentTime - this.currentChapter.start)
currentTime = currChapTime / this.currentPlaybackRate
} }
ts.innerText = this.$secondsToTimestamp(currentTime)
ts.innerText = this.$secondsToTimestamp(currentTime / this.currentPlaybackRate)
}, },
timeupdate() { timeupdate() {
if (!this.$refs.playedTrack) { if (!this.$refs.playedTrack) {
@@ -536,22 +536,24 @@ export default {
}, },
updateTrack() { updateTrack() {
// Update progress track UI // Update progress track UI
let percentDone = this.currentTime / this.totalDuration let currentTimeToUse = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
let percentDone = currentTimeToUse / this.totalDuration
const totalPercentDone = percentDone const totalPercentDone = percentDone
let bufferedPercent = this.bufferedTime / this.totalDuration let bufferedPercent = this.bufferedTime / this.totalDuration
const totalBufferedPercent = bufferedPercent const totalBufferedPercent = bufferedPercent
if (this.useChapterTrack && this.currentChapter) { if (this.useChapterTrack && this.currentChapter) {
const currChapTime = this.currentTime - this.currentChapter.start const currChapTime = currentTimeToUse - 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))
} }
const 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'
if (this.$refs.trackCursor) { if (this.$refs.trackCursor) {
this.$refs.trackCursor.style.left = ptWidth - 8 + 'px' this.$refs.trackCursor.style.left = ptWidth - 14 + 'px'
} }
if (this.useChapterTrack) { if (this.useChapterTrack) {
@@ -572,35 +574,23 @@ export default {
AbsAudioPlayer.seek({ value: Math.floor(time) }) AbsAudioPlayer.seek({ value: Math.floor(time) })
if (this.$refs.playedTrack) { if (this.$refs.playedTrack) {
var perc = time / this.totalDuration const perc = time / this.totalDuration
var ptWidth = Math.round(perc * this.trackWidth) const ptWidth = Math.round(perc * this.trackWidth)
this.$refs.playedTrack.style.width = ptWidth + 'px' this.$refs.playedTrack.style.width = ptWidth + 'px'
this.$refs.playedTrack.classList.remove('bg-gray-200') this.$refs.playedTrack.classList.remove('bg-gray-200')
this.$refs.playedTrack.classList.add('bg-yellow-300') this.$refs.playedTrack.classList.add('bg-yellow-300')
} }
}, },
clickTrack(e) { async touchstartCursor(e) {
if (this.isLoading || this.lockUi) return if (!e || !e.touches || !this.$refs.track || !this.showFullscreen || this.lockUi) return
if (!this.showFullscreen) {
// Track not clickable on mini-player
return
}
if (e) e.stopPropagation()
var offsetX = e.offsetX await this.$hapticsImpact()
var perc = offsetX / this.trackWidth this.isDraggingCursor = true
var time = 0 this.draggingTouchStartX = e.touches[0].pageX
if (this.useChapterTrack && this.currentChapter) { this.draggingTouchStartTime = this.currentTime
time = perc * this.currentChapterDuration + this.currentChapter.start this.draggingCurrentTime = this.currentTime
} else { this.updateTrack()
time = perc * this.totalDuration
}
if (isNaN(time) || time === null) {
console.error('Invalid time', perc, time)
return
}
this.seek(time)
}, },
async playPauseClick() { async playPauseClick() {
await this.$hapticsImpact() await this.$hapticsImpact()
@@ -653,24 +643,11 @@ export default {
touchend(e) { touchend(e) {
if (!e.changedTouches) return if (!e.changedTouches) return
if (this.touchTrackStart) { if (this.isDraggingCursor) {
var touch = e.changedTouches[0] if (this.draggingCurrentTime !== this.currentTime) {
const touchOnTrackPos = touch.pageX - 12 this.seek(this.draggingCurrentTime)
const dragPercent = Math.max(0, Math.min(1, touchOnTrackPos / this.trackWidth))
var seekToTime = 0
if (this.useChapterTrack && this.currentChapter) {
const currChapTime = dragPercent * this.currentChapterDuration
seekToTime = this.currentChapter.start + currChapTime
} else {
seekToTime = dragPercent * this.totalDuration
} }
this.seek(seekToTime) this.isDraggingCursor = false
if (this.$refs.draggingTrack) {
this.$refs.draggingTrack.style.width = '0px'
}
this.touchTrackStart = false
} else if (this.showFullscreen) { } else if (this.showFullscreen) {
this.touchEndY = e.changedTouches[0].screenY this.touchEndY = e.changedTouches[0].screenY
var touchDuration = Date.now() - this.touchStartTime var touchDuration = Date.now() - this.touchStartTime
@@ -682,29 +659,24 @@ export default {
} }
}, },
touchmove(e) { touchmove(e) {
if (!this.touchTrackStart) return if (!this.isDraggingCursor || !e.touches) return
var touch = e.touches[0] const distanceMoved = e.touches[0].pageX - this.draggingTouchStartX
const touchOnTrackPos = touch.pageX - 12 let duration = this.totalDuration
const dragPercent = Math.max(0, Math.min(1, touchOnTrackPos / this.trackWidth)) let minTime = 0
this.dragPercent = dragPercent let maxTime = duration
if (this.useChapterTrack && this.currentChapter) {
if (this.$refs.draggingTrack) { duration = this.currentChapterDuration
this.$refs.draggingTrack.style.width = this.dragPercent * this.trackWidth + 'px' minTime = this.currentChapter.start
maxTime = minTime + duration
} }
var ts = this.$refs.currentTimestamp const timePerPixel = duration / this.trackWidth
if (ts) { const newTime = this.draggingTouchStartTime + timePerPixel * distanceMoved
var currTimeStr = '' this.draggingCurrentTime = Math.min(maxTime, Math.max(minTime, newTime))
if (this.useChapterTrack && this.currentChapter) {
const currChapTime = dragPercent * this.currentChapterDuration this.updateTimestamp()
currTimeStr = this.$secondsToTimestamp(currChapTime) this.updateTrack()
} else {
const dragTime = dragPercent * this.totalDuration
currTimeStr = this.$secondsToTimestamp(dragTime)
}
ts.innerText = currTimeStr
}
}, },
async clickMenuAction(action) { async clickMenuAction(action) {
await this.$hapticsImpact() await this.$hapticsImpact()
@@ -723,6 +695,7 @@ export default {
this.updateTimestamp() this.updateTimestamp()
this.updateTrack() this.updateTrack()
this.updateReadyTrack() this.updateReadyTrack()
this.$localStore.setUseTotalTrack(this.useTotalTrack)
this.$localStore.setUseChapterTrack(this.useChapterTrack) this.$localStore.setUseChapterTrack(this.useChapterTrack)
} else if (action === 'total_track') { } else if (action === 'total_track') {
this.useTotalTrack = !this.useTotalTrack this.useTotalTrack = !this.useTotalTrack
@@ -732,6 +705,7 @@ export default {
this.updateTrack() this.updateTrack()
this.updateReadyTrack() this.updateReadyTrack()
this.$localStore.setUseTotalTrack(this.useTotalTrack) this.$localStore.setUseTotalTrack(this.useTotalTrack)
this.$localStore.setUseChapterTrack(this.useChapterTrack)
} else if (action === 'close') { } else if (action === 'close') {
this.closePlayback() this.closePlayback()
} }
@@ -747,7 +721,7 @@ export default {
AbsAudioPlayer.closePlayback() AbsAudioPlayer.closePlayback()
}, },
endPlayback() { endPlayback() {
this.$store.commit('setPlayerItem', null) this.$store.commit('setPlaybackSession', null)
this.showFullscreen = false this.showFullscreen = false
this.isEnded = false this.isEnded = false
this.isLoading = false this.isLoading = false
@@ -793,7 +767,7 @@ export default {
this.isEnded = false this.isEnded = false
this.isLoading = true this.isLoading = true
this.syncStatus = 0 this.syncStatus = 0
this.$store.commit('setPlayerItem', this.playbackSession) this.$store.commit('setPlaybackSession', this.playbackSession)
// Set track width // Set track width
this.$nextTick(() => { this.$nextTick(() => {
@@ -820,6 +794,7 @@ export default {
}, },
async init() { async init() {
this.useChapterTrack = await this.$localStore.getUseChapterTrack() this.useChapterTrack = await this.$localStore.getUseChapterTrack()
this.useTotalTrack = await this.$localStore.getUseTotalTrack()
this.lockUi = await this.$localStore.getPlayerLock() this.lockUi = await this.$localStore.getPlayerLock()
this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession) this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession)
@@ -850,7 +825,7 @@ export default {
document.documentElement.style.setProperty('--cover-image-height', coverHeight + 'px') document.documentElement.style.setProperty('--cover-image-height', coverHeight + 'px')
document.documentElement.style.setProperty('--cover-image-width-collapsed', coverImageWidthCollapsed + 'px') document.documentElement.style.setProperty('--cover-image-width-collapsed', coverImageWidthCollapsed + 'px')
document.documentElement.style.setProperty('--cover-image-height-collapsed', 46 + 'px') document.documentElement.style.setProperty('--cover-image-height-collapsed', 46 + 'px')
document.documentElement.style.setProperty('--title-author-left-offset-collapsed', 24 + coverImageWidthCollapsed + 'px') document.documentElement.style.setProperty('--title-author-left-offset-collapsed', 30 + coverImageWidthCollapsed + 'px')
}, },
minimizePlayerEvt() { minimizePlayerEvt() {
this.collapseFullscreen() this.collapseFullscreen()
@@ -917,7 +892,7 @@ export default {
--cover-image-height: 0px; --cover-image-height: 0px;
--cover-image-width-collapsed: 46px; --cover-image-width-collapsed: 46px;
--cover-image-height-collapsed: 46px; --cover-image-height-collapsed: 46px;
--title-author-left-offset-collapsed: 70px; --title-author-left-offset-collapsed: 80px;
} }
.playerContainer { .playerContainer {
@@ -944,12 +919,14 @@ export default {
.cover-wrapper { .cover-wrapper {
bottom: 68px; bottom: 68px;
left: 12px; left: 24px;
height: var(--cover-image-height-collapsed); height: var(--cover-image-height-collapsed);
width: var(--cover-image-width-collapsed); width: var(--cover-image-width-collapsed);
transition: all 0.25s cubic-bezier(0.39, 0.575, 0.565, 1); transition: all 0.25s cubic-bezier(0.39, 0.575, 0.565, 1);
transition-property: left, bottom, width, height; transition-property: left, bottom, width, height;
transform-origin: left bottom; transform-origin: left bottom;
border-radius: 3px;
overflow: hidden;
} }
.total-track { .total-track {
@@ -990,19 +967,17 @@ export default {
pointer-events: auto; pointer-events: auto;
} }
.fullscreen .title-author-texts .title-text { .fullscreen .title-author-texts .title-text {
font-size: clamp(0.8rem, calc(var(--cover-image-height) / 260 * 20), 1.5rem); font-size: clamp(0.8rem, calc(var(--cover-image-height) / 260 * 20), 1.3rem);
} }
.fullscreen .title-author-texts .author-text { .fullscreen .title-author-texts .author-text {
font-size: clamp(0.6rem, calc(var(--cover-image-height) / 260 * 16), 1.1rem); font-size: clamp(0.6rem, calc(var(--cover-image-height) / 260 * 16), 1rem);
} }
#playerControls { #playerControls {
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: width, bottom; transition-property: width, bottom;
height: 48px; width: 128px;
width: 140px; padding-right: 24px;
padding-left: 12px;
padding-right: 12px;
bottom: 70px; bottom: 70px;
} }
#playerControls .jump-icon { #playerControls .jump-icon {
@@ -1020,7 +995,7 @@ export default {
width: 40px; width: 40px;
min-width: 40px; min-width: 40px;
min-height: 40px; min-height: 40px;
margin: 0px 14px; margin: 0px 7px;
} }
#playerControls .play-btn .material-icons { #playerControls .play-btn .material-icons {
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);
@@ -1035,18 +1010,21 @@ export default {
width: var(--cover-image-width); width: var(--cover-image-width);
left: calc(50% - (calc(var(--cover-image-width)) / 2)); left: calc(50% - (calc(var(--cover-image-width)) / 2));
bottom: calc(50% + 120px - (calc(var(--cover-image-height)) / 2)); bottom: calc(50% + 120px - (calc(var(--cover-image-height)) / 2));
border-radius: 16px;
overflow: hidden;
} }
.fullscreen #playerControls { .fullscreen #playerControls {
width: 100%; width: 100%;
bottom: 94px; padding-left: 24px;
padding-right: 24px;
bottom: 78px;
left: 0;
} }
.fullscreen #playerControls .jump-icon { .fullscreen #playerControls .jump-icon {
margin: 0px 18px;
font-size: 2.4rem; font-size: 2.4rem;
} }
.fullscreen #playerControls .next-icon { .fullscreen #playerControls .next-icon {
margin: 0px 20px;
font-size: 2rem; font-size: 2rem;
} }
.fullscreen #playerControls .play-btn { .fullscreen #playerControls .play-btn {
@@ -1054,7 +1032,6 @@ export default {
width: 65px; width: 65px;
min-width: 65px; min-width: 65px;
min-height: 65px; min-height: 65px;
margin: 0px 26px;
} }
.fullscreen #playerControls .play-btn .material-icons { .fullscreen #playerControls .play-btn .material-icons {
font-size: 2.1rem; font-size: 2.1rem;
+9 -3
View File
@@ -110,7 +110,7 @@ export default {
}, },
streamProgress(data) { streamProgress(data) {
if (!data.numSegments) return if (!data.numSegments) return
var chunks = data.chunks const chunks = data.chunks
if (this.$refs.audioPlayer) { if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments) this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
} }
@@ -214,6 +214,8 @@ export default {
if (startTime !== undefined && startTime !== null) { if (startTime !== undefined && startTime !== null) {
// seek to start time // seek to start time
AbsAudioPlayer.seek({ value: Math.floor(startTime) }) AbsAudioPlayer.seek({ value: Math.floor(startTime) })
} else if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.play()
} }
return return
} }
@@ -263,8 +265,7 @@ export default {
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress) this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
}, },
onMediaPlayerChanged(data) { onMediaPlayerChanged(data) {
var mediaPlayer = data.value this.$store.commit('setMediaPlayer', data.value)
this.$store.commit('setMediaPlayer', mediaPlayer)
}, },
onReady() { onReady() {
// The UI is reporting elsewhere we are ready // The UI is reporting elsewhere we are ready
@@ -281,6 +282,9 @@ export default {
this.$store.commit('setIsFirstAudioLoad', false) // Only run this once on app launch this.$store.commit('setIsFirstAudioLoad', false) // Only run this once on app launch
AbsAudioPlayer.onReady() AbsAudioPlayer.onReady()
} }
},
playbackTimeUpdate(currentTime) {
this.$refs.audioPlayer?.seek(currentTime)
} }
}, },
mounted() { mounted() {
@@ -298,6 +302,7 @@ export default {
this.$eventBus.$on('close-stream', this.closeStreamOnly) this.$eventBus.$on('close-stream', this.closeStreamOnly)
this.$eventBus.$on('cast-local-item', this.castLocalItem) this.$eventBus.$on('cast-local-item', this.castLocalItem)
this.$eventBus.$on('user-settings', this.settingsUpdated) this.$eventBus.$on('user-settings', this.settingsUpdated)
this.$eventBus.$on('playback-time-update', this.playbackTimeUpdate)
}, },
beforeDestroy() { beforeDestroy() {
if (this.onLocalMediaProgressUpdateListener) this.onLocalMediaProgressUpdateListener.remove() if (this.onLocalMediaProgressUpdateListener) this.onLocalMediaProgressUpdateListener.remove()
@@ -311,6 +316,7 @@ export default {
this.$eventBus.$off('close-stream', this.closeStreamOnly) this.$eventBus.$off('close-stream', this.closeStreamOnly)
this.$eventBus.$off('cast-local-item', this.castLocalItem) this.$eventBus.$off('cast-local-item', this.castLocalItem)
this.$eventBus.$off('user-settings', this.settingsUpdated) this.$eventBus.$off('user-settings', this.settingsUpdated)
this.$eventBus.$off('playback-time-update', this.playbackTimeUpdate)
} }
} }
</script> </script>
+33 -1
View File
@@ -50,6 +50,9 @@ export default {
watch: { watch: {
showBookshelfListView(newVal) { showBookshelfListView(newVal) {
this.resetEntities() this.resetEntities()
},
seriesId() {
this.resetEntities()
} }
}, },
computed: { computed: {
@@ -85,6 +88,12 @@ export default {
filterBy() { filterBy() {
return this.$store.getters['user/getUserSetting']('mobileFilterBy') return this.$store.getters['user/getUserSetting']('mobileFilterBy')
}, },
collapseSeries() {
return this.$store.getters['user/getUserSetting']('collapseSeries')
},
collapseBookSeries() {
return this.$store.getters['user/getUserSetting']('collapseBookSeries')
},
isCoverSquareAspectRatio() { isCoverSquareAspectRatio() {
return this.bookCoverAspectRatio === 1 return this.bookCoverAspectRatio === 1
}, },
@@ -155,7 +164,7 @@ export default {
const entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `items` : this.entityName const entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `items` : this.entityName
const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : '' const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1` const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1&include=rssfeed`
const payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => { const payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
console.error('failed to fetch books', error) console.error('failed to fetch books', error)
@@ -356,6 +365,9 @@ 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)}`)
if (this.collapseBookSeries) {
searchParams.set('collapseseries', 1)
}
} else { } else {
if (this.filterBy && this.filterBy !== 'all') { if (this.filterBy && this.filterBy !== 'all') {
searchParams.set('filter', this.filterBy) searchParams.set('filter', this.filterBy)
@@ -452,6 +464,12 @@ export default {
this.libraryItemUpdated(ab) this.libraryItemUpdated(ab)
}) })
}, },
screenOrientationChange() {
setTimeout(() => {
console.log('LazyBookshelf Screen orientation change')
this.resetEntities()
}, 50)
},
initListeners() { initListeners() {
const bookshelf = document.getElementById('bookshelf-wrapper') const bookshelf = document.getElementById('bookshelf-wrapper')
if (bookshelf) { if (bookshelf) {
@@ -466,6 +484,13 @@ export default {
this.$socket.$on('item_removed', this.libraryItemRemoved) this.$socket.$on('item_removed', this.libraryItemRemoved)
this.$socket.$on('items_updated', this.libraryItemsUpdated) this.$socket.$on('items_updated', this.libraryItemsUpdated)
this.$socket.$on('items_added', this.libraryItemsAdded) this.$socket.$on('items_added', this.libraryItemsAdded)
if (screen.orientation) {
// Not available on ios
screen.orientation.addEventListener('change', this.screenOrientationChange)
} else {
document.addEventListener('orientationchange', this.screenOrientationChange)
}
}, },
removeListeners() { removeListeners() {
const bookshelf = document.getElementById('bookshelf-wrapper') const bookshelf = document.getElementById('bookshelf-wrapper')
@@ -481,6 +506,13 @@ export default {
this.$socket.$off('item_removed', this.libraryItemRemoved) this.$socket.$off('item_removed', this.libraryItemRemoved)
this.$socket.$off('items_updated', this.libraryItemsUpdated) this.$socket.$off('items_updated', this.libraryItemsUpdated)
this.$socket.$off('items_added', this.libraryItemsAdded) this.$socket.$off('items_added', this.libraryItemsAdded)
if (screen.orientation) {
// Not available on ios
screen.orientation.removeEventListener('change', this.screenOrientationChange)
} else {
document.removeEventListener('orientationchange', this.screenOrientationChange)
}
} }
}, },
updated() { updated() {
-20
View File
@@ -7,26 +7,6 @@
<p class="truncate text-sm">{{ name }}</p> <p class="truncate text-sm">{{ name }}</p>
</div> </div>
</div> </div>
<!-- <div class="flex h-full px-1 overflow-hidden shadow-sm">
<div style="max-height: 48px; max-width: 48px" class="w-12 h-12 bg-primary overflow-hidden rounded">
<svg width="140%" height="140%" style="margin-left: -20%; margin-top: -20%; opacity: 0.6" viewBox="0 0 177 266" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="white" d="M40.7156 165.47C10.2694 150.865 -31.5407 148.629 -38.0532 155.529L63.3191 204.159L76.9443 190.899C66.828 181.394 54.006 171.846 40.7156 165.47Z" stroke="white" stroke-width="4" transform="translate(-2 -1)" />
<path d="M-38.0532 155.529C-31.5407 148.629 10.2694 150.865 40.7156 165.47C54.006 171.846 66.828 181.394 76.9443 190.899L95.0391 173.37C80.6681 159.403 64.7526 149.155 51.5747 142.834C21.3549 128.337 -46.2471 114.563 -60.6897 144.67L-71.5489 167.307L44.5864 223.019L63.3191 204.159L-38.0532 155.529Z" fill="white" />
<path
d="M105.87 29.6508C80.857 17.6515 50.8784 28.1923 38.879 53.2056C26.8797 78.219 37.4205 108.198 62.4338 120.197C87.4472 132.196 117.426 121.656 129.425 96.6422C141.425 71.6288 130.884 41.6502 105.87 29.6508ZM106.789 85.783C112.761 73.3329 107.461 58.2599 95.0112 52.2874C82.5611 46.3148 67.4881 51.6147 61.5156 64.0648C55.543 76.5149 60.8429 91.5879 73.293 97.5604C85.7431 103.533 100.816 98.2331 106.789 85.783Z"
fill="white"
/>
<path
d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01ZM181.725 108.497C179.624 108.491 177.436 109.326 175.835 110.918L160.415 126.257L191.848 157.856L207.268 142.517C210.554 139.248 210.568 133.954 207.299 130.667L187.685 110.95C186.009 109.264 183.91 108.502 181.725 108.497ZM151.399 135.226L58.2034 227.931L58.1203 259.447L89.6359 259.53L182.831 166.825L151.399 135.226Z"
fill="white"
/>
<path d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01Z" fill="white" stroke="white" stroke-width="10px" />
</svg>
</div>
<div class="flex-grow px-2 authorSearchCardContent h-full">
<p class="truncate text-sm">{{ author }}</p>
</div>
</div> -->
</template> </template>
<script> <script>
+4 -1
View File
@@ -10,7 +10,7 @@
<p v-if="matchKey !== 'authors'" class="text-xs text-gray-200 truncate">by {{ authorName }}</p> <p v-if="matchKey !== 'authors'" class="text-xs text-gray-200 truncate">by {{ authorName }}</p>
<p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" /> <p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" /> <div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin' || matchKey === 'episode' || matchKey === 'narrators'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
</div> </div>
</div> </div>
</template> </template>
@@ -78,11 +78,14 @@ export default {
var lastPart = this.matchText.substr(totalLenSoFar) var lastPart = this.matchText.substr(totalLenSoFar)
html += lastPart html += lastPart
if (this.matchKey === 'episode') return `<p class="truncate">Episode: ${html}</p>`
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>` if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
if (this.matchKey === 'subtitle') return `<p class="truncate">${html}</p>`
if (this.matchKey === 'authors') return `by ${html}` if (this.matchKey === 'authors') return `by ${html}`
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>` if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`
if (this.matchKey === 'asin') return `<p class="truncate">ASIN: ${html}</p>` if (this.matchKey === 'asin') return `<p class="truncate">ASIN: ${html}</p>`
if (this.matchKey === 'series') return `<p class="truncate">Series: ${html}</p>` if (this.matchKey === 'series') return `<p class="truncate">Series: ${html}</p>`
if (this.matchKey === 'narrators') return `<p class="truncate">Narrator: ${html}</p>`
return `${html}` return `${html}`
} }
}, },
+83 -70
View File
@@ -10,11 +10,16 @@
<p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }"> <p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
{{ displayTitle }} {{ displayTitle }}
</p> </p>
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayAuthor || '&nbsp;' }}</p> <p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayLineTwo || '&nbsp;' }}</p>
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p> <p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
</div> </div>
<div v-if="booksInSeries" class="absolute z-20 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ booksInSeries }}</div> <div v-if="seriesSequenceList" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20 text-right" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }" style="background-color: #78350f">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequenceList }}</p>
</div>
<div v-else-if="booksInSeries" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }" style="background-color: #cd9d49dd">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ booksInSeries }}</p>
</div>
<div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10"> <div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10">
<div v-show="libraryItem && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: sizeMultiplier * 0.5 + 'rem' }"> <div v-show="libraryItem && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: sizeMultiplier * 0.5 + 'rem' }">
@@ -35,13 +40,14 @@
</div> </div>
<!-- Play/pause button for podcast episode --> <!-- Play/pause button for podcast episode -->
<div v-if="recentEpisode" class="absolute z-10 top-0 left-0 bottom-0 right-0 m-auto flex items-center justify-center w-12 h-12 rounded-full bg-white bg-opacity-70"> <div v-if="recentEpisode" class="absolute z-10 top-0 left-0 bottom-0 right-0 m-auto flex items-center justify-center w-12 h-12 rounded-full bg-white bg-opacity-70" @click.stop="playEpisode">
<span class="material-icons text-6xl text-black text-opacity-80">{{ streamIsPlaying ? 'pause_circle' : 'play_circle_filled' }}</span> <span class="material-icons text-6xl text-black text-opacity-80">{{ streamIsPlaying ? 'pause_circle' : 'play_circle_filled' }}</span>
</div> </div>
<!-- No progress shown for collapsed series in library --> <!-- No progress shown for collapsed series in library -->
<div v-if="!collapsedSeries && (!isPodcast || recentEpisode)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div> <div v-if="!collapsedSeries && (!isPodcast || recentEpisode)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
<!-- Downloaded icon -->
<div v-if="showHasLocalDownload" class="absolute right-0 top-0 z-20" :style="{ top: (isPodcast ? 1.75 : 0.375) * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }"> <div v-if="showHasLocalDownload" class="absolute right-0 top-0 z-20" :style="{ top: (isPodcast ? 1.75 : 0.375) * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<span class="material-icons text-2xl text-success">{{ isLocalOnly ? 'task' : 'download_done' }}</span> <span class="material-icons text-2xl text-success">{{ isLocalOnly ? 'task' : 'download_done' }}</span>
</div> </div>
@@ -51,14 +57,21 @@
<span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span> <span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span>
</div> </div>
<!-- rss feed icon -->
<div v-if="rssFeed" class="absolute text-success top-0 left-0 z-10" :style="{ padding: 0.375 * sizeMultiplier + 'rem' }">
<span class="material-icons" :style="{ fontSize: sizeMultiplier * 1.5 + 'rem' }">rss_feed</span>
</div>
<!-- Series sequence --> <!-- Series sequence -->
<div v-if="seriesSequence && showSequence && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }"> <div v-if="seriesSequence && showSequence && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequence }}</p> <p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequence }}</p>
</div> </div>
<!-- Podcast Episode # --> <!-- Podcast Episode # -->
<div v-if="recentEpisodeNumber && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }"> <div v-if="recentEpisodeNumber !== null && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">Episode #{{ recentEpisodeNumber }}</p> <p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">
Episode<span v-if="recentEpisodeNumber"> #{{ recentEpisodeNumber }}</span>
</p>
</div> </div>
<!-- Podcast Num Episodes --> <!-- Podcast Num Episodes -->
@@ -205,7 +218,7 @@ export default {
return this.mediaMetadata.series return this.mediaMetadata.series
}, },
seriesSequence() { seriesSequence() {
return this.series ? this.series.sequence : null return this.series?.sequence || null
}, },
recentEpisode() { recentEpisode() {
// Only added to item when getting currently listening podcasts // Only added to item when getting currently listening podcasts
@@ -216,7 +229,7 @@ export default {
if (this.recentEpisode.episode) { if (this.recentEpisode.episode) {
return this.recentEpisode.episode.replace(/^#/, '') return this.recentEpisode.episode.replace(/^#/, '')
} }
return this.recentEpisode.index return ''
}, },
collapsedSeries() { collapsedSeries() {
// Only added to item object when collapseSeries is enabled // Only added to item object when collapseSeries is enabled
@@ -224,24 +237,38 @@ export default {
}, },
booksInSeries() { booksInSeries() {
// Only added to item object when collapseSeries is enabled // Only added to item object when collapseSeries is enabled
return this.collapsedSeries ? this.collapsedSeries.numBooks : 0 return this.collapsedSeries?.numBooks || 0
},
seriesSequenceList() {
return this.collapsedSeries?.seriesSequenceList || null
},
libraryItemIdsInSeries() {
// Only added to item object when collapseSeries is enabled
return this.collapsedSeries?.libraryItemIds || []
}, },
displayTitle() { displayTitle() {
if (this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix && this.title.toLowerCase().startsWith('the ')) { if (this.recentEpisode) return this.recentEpisode.title
return this.title.substr(4) + ', The'
} const ignorePrefix = this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix
return this.title if (this.collapsedSeries) return ignorePrefix ? this.collapsedSeries.nameIgnorePrefix : this.collapsedSeries.name
return ignorePrefix ? this.mediaMetadata.titleIgnorePrefix : this.title
}, },
displayAuthor() { displayLineTwo() {
if (this.recentEpisode) return this.title
if (this.collapsedSeries) return ''
if (this.isPodcast) return this.author
if (this.orderBy === 'media.metadata.authorNameLF') return this.authorLF if (this.orderBy === 'media.metadata.authorNameLF') return this.authorLF
return this.author return this.author
}, },
displaySortLine() { displaySortLine() {
if (this.collapsedSeries) return null
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs) if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs)
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs) if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs)
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt) if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt)
if (this.orderBy === 'media.duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false) if (this.orderBy === 'media.duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size) if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
if (this.orderBy === 'media.numTracks') return `${this.numEpisodes} Episodes`
return null return null
}, },
episodeProgress() { episodeProgress() {
@@ -255,29 +282,32 @@ export default {
if (this.isLocal) return this.store.getters['globals/getLocalMediaProgressById'](this.libraryItemId) if (this.isLocal) return this.store.getters['globals/getLocalMediaProgressById'](this.libraryItemId)
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId) return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
}, },
useEBookProgress() {
if (!this.userProgress || this.userProgress.progress) return false
return this.userProgress.ebookProgress > 0
},
userProgressPercent() { userProgressPercent() {
return this.userProgress ? this.userProgress.progress || 0 : 0 if (this.useEBookProgress) return Math.max(Math.min(1, this.userProgress.ebookProgress), 0)
return Math.max(Math.min(1, this.userProgress?.progress || 0), 0) || 0
}, },
itemIsFinished() { itemIsFinished() {
return this.userProgress ? !!this.userProgress.isFinished : false return !!this.userProgress?.isFinished
}, },
showError() { showError() {
return this.numMissingParts || this.isMissing || this.isInvalid return this.numMissingParts || this.isMissing || this.isInvalid
}, },
playerIsLocal() {
return !!this.$store.state.playerIsLocal
},
localLibraryItemId() { localLibraryItemId() {
if (this.isLocal) return this.libraryItemId if (this.isLocal) return this.libraryItemId
return this.localLibraryItem ? this.localLibraryItem.id : null return this.localLibraryItem?.id || null
},
localEpisode() {
if (!this.recentEpisode || !this.localLibraryItem) return null
// Current recentEpisode is only implemented server side so this will always be the serverEpisodeId
return this.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId === this.recentEpisode.id)
}, },
isStreaming() { isStreaming() {
if (this.isPodcast) { if (this.isPodcast) {
if (this.playerIsLocal) { return this.$store.getters['getIsMediaStreaming'](this.libraryItemId, this.recentEpisode.id)
// Check is streaming local version of this episode
return false // episode cards not implemented for local yet
}
return this.$store.getters['getIsEpisodeStreaming'](this.libraryItemId, this.recentEpisode.id)
} else { } else {
return false // not yet necessary for books return false // not yet necessary for books
} }
@@ -353,13 +383,16 @@ export default {
showHasLocalDownload() { showHasLocalDownload() {
if (this.localLibraryItem || this.isLocal) { if (this.localLibraryItem || this.isLocal) {
if (this.recentEpisode && !this.isLocal) { if (this.recentEpisode && !this.isLocal) {
const localEpisode = this.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId === this.recentEpisode.id) return !!this.localEpisode
return !!localEpisode
} else { } else {
return true return true
} }
} }
return false return false
},
rssFeed() {
if (this.booksInSeries) return null
return this._libraryItem.rssFeed || null
} }
}, },
methods: { methods: {
@@ -401,38 +434,37 @@ export default {
// Server books may have a local library item // Server books may have a local library item
this.localLibraryItem = localLibraryItem this.localLibraryItem = localLibraryItem
}, },
async playEpisode() {
await this.$hapticsImpact()
const eventBus = this.$eventBus || this.$nuxt.$eventBus
if (this.streamIsPlaying) {
eventBus.$emit('pause-item')
return
}
if (this.localEpisode) {
// Play episode locally
eventBus.$emit('play-item', {
libraryItemId: this.localLibraryItemId,
episodeId: this.localEpisode.id,
serverLibraryItemId: this.libraryItemId,
serverEpisodeId: this.recentEpisode.id
})
return
}
eventBus.$emit('play-item', { libraryItemId: this.libraryItemId, episodeId: this.recentEpisode.id })
},
async 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) {
await this.$hapticsImpact()
var eventBus = this.$eventBus || this.$nuxt.$eventBus
if (this.streamIsPlaying) {
eventBus.$emit('pause-item')
return
}
if (this.localLibraryItem) {
const localEpisode = this.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId === this.recentEpisode.id)
if (localEpisode) {
// Play episode locally
eventBus.$emit('play-item', {
libraryItemId: this.localLibraryItemId,
episodeId: localEpisode.id,
serverLibraryItemId: this.libraryItemId,
serverEpisodeId: this.recentEpisode.id
})
return
}
}
eventBus.$emit('play-item', { libraryItemId: this.libraryItemId, episodeId: this.recentEpisode.id })
} else { } else {
var router = this.$router || this.$nuxt.$router const router = this.$router || this.$nuxt.$router
if (router) { if (router) {
if (this.collapsedSeries) router.push(`/library/${this.libraryId}/series/${this.collapsedSeries.id}`) if (this.recentEpisode) router.push(`/item/${this.libraryItemId}/${this.recentEpisode.id}`)
else if (this.collapsedSeries) router.push(`/bookshelf/series/${this.collapsedSeries.id}`)
else router.push(`/item/${this.libraryItemId}`) else router.push(`/item/${this.libraryItemId}`)
} }
} }
@@ -440,25 +472,6 @@ export default {
editClick() { editClick() {
this.$emit('edit', this.libraryItem) this.$emit('edit', this.libraryItem)
}, },
toggleFinished() {
var updatePayload = {
isFinished: !this.itemIsFinished
}
this.isProcessingReadUpdate = true
var toast = this.$toast || this.$nuxt.$toast
var axios = this.$axios || this.$nuxt.$axios
axios
.$patch(`/api/me/progress/${this.libraryItemId}`, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
})
.catch((error) => {
console.error('Failed', error)
this.isProcessingReadUpdate = false
toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
})
},
rescan() { rescan() {
this.rescanning = true this.rescanning = true
this.$axios this.$axios
-19
View File
@@ -349,25 +349,6 @@ export default {
editClick() { editClick() {
this.$emit('edit', this.libraryItem) this.$emit('edit', this.libraryItem)
}, },
toggleFinished() {
var updatePayload = {
isFinished: !this.itemIsFinished
}
this.isProcessingReadUpdate = true
var toast = this.$toast || this.$nuxt.$toast
var axios = this.$axios || this.$nuxt.$axios
axios
.$patch(`/api/me/progress/${this.libraryItemId}`, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
})
.catch((error) => {
console.error('Failed', error)
this.isProcessingReadUpdate = false
toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
})
},
rescan() { rescan() {
this.rescanning = true this.rescanning = true
this.$axios this.$axios
+23
View File
@@ -5,6 +5,8 @@
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="title" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="title" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div> </div>
<div v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b w-full" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" />
<div v-if="isAltViewEnabled && isCategorized" class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center"> <div v-if="isAltViewEnabled && isCategorized" class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p> <p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
</div> </div>
@@ -53,6 +55,27 @@ export default {
books() { books() {
return this.series ? this.series.books || [] : [] return this.series ? this.series.books || [] : []
}, },
seriesBookProgress() {
return this.books
.map((libraryItem) => {
return this.store.getters['user/getUserMediaProgress'](libraryItem.id)
})
.filter((p) => !!p)
},
seriesBooksFinished() {
return this.seriesBookProgress.filter((p) => p.isFinished)
},
hasSeriesBookInProgress() {
return this.seriesBookProgress.some((p) => !p.isFinished && p.progress > 0)
},
seriesPercentInProgress() {
let totalFinishedAndInProgress = this.seriesBooksFinished.length
if (this.hasSeriesBookInProgress) totalFinishedAndInProgress += 1
return Math.min(1, Math.max(0, totalFinishedAndInProgress / this.books.length))
},
isSeriesFinished() {
return this.books.length === this.seriesBooksFinished.length
},
store() { store() {
return this.$store || this.$nuxt.$store return this.$store || this.$nuxt.$store
}, },
+34
View File
@@ -0,0 +1,34 @@
<template>
<div class="flex h-full px-1 overflow-hidden">
<div class="w-10 h-10 flex items-center justify-center">
<span class="material-icons text-2xl text-gray-200">record_voice_over</span>
</div>
<div class="flex-grow px-2 narratorSearchCardContent h-full">
<p class="truncate text-sm">{{ narrator }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
narrator: String
},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
<style scoped>
.narratorSearchCardContent {
width: calc(100% - 40px);
height: 40px;
display: flex;
flex-direction: column;
justify-content: center;
}
</style>
+1 -1
View File
@@ -70,7 +70,7 @@ export default {
return this.libraryItem.isLocal return this.libraryItem.isLocal
}, },
localCover() { localCover() {
return this.libraryItem ? this.libraryItem.coverContentUrl : null return this.libraryItem?.coverContentUrl || null
}, },
squareAspectRatio() { squareAspectRatio() {
return this.bookCoverAspectRatio === 1 return this.bookCoverAspectRatio === 1
+8 -8
View File
@@ -101,15 +101,15 @@ export default {
icon: 'collections_bookmark', icon: 'collections_bookmark',
iconClass: 'text-xl', iconClass: 'text-xl',
text: 'Collections' text: 'Collections'
},
{
to: '/bookshelf/authors',
routeName: 'bookshelf-authors',
iconPack: 'abs-icons',
icon: 'authors',
iconClass: 'text-2xl',
text: 'Authors'
} }
// {
// to: '/bookshelf/authors',
// routeName: 'bookshelf-authors',
// iconPack: 'abs-icons',
// icon: 'authors',
// iconClass: 'text-2xl pb-px',
// text: 'Authors'
// }
] ]
} }
+44 -5
View File
@@ -2,11 +2,8 @@
<div class="w-full h-9 bg-bg relative z-20"> <div class="w-full h-9 bg-bg relative z-20">
<div id="bookshelf-toolbar" class="absolute top-0 left-0 w-full h-full z-20 flex items-center px-2"> <div id="bookshelf-toolbar" class="absolute top-0 left-0 w-full h-full z-20 flex items-center px-2">
<div class="flex items-center w-full text-sm"> <div class="flex items-center w-full text-sm">
<nuxt-link to="/bookshelf/series" v-if="selectedSeriesName" class="pt-1">
<span class="material-icons">arrow_back</span>
</nuxt-link>
<p v-show="!selectedSeriesName" class="pt-1">{{ totalEntities }} {{ entityTitle }}</p> <p v-show="!selectedSeriesName" class="pt-1">{{ totalEntities }} {{ entityTitle }}</p>
<p v-show="selectedSeriesName" class="ml-2pt-1">{{ selectedSeriesName }} ({{ totalEntities }})</p> <p v-show="selectedSeriesName" class="ml-2 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="changeView">{{ !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'">
@@ -16,11 +13,13 @@
</div> </div>
<span class="material-icons px-2" @click="showSortModal = true">sort</span> <span class="material-icons px-2" @click="showSortModal = true">sort</span>
</template> </template>
<span v-if="(page == 'library' && isBookLibrary) || seriesBookPage" class="material-icons px-2" @click="showMoreMenuDialog = true">more_vert</span>
</div> </div>
</div> </div>
<modals-order-modal v-model="showSortModal" :order-by.sync="settings.mobileOrderBy" :descending.sync="settings.mobileOrderDesc" @change="updateOrder" /> <modals-order-modal v-model="showSortModal" :order-by.sync="settings.mobileOrderBy" :descending.sync="settings.mobileOrderDesc" @change="updateOrder" />
<modals-filter-modal v-model="showFilterModal" :filter-by.sync="settings.mobileFilterBy" @change="updateFilter" /> <modals-filter-modal v-model="showFilterModal" :filter-by.sync="settings.mobileFilterBy" @change="updateFilter" />
<modals-dialog v-model="showMoreMenuDialog" :items="menuItems" @action="clickMenuAction" />
</div> </div>
</template> </template>
@@ -31,7 +30,8 @@ export default {
showSortModal: false, showSortModal: false,
showFilterModal: false, showFilterModal: false,
settings: {}, settings: {},
totalEntities: 0 totalEntities: 0,
showMoreMenuDialog: false
} }
}, },
computed: { computed: {
@@ -44,6 +44,12 @@ export default {
this.$store.commit('globals/setBookshelfListView', val) this.$store.commit('globals/setBookshelfListView', val)
} }
}, },
currentLibraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
isBookLibrary() {
return this.currentLibraryMediaType === 'book'
},
hasFilters() { hasFilters() {
return this.$store.getters['user/getUserSetting']('mobileFilterBy') !== 'all' return this.$store.getters['user/getUserSetting']('mobileFilterBy') !== 'all'
}, },
@@ -66,6 +72,8 @@ export default {
return 'Collections' return 'Collections'
} else if (this.page === 'playlists') { } else if (this.page === 'playlists') {
return 'Playlists' return 'Playlists'
} else if (this.page === 'authors') {
return 'Authors'
} }
return '' return ''
}, },
@@ -77,9 +85,40 @@ export default {
}, },
isPodcast() { isPodcast() {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] === 'podcast' return this.$store.getters['libraries/getCurrentLibraryMediaType'] === 'podcast'
},
menuItems() {
if (!this.isBookLibrary) return []
if (this.seriesBookPage) {
return [
{
text: 'Collapse Sub-Series',
value: 'collapse_subseries',
icon: this.settings.collapseBookSeries ? 'check_box' : 'check_box_outline_blank'
}
]
} else {
return [
{
text: 'Collapse Series',
value: 'collapse_series',
icon: this.settings.collapseSeries ? 'check_box' : 'check_box_outline_blank'
}
]
}
} }
}, },
methods: { methods: {
clickMenuAction(action) {
this.showMoreMenuDialog = false
if (action === 'collapse_series') {
this.settings.collapseSeries = !this.settings.collapseSeries
this.saveSettings()
} else if (action === 'collapse_subseries') {
this.settings.collapseBookSeries = !this.settings.collapseBookSeries
this.saveSettings()
}
},
updateOrder() { updateOrder() {
this.saveSettings() this.saveSettings()
}, },
@@ -0,0 +1,95 @@
<template>
<modals-modal v-model="show" :width="200" height="100%">
<template #outer>
<div class="absolute top-8 left-4 z-40">
<p class="text-white text-2xl truncate">Auto Rewind Time</p>
</div>
</template>
<div
class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center"
@click="
show = false
manualTimerModal = false
"
>
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
<div v-if="manualTimerModal" class="p-4">
<div class="flex mb-4" @click="manualTimerModal = false">
<span class="material-icons text-3xl">arrow_back</span>
</div>
<div class="flex my-2 justify-between">
<ui-btn @click="decreaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">remove</span></ui-btn>
<p class="text-2xl font-mono text-center">{{ manualTimeoutMin }} min</p>
<ui-btn @click="increaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">add</span></ui-btn>
</div>
<ui-btn @click="clickedOption(manualTimeoutMin)" class="w-full">Set Timer</ui-btn>
</div>
<ul v-else class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="timeout in timeouts">
<li :key="timeout" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption(timeout)">
<div class="flex items-center justify-center">
<span class="font-normal block truncate text-lg">{{ timeout }} min</span>
</div>
</li>
</template>
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="manualTimerModal = true">
<div class="flex items-center justify-center">
<span class="font-normal block truncate text-lg text-center">Custom time</span>
</div>
</li>
</ul>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean
},
data() {
return {
manualTimerModal: null,
manualTimeoutMin: 1
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
timeouts() {
return [5, 10, 15, 30, 45, 60, 90]
}
},
methods: {
async clickedChapterOption() {
await this.$hapticsImpact()
this.show = false
this.$nextTick(() => this.$emit('change', 0))
},
async clickedOption(timeoutMin) {
await this.$hapticsImpact()
const timeout = timeoutMin * 1000 * 60
this.show = false
this.manualTimerModal = false
this.$nextTick(() => this.$emit('change', timeout))
},
async increaseManualTimeout() {
await this.$hapticsImpact()
this.manualTimeoutMin++
},
async decreaseManualTimeout() {
await this.$hapticsImpact()
if (this.manualTimeoutMin > 1) this.manualTimeoutMin--
}
},
mounted() {}
}
</script>
+1 -1
View File
@@ -1,7 +1,7 @@
<template> <template>
<modals-modal v-model="show" :width="300" height="100%"> <modals-modal v-model="show" :width="300" height="100%">
<template #outer> <template #outer>
<div class="absolute top-5 left-4 z-40"> <div class="absolute top-8 left-4 z-40">
<p class="text-white text-2xl truncate">Bookmarks</p> <p class="text-white text-2xl truncate">Bookmarks</p>
</div> </div>
</template> </template>
+21 -14
View File
@@ -1,20 +1,20 @@
<template> <template>
<modals-modal v-model="show" :width="350" height="100%"> <modals-modal v-model="show" :width="400" height="100%">
<template #outer> <template #outer>
<div v-if="currentChapter" class="absolute top-7 left-4 z-40" style="max-width: 80%"> <div v-if="currentChapter" class="absolute top-10 left-4 z-40 pt-1" style="max-width: 80%">
<p class="text-white text-lg truncate">Current: {{ currentChapterTitle }}</p> <p class="text-white text-lg truncate">{{ chapters.length }} Chapters</p>
</div> </div>
</template> </template>
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false"> <div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop> <div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-secondary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label"> <ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="(chapter, index) in chapters"> <template v-for="(chapter, index) in chapters">
<li :key="chapter.id" :id="`chapter-row-${chapter.id}`" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="currentChapterId === chapter.id ? 'bg-bg bg-opacity-80' : ''" role="option" @click="clickedOption(chapter)"> <li :key="chapter.id" :id="`chapter-row-${chapter.id}`" class="text-gray-50 select-none relative py-4 cursor-pointer" :class="currentChapterId === chapter.id ? 'bg-primary bg-opacity-80' : ''" role="option" @click="clickedOption(chapter)">
<div class="relative flex items-center pl-3 pr-20"> <div class="relative flex items-center pl-3 pr-20">
<p class="font-normal block truncate text-sm text-white text-opacity-80">{{ index + 1 }} - {{ chapter.title }}</p> <p class="font-normal block truncate text-sm text-white text-opacity-80">{{ index + 1 }} - {{ chapter.title }}</p>
<div class="absolute top-0 right-3 -mt-0.5"> <div class="absolute top-0 right-3 -mt-0.5">
<span class="font-mono text-white text-opacity-90 leading-3 text-sm" style="letter-spacing: -0.5px">{{ $secondsToTimestamp(chapter.start) }}</span> <span class="font-mono text-white text-opacity-90 leading-3 text-sm" style="letter-spacing: -0.5px">{{ $secondsToTimestamp(chapter.start / _playbackRate) }}</span>
</div> </div>
</div> </div>
@@ -38,14 +38,17 @@ export default {
currentChapter: { currentChapter: {
type: Object, type: Object,
default: () => null default: () => null
} },
playbackRate: Number
}, },
data() { data() {
return {} return {}
}, },
watch: { watch: {
value(newVal) { value(newVal) {
this.$nextTick(this.scrollToChapter) if (newVal) {
this.$nextTick(this.scrollToChapter)
}
} }
}, },
computed: { computed: {
@@ -57,11 +60,15 @@ export default {
this.$emit('input', val) this.$emit('input', val)
} }
}, },
_playbackRate() {
if (!this.playbackRate || isNaN(this.playbackRate)) return 1
return this.playbackRate
},
currentChapterId() { currentChapterId() {
return this.currentChapter ? this.currentChapter.id : null return this.currentChapter?.id
}, },
currentChapterTitle() { currentChapterTitle() {
return this.currentChapter ? this.currentChapter.title : null return this.currentChapter?.title || null
} }
}, },
methods: { methods: {
@@ -71,12 +78,12 @@ export default {
scrollToChapter() { scrollToChapter() {
if (!this.currentChapterId) return if (!this.currentChapterId) return
var container = this.$refs.container const container = this.$refs.container
if (container) { if (container) {
var currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`) const currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
if (currChapterEl) { if (currChapterEl) {
var offsetTop = currChapterEl.offsetTop const offsetTop = currChapterEl.offsetTop
var containerHeight = container.clientHeight const containerHeight = container.clientHeight
container.scrollTo({ top: offsetTop - containerHeight / 2 }) container.scrollTo({ top: offsetTop - containerHeight / 2 })
} }
} }
+44 -7
View File
@@ -1,17 +1,17 @@
<template> <template>
<modals-modal v-model="show" :width="300" height="100%"> <modals-modal v-model="show" :width="width" height="100%">
<template #outer> <template #outer>
<div v-if="title" class="absolute top-7 left-4 z-40" style="max-width: 80%"> <div v-if="title" class="absolute top-8 left-4 z-40" style="max-width: 80%">
<p class="text-white text-lg truncate">{{ title }}</p> <p class="text-white text-xl truncate">{{ title }}</p>
</div> </div>
</template> </template>
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false"> <div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20 p-2" style="max-height: 75%" @click.stop> <div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white/20 p-2" style="max-height: 75%" @click.stop>
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label"> <ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items"> <template v-for="item in itemsToShow">
<slot :name="item.value" :item="item" :selected="item.value === selected"> <slot :name="item.value" :item="item" :selected="item.value === selected">
<li :key="item.value" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" role="option" @click="clickedOption(item.value)"> <li :key="item.value" :ref="`item-${item.value}`" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" :style="{ paddingTop: itemPaddingY, paddingBottom: itemPaddingY }" role="option" @click="clickedOption(item.value)">
<div class="relative flex items-center px-3"> <div class="relative flex items-center px-3">
<span v-if="item.icon" class="material-icons-outlined text-xl mr-2 text-white text-opacity-80">{{ item.icon }}</span> <span v-if="item.icon" class="material-icons-outlined text-xl mr-2 text-white text-opacity-80">{{ item.icon }}</span>
<p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p> <p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p>
@@ -34,11 +34,27 @@ export default {
type: Array, type: Array,
default: () => [] default: () => []
}, },
selected: String // optional selected: [String, Number], // optional
itemPaddingY: {
type: String,
default: '16px'
},
width: {
type: Number,
default: 300
}
}, },
data() { data() {
return {} return {}
}, },
watch: {
show: {
immediate: true,
handler(newVal) {
if (newVal) this.$nextTick(this.init)
}
}
},
computed: { computed: {
show: { show: {
get() { get() {
@@ -47,11 +63,32 @@ export default {
set(val) { set(val) {
this.$emit('input', val) this.$emit('input', val)
} }
},
itemsToShow() {
return this.items.map((i) => {
if (typeof i === 'string') {
return {
text: i,
value: i
}
}
return i
})
} }
}, },
methods: { methods: {
clickedOption(action) { clickedOption(action) {
this.$emit('action', action) this.$emit('action', action)
},
init() {
if (this.selected && this.$refs[`item-${this.selected}`]?.[0]) {
// Set scroll position so that selected item is in the center
const containerOffset = this.$refs.container.offsetTop + this.$refs.container.clientHeight / 2
const scrollAmount = this.$refs[`item-${this.selected}`][0].offsetTop - containerOffset
this.$refs.container.scrollTo({
top: scrollAmount
})
}
} }
}, },
mounted() {} mounted() {}
+1 -1
View File
@@ -1,7 +1,7 @@
<template> <template>
<modals-modal v-model="show" width="90%" height="100%"> <modals-modal v-model="show" width="90%" height="100%">
<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-10 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>
</div> </div>
</template> </template>
+77
View File
@@ -0,0 +1,77 @@
<template>
<div ref="wrapper" class="modal modal-bg w-screen fixed bottom-0 left-0 flex items-center justify-center z-50" :class="halfScreen ? 'h-[50vh]' : 'h-screen'" @click.stop @touchstart.stop @touchend.stop>
<div ref="content" class="relative text-white h-full w-full bg-bg">
<slot />
</div>
</div>
</template>
<script>
export default {
props: {
value: Boolean,
processing: Boolean,
halfScreen: Boolean
},
data() {
return {
el: null,
content: null
}
},
watch: {
show(newVal) {
if (newVal) {
this.setShow()
} else {
this.setHide()
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
}
},
methods: {
setShow() {
this.$store.commit('globals/setIsModalOpen', true)
document.body.appendChild(this.el)
setTimeout(() => {
this.content.style.transform = 'translateY(0)'
}, 10)
document.documentElement.classList.add('modal-open')
},
setHide() {
this.$store.commit('globals/setIsModalOpen', false)
this.content.style.transform = 'translateY(100vh)'
setTimeout(() => {
this.el.remove()
document.documentElement.classList.remove('modal-open')
}, 250)
},
closeModalEvt() {
console.log('Close modal event')
this.show = false
}
},
mounted() {
this.$eventBus.$on('close-modal', this.closeModalEvt)
this.el = this.$refs.wrapper
this.content = this.$refs.content
this.content.style.transform = 'translateY(100vh)'
this.content.style.transition = 'transform 0.25s cubic-bezier(0.16, 1, 0.3, 1)'
this.el.remove()
},
beforeDestroy() {
this.$eventBus.$off('close-modal', this.closeModalEvt)
}
}
</script>
+8 -2
View File
@@ -1,14 +1,17 @@
<template> <template>
<modals-modal v-model="show" :width="400" height="100%"> <modals-modal v-model="show" :width="400" height="100%">
<template #outer> <template #outer>
<div class="absolute top-5 left-4 z-40"> <div class="absolute top-8 left-4 z-40">
<p class="text-white text-2xl truncate">Details</p> <p class="text-white text-2xl truncate">Details</p>
</div> </div>
</template> </template>
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false"> <div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20 p-2" style="max-height: 75%" @click.stop> <div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20 p-2" style="max-height: 75%" @click.stop>
<p class="mb-1">{{ mediaMetadata.title }}</p> <p class="mb-2">{{ mediaMetadata.title }}</p>
<div v-if="size" class="text-sm mb-2">Size: {{ $bytesPretty(size) }}</div>
<p class="mb-1 text-xs text-gray-200">ID: {{ _libraryItem.id }}</p> <p class="mb-1 text-xs text-gray-200">ID: {{ _libraryItem.id }}</p>
</div> </div>
</div> </div>
@@ -44,6 +47,9 @@ export default {
}, },
mediaMetadata() { mediaMetadata() {
return this.media.metadata || {} return this.media.metadata || {}
},
size() {
return this.media.size
} }
}, },
methods: {}, methods: {},
+3 -3
View File
@@ -1,16 +1,16 @@
<template> <template>
<modals-modal v-model="show" :width="300" :processing="processing" height="100%"> <modals-modal v-model="show" :width="300" :processing="processing" height="100%">
<template #outer> <template #outer>
<div class="absolute top-4 left-4 z-40" style="max-width: 80%"> <div class="absolute top-8 left-4 z-40" style="max-width: 80%">
<p class="text-white text-2xl truncate">Libraries</p> <p class="text-white text-2xl truncate">Libraries</p>
</div> </div>
</template> </template>
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false"> <div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop> <div class="w-full overflow-x-hidden overflow-y-auto bg-secondary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label"> <ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="library in libraries"> <template v-for="library in libraries">
<li :key="library.id" class="text-gray-50 select-none relative py-3 cursor-pointer hover:bg-black-400" :class="currentLibraryId === library.id ? 'bg-bg bg-opacity-80' : ''" role="option" @click="clickedOption(library)"> <li :key="library.id" class="text-gray-50 select-none relative py-3 cursor-pointer" :class="currentLibraryId === library.id ? 'bg-primary bg-opacity-80' : ''" role="option" @click="clickedOption(library)">
<div v-show="currentLibraryId === library.id" class="absolute top-0 left-0 w-0.5 bg-warning h-full" /> <div v-show="currentLibraryId === library.id" class="absolute top-0 left-0 w-0.5 bg-warning h-full" />
<div class="flex items-center px-3"> <div class="flex items-center px-3">
<ui-library-icon :icon="library.icon" /> <ui-library-icon :icon="library.icon" />
+3 -3
View File
@@ -2,7 +2,7 @@
<div ref="wrapper" class="modal modal-bg w-full h-full max-h-screen fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-50 opacity-0"> <div ref="wrapper" class="modal modal-bg w-full h-full max-h-screen fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-50 opacity-0">
<div class="absolute top-0 left-0 w-full h-40 bg-gradient-to-b from-black to-transparent opacity-90 pointer-events-none" /> <div class="absolute top-0 left-0 w-full h-40 bg-gradient-to-b from-black to-transparent opacity-90 pointer-events-none" />
<div class="absolute z-40 top-4 right-4 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="show = false"> <div class="absolute z-40 top-10 right-4 h-10 w-10 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="show = false">
<span class="material-icons text-4xl">close</span> <span class="material-icons text-4xl">close</span>
</div> </div>
<slot name="outer" /> <slot name="outer" />
@@ -70,9 +70,9 @@ export default {
} }
}, },
methods: { methods: {
clickBg(vm, ev) { clickBg(ev) {
if (this.processing && this.persistent) return if (this.processing && this.persistent) return
if (vm.srcElement.classList.contains('modal-bg')) { if (ev && ev.srcElement && ev.srcElement.classList && ev.srcElement.classList.contains('modal-bg')) {
this.show = false this.show = false
} }
}, },
+21 -1
View File
@@ -22,7 +22,8 @@ export default {
props: { props: {
value: Boolean, value: Boolean,
orderBy: String, orderBy: String,
descending: Boolean descending: Boolean,
episodes: Boolean
}, },
data() { data() {
return { return {
@@ -89,6 +90,24 @@ export default {
text: 'File Modified', text: 'File Modified',
value: 'mtimeMs' value: 'mtimeMs'
} }
],
episodeItems: [
{
text: 'Pub Date',
value: 'publishedAt'
},
{
text: 'Title',
value: 'title'
},
{
text: 'Season',
value: 'season'
},
{
text: 'Episode',
value: 'episode'
}
] ]
} }
}, },
@@ -121,6 +140,7 @@ export default {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] === 'podcast' return this.$store.getters['libraries/getCurrentLibraryMediaType'] === 'podcast'
}, },
items() { items() {
if (this.episodes) return this.episodeItems
if (this.isPodcast) return this.podcastItems if (this.isPodcast) return this.podcastItems
return this.bookItems return this.bookItems
} }
+1 -1
View File
@@ -1,7 +1,7 @@
<template> <template>
<modals-modal v-model="show" @input="modalInput" :width="200" height="100%"> <modals-modal v-model="show" @input="modalInput" :width="200" height="100%">
<template #outer> <template #outer>
<div class="absolute top-5 left-4 z-40"> <div class="absolute top-8 left-4 z-40">
<p class="text-white text-2xl truncate">Playback Speed</p> <p class="text-white text-2xl truncate">Playback Speed</p>
</div> </div>
</template> </template>
@@ -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%">
<template #outer> <template #outer>
<div class="absolute top-6 left-4 z-40"> <div class="absolute top-8 left-4 z-40">
<p class="text-white text-2xl truncate">Feed Episodes</p> <p class="text-white text-2xl truncate">Feed Episodes</p>
</div> </div>
</template> </template>
@@ -22,7 +22,7 @@
</div> </div>
</template> </template>
</div> </div>
<div class="absolute bottom-0 left-0 w-full flex items-center" style="height: 50px"> <div class="absolute bottom-6 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> <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>
</div> </div>
@@ -130,8 +130,8 @@ export default {
<style> <style>
.feed-content { .feed-content {
height: calc(100vh - 125px); height: calc(100vh - 150px);
max-height: calc(100vh - 125px); max-height: calc(100vh - 150px);
margin-top: 20px; margin-top: 5px;
} }
</style> </style>
+34 -13
View File
@@ -1,8 +1,8 @@
<template> <template>
<modals-modal v-model="show" :width="300" height="100%"> <modals-modal v-model="show" :width="300" height="100%">
<template #outer> <template #outer>
<div class="absolute top-7 left-4 z-40" style="max-width: 80%"> <div class="absolute top-10 left-4 z-40" style="max-width: 80%">
<p class="text-white text-lg truncate">Select Local Folder</p> <p class="text-white text-lg truncate">Select Download Location</p>
</div> </div>
</template> </template>
@@ -10,8 +10,9 @@
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop> <div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label"> <ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="folder in localFolders"> <template v-for="folder in localFolders">
<li :key="folder.id" :id="`folder-${folder.id}`" class="text-gray-50 select-none relative py-4" role="option" @click="clickedOption(folder)"> <li :key="folder.id" :id="`folder-${folder.id}`" class="text-gray-50 select-none relative py-5" role="option" @click="clickedOption(folder)">
<div class="relative flex items-center pl-3" style="padding-right: 4.5rem"> <div class="relative flex items-center pl-3" style="padding-right: 4.5rem">
<span class="material-icons-outlined text-xl mr-2 text-white text-opacity-80">folder</span>
<p class="font-normal block truncate text-sm text-white text-opacity-80">{{ folder.name }}</p> <p class="font-normal block truncate text-sm text-white text-opacity-80">{{ folder.name }}</p>
</div> </div>
</li> </li>
@@ -24,36 +25,56 @@
<script> <script>
export default { export default {
props: {
value: Boolean,
mediaType: String
},
data() { data() {
return { return {
localFolders: [] localFolders: []
} }
}, },
watch: { watch: {
value(newVal) { show(newVal) {
this.$nextTick(this.init) if (newVal) {
this.$nextTick(this.init)
}
} }
}, },
computed: { computed: {
show: { show: {
get() { get() {
return this.value return this.$store.state.globals.showSelectLocalFolderModal
}, },
set(val) { set(val) {
this.$emit('input', val) this.$store.commit('globals/setShowSelectLocalFolderModal', val)
} }
},
modalData() {
return this.$store.state.globals.localFolderSelectData || {}
},
callback() {
return this.modalData.callback
},
mediaType() {
return this.modalData.mediaType
} }
}, },
methods: { methods: {
clickedOption(folder) { clickedOption(folder) {
this.$emit('select', folder) this.show = false
if (!this.callback) {
console.error('Callback not set')
return
}
this.callback(folder)
}, },
async init() { async init() {
var localFolders = (await this.$db.getLocalFolders()) || [] const localFolders = (await this.$db.getLocalFolders()) || []
if (!localFolders.some((lf) => lf.id === `internal-${this.mediaType}`)) {
localFolders.push({
id: `internal-${this.mediaType}`,
name: 'Internal App Storage',
mediaType: this.mediaType
})
}
this.localFolders = localFolders.filter((lf) => lf.mediaType == this.mediaType) this.localFolders = localFolders.filter((lf) => lf.mediaType == this.mediaType)
} }
}, },
+1 -1
View File
@@ -1,7 +1,7 @@
<template> <template>
<modals-modal v-model="show" :width="200" height="100%"> <modals-modal v-model="show" :width="200" height="100%">
<template #outer> <template #outer>
<div class="absolute top-5 left-4 z-40"> <div class="absolute top-8 left-4 z-40">
<p class="text-white text-2xl truncate">Sleep Timer</p> <p class="text-white text-2xl truncate">Sleep Timer</p>
</div> </div>
</template> </template>
+1 -1
View File
@@ -1,7 +1,7 @@
<template> <template>
<modals-modal v-model="show" :width="200" height="100%"> <modals-modal v-model="show" :width="200" height="100%">
<template #outer> <template #outer>
<div class="absolute top-5 left-4 z-40"> <div class="absolute top-8 left-4 z-40">
<p class="text-white text-2xl truncate">Sleep Timer</p> <p class="text-white text-2xl truncate">Sleep Timer</p>
</div> </div>
</template> </template>
+39 -34
View File
@@ -1,41 +1,49 @@
<template> <template>
<modals-modal v-model="show" :width="360" height="100%" :processing="processing"> <modals-fullscreen-modal v-model="show" :processing="processing">
<template #outer> <div class="flex items-end justify-between h-16 px-4 pb-2">
<div class="absolute top-5 left-4 z-40"> <h1 class="text-lg">Add to Playlist</h1>
<p class="text-white text-2xl truncate">Add to Playlist</p> <button class="flex" @click="show = false">
</div> <span class="material-icons">close</span>
</template> </button>
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false"> </div>
<div ref="container" class="w-full rounded-lg bg-primary border border-white border-opacity-20 overflow-y-auto overflow-x-hidden" style="max-height: 80vh" @click.stop.prevent>
<div class="w-full h-full p-4" v-show="showPlaylistNameInput">
<div class="flex mb-4 items-center">
<div class="w-9 h-9 flex items-center justify-center rounded-full hover:bg-white hover:bg-opacity-10 cursor-pointer" @click.stop="showPlaylistNameInput = false">
<span class="material-icons text-3xl">arrow_back</span>
</div>
<p class="text-xl pl-2">New Playlist</p>
<div class="flex-grow" />
</div>
<ui-text-input-with-label v-model="newPlaylistName" label="Name" /> <!-- create new playlist form -->
<div class="flex justify-end mt-6"> <div v-if="showPlaylistNameInput" class="w-full h-full max-h-[calc(100vh-128px)] flex items-center">
<ui-btn color="success" :loading="processing" class="w-full" @click.stop="submitCreatePlaylist">Create</ui-btn> <div class="w-full px-4">
<div class="flex mb-4 items-center">
<div class="w-9 h-9 flex items-center justify-center rounded-full hover:bg-white hover:bg-opacity-10 cursor-pointer" @click.stop="showPlaylistNameInput = false">
<span class="material-icons text-3xl">arrow_back</span>
</div> </div>
<p class="text-xl pl-2">New Playlist</p>
<div class="flex-grow" />
</div> </div>
<div class="w-full h-full" v-show="!showPlaylistNameInput">
<template v-for="playlist in sortedPlaylists"> <ui-text-input-with-label v-model="newPlaylistName" label="Name" />
<modals-playlists-playlist-row :key="playlist.id" :in-playlist="playlist.isItemIncluded" :playlist="playlist" @click="clickPlaylist" /> <div class="flex justify-end mt-6">
</template> <ui-btn color="success" :loading="processing" class="w-full" @click.stop="submitCreatePlaylist">Create</ui-btn>
<div v-if="!playlists.length" class="flex h-32 items-center justify-center">
<p class="text-xl">{{ loading ? 'Loading..' : 'No Playlists' }}</p>
</div>
<ui-btn :loading="processing" color="success" class="w-full flex items-center justify-center" @click.stop="createPlaylist">
<span class="material-icons text-xl">add</span>
<p class="text-base pl-2">New Playlist</p>
</ui-btn>
</div> </div>
</div> </div>
</div> </div>
</modals-modal>
<!-- playlists list -->
<div v-if="!showPlaylistNameInput" class="w-full overflow-y-auto overflow-x-hidden h-full max-h-[calc(100vh-128px)]">
<div class="w-full h-full" v-show="!showPlaylistNameInput">
<template v-for="playlist in sortedPlaylists">
<modals-playlists-playlist-row :key="playlist.id" :in-playlist="playlist.isItemIncluded" :playlist="playlist" @click="clickPlaylist" @close="show = false" />
</template>
<div v-if="!playlists.length" class="flex h-full items-center justify-center">
<p class="text-xl">{{ loading ? 'Loading..' : 'No Playlists' }}</p>
</div>
</div>
</div>
<!-- create playlist btn -->
<div v-if="!showPlaylistNameInput" class="flex items-start justify-between h-16 pt-2 absolute bottom-0 left-0 w-full">
<ui-btn :loading="processing" color="success" class="w-full h-full flex items-center justify-center" @click.stop="createPlaylist">
<p class="text-base">Create New Playlist</p>
</ui-btn>
</div>
</modals-fullscreen-modal>
</template> </template>
<script> <script>
@@ -131,7 +139,6 @@ export default {
.$post(`/api/playlists/${playlist.id}/batch/remove`, { items: itemObjects }) .$post(`/api/playlists/${playlist.id}/batch/remove`, { items: itemObjects })
.then((updatedPlaylist) => { .then((updatedPlaylist) => {
console.log(`Items removed from playlist`, updatedPlaylist) console.log(`Items removed from playlist`, updatedPlaylist)
this.$toast.success('Playlist item(s) removed')
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to remove items from playlist', error) console.error('Failed to remove items from playlist', error)
@@ -150,7 +157,6 @@ export default {
.$post(`/api/playlists/${playlist.id}/batch/add`, { items: itemObjects }) .$post(`/api/playlists/${playlist.id}/batch/add`, { items: itemObjects })
.then((updatedPlaylist) => { .then((updatedPlaylist) => {
console.log(`Items added to playlist`, updatedPlaylist) console.log(`Items added to playlist`, updatedPlaylist)
this.$toast.success('Items added to playlist')
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to add items to playlist', error) console.error('Failed to add items to playlist', error)
@@ -182,7 +188,6 @@ export default {
.$post('/api/playlists', newPlaylist) .$post('/api/playlists', newPlaylist)
.then((data) => { .then((data) => {
console.log('New playlist created', data) console.log('New playlist created', data)
this.$toast.success(`Playlist "${data.name}" created`)
this.newPlaylistName = '' this.newPlaylistName = ''
this.showPlaylistNameInput = false this.showPlaylistNameInput = false
}) })
+10 -7
View File
@@ -1,16 +1,15 @@
<template> <template>
<div :key="playlist.id" :id="`playlist-row-${playlist.id}`" class="flex items-center px-3 py-2 justify-start cursor-pointer relative" :class="inPlaylist ? 'bg-bg bg-opacity-60' : 'bg-opacity-20'"> <div :key="playlist.id" :id="`playlist-row-${playlist.id}`" class="flex items-center px-3 py-2 justify-start relative border-y border-white/5" :class="inPlaylist ? 'bg-primary/20' : ''">
<div v-if="inPlaylist" class="absolute top-0 left-0 h-full w-1 bg-success z-10" /> <div v-if="inPlaylist" class="absolute top-0 left-0 h-full w-1 bg-success z-10" />
<div class="w-16 max-w-16 text-center"> <div class="w-14 min-w-[56px] text-center" @click.stop="clickCover">
<covers-playlist-cover :items="items" :width="52" :height="52" /> <covers-playlist-cover :items="items" :width="52" :height="52" />
</div> </div>
<div class="flex-grow overflow-hidden"> <div class="flex-grow overflow-hidden">
<p class="pl-1 pr-2 truncate text-sm">{{ playlist.name }}</p> <p class="px-2 truncate text-sm">{{ playlist.name }}</p>
</div> </div>
<div class="w-24 min-w-[96px] px-1">
<div class="absolute top-0 right-0 h-full flex items-center px-3" @click.stop="click"> <ui-btn v-if="inPlaylist" small class="w-full" @click.stop="click">Remove</ui-btn>
<span v-if="inPlaylist" class="material-icons text-error">remove</span> <ui-btn v-else small class="w-full" @click.stop="click">Add</ui-btn>
<span v-else class="material-icons text-success">add</span>
</div> </div>
</div> </div>
</template> </template>
@@ -35,6 +34,10 @@ export default {
methods: { methods: {
click() { click() {
this.$emit('click', this.playlist) this.$emit('click', this.playlist)
},
clickCover() {
this.$emit('close')
this.$router.push(`/playlist/${this.playlist.id}`)
} }
}, },
mounted() {} mounted() {}
@@ -0,0 +1,90 @@
<template>
<div class="w-full py-2 text-sm">
<div class="flex -mb-px">
<div class="w-1/2 h-6 rounded-tl-md relative border border-white/10 flex items-center justify-center cursor-pointer" :class="!showAdvancedView ? 'text-white bg-bg border-b-bg' : 'text-gray-400 bg-primary bg-opacity-70'" @click="showAdvancedView = false">
<p class="text-sm">{{ $strings.HeaderRSSFeedGeneral }}</p>
</div>
<div class="w-1/2 h-6 rounded-tr-md relative border border-white/10 flex items-center justify-center -ml-px cursor-pointer" :class="showAdvancedView ? 'text-white bg-bg border-b-bg' : 'text-gray-400 bg-primary bg-opacity-70'" @click="showAdvancedView = true">
<p class="text-sm">{{ $strings.HeaderAdvanced }}</p>
</div>
</div>
<div class="px-2 py-4 md:p-4 border border-white/10 rounded-b-md mr-px" style="min-height: 220px">
<template v-if="!showAdvancedView">
<div class="flex-grow pt-2 mb-2">
<ui-checkbox v-model="preventIndexing" :label="$strings.LabelPreventIndexing" checkbox-bg="primary" border-color="gray-600" label-class="pl-2" />
</div>
</template>
<template v-else>
<div class="flex-grow pt-2 mb-2">
<ui-checkbox v-model="preventIndexing" :label="$strings.LabelPreventIndexing" checkbox-bg="primary" border-color="gray-600" label-class="pl-2" />
</div>
<div class="w-full relative mb-1">
<ui-text-input-with-label v-model="ownerName" :label="$strings.LabelRSSFeedCustomOwnerName" />
</div>
<div class="w-full relative mb-1">
<ui-text-input-with-label v-model="ownerEmail" :label="$strings.LabelRSSFeedCustomOwnerEmail" />
</div>
</template>
</div>
</div>
</template>
<script>
export default {
props: {
value: {
type: Object,
default: () => {
return {
preventIndexing: true,
ownerName: '',
ownerEmail: ''
}
}
}
},
data() {
return {
showAdvancedView: false
}
},
watch: {},
computed: {
preventIndexing: {
get() {
return this.value.preventIndexing
},
set(value) {
this.$emit('input', {
...this.value,
preventIndexing: value
})
}
},
ownerName: {
get() {
return this.value.ownerName
},
set(value) {
this.$emit('input', {
...this.value,
ownerName: value
})
}
},
ownerEmail: {
get() {
return this.value.ownerEmail
},
set(value) {
this.$emit('input', {
...this.value,
ownerEmail: value
})
}
}
},
methods: {},
mounted() {}
}
</script>
+188
View File
@@ -0,0 +1,188 @@
<template>
<modals-fullscreen-modal v-model="show" :processing="processing">
<div class="flex items-end justify-end h-16 pr-4 pb-2 mb-2">
<!-- <h1 class="text-lg">RSS Feed</h1> -->
<button class="flex" @click="show = false">
<span class="material-icons">close</span>
</button>
</div>
<div class="w-full px-2 h-[calc(100%-138px)] overflow-y-auto">
<div v-if="currentFeed" class="w-full">
<div class="w-full relative">
<h1 class="text-lg mb-4">RSS feed is open</h1>
<ui-text-input v-model="currentFeed.feedUrl" class="text-sm" readonly />
<span class="material-icons absolute right-2 bottom-2 p-0.5 text-base" :class="linkCopied ? 'text-success' : 'text-gray-300'" @click="copyToClipboard(currentFeed.feedUrl)">{{ linkCopied ? 'done' : 'content_copy' }}</span>
</div>
<div v-if="currentFeed.meta" class="mt-5">
<div class="flex py-0.5">
<div class="w-48">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedPreventIndexing }}</span>
</div>
<div>{{ currentFeed.meta.preventIndexing ? 'Yes' : 'No' }}</div>
</div>
<div v-if="currentFeed.meta.ownerName" class="flex py-0.5">
<div class="w-48">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedCustomOwnerName }}</span>
</div>
<div>{{ currentFeed.meta.ownerName }}</div>
</div>
<div v-if="currentFeed.meta.ownerEmail" class="flex py-0.5">
<div class="w-48">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedCustomOwnerEmail }}</span>
</div>
<div>{{ currentFeed.meta.ownerEmail }}</div>
</div>
</div>
</div>
<div v-else class="w-full">
<div class="w-full relative mb-2">
<ui-text-input-with-label v-model="newFeedSlug" :label="$strings.LabelRSSFeedSlug" />
<p class="text-xs text-gray-400 py-0.5 px-1">{{ $getString('MessageFeedURLWillBe', [demoFeedUrl]) }}</p>
</div>
<modals-rssfeeds-rss-feed-metadata-builder v-model="metadataDetails" />
<p v-if="isHttp" class="w-full pt-2 text-warning text-xs">{{ $strings.NoteRSSFeedPodcastAppsHttps }}</p>
<p v-if="hasEpisodesWithoutPubDate" class="w-full pt-2 text-warning text-xs">{{ $strings.NoteRSSFeedPodcastAppsPubDate }}</p>
</div>
</div>
<div v-show="userIsAdminOrUp" class="flex items-center px-2 h-16">
<div class="flex-grow" />
<ui-btn v-if="currentFeed" color="error" class="w-full" @click="closeFeed">{{ $strings.ButtonCloseFeed }}</ui-btn>
<ui-btn v-else color="success" class="w-full" @click="openFeed">{{ $strings.ButtonOpenFeed }}</ui-btn>
</div>
</modals-fullscreen-modal>
</template>
<script>
export default {
data() {
return {
processing: false,
newFeedSlug: null,
currentFeed: null,
metadataDetails: {
preventIndexing: true,
ownerName: '',
ownerEmail: ''
},
linkCopied: false
}
},
watch: {
show: {
immediate: true,
handler(newVal) {
if (newVal) {
this.linkCopied = false
this.init()
}
}
}
},
computed: {
show: {
get() {
return this.$store.state.globals.showRSSFeedOpenCloseModal
},
set(val) {
this.$store.commit('globals/setShowRSSFeedOpenCloseModal', val)
}
},
serverAddress() {
return this.$store.getters['user/getServerAddress']
},
rssFeedEntity() {
return this.$store.state.globals.rssFeedEntity || {}
},
entityId() {
return this.rssFeedEntity.id
},
entityType() {
return this.rssFeedEntity.type
},
entityFeed() {
return this.rssFeedEntity.feed
},
hasEpisodesWithoutPubDate() {
return !!this.rssFeedEntity.hasEpisodesWithoutPubDate
},
title() {
return this.rssFeedEntity.name
},
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
demoFeedUrl() {
return `${this.serverAddress}/feed/${this.newFeedSlug}`
},
isHttp() {
return !!this.serverAddress?.startsWith('http://')
}
},
methods: {
openFeed() {
if (!this.newFeedSlug) {
this.$toast.error('Must set a feed slug')
return
}
const sanitized = this.$sanitizeSlug(this.newFeedSlug)
if (this.newFeedSlug !== sanitized) {
this.newFeedSlug = sanitized
this.$toast.warning('Slug had to be modified - Run again')
return
}
const payload = {
serverAddress: this.serverAddress,
slug: this.newFeedSlug,
metadataDetails: this.metadataDetails
}
console.log('Payload', payload)
this.$axios
.$post(`/api/feeds/${this.entityType}/${this.entityId}/open`, payload)
.then((data) => {
console.log('Opened RSS Feed', data)
this.currentFeed = data.feed
})
.catch((error) => {
console.error('Failed to open RSS Feed', error)
const errorMsg = error.response ? error.response.data : null
this.$toast.error(errorMsg || 'Failed to open RSS Feed')
})
},
async copyToClipboard(str) {
await this.$copyToClipboard(str)
this.linkCopied = true
},
closeFeed() {
this.processing = true
this.$axios
.$post(`/api/feeds/${this.currentFeed.id}/close`)
.then(() => {
this.$toast.success(this.$strings.ToastRSSFeedCloseSuccess)
this.show = false
})
.catch((error) => {
console.error('Failed to close RSS feed', error)
this.$toast.error(this.$strings.ToastRSSFeedCloseFailed)
})
.finally(() => {
this.processing = false
})
},
init() {
if (!this.entityId) return
this.newFeedSlug = this.entityId
this.currentFeed = this.entityFeed
}
},
mounted() {}
}
</script>
+184 -42
View File
@@ -1,28 +1,17 @@
<template> <template>
<div id="comic-reader" class="w-full h-full"> <div id="comic-reader" class="w-full h-full relative">
<div v-show="showPageMenu" v-click-outside="clickOutside" class="pagemenu absolute right-20 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-52" style="top: 72px"> <modals-modal v-model="showInfoMenu" height="90%">
<div v-for="(file, index) in pages" :key="file" class="w-full cursor-pointer hover:bg-black-200 px-2 py-1" :class="page === index ? 'bg-black-200' : ''" @click="setPage(index)"> <div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click.stop="showInfoMenu = false">
<p class="text-sm truncate">{{ file }}</p> <div class="w-full overflow-x-hidden overflow-y-auto bg-secondary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
<div v-for="key in comicMetadataKeys" :key="key" class="w-full px-2 py-1">
<p class="text-xs">
<strong>{{ key }}</strong>
: {{ comicMetadata[key] }}
</p>
</div>
</div>
</div> </div>
</div> </modals-modal>
<div v-show="showInfoMenu" v-click-outside="clickOutside" class="pagemenu absolute top-20 right-0 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-full" style="top: 72px">
<div v-for="key in comicMetadataKeys" :key="key" class="w-full px-2 py-1">
<p class="text-xs">
<strong>{{ key }}</strong>
: {{ comicMetadata[key] }}
</p>
</div>
</div>
<div v-if="comicMetadata" class="absolute top-8 right-36 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="showInfoMenu = !showInfoMenu">
<span class="material-icons text-lg">more</span>
</div>
<div class="absolute top-8 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" style="right: 92px" @mousedown.prevent @click.stop.prevent="showPageMenu = !showPageMenu">
<span class="material-icons text-lg">menu</span>
</div>
<div class="absolute top-8 right-4 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20">
<p class="font-mono">{{ page + 1 }} / {{ numPages }}</p>
</div>
<div class="overflow-hidden m-auto comicwrapper relative"> <div class="overflow-hidden m-auto comicwrapper relative">
<div class="h-full flex justify-center"> <div class="h-full flex justify-center">
@@ -33,6 +22,13 @@
<ui-loading-indicator /> <ui-loading-indicator />
</div> </div>
</div> </div>
<div class="fixed left-0 h-8 w-full bg-primary px-4 flex items-center text-white/80" :style="{ bottom: isPlayerOpen ? '120px' : '0px' }">
<div class="flex-grow" />
<p class="text-xs">{{ page }} / {{ numPages }}</p>
</div>
<modals-dialog v-model="showPageMenu" :items="pageItems" :selected="page" :width="360" item-padding-y="8px" @action="setPage" />
</div> </div>
</template> </template>
@@ -47,7 +43,13 @@ Archive.init({
export default { export default {
props: { props: {
url: String url: String,
libraryItem: {
type: Object,
default: () => {}
},
isLocal: Boolean,
keepProgress: Boolean
}, },
data() { data() {
return { return {
@@ -61,7 +63,8 @@ export default {
showInfoMenu: false, showInfoMenu: false,
loadTimeout: null, loadTimeout: null,
loadedFirstPage: false, loadedFirstPage: false,
comicMetadata: null comicMetadata: null,
pageMenuWidth: 256
} }
}, },
watch: { watch: {
@@ -73,20 +76,126 @@ export default {
} }
}, },
computed: { computed: {
userToken() {
return this.$store.getters['user/getToken']
},
libraryItemId() {
return this.libraryItem?.id
},
localLibraryItem() {
if (this.isLocal) return this.libraryItem
return this.libraryItem.localLibraryItem || null
},
localLibraryItemId() {
return this.localLibraryItem?.id
},
serverLibraryItemId() {
if (!this.isLocal) return this.libraryItem.id
// Check if local library item is connected to the current server
if (!this.libraryItem.serverAddress || !this.libraryItem.libraryItemId) return null
if (this.$store.getters['user/getServerAddress'] === this.libraryItem.serverAddress) {
return this.libraryItem.libraryItemId
}
return null
},
comicMetadataKeys() { comicMetadataKeys() {
return this.comicMetadata ? Object.keys(this.comicMetadata) : [] return this.comicMetadata ? Object.keys(this.comicMetadata) : []
}, },
canGoNext() { canGoNext() {
return this.page < this.numPages - 1 return this.page < this.numPages
}, },
canGoPrev() { canGoPrev() {
return this.page > 0 return this.page > 1
},
userItemProgress() {
if (this.isLocal) return this.localItemProgress
return this.serverItemProgress
},
localItemProgress() {
return this.$store.getters['globals/getLocalMediaProgressById'](this.localLibraryItemId)
},
serverItemProgress() {
return this.$store.getters['user/getUserMediaProgress'](this.serverLibraryItemId)
},
savedPage() {
if (!this.keepProgress) return 0
// Validate ebookLocation is a number
if (!this.userItemProgress?.ebookLocation || isNaN(this.userItemProgress.ebookLocation)) return 0
return Number(this.userItemProgress.ebookLocation)
},
selectedCleanedPage() {
return this.cleanedPageNames[this.page - 1]
},
cleanedPageNames() {
return (
this.pages?.map((p) => {
if (p.length > 40) {
let firstHalf = p.slice(0, 18)
let lastHalf = p.slice(p.length - 17)
return `${firstHalf} ... ${lastHalf}`
}
return p
}) || []
)
},
pageItems() {
let index = 1
return this.cleanedPageNames.map((p) => {
return {
text: p,
value: index++
}
})
},
isPlayerOpen() {
return this.$store.getters['getIsPlayerOpen']
} }
}, },
methods: { methods: {
clickOutside() { async updateProgress() {
if (this.showPageMenu) this.showPageMenu = false if (!this.keepProgress) return
if (this.showInfoMenu) this.showInfoMenu = false
if (!this.numPages) {
console.error('Num pages not loaded')
return
}
if (this.savedPage === this.page) {
return
}
const payload = {
ebookLocation: String(this.page),
ebookProgress: Math.max(0, Math.min(1, (Number(this.page) - 1) / Number(this.numPages)))
}
// Update local item
if (this.localLibraryItemId) {
const localPayload = {
localLibraryItemId: this.localLibraryItemId,
...payload
}
const localResponse = await this.$db.updateLocalEbookProgress(localPayload)
if (localResponse.localMediaProgress) {
this.$store.commit('globals/updateLocalMediaProgress', localResponse.localMediaProgress)
}
}
// Update server item
if (this.serverLibraryItemId) {
this.$axios.$patch(`/api/me/progress/${this.serverLibraryItemId}`, payload).catch((error) => {
console.error('ComicReader.updateProgress failed:', error)
})
}
},
clickShowInfoMenu() {
this.showInfoMenu = !this.showInfoMenu
this.showPageMenu = false
},
clickShowPageMenu() {
if (!this.numPages) return
this.showPageMenu = !this.showPageMenu
this.showInfoMenu = false
}, },
next() { next() {
if (!this.canGoNext) return if (!this.canGoNext) return
@@ -96,12 +205,15 @@ export default {
if (!this.canGoPrev) return if (!this.canGoPrev) return
this.setPage(this.page - 1) this.setPage(this.page - 1)
}, },
setPage(index) { setPage(page) {
if (index < 0 || index > this.numPages - 1) { if (page <= 0 || page > this.numPages) {
return return
} }
var filename = this.pages[index] this.showPageMenu = false
this.page = index const filename = this.pages[page - 1]
this.page = page
this.updateProgress()
return this.extractFile(filename) return this.extractFile(filename)
}, },
setLoadTimeout() { setLoadTimeout() {
@@ -131,11 +243,14 @@ export default {
}, },
async extract() { async extract() {
this.loading = true this.loading = true
console.log('Extracting', this.url)
var buff = await this.$axios.$get(this.url, { const buff = await this.$axios.$get(this.url, {
responseType: 'blob' responseType: 'blob',
headers: {
Authorization: `Bearer ${this.userToken}`
}
}) })
const archive = await Archive.open(buff) const archive = await Archive.open(buff)
const originalFilesObject = await archive.getFilesObject() const originalFilesObject = await archive.getFilesObject()
// to support images in subfolders we need to flatten the object // to support images in subfolders we need to flatten the object
@@ -150,10 +265,33 @@ export default {
this.numPages = this.pages.length this.numPages = this.pages.length
// Calculate page menu size
const largestFilename = this.cleanedPageNames
.map((p) => p)
.sort((a, b) => a.length - b.length)
.pop()
const pEl = document.createElement('p')
pEl.innerText = largestFilename
pEl.style.fontSize = '0.875rem'
pEl.style.opacity = 0
pEl.style.position = 'absolute'
document.body.appendChild(pEl)
const textWidth = pEl.getBoundingClientRect()?.width
if (textWidth) {
this.pageMenuWidth = textWidth + (16 + 5 + 2 + 5)
}
pEl.remove()
if (this.pages.length) { if (this.pages.length) {
this.loading = false this.loading = false
await this.setPage(0)
const startPage = this.savedPage > 0 && this.savedPage <= this.numPages ? this.savedPage : 1
await this.setPage(startPage)
this.loadedFirstPage = true this.loadedFirstPage = true
this.$emit('loaded', {
hasMetadata: this.comicMetadata
})
} else { } else {
this.$toast.error('Unable to extract pages') this.$toast.error('Unable to extract pages')
this.loading = false this.loading = false
@@ -180,7 +318,6 @@ export default {
return flattenObject(filesObject) return flattenObject(filesObject)
}, },
async extractXmlFile(filename) { async extractXmlFile(filename) {
console.log('extracting xml filename', filename)
try { try {
var file = await this.filesObject[filename].extract() var file = await this.filesObject[filename].extract()
var reader = new FileReader() var reader = new FileReader()
@@ -235,11 +372,16 @@ export default {
<style scoped> <style scoped>
#comic-reader { #comic-reader {
height: calc(100% - 32px); height: calc(100% - 36px);
max-height: calc(100% - 36px);
padding-top: 36px;
} }
.pagemenu { .reader-player-open #comic-reader {
max-height: calc(100% - 80px); height: calc(100% - 156px);
max-height: calc(100% - 156px);
padding-top: 36px;
} }
.comicimg { .comicimg {
height: 100%; height: 100%;
margin: auto; margin: auto;
+292 -72
View File
@@ -1,11 +1,10 @@
<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="h-full w-full"></div>
<div class="fixed left-0 h-8 w-full bg-bg px-2 flex items-center" :style="{ bottom: playerLibraryItemId ? '120px' : '0px' }">
<p class="text-xs">epub</p>
<div class="flex-grow" />
<p class="text-sm">{{ progress }}%</p> <div class="fixed left-0 h-8 w-full px-4 flex items-center" :class="isLightTheme ? 'bg-white text-black' : 'bg-primary text-white/80'" :style="{ bottom: isPlayerOpen ? '120px' : '0px' }">
<div class="flex-grow" />
<p class="text-xs">{{ progress }}%</p>
</div> </div>
</div> </div>
</template> </template>
@@ -15,34 +14,126 @@ import ePub from 'epubjs'
export default { export default {
props: { props: {
url: String url: String,
libraryItem: {
type: Object,
default: () => {}
},
isLocal: Boolean,
keepProgress: Boolean
}, },
data() { data() {
return { return {
/** @type {ePub.Book} */
book: null, book: null,
/** @type {ePub.Rendition} */
rendition: null, rendition: null,
chapters: [],
title: '',
author: '',
progress: 0, progress: 0,
hasNext: true, ereaderSettings: {
hasPrev: false theme: 'dark',
fontScale: 100,
lineSpacing: 115
}
} }
}, },
watch: { watch: {
playerLibraryItemId() { isPlayerOpen() {
this.updateHeight() this.updateHeight()
} }
}, },
computed: { computed: {
playerLibraryItemId() { userToken() {
return this.$store.state.playerLibraryItemId return this.$store.getters['user/getToken']
},
/** @returns {string} */
libraryItemId() {
return this.libraryItem?.id
},
localLibraryItem() {
if (this.isLocal) return this.libraryItem
return this.libraryItem.localLibraryItem || null
},
localLibraryItemId() {
return this.localLibraryItem?.id
},
serverLibraryItemId() {
if (!this.isLocal) return this.libraryItem.id
// Check if local library item is connected to the current server
if (!this.libraryItem.serverAddress || !this.libraryItem.libraryItemId) return null
if (this.$store.getters['user/getServerAddress'] === this.libraryItem.serverAddress) {
return this.libraryItem.libraryItemId
}
return null
},
isPlayerOpen() {
return this.$store.getters['getIsPlayerOpen']
}, },
readerHeightOffset() { readerHeightOffset() {
return this.playerLibraryItemId ? 164 : 64 return this.isPlayerOpen ? 204 : 104
},
/** @returns {Array<ePub.NavItem>} */
chapters() {
return this.book?.navigation?.toc || []
},
userItemProgress() {
if (this.isLocal) return this.localItemProgress
return this.serverItemProgress
},
localItemProgress() {
return this.$store.getters['globals/getLocalMediaProgressById'](this.localLibraryItemId)
},
serverItemProgress() {
return this.$store.getters['user/getUserMediaProgress'](this.serverLibraryItemId)
},
localStorageLocationsKey() {
return `ebookLocations-${this.libraryItemId}`
},
savedEbookLocation() {
if (!this.keepProgress) return null
if (!this.userItemProgress?.ebookLocation) return null
// Validate ebookLocation is an epubcfi
if (!String(this.userItemProgress.ebookLocation).startsWith('epubcfi')) return null
return this.userItemProgress.ebookLocation
},
isLightTheme() {
return this.ereaderSettings.theme === 'light'
},
themeRules() {
const isDark = this.ereaderSettings.theme === 'dark'
const fontColor = isDark ? '#fff' : '#000'
const backgroundColor = isDark ? 'rgb(35 35 35)' : 'rgb(255, 255, 255)'
const lineSpacing = this.ereaderSettings.lineSpacing / 100
const fontScale = this.ereaderSettings.fontScale / 100
return {
'*': {
color: `${fontColor}!important`,
'background-color': `${backgroundColor}!important`,
'line-height': lineSpacing * fontScale + 'rem!important'
},
a: {
color: `${fontColor}!important`
}
}
} }
}, },
methods: { methods: {
updateSettings(settings) {
this.ereaderSettings = settings
if (!this.rendition) return
this.applyTheme()
const fontScale = settings.fontScale || 100
this.rendition.themes.fontSize(`${fontScale}%`)
this.rendition.spread(settings.spread || 'auto')
},
goToChapter(href) {
return this.rendition?.display(href)
},
updateHeight() { updateHeight() {
if (this.rendition && this.rendition.resize) { if (this.rendition && this.rendition.resize) {
this.rendition.resize(window.innerWidth, window.innerHeight - this.readerHeightOffset) this.rendition.resize(window.innerWidth, window.innerHeight - this.readerHeightOffset)
@@ -58,80 +149,209 @@ export default {
this.rendition.next() this.rendition.next()
} }
}, },
keyUp() { /**
if ((e.keyCode || e.which) == 37) { * @param {object} payload
this.prev() * @param {string} payload.ebookLocation - CFI of the current location
} else if ((e.keyCode || e.which) == 39) { * @param {string} payload.ebookProgress - eBook Progress Percentage
this.next() */
async updateProgress(payload) {
if (!this.keepProgress) return
// Update local item
if (this.localLibraryItemId) {
const localPayload = {
localLibraryItemId: this.localLibraryItemId,
...payload
}
const localResponse = await this.$db.updateLocalEbookProgress(localPayload)
if (localResponse.localMediaProgress) {
this.$store.commit('globals/updateLocalMediaProgress', localResponse.localMediaProgress)
}
}
// Update server item
if (this.serverLibraryItemId) {
this.$axios.$patch(`/api/me/progress/${this.serverLibraryItemId}`, payload).catch((error) => {
console.error('EpubReader.updateProgress failed:', error)
})
}
},
getAllEbookLocationData() {
const locations = []
let totalSize = 0 // Total in bytes
for (const key in localStorage) {
if (!localStorage.hasOwnProperty(key) || !key.startsWith('ebookLocations-')) {
continue
}
try {
const ebookLocations = JSON.parse(localStorage[key])
if (!ebookLocations.locations) throw new Error('Invalid locations object')
ebookLocations.key = key
ebookLocations.size = (localStorage[key].length + key.length) * 2
locations.push(ebookLocations)
totalSize += ebookLocations.size
} catch (error) {
console.error('Failed to parse ebook locations', key, error)
localStorage.removeItem(key)
}
}
// Sort by oldest lastAccessed first
locations.sort((a, b) => a.lastAccessed - b.lastAccessed)
return {
locations,
totalSize
}
},
/** @param {string} locationString */
checkSaveLocations(locationString) {
const maxSizeInBytes = 3000000 // Allow epub locations to take up to 3MB of space
const newLocationsSize = JSON.stringify({ lastAccessed: Date.now(), locations: locationString }).length * 2
// Too large overall
if (newLocationsSize > maxSizeInBytes) {
console.error('Epub locations are too large to store. Size =', newLocationsSize)
return
}
const ebookLocationsData = this.getAllEbookLocationData()
let availableSpace = maxSizeInBytes - ebookLocationsData.totalSize
// Remove epub locations until there is room for locations
while (availableSpace < newLocationsSize && ebookLocationsData.locations.length) {
const oldestLocation = ebookLocationsData.locations.shift()
console.log(`Removing cached locations for epub "${oldestLocation.key}" taking up ${oldestLocation.size} bytes`)
availableSpace += oldestLocation.size
localStorage.removeItem(oldestLocation.key)
}
console.log(`Cacheing epub locations with key "${this.localStorageLocationsKey}" taking up ${newLocationsSize} bytes`)
this.saveLocations(locationString)
},
/** @param {string} locationString */
saveLocations(locationString) {
localStorage.setItem(
this.localStorageLocationsKey,
JSON.stringify({
lastAccessed: Date.now(),
locations: locationString
})
)
},
loadLocations() {
const locationsObjString = localStorage.getItem(this.localStorageLocationsKey)
if (!locationsObjString) return null
const locationsObject = JSON.parse(locationsObjString)
// Remove invalid location objects
if (!locationsObject.locations) {
console.error('Invalid epub locations stored', this.localStorageLocationsKey)
localStorage.removeItem(this.localStorageLocationsKey)
return null
}
// Update lastAccessed
this.saveLocations(locationsObject.locations)
return locationsObject.locations
},
/** @param {string} location - CFI of the new location */
relocated(location) {
if (this.savedEbookLocation === location.start.cfi) {
return
}
if (location.end.percentage) {
this.updateProgress({
ebookLocation: location.start.cfi,
ebookProgress: location.end.percentage
})
this.progress = Math.round(location.end.percentage * 100)
} else {
this.updateProgress({
ebookLocation: location.start.cfi
})
} }
}, },
initEpub() { initEpub() {
var book = ePub(this.url) this.progress = Math.round((this.userItemProgress?.ebookProgress || 0) * 100)
this.book = book
this.rendition = book.renderTo('viewer', { /** @type {EpubReader} */
const reader = this
console.log('initEpub', reader.url)
/** @type {ePub.Book} */
reader.book = new ePub(reader.url, {
width: window.innerWidth,
height: window.innerHeight - this.readerHeightOffset,
openAs: 'epub',
requestHeaders: {
Authorization: `Bearer ${this.userToken}`
}
})
/** @type {ePub.Rendition} */
reader.rendition = reader.book.renderTo('viewer', {
width: window.innerWidth, width: window.innerWidth,
height: window.innerHeight - this.readerHeightOffset, height: window.innerHeight - this.readerHeightOffset,
snap: true, snap: true,
manager: 'continuous', manager: 'continuous',
flow: 'paginated' flow: 'paginated'
}) })
const displayed = this.rendition.display()
book.ready reader.book.ready.then(() => {
.then(() => { // load saved progress
console.log('Book ready') // when not checking spine first uncaught exception is thrown
return book.locations.generate(1600) if (this.savedEbookLocation && reader.book.spine.get(this.savedEbookLocation)) {
reader.rendition.display(this.savedEbookLocation)
} else {
reader.rendition.display(reader.book.locations.start)
}
reader.rendition.on('rendered', () => {
this.applyTheme()
}) })
.then((locations) => {
// console.log('Loaded locations', locations) // set up event listeners
// Wait for book to be rendered to get current page reader.rendition.on('relocated', reader.relocated)
displayed.then(() => {
// Get the current CFI reader.rendition.on('displayError', (err) => {
var currentLocation = this.rendition.currentLocation() console.log('Display error', err)
if (!currentLocation.start) { })
console.error('No Start', currentLocation)
} else { reader.rendition.on('touchstart', (event) => {
var currentPage = book.locations.percentageFromCfi(currentLocation.start.cfi) this.$emit('touchstart', event)
// console.log('current page', currentPage) })
} reader.rendition.on('touchend', (event) => {
this.$emit('touchend', event)
})
// load ebook cfi locations
const savedLocations = this.loadLocations()
if (savedLocations) {
reader.book.locations.load(savedLocations)
} else {
reader.book.locations.generate().then(() => {
this.checkSaveLocations(reader.book.locations.save())
}) })
}) }
book.loaded.navigation.then((toc) => {
var _chapters = []
toc.forEach((chapter) => {
_chapters.push(chapter)
})
this.chapters = _chapters
}) })
book.loaded.metadata.then((metadata) => { },
// this.author = metadata.creator applyTheme() {
// this.title = metadata.title if (!this.rendition) return
}) this.rendition.getContents().forEach((c) => {
c.addStylesheetRules(this.themeRules)
// const spine_get = book.spine.get.bind(book.spine)
// book.spine.get = function (target) {
// var t = spine_get(target)
// console.log(t, target)
// // while (t == null && target.includes('#')) {
// // target = target.split('#')[0]
// // t = spine_get(target)
// // }
// return t
// }
this.rendition.on('keyup', this.keyUp)
this.rendition.on('relocated', (location) => {
var percent = book.locations.percentageFromCfi(location.start.cfi)
this.progress = Math.floor(percent * 100)
this.hasNext = !location.atEnd
this.hasPrev = !location.atStart
}) })
} }
}, },
beforeDestroy() {
this.book?.destroy()
},
mounted() { mounted() {
this.initEpub() this.initEpub()
} }
+17 -15
View File
@@ -1,12 +1,8 @@
<template> <template>
<div class="mobi-ebook-viewer w-full"> <div class="mobi-ebook-viewer w-full relative">
<div class="absolute overflow-hidden left-0 right-0 w-full max-w-full m-auto z-10 border border-black border-opacity-20 shadow-md bg-white"> <div class="absolute overflow-hidden left-0 top-0 w-screen max-w-screen m-auto z-10 border border-black border-opacity-20 shadow-md bg-white">
<iframe title="html-viewer" class="w-full overflow-hidden"> Loading </iframe> <iframe title="html-viewer" class="w-full overflow-hidden"> Loading </iframe>
</div> </div>
<div class="fixed bottom-0 left-0 h-8 w-full bg-bg px-2 flex items-center z-20" :style="{ bottom: playerLibraryItemId ? '100px' : '0px' }">
<p class="text-xs">mobi</p>
<div class="flex-grow" />
</div>
</div> </div>
</template> </template>
@@ -17,17 +13,18 @@ import defaultCss from '@/assets/ebooks/basic.js'
export default { export default {
props: { props: {
url: String url: String,
libraryItem: {
type: Object,
default: () => {}
}
}, },
data() { data() {
return {} return {}
}, },
computed: { computed: {
playerLibraryItemId() { userToken() {
return this.$store.state.playerLibraryItemId return this.$store.getters['user/getToken']
},
readerHeightOffset() {
return this.playerLibraryItemId ? 164 : 64
} }
}, },
methods: { methods: {
@@ -88,7 +85,10 @@ export default {
async initMobi() { async initMobi() {
// Fetch mobi file as blob // Fetch mobi file as blob
var buff = await this.$axios.$get(this.url, { var buff = await this.$axios.$get(this.url, {
responseType: 'blob' responseType: 'blob',
headers: {
Authorization: `Bearer ${this.userToken}`
}
}) })
var reader = new FileReader() var reader = new FileReader()
reader.onload = async (event) => { reader.onload = async (event) => {
@@ -124,11 +124,13 @@ export default {
.mobi-ebook-viewer { .mobi-ebook-viewer {
height: calc(100% - 32px); height: calc(100% - 32px);
max-height: calc(100% - 32px); max-height: calc(100% - 32px);
overflow: hidden; overflow-x: hidden;
overflow-y: auto;
} }
.reader-player-open .mobi-ebook-viewer { .reader-player-open .mobi-ebook-viewer {
height: calc(100% - 132px); height: calc(100% - 132px);
max-height: calc(100% - 132px); max-height: calc(100% - 132px);
overflow: hidden; overflow-x: hidden;
overflow-y: auto;
} }
</style> </style>
+139 -16
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="w-full h-full pt-20 relative"> <div class="w-full h-full relative">
<div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent> <div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent>
<div class="flex items-center justify-center h-full w-1/2"> <div class="flex items-center justify-center h-full w-1/2">
<span class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span> <span class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span>
@@ -11,69 +11,192 @@
</div> </div>
</div> </div>
<div class="absolute top-0 right-1 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-6 flex items-center text-center"> <div class="h-full flex items-center justify-center">
<p class="font-mono text-xs">{{ page }} / {{ numPages }}</p> <div :style="{ width: pdfWidth + 'px', height: pdfHeight + 'px' }" class="w-full h-full overflow-auto">
<div v-if="loadedRatio > 0 && loadedRatio < 1" style="background-color: green; color: white; text-align: center" :style="{ width: loadedRatio * 100 + '%' }">{{ Math.floor(loadedRatio * 100) }}%</div>
<pdf ref="pdf" class="m-auto z-10 border border-black border-opacity-20 shadow-md bg-white" :src="pdfDocInitParams" :page="page" :rotate="rotate" @progress="loadedRatio = $event" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event" @loaded="loadedEvt"></pdf>
</div>
</div> </div>
<div :style="{ height: pdfHeight + 'px' }" class="overflow-hidden m-auto"> <div class="fixed left-0 h-8 w-full bg-primary px-4 flex items-center text-white/80" :style="{ bottom: isPlayerOpen ? '120px' : '0px' }">
<div class="flex items-center justify-center"> <div class="flex-grow" />
<div :style="{ width: pdfWidth + 'px', height: pdfHeight + 'px' }" class="w-full h-full overflow-auto"> <p class="text-xs">{{ page }} / {{ numPages }}</p>
<div v-if="loadedRatio > 0 && loadedRatio < 1" style="background-color: green; color: white; text-align: center" :style="{ width: loadedRatio * 100 + '%' }">{{ Math.floor(loadedRatio * 100) }}%</div>
<pdf ref="pdf" class="m-auto z-10 border border-black border-opacity-20 shadow-md bg-white" :src="url" :page="page" :rotate="rotate" @progress="loadedRatio = $event" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event"></pdf>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import pdf from 'vue-pdf' import pdf from '@teckel/vue-pdf'
export default { export default {
components: { components: {
pdf pdf
}, },
props: { props: {
url: String url: String,
libraryItem: {
type: Object,
default: () => {}
},
isLocal: Boolean,
keepProgress: Boolean
}, },
data() { data() {
return { return {
rotate: 0, rotate: 0,
loadedRatio: 0, loadedRatio: 0,
page: 1, page: 1,
numPages: 0 numPages: 0,
windowWidth: 0,
windowHeight: 0
} }
}, },
computed: { computed: {
userToken() {
return this.$store.getters['user/getToken']
},
localLibraryItem() {
if (this.isLocal) return this.libraryItem
return this.libraryItem.localLibraryItem || null
},
localLibraryItemId() {
return this.localLibraryItem?.id
},
serverLibraryItemId() {
if (!this.isLocal) return this.libraryItem.id
// Check if local library item is connected to the current server
if (!this.libraryItem.serverAddress || !this.libraryItem.libraryItemId) return null
if (this.$store.getters['user/getServerAddress'] === this.libraryItem.serverAddress) {
return this.libraryItem.libraryItemId
}
return null
},
pdfWidth() { pdfWidth() {
return this.pdfHeight * 0.6667 if (this.windowWidth > this.windowHeight) {
// Landscape
return this.windowHeight * 0.6
} else {
// Portrait
return this.windowWidth
}
}, },
pdfHeight() { pdfHeight() {
return window.innerHeight - 120 return this.pdfWidth * 1.667
}, },
canGoNext() { canGoNext() {
return this.page < this.numPages return this.page < this.numPages
}, },
canGoPrev() { canGoPrev() {
return this.page > 1 return this.page > 1
},
userItemProgress() {
if (this.isLocal) return this.localItemProgress
return this.serverItemProgress
},
localItemProgress() {
return this.$store.getters['globals/getLocalMediaProgressById'](this.localLibraryItemId)
},
serverItemProgress() {
return this.$store.getters['user/getUserMediaProgress'](this.serverLibraryItemId)
},
savedPage() {
if (!this.keepProgress) return 0
// Validate ebookLocation is a number
if (!this.userItemProgress?.ebookLocation || isNaN(this.userItemProgress.ebookLocation)) return 0
return Number(this.userItemProgress.ebookLocation)
},
pdfDocInitParams() {
return {
url: this.url,
httpHeaders: {
Authorization: `Bearer ${this.userToken}`
}
}
},
isPlayerOpen() {
return this.$store.getters['getIsPlayerOpen']
} }
}, },
methods: { methods: {
async updateProgress() {
if (!this.keepProgress) return
if (!this.numPages) {
console.error('Num pages not loaded')
return
}
const payload = {
ebookLocation: String(this.page),
ebookProgress: Math.max(0, Math.min(1, (Number(this.page) - 1) / Number(this.numPages)))
}
// Update local item
if (this.localLibraryItemId) {
const localPayload = {
localLibraryItemId: this.localLibraryItemId,
...payload
}
const localResponse = await this.$db.updateLocalEbookProgress(localPayload)
if (localResponse.localMediaProgress) {
this.$store.commit('globals/updateLocalMediaProgress', localResponse.localMediaProgress)
}
}
// Update server item
if (this.serverLibraryItemId) {
this.$axios.$patch(`/api/me/progress/${this.serverLibraryItemId}`, payload).catch((error) => {
console.error('PdfReader.updateProgress failed:', error)
})
}
},
loadedEvt() {
if (this.savedPage && this.savedPage > 0 && this.savedPage <= this.numPages) {
this.page = this.savedPage
}
},
numPagesLoaded(e) { numPagesLoaded(e) {
this.numPages = e this.numPages = e
}, },
prev() { prev() {
if (this.page <= 1) return if (this.page <= 1) return
this.page-- this.page--
this.updateProgress()
}, },
next() { next() {
if (this.page >= this.numPages) return if (this.page >= this.numPages) return
this.page++ this.page++
this.updateProgress()
}, },
error(err) { error(err) {
console.error(err) console.error(err)
},
screenOrientationChange() {
this.windowWidth = window.innerWidth
this.windowHeight = window.innerHeight
} }
}, },
mounted() {} mounted() {
this.windowWidth = window.innerWidth
this.windowHeight = window.innerHeight
if (screen.orientation) {
// Not available on ios
screen.orientation.addEventListener('change', this.screenOrientationChange)
} else {
document.addEventListener('orientationchange', this.screenOrientationChange)
}
window.addEventListener('resize', this.screenOrientationChange)
},
beforeDestroy() {
if (screen.orientation) {
// Not available on ios
screen.orientation.removeEventListener('change', this.screenOrientationChange)
} else {
document.removeEventListener('orientationchange', this.screenOrientationChange)
}
window.removeEventListener('resize', this.screenOrientationChange)
}
} }
</script> </script>
+239 -35
View File
@@ -1,15 +1,95 @@
<template> <template>
<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 v-if="show" :data-theme="ereaderTheme" class="group fixed top-0 left-0 right-0 layout-wrapper w-full z-40 pt-8 data-[theme=dark]:bg-primary data-[theme=dark]:text-white data-[theme=light]:bg-white data-[theme=light]:text-black" :class="{ 'reader-player-open': isPlayerOpen }">
<div class="h-8 w-full bg-primary flex items-center px-2 fixed top-0 left-0 z-30 box-shadow-sm"> <!-- toolbar -->
<p class="w-5/6 truncate">{{ title }}</p> <div class="h-32 pt-10 w-full px-2 fixed top-0 left-0 z-30 transition-transform bg-bg text-white" :class="showingToolbar ? 'translate-y-0' : '-translate-y-32'" @touchstart.stop @mousedown.stop @touchend.stop @mouseup.stop>
<div class="flex-grow" /> <div class="flex items-center mb-2">
<span class="material-icons text-xl text-white" @click.stop="show = false">close</span> <button type="button" class="inline-flex mx-2" @click.stop="show = false">
<span class="material-icons-outlined text-3xl text-white">chevron_left</span>
</button>
<div class="flex-grow" />
<button v-if="isComic || isEpub" type="button" class="inline-flex mx-2" @click.stop="clickTOCBtn">
<span class="material-icons-outlined text-2xl text-white">format_list_bulleted</span>
</button>
<button v-if="isEpub" type="button" class="inline-flex mx-2" @click.stop="clickSettingsBtn">
<span class="material-icons text-2xl text-white">settings</span>
</button>
<button v-if="comicHasMetadata" type="button" class="inline-flex mx-2" @click.stop="clickMetadataBtn">
<span class="material-icons text-2xl text-white">more</span>
</button>
</div>
<p class="text-center truncate">{{ title }}</p>
</div> </div>
<component v-if="readerComponentName" ref="readerComponent" :is="readerComponentName" :url="ebookUrl" />
<!-- ereader -->
<component v-if="readerComponentName" ref="readerComponent" :is="readerComponentName" :url="ebookUrl" :library-item="selectedLibraryItem" :is-local="isLocal" :keep-progress="keepProgress" @touchstart="touchstart" @touchend="touchend" @loaded="readerLoaded" @hook:mounted="readerMounted" />
<!-- table of contents modal -->
<modals-fullscreen-modal v-model="showTOCModal" :theme="ereaderTheme">
<div class="flex items-end justify-between h-20 px-4 pb-2">
<h1 class="text-lg">Table of Contents</h1>
<button class="flex" @click.stop="showTOCModal = false">
<span class="material-icons">close</span>
</button>
</div>
<!-- chapters list -->
<div class="w-full overflow-y-auto overflow-x-hidden h-full max-h-[calc(100vh-85px)]">
<div class="w-full h-full px-4">
<ul>
<li v-for="chapter in chapters" :key="chapter.id" class="py-1">
<a :href="chapter.href" class="opacity-80 hover:opacity-100" @click.prevent="goToChapter(chapter.href)">{{ chapter.label }}</a>
<ul v-if="chapter.subitems.length">
<li v-for="subchapter in chapter.subitems" :key="subchapter.id" class="py-1 pl-4">
<a :href="subchapter.href" class="opacity-80 hover:opacity-100" @click.prevent="goToChapter(subchapter.href)">{{ subchapter.label }}</a>
</li>
</ul>
</li>
</ul>
<div v-if="!chapters.length" class="flex h-full items-center justify-center">
<p class="text-xl">{{ 'No Chapters' }}</p>
</div>
</div>
</div>
</modals-fullscreen-modal>
<!-- ereader settings modal -->
<modals-fullscreen-modal v-model="showSettingsModal" :theme="ereaderTheme" half-screen>
<div class="flex items-end justify-between h-20 px-4 pb-2 mb-8">
<h1 class="text-lg">Ereader Settings</h1>
<button class="flex" @click="showSettingsModal = false">
<span class="material-icons">close</span>
</button>
</div>
<div class="w-full overflow-y-auto overflow-x-hidden h-full max-h-[calc(100vh-85px)]">
<div class="w-full h-full px-4">
<div class="flex items-center mb-8">
<div class="w-32">
<p class="text-base">Theme:</p>
</div>
<ui-toggle-btns v-model="ereaderSettings.theme" :items="themeItems" @input="settingsUpdated" />
</div>
<div class="flex items-center mb-8">
<div class="w-32">
<p class="text-base">Font scale:</p>
</div>
<ui-range-input v-model="ereaderSettings.fontScale" :min="5" :max="300" :step="5" input-width="180px" @input="settingsUpdated" />
</div>
<div class="flex items-center mb-8">
<div class="w-32">
<p class="text-base">Line spacing:</p>
</div>
<ui-range-input v-model="ereaderSettings.lineSpacing" :min="100" :max="300" :step="5" input-width="180px" @input="settingsUpdated" />
</div>
</div>
</div>
</modals-fullscreen-modal>
</div> </div>
</template> </template>
<script> <script>
import { Capacitor } from '@capacitor/core'
export default { export default {
data() { data() {
return { return {
@@ -17,16 +97,32 @@ export default {
touchstartY: 0, touchstartY: 0,
touchendX: 0, touchendX: 0,
touchendY: 0, touchendY: 0,
touchstartTime: 0 touchstartTime: 0,
touchIdentifier: null,
showingToolbar: false,
showTOCModal: false,
showSettingsModal: false,
comicHasMetadata: false,
chapters: [],
ereaderSettings: {
theme: 'dark',
fontScale: 100,
lineSpacing: 115
}
} }
}, },
watch: { watch: {
show: { show: {
handler(newVal) { handler(newVal) {
if (newVal) { if (newVal) {
this.comicHasMetadata = false
this.registerListeners() this.registerListeners()
this.hideToolbar()
console.log('showReader for ebookFile', JSON.stringify(this.ebookFile))
} else { } else {
this.unregisterListeners() this.unregisterListeners()
this.$showHideStatusBar(true)
} }
} }
} }
@@ -47,10 +143,26 @@ export default {
return this.$store.state.selectedLibraryItem return this.$store.state.selectedLibraryItem
}, },
media() { media() {
return this.selectedLibraryItem ? this.selectedLibraryItem.media : null return this.selectedLibraryItem?.media || null
}, },
mediaMetadata() { mediaMetadata() {
return this.media ? this.media.metadata || {} : {} return this.media?.metadata || {}
},
ereaderTheme() {
if (this.isEpub) return this.ereaderSettings.theme
return 'dark'
},
themeItems() {
return [
{
text: 'Dark',
value: 'dark'
},
{
text: 'Light',
value: 'light'
}
]
}, },
readerComponentName() { readerComponentName() {
if (this.ebookType === 'epub') return 'readers-epub-reader' if (this.ebookType === 'epub') return 'readers-epub-reader'
@@ -59,17 +171,19 @@ export default {
else if (this.ebookType === 'pdf') return 'readers-pdf-reader' else if (this.ebookType === 'pdf') return 'readers-pdf-reader'
return null return null
}, },
folderId() {
return this.selectedLibraryItem ? this.selectedLibraryItem.folderId : null
},
libraryId() {
return this.selectedLibraryItem ? this.selectedLibraryItem.libraryId : null
},
ebookFile() { ebookFile() {
return this.media ? this.media.ebookFile : null // ebook file id is passed when reading a supplementary ebook
if (this.ebookFileId) {
return this.selectedLibraryItem.libraryFiles.find((lf) => lf.ino === this.ebookFileId)
}
return this.media.ebookFile
}, },
ebookFormat() { ebookFormat() {
if (!this.ebookFile) return null if (!this.ebookFile) return null
// Use file extension for supplementary ebook
if (!this.ebookFile.ebookFormat) {
return this.ebookFile.metadata.ext.toLowerCase().slice(1)
}
return this.ebookFile.ebookFormat return this.ebookFile.ebookFormat
}, },
ebookType() { ebookType() {
@@ -91,27 +205,69 @@ export default {
isComic() { isComic() {
return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr' return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr'
}, },
isLocal() {
return !!this.ebookFile?.isLocal || !!this.ebookFile?.localFileId
},
localContentUrl() {
return this.ebookFile?.contentUrl
},
ebookUrl() { ebookUrl() {
if (!this.ebookFile) return null if (!this.ebookFile) return null
let filepath = '' if (this.localContentUrl) {
if (this.selectedLibraryItem.isFile) { return Capacitor.convertFileSrc(this.localContentUrl)
filepath = this.$encodeUriPath(this.ebookFile.metadata.filename)
} else {
const itemRelPath = this.selectedLibraryItem.relPath
if (itemRelPath.startsWith('/')) itemRelPath = itemRelPath.slice(1)
const relPath = this.ebookFile.metadata.relPath
if (relPath.startsWith('/')) relPath = relPath.slice(1)
filepath = this.$encodeUriPath(`${itemRelPath}/${relPath}`)
} }
const serverAddress = this.$store.getters['user/getServerAddress'] const serverAddress = this.$store.getters['user/getServerAddress']
return `${serverAddress}/ebook/${this.libraryId}/${this.folderId}/${filepath}`
if (this.ebookFileId) {
return `${serverAddress}/api/items/${this.selectedLibraryItem.id}/ebook/${this.ebookFileId}`
}
return `${serverAddress}/api/items/${this.selectedLibraryItem.id}/ebook`
}, },
playerLibraryItemId() { isPlayerOpen() {
return this.$store.state.playerLibraryItemId return this.$store.getters['getIsPlayerOpen']
},
keepProgress() {
return this.$store.state.ereaderKeepProgress
},
ebookFileId() {
return this.$store.state.ereaderFileId
} }
}, },
methods: { methods: {
settingsUpdated() {
this.$refs.readerComponent?.updateSettings?.(this.ereaderSettings)
localStorage.setItem('ereaderSettings', JSON.stringify(this.ereaderSettings))
},
goToChapter(href) {
this.showTOCModal = false
this.$refs.readerComponent?.goToChapter(href)
},
readerMounted() {
if (this.isEpub) {
this.loadEreaderSettings()
}
},
readerLoaded(data) {
if (this.isComic) {
this.comicHasMetadata = data.hasMetadata
}
},
clickMetadataBtn() {
this.$refs.readerComponent?.clickShowInfoMenu()
},
clickTOCBtn() {
this.hideToolbar()
if (this.isComic) {
this.$refs.readerComponent?.clickShowPageMenu?.()
} else {
this.chapters = this.$refs.readerComponent?.chapters || []
this.showTOCModal = true
}
},
clickSettingsBtn() {
this.hideToolbar()
this.showSettingsModal = true
},
next() { next() {
if (this.$refs.readerComponent && this.$refs.readerComponent.next) { if (this.$refs.readerComponent && this.$refs.readerComponent.next) {
this.$refs.readerComponent.next() this.$refs.readerComponent.next()
@@ -123,7 +279,7 @@ export default {
} }
}, },
handleGesture() { handleGesture() {
// Touch must be less than 1s. Must be > 100px drag and X distance > Y distance // Touch must be less than 1s. Must be > 60px drag and X distance > Y distance
const touchTimeMs = Date.now() - this.touchstartTime const touchTimeMs = Date.now() - this.touchstartTime
if (touchTimeMs >= 1000) { if (touchTimeMs >= 1000) {
console.log('Touch too long', touchTimeMs) console.log('Touch too long', touchTimeMs)
@@ -132,32 +288,80 @@ export default {
const touchDistanceX = Math.abs(this.touchendX - this.touchstartX) const touchDistanceX = Math.abs(this.touchendX - this.touchstartX)
const touchDistanceY = Math.abs(this.touchendY - this.touchstartY) const touchDistanceY = Math.abs(this.touchendY - this.touchstartY)
if (touchDistanceX < 100 || touchDistanceY > touchDistanceX) return const touchDistance = Math.sqrt(Math.pow(this.touchstartX - this.touchendX, 2) + Math.pow(this.touchstartY - this.touchendY, 2))
if (touchDistance < 60) {
if (this.showSettingsModal) {
this.showSettingsModal = false
} else {
this.toggleToolbar()
}
return
}
if (touchDistanceX < 60 || touchDistanceY > touchDistanceX) {
return
}
this.hideToolbar()
if (this.touchendX < this.touchstartX) { if (this.touchendX < this.touchstartX) {
console.log('swiped left')
this.next() this.next()
} }
if (this.touchendX > this.touchstartX) { if (this.touchendX > this.touchstartX) {
console.log('swiped right')
this.prev() this.prev()
} }
}, },
showToolbar() {
this.showingToolbar = true
this.$showHideStatusBar(true)
},
hideToolbar() {
this.showingToolbar = false
this.$showHideStatusBar(false)
},
toggleToolbar() {
if (this.showingToolbar) this.hideToolbar()
else this.showToolbar()
},
touchstart(e) { touchstart(e) {
this.touchstartX = e.changedTouches[0].screenX // Ignore rapid touch
this.touchstartY = e.changedTouches[0].screenY if (this.touchstartTime && Date.now() - this.touchstartTime < 250) {
return
}
this.touchstartX = e.touches[0].screenX
this.touchstartY = e.touches[0].screenY
this.touchstartTime = Date.now() this.touchstartTime = Date.now()
this.touchIdentifier = e.touches[0].identifier
}, },
touchend(e) { touchend(e) {
if (this.touchIdentifier !== e.changedTouches[0].identifier) {
return
}
this.touchendX = e.changedTouches[0].screenX this.touchendX = e.changedTouches[0].screenX
this.touchendY = e.changedTouches[0].screenY this.touchendY = e.changedTouches[0].screenY
this.handleGesture() this.handleGesture()
}, },
closeEvt() {
this.show = false
},
loadEreaderSettings() {
try {
const settings = localStorage.getItem('ereaderSettings')
if (settings) {
this.ereaderSettings = JSON.parse(settings)
this.settingsUpdated()
}
} catch (error) {
console.error('Failed to load ereader settings', error)
}
},
registerListeners() { registerListeners() {
this.$eventBus.$on('close-ebook', this.closeEvt)
document.body.addEventListener('touchstart', this.touchstart) document.body.addEventListener('touchstart', this.touchstart)
document.body.addEventListener('touchend', this.touchend) document.body.addEventListener('touchend', this.touchend)
}, },
unregisterListeners() { unregisterListeners() {
this.$eventBus.$on('close-ebook', this.closeEvt)
document.body.removeEventListener('touchstart', this.touchstart) document.body.removeEventListener('touchstart', this.touchstart)
document.body.removeEventListener('touchend', this.touchend) document.body.removeEventListener('touchend', this.touchend)
} }
+72
View File
@@ -0,0 +1,72 @@
<template>
<div class="w-full my-4">
<div class="w-full bg-primary px-4 py-2 flex items-center" :class="expanded ? 'rounded-t-md' : 'rounded-md'" @click.stop="clickBar">
<p class="pr-2">Chapters</p>
<div class="h-6 w-6 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
<span class="text-xs font-mono">{{ chapters.length }}</span>
</div>
<div class="flex-grow" />
<div class="h-10 w-10 rounded-full flex justify-center items-center duration-500" :class="expanded ? 'transform rotate-180' : ''">
<span class="material-icons text-3xl">expand_more</span>
</div>
</div>
<transition name="slide">
<table class="text-xs tracksTable" v-show="expanded">
<tr>
<th class="text-left">Title</th>
<th class="text-center w-16">Start</th>
</tr>
<tr v-for="chapter in chapters" :key="chapter.id">
<td>
{{ chapter.title }}
</td>
<td class="font-mono text-center underline w-16" @click.stop="goToTimestamp(chapter.start)">
{{ $secondsToTimestamp(chapter.start) }}
</td>
</tr>
</table>
</transition>
</div>
</template>
<script>
export default {
props: {
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {
expanded: false
}
},
computed: {
libraryItemId() {
return this.libraryItem.id
},
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
metadata() {
return this.media.metadata || {}
},
chapters() {
return this.media.chapters || []
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
}
},
methods: {
clickBar() {
this.expanded = !this.expanded
},
goToTimestamp(time) {
this.$emit('playAtTimestamp', time)
}
},
mounted() {}
}
</script>
+60
View File
@@ -0,0 +1,60 @@
<template>
<div class="w-full my-4">
<div class="w-full bg-primary px-4 py-2 flex items-center" :class="showTracks ? 'rounded-t-md' : 'rounded-md'" @click.stop="clickBar">
<p class="pr-2">{{ title }}</p>
<div class="h-6 w-6 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
<span class="text-xs font-mono">{{ tracks.length }}</span>
</div>
<div class="flex-grow" />
<div class="h-10 w-10 rounded-full flex justify-center items-center duration-500" :class="showTracks ? 'transform rotate-180' : ''">
<span class="material-icons text-3xl">expand_more</span>
</div>
</div>
<transition name="slide">
<div class="w-full" v-show="showTracks">
<table class="text-xs tracksTable">
<tr>
<th class="text-left">Filename</th>
<th class="text-center w-16">Duration</th>
</tr>
<template v-for="track in tracks">
<tr :key="track.index">
<td>{{ (track.metadata && track.metadata.filename) || track.title || 'Unknown' }}</td>
<td class="font-mono text-center w-16">
{{ $secondsToTimestamp(track.duration) }}
</td>
</tr>
</template>
</table>
</div>
</transition>
</div>
</template>
<script>
export default {
props: {
title: {
type: String,
default: 'Audio Tracks'
},
tracks: {
type: Array,
default: () => []
},
libraryItemId: String
},
data() {
return {
showTracks: false
}
},
computed: {},
methods: {
clickBar() {
this.showTracks = !this.showTracks
}
},
mounted() {}
}
</script>
+30 -15
View File
@@ -1,16 +1,21 @@
<template> <template>
<div class="w-full px-2 py-2 overflow-hidden relative"> <div class="w-full px-2 py-2 overflow-hidden relative">
<nuxt-link v-if="book" :to="`/item/${book.id}`" class="flex w-full"> <nuxt-link v-if="book" :to="`/item/${book.id}`" class="flex items-center w-full">
<div class="h-full relative" :style="{ width: bookWidth + 'px' }"> <div class="h-full relative" :style="{ width: bookWidth + 'px' }">
<covers-book-cover :library-item="book" :width="bookWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <covers-book-cover :library-item="book" :width="bookWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div> </div>
<div class="flex-grow book-table-content h-full px-2 flex items-center"> <div class="book-table-content h-full px-2 flex items-center">
<div class="max-w-full"> <div class="max-w-full">
<p class="truncate block text-sm">{{ bookTitle }}</p> <p class="truncate block text-sm">{{ bookTitle }}</p>
<p class="truncate block text-gray-400 text-xs">{{ bookAuthor }}</p> <p class="truncate block text-gray-400 text-xs">{{ bookAuthor }}</p>
<p class="text-xxs text-gray-500">{{ bookDuration }}</p> <p class="text-xxs text-gray-500">{{ bookDuration }}</p>
</div> </div>
</div> </div>
<div class="w-8 min-w-8 flex justify-center">
<button v-if="showPlayBtn" class="w-8 h-8 rounded-full border border-white border-opacity-20 flex items-center justify-center" @click.stop.prevent="playClick">
<span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
</button>
</div>
</nuxt-link> </nuxt-link>
</div> </div>
</template> </template>
@@ -31,6 +36,9 @@ export default {
} }
}, },
computed: { computed: {
libraryItemId() {
return this.book.id
},
media() { media() {
return this.book.media || {} return this.book.media || {}
}, },
@@ -59,22 +67,29 @@ export default {
isMissing() { isMissing() {
return this.book.isMissing return this.book.isMissing
}, },
isIncomplete() { isInvalid() {
return this.book.isIncomplete return this.book.isInvalid
},
numTracks() {
return this.book.numTracks
},
isStreaming() {
return this.$store.getters['getIsItemStreaming'](this.book.id)
}, },
showPlayBtn() { showPlayBtn() {
return !this.isMissing && !this.isIncomplete && !this.isStreaming && this.numTracks return !this.isMissing && !this.isInvalid && this.tracks.length
},
isStreaming() {
return this.$store.getters['getIsMediaStreaming'](this.libraryItemId)
},
streamIsPlaying() {
return this.$store.state.playerIsPlaying && this.isStreaming
} }
}, },
methods: { methods: {
clickEdit() { async playClick() {
this.$emit('edit', this.book) await this.$hapticsImpact()
if (this.streamIsPlaying) {
this.$eventBus.$emit('pause-item')
} else {
this.$eventBus.$emit('play-item', {
libraryItemId: this.libraryItemId
})
}
} }
}, },
mounted() {} mounted() {}
@@ -83,7 +98,7 @@ export default {
<style> <style>
.book-table-content { .book-table-content {
width: calc(100% - 50px); width: calc(100% - 82px);
max-width: calc(100% - 50px); max-width: calc(100% - 82px);
} }
</style> </style>
+102
View File
@@ -0,0 +1,102 @@
<template>
<div class="w-full my-2">
<div class="w-full bg-primary px-4 py-2 flex items-center" :class="showFiles ? 'rounded-t-md' : 'rounded-md'" @click.stop="clickBar">
<p class="pr-2">Ebook Files</p>
<div class="h-6 w-6 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
<span class="text-xs font-mono">{{ ebookFiles.length }}</span>
</div>
<div class="flex-grow" />
<div class="h-10 w-10 rounded-full flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''">
<span class="material-icons text-3xl">expand_more</span>
</div>
</div>
<transition name="slide">
<div class="w-full" v-show="showFiles">
<table class="text-sm tracksTable">
<tr>
<th class="text-left px-4">Filename</th>
<th class="text-left px-4 w-16">Read</th>
<th v-if="userCanUpdate && !libraryIsAudiobooksOnly" class="text-center w-16"></th>
</tr>
<template v-for="file in ebookFiles">
<tables-ebook-files-table-row :key="file.path" :libraryItemId="libraryItemId" :file="file" @read="readEbook" @more="showMore" />
</template>
</table>
</div>
</transition>
<modals-dialog v-model="showMoreMenu" :items="moreMenuItems" @action="moreMenuAction" />
</div>
</template>
<script>
export default {
props: {
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {
processing: false,
showFiles: false,
showMoreMenu: false,
moreMenuItems: [],
selectedFile: null
}
},
computed: {
libraryItemId() {
return this.libraryItem.id
},
userToken() {
return this.$store.getters['user/getToken']
},
ebookFiles() {
return (this.libraryItem.libraryFiles || []).filter((lf) => lf.fileType === 'ebook')
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
libraryIsAudiobooksOnly() {
return this.$store.getters['libraries/getLibraryIsAudiobooksOnly']
}
},
methods: {
moreMenuAction(action) {
this.showMoreMenu = false
if (action === 'updateStatus') {
this.updateEbookStatus()
}
},
showMore({ file, items }) {
this.showMoreMenu = true
this.selectedFile = file
this.moreMenuItems = items
},
readEbook(fileIno) {
this.$store.commit('showReader', { libraryItem: this.libraryItem, keepProgress: false, fileId: fileIno })
},
clickBar() {
this.showFiles = !this.showFiles
},
updateEbookStatus() {
this.processing = true
this.$axios
.$patch(`/api/items/${this.libraryItemId}/ebook/${this.selectedFile.ino}/status`)
.then(() => {
this.$toast.success('Ebook updated')
})
.catch((error) => {
console.error('Failed to update ebook', error)
this.$toast.error('Failed to update ebook')
})
.finally(() => {
this.processing = false
})
}
},
mounted() {}
}
</script>
@@ -0,0 +1,63 @@
<template>
<tr>
<td class="px-4">{{ file.metadata.filename }} <span v-if="isPrimary" class="material-icons-outlined text-success align-text-bottom text-base">check_circle</span></td>
<td class="text-xs w-16">
<ui-icon-btn icon="auto_stories" outlined borderless icon-font-size="1.125rem" :size="8" @click="readEbook" />
</td>
<td v-if="contextMenuItems.length" class="text-center">
<ui-icon-btn icon="more_vert" borderless @click="clickMore" />
</td>
</tr>
</template>
<script>
export default {
props: {
libraryItemId: String,
showFullPath: Boolean,
file: {
type: Object,
default: () => {}
}
},
data() {
return {}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
isPrimary() {
return !this.file.isSupplementary
},
libraryIsAudiobooksOnly() {
return this.$store.getters['libraries/getLibraryIsAudiobooksOnly']
},
contextMenuItems() {
const items = []
if (this.userCanUpdate && !this.libraryIsAudiobooksOnly) {
items.push({
text: this.isPrimary ? 'Set as supplementary' : 'Set as primary',
value: 'updateStatus'
})
}
return items
}
},
methods: {
clickMore() {
this.$emit('more', {
file: this.file,
items: this.contextMenuItems
})
},
readEbook() {
this.$emit('read', this.file.ino)
}
},
mounted() {}
}
</script>
+51 -13
View File
@@ -1,16 +1,21 @@
<template> <template>
<div class="w-full px-2 py-2 overflow-hidden relative"> <div class="w-full px-2 py-2 overflow-hidden relative">
<nuxt-link v-if="libraryItem" :to="`/item/${libraryItem.id}`" class="flex w-full"> <nuxt-link v-if="libraryItem" :to="`/item/${libraryItem.id}`" class="flex items-center w-full">
<div class="h-full relative" :style="{ width: '50px' }"> <div class="h-full relative" :style="{ width: '50px' }">
<covers-book-cover :library-item="libraryItem" :width="50" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <covers-book-cover :library-item="libraryItem" :width="50" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div> </div>
<div class="flex-grow item-table-content h-full px-2 flex items-center"> <div class="item-table-content h-full px-2 flex items-center">
<div class="max-w-full"> <div class="max-w-full">
<p class="truncate block text-sm">{{ itemTitle }}</p> <p class="truncate block text-sm">{{ itemTitle }} <span v-if="localLibraryItem" class="material-icons text-success text-base align-text-bottom">download_done</span></p>
<p class="truncate block text-gray-400 text-xs">{{ bookAuthorName }}</p> <p v-if="authorName" class="truncate block text-gray-300 text-xs">{{ authorName }}</p>
<p class="text-xxs text-gray-500">{{ itemDuration }}</p> <p class="text-xxs text-gray-400">{{ itemDuration }}</p>
</div> </div>
</div> </div>
<div class="w-8 min-w-8 flex justify-center">
<button v-if="showPlayBtn" class="w-8 h-8 rounded-full border border-white border-opacity-20 flex items-center justify-center" @click.stop.prevent="playClick">
<span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
</button>
</div>
</nuxt-link> </nuxt-link>
</div> </div>
</template> </template>
@@ -34,11 +39,17 @@ export default {
libraryItem() { libraryItem() {
return this.item.libraryItem || {} return this.item.libraryItem || {}
}, },
localLibraryItem() {
return this.item.localLibraryItem
},
episode() { episode() {
return this.item.episode return this.item.episode
}, },
episodeId() { episodeId() {
return this.episode ? this.episode.id : null return this.episode?.id || null
},
localEpisode() {
return this.item.localEpisode
}, },
media() { media() {
return this.libraryItem.media || {} return this.libraryItem.media || {}
@@ -61,6 +72,10 @@ export default {
bookAuthorName() { bookAuthorName() {
return this.bookAuthors.map((au) => au.name).join(', ') return this.bookAuthors.map((au) => au.name).join(', ')
}, },
authorName() {
if (this.episode) return this.mediaMetadata.author
return this.bookAuthorName
},
itemDuration() { itemDuration() {
if (this.episode) return this.$elapsedPretty(this.episode.duration) if (this.episode) return this.$elapsedPretty(this.episode.duration)
return this.$elapsedPretty(this.media.duration) return this.$elapsedPretty(this.media.duration)
@@ -83,21 +98,44 @@ export default {
isInvalid() { isInvalid() {
return this.libraryItem.isInvalid return this.libraryItem.isInvalid
}, },
isStreaming() {
return this.$store.getters['getIsItemStreaming'](this.item.id)
},
showPlayBtn() { showPlayBtn() {
return !this.isMissing && !this.isInvalid && !this.isStreaming && (this.tracks.length || this.episode) return !this.isMissing && !this.isInvalid && (this.tracks.length || this.episode)
},
isStreaming() {
if (this.localLibraryItem && this.localEpisode && this.$store.getters['getIsMediaStreaming'](this.localLibraryItem.id, this.localEpisode.id)) return true
return this.$store.getters['getIsMediaStreaming'](this.libraryItem.id, this.episodeId)
},
streamIsPlaying() {
return this.$store.state.playerIsPlaying && this.isStreaming
}
},
methods: {
async playClick() {
await this.$hapticsImpact()
if (this.streamIsPlaying) {
this.$eventBus.$emit('pause-item')
} else if (this.localLibraryItem) {
this.$eventBus.$emit('play-item', {
libraryItemId: this.localLibraryItem.id,
episodeId: this.localEpisode?.id,
serverLibraryItemId: this.libraryItem.id,
serverEpisodeId: this.episodeId
})
} else {
this.$eventBus.$emit('play-item', {
libraryItemId: this.libraryItem.id,
episodeId: this.episodeId
})
}
} }
}, },
methods: {},
mounted() {} mounted() {}
} }
</script> </script>
<style> <style>
.item-table-content { .item-table-content {
width: calc(100% - 50px); width: calc(100% - 82px);
max-width: calc(100% - 50px); max-width: calc(100% - 82px);
} }
</style> </style>
+58 -83
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="w-full 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" @click.stop="goToEpisodePage">
<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>
@@ -16,31 +16,36 @@
<p class="text-sm font-semibold"> <p class="text-sm font-semibold">
{{ title }} {{ title }}
</p> </p>
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5 default-style" v-html="description" />
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5" v-html="subtitle" />
<div v-if="episodeNumber || season || episodeType" class="flex py-2 items-center -mx-0.5">
<div v-if="episodeNumber" class="px-2 pt-px pb-0.5 mx-0.5 bg-primary bg-opacity-50 rounded-full text-xs font-light text-gray-200">Episode #{{ episodeNumber }}</div>
<div v-if="season" class="px-2 pt-px pb-0.5 mx-0.5 bg-primary bg-opacity-50 rounded-full text-xs font-light text-gray-200">Season #{{ season }}</div>
<div v-if="episodeType" class="px-2 pt-px pb-0.5 mx-0.5 bg-primary bg-opacity-50 rounded-full text-xs font-light text-gray-200 capitalize">
{{ episodeType }}
</div>
</div>
<div class="flex items-center pt-2"> <div class="flex items-center pt-2">
<div class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click="playClick"> <div class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick">
<span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span> <span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
<p class="pl-2 pr-1 text-sm font-semibold">{{ timeRemaining }}</p> <p class="pl-2 pr-1 text-sm font-semibold">{{ timeRemaining }}</p>
</div> </div>
<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"> <button v-if="!isLocal" class="mx-1.5 mt-1.5" @click.stop="addToPlaylist">
<span class="material-icons text-2xl">playlist_add</span> <span class="material-icons text-2xl">playlist_add</span>
</button> </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.5 mt-2 text-xl" :class="downloadItem ? 'animate-bounce text-warning text-opacity-75' : ''" @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.stop="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" /> <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>
@@ -77,9 +82,6 @@ export default {
} }
}, },
computed: { computed: {
isAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
isIos() { isIos() {
return this.$platform === 'ios' return this.$platform === 'ios'
}, },
@@ -95,23 +97,24 @@ export default {
title() { title() {
return this.episode.title || '' return this.episode.title || ''
}, },
description() { subtitle() {
if (this.episode.subtitle) return this.episode.subtitle return this.episode.subtitle || this.episode.description || ''
var desc = this.episode.description || '' },
return desc episodeNumber() {
return this.episode.episode
},
season() {
return this.episode.season
},
episodeType() {
if (this.episode.episodeType === 'full') return null // only show Trailer/Bonus
return this.episode.episodeType
}, },
duration() { duration() {
return this.$secondsToTimestamp(this.episode.duration) return this.$secondsToTimestamp(this.episode.duration)
}, },
isStreaming() { isStreaming() {
if (this.playerIsLocal && this.localLibraryItemId && this.localEpisode) { return this.$store.getters['getIsMediaStreaming'](this.libraryItemId, this.episode.id)
// Check is streaming local version of this episode
return this.$store.getters['getIsEpisodeStreaming'](this.localLibraryItemId, this.localEpisode.id)
}
return this.$store.getters['getIsEpisodeStreaming'](this.libraryItemId, this.episode.id)
},
playerIsLocal() {
return !!this.$store.state.playerIsLocal
}, },
streamIsPlaying() { streamIsPlaying() {
return this.$store.state.playerIsPlaying && this.isStreaming return this.$store.state.playerIsPlaying && this.isStreaming
@@ -152,28 +155,8 @@ export default {
} }
}, },
methods: { methods: {
async deleteEpisodeFromServerClick() { goToEpisodePage() {
const { value } = await Dialog.confirm({ this.$router.push(`/item/${this.libraryItemId}/${this.episode.id}`)
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() { addToPlaylist() {
this.$emit('addToPlaylist', this.episode) this.$emit('addToPlaylist', this.episode)
@@ -196,29 +179,33 @@ export default {
} }
}, },
async download(selectedLocalFolder = null) { async download(selectedLocalFolder = null) {
var localFolder = selectedLocalFolder let localFolder = selectedLocalFolder
if (!localFolder) { if (!localFolder) {
var localFolders = (await this.$db.getLocalFolders()) || [] const localFolders = (await this.$db.getLocalFolders()) || []
console.log('Local folders loaded', localFolders.length) console.log('Local folders loaded', localFolders.length)
var foldersWithMediaType = localFolders.filter((lf) => { const foldersWithMediaType = localFolders.filter((lf) => {
console.log('Checking local folder', lf.mediaType) console.log('Checking local folder', lf.mediaType)
return lf.mediaType == this.mediaType return lf.mediaType == this.mediaType
}) })
console.log('Folders with media type', this.mediaType, foldersWithMediaType.length) console.log('Folders with media type', this.mediaType, foldersWithMediaType.length)
const internalStorageFolder = foldersWithMediaType.find((f) => f.id === `internal-${this.mediaType}`)
if (!foldersWithMediaType.length) { if (!foldersWithMediaType.length) {
// No local folders or no local folders with this media type localFolder = {
localFolder = await this.selectFolder() id: `internal-${this.mediaType}`,
} else if (foldersWithMediaType.length == 1) { name: 'Internal App Storage',
console.log('Only 1 local folder with this media type - auto select it') mediaType: this.mediaType
localFolder = foldersWithMediaType[0] }
} else if (foldersWithMediaType.length === 1 && internalStorageFolder) {
localFolder = internalStorageFolder
} else { } else {
console.log('Multiple folders with media type') this.$store.commit('globals/showSelectLocalFolderModal', {
// this.showSelectLocalFolder = true mediaType: this.mediaType,
callback: (folder) => {
this.download(folder)
}
})
return return
} }
if (!localFolder) {
return this.$toast.error('Invalid download folder')
}
} }
console.log('Local folder', JSON.stringify(localFolder)) console.log('Local folder', JSON.stringify(localFolder))
@@ -271,49 +258,37 @@ export default {
}, },
async toggleFinished() { async toggleFinished() {
await this.$hapticsImpact() await this.$hapticsImpact()
this.isProcessingReadUpdate = true this.isProcessingReadUpdate = true
if (this.isLocal || this.localEpisode) { if (this.isLocal || this.localEpisode) {
var isFinished = !this.userIsFinished const isFinished = !this.userIsFinished
var localLibraryItemId = this.isLocal ? this.libraryItemId : this.localLibraryItemId const localLibraryItemId = this.isLocal ? this.libraryItemId : this.localLibraryItemId
var localEpisodeId = this.isLocal ? this.episode.id : this.localEpisode.id const localEpisodeId = this.isLocal ? this.episode.id : this.localEpisode.id
var payload = await this.$db.updateLocalMediaProgressFinished({ localLibraryItemId, localEpisodeId, isFinished }) const payload = await this.$db.updateLocalMediaProgressFinished({ localLibraryItemId, localEpisodeId, isFinished })
console.log('toggleFinished payload', JSON.stringify(payload)) console.log('toggleFinished payload', JSON.stringify(payload))
if (!payload || payload.error) { if (payload?.error) {
var errorMsg = payload ? payload.error : 'Unknown error' this.$toast.error(payload?.error || 'Unknown error')
this.$toast.error(errorMsg)
} else { } else {
var localMediaProgress = payload.localMediaProgress const localMediaProgress = payload.localMediaProgress
console.log('toggleFinished localMediaProgress', JSON.stringify(localMediaProgress)) console.log('toggleFinished localMediaProgress', JSON.stringify(localMediaProgress))
if (localMediaProgress) { if (localMediaProgress) {
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress) this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
} }
var lmp = this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId, this.episode.id)
console.log('toggleFinished Check LMP', this.libraryItemId, this.episode.id, JSON.stringify(lmp))
var serverUpdated = payload.server
if (serverUpdated) {
this.$toast.success(`Local & Server Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
} else {
this.$toast.success(`Local Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
}
} }
this.isProcessingReadUpdate = false this.isProcessingReadUpdate = false
} else { } else {
var updatePayload = { const updatePayload = {
isFinished: !this.userIsFinished isFinished: !this.userIsFinished
} }
this.$axios this.$axios
.$patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload) .$patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
this.$toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
})
.catch((error) => { .catch((error) => {
console.error('Failed', error) console.error('Failed', error)
this.isProcessingReadUpdate = false
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`) this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
}) })
.finally(() => {
this.isProcessingReadUpdate = false
})
} }
} }
} }

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