Compare commits

...
Author SHA1 Message Date
advplyr f89148b92e iOS version bump 0.9.68 2023-11-25 15:03:14 -06:00
advplyr 88fbebe5c4 Fix:Error going to local library item page that is storing an old server library item id when server is now using new ids #953 2023-11-25 14:38:40 -06:00
advplyr e8abb18350 Version bump v0.9.68-beta 2023-11-25 13:26:12 -06:00
advplyr cc9dead1eb Remove unused query property 2023-11-25 13:10:46 -06:00
advplyr f9dd8188ee Update:Clicking local podcast from home page will filter by downloaded episodes #958 2023-11-25 13:09:43 -06:00
advplyr 579785d2c6 Add:Downloaded filter for podcast episodes #959
Fix:More menu button not working for podcasts with some episodes downloaded
2023-11-25 12:56:29 -06:00
advplyr 257a1ceb51 Fix:Check network connection before redirecting local item to server item #958 2023-11-25 12:28:28 -06:00
advplyr 5c60cd136c Fix password login to send empty string instead of null to support passwordless root user login 2023-11-25 11:17:35 -06:00
advplyr 2bfe548c52 Add:Monochrome app icon & capitalize app name #786 2023-11-22 14:00:55 -06:00
advplyr 164fd92a18 Fix:Android auto unable to play podcast episodes #832 2023-11-21 11:33:45 -06:00
advplyr 0036a19659 Fix:Downloaded item server user id mismatch alert to support connections with old user id #945 2023-11-21 10:55:26 -06:00
advplyr ef4d7ba1bf Fix openid path that is still expecting a trailing slash 2023-11-19 14:45:35 -06:00
advplyr 8dbffa7993 iOS version bump 0.9.67 2023-11-17 15:44:33 -06:00
advplyr 17ff2ed5f4 Version bump 0.9.67-beta 2023-11-17 15:27:02 -06:00
advplyr 6fe470cfc1 Update:Android remove folder scanning and ffmpegkit 2023-11-16 14:52:11 -06:00
advplyr 301e9b213f Update message alerts for locally downloaded media. Added alert for same server address but different user. 2023-11-16 13:28:12 -06:00
advplyr 2b533524fc Add:Tags in search results #940 2023-11-15 14:30:57 -06:00
advplyr 632b1f526b Update:Library item pages show alerts on if the item is linked to the current server
- Local library item pages redirect to server item page if connected to the same server address
2023-11-14 17:13:49 -06:00
advplyr 81b11bac93 Fix:iOS downloading not saving progress for local copy #825 2023-11-13 16:33:19 -06:00
advplyr a88e404ccf Update:Trim trailing slash from server address input 2023-11-13 15:22:15 -06:00
advplyr a7cbdbfbbb Update:iOS remove paused timer that synced with the server when current media was paused 2023-11-12 13:49:20 -06:00
advplyr ad5146e2ee Update:iOS remove unused logic of opening last active session on app launch 2023-11-12 13:36:23 -06:00
advplyr 36be91962c Update:iOS using new sync local sessions endpoint
- remove local session sync function call on starting playback
- Add User model and getCurrentUser api function
2023-11-12 13:19:36 -06:00
advplyr ff5a1bb09f Update:Server connect page shows message informing new users #912 2023-11-12 10:30:39 -06:00
advplyrandGitHub 5060554640 Merge pull request #887 from atoghyani/ios-sleep-timer-reset-after-resume
Adding a check so chapter sleep timer is set after resume only when a…
2023-11-12 08:35:44 -06:00
advplyrandGitHub 93cb319e97 Merge pull request #906 from Sapd/oauth2-support
OAuth2 Support
2023-11-11 14:49:49 -06:00
Denis Arnst a42dfa5649 Don't check version for now
- Be more explicit on error 404, to not confuse users
- Error for when there is no authCode
2023-11-11 21:30:10 +01:00
advplyr 6a938b8da1 Update button UI to be full width 2023-11-11 14:00:48 -06:00
advplyr 4f994072ad Check for duplicate config when authenticating with openid 2023-11-11 13:55:32 -06:00
advplyr 7c0ef179ec Update:Podcast search page renamed to add #936 2023-11-10 16:36:07 -06:00
Denis Arnst 5fdd0c6672 oauth2: Fix PKCE
- Send challenge to the SSO provider itself too
- Fix challenge generation
- Removed semicolons
2023-11-10 22:30:55 +01:00
advplyr e9f523d7cb Fix:Android downloading finished item not marked as finished #903 2023-11-07 16:04:01 -06:00
advplyr c1b13dcd24 Update:Hide duration on collection items that have no duration 2023-11-06 16:21:52 -06:00
Denis Arnst 0daa043f14 oauth: Customizable OpenID Buttontext and auto-launch 2023-11-05 21:35:42 +01:00
Denis Arnst 1a6b716046 oauth2: Add comments
- Move appUrlOpen to represent the correct order of flow
2023-11-05 21:01:51 +01:00
Denis Arnst 945baa24f0 Connect: Check if audiobookshelf and check for version 2023-11-05 20:32:14 +01:00
advplyr 30d4e709f0 Merge branch 'master' into oauth2-support 2023-11-05 10:58:17 -06:00
Denis Arnst 8d8782a5a9 oauth2: Implement PKCE
- Also fixed URLs, the / after ${this.serverConfig.address} had to be removed, because during Connection flow the URL is now (re)set correctly uniformly
- Removed now uneccessary callback? parameter from first auth request
2023-11-05 17:27:04 +01:00
advplyr 087e8553c4 Update:Bookmarks modal is not closed on updating/deleting and does not show a toast #882 2023-11-04 16:08:19 -05:00
Denis Arnst 2984398051 ServerConnect: Improve connect flow
* Prefer HTTPS if no protocol given
* Retry with HTTP if no protocol given and HTTPS TCP establishment was not successful
* Do validation checks, i.e. control for unexpected redirects. Don't allow for protocol downgrade
* Provide good user messages on failure
* Fix a bug where it would use the wrong protocol after connecting bc. of unchecked redirect
* Reworked getRequest(...) to get information about the resulting URL and detailed errors
2023-11-04 15:25:03 +01:00
advplyr e9251db647 Add more menu on playlist item row to show library item more menu #914 2023-11-03 15:53:38 -05:00
Denis Arnst 6c88337180 oauth2: Force HTTPS, check state
Also improve error handling
2023-11-03 21:29:37 +01:00
advplyr 74b488ad0f Update playlist table design to include progress bar on items #914 2023-11-03 13:00:45 -05:00
advplyr fc7af6d1fc Add:Send ebook to device button #909 2023-11-02 16:10:55 -05:00
advplyr 737d8f19b3 Move appUrlOpen listener to plugin and listen for eventBus event 2023-11-01 10:16:31 -05:00
advplyr d613d8954d Fix host path extra port, fix formatting 2023-11-01 09:30:52 -05:00
advplyr 04e468b43d Fix:Android seeking from notification widget on multi-track books #894 2023-10-31 16:20:28 -05:00
advplyr dc8178769b Fix:Series page sort order by name #925 2023-10-31 16:00:08 -05:00
Denis Arnst 8b631f7860 oauth2 support on Android 2023-10-13 23:14:52 +02:00
Denis Arnst e521ddfab6 OAuth2 Support
Using CapacitorHttp and in-app link
2023-10-08 18:36:28 +02:00
advplyr 6b164bdb27 Fix:Clicking series item in list view when collapsing series #877 2023-10-07 13:39:47 -05:00
Arian Toghyani 2eb399b8a7 Adding a check so chapter sleep timer is set after resume only when a chapter sleep timer was set before 2023-09-26 22:35:01 -07:00
advplyr 46f558d6e0 Update axios requests to nativeHttp, add openid connect auth button 2023-09-25 17:30:39 -05:00
advplyr 00bed39733 iOS version bump 0.9.66 2023-09-17 14:12:08 -05:00
advplyr 2048795c92 Version bump 0.9.66-beta 2023-09-17 13:35:08 -05:00
advplyr 8c9bd53934 Hide lock orientation setting on iOS because it is not supported 2023-09-17 12:53:38 -05:00
advplyr 01178d00bf Replace existing axios calls with native https calls 2023-09-17 12:43:50 -05:00
advplyr 7541e4b660 Fix:Android mp3 index seeking setting not sticking #638 2023-09-17 11:51:15 -05:00
advplyr 2366c938b5 Update:Remove non-breaking spaces from comma separated name strings 2023-09-17 11:37:31 -05:00
advplyr 59480ad114 Update:Add switch server/user button in side drawer and change button text on account page. Update logout to disconnect #628 2023-09-17 10:23:35 -05:00
advplyr ce7779607f Update:Bookmarks modal UI #876 2023-09-17 09:28:43 -05:00
advplyr 04bc74babe Update:Epub ereader show location at bottom of page #766 2023-09-16 15:12:20 -05:00
advplyr ac2b674ba5 Fix:Epub ereader swiping moving 2 pages #802 2023-09-16 09:38:20 -05:00
advplyr e53901e7a4 Fix:Incorrect line-height on epub titles #874 2023-09-16 09:30:51 -05:00
advplyr 3ede41f496 Add java version to github workflow 2023-09-16 08:18:13 -05:00
advplyr 0adeaaa04d Add java version to github workflow 2023-09-16 08:08:45 -05:00
advplyr 57d1fbfa83 Disable CapacitorHttp and add plugin to replace axios calls #781 2023-09-15 17:35:44 -05:00
advplyr 27bbe40874 Update to capacitor v5, update to latest gradle and android target 34 2023-09-14 18:05:49 -05:00
advplyr 60da6093f3 Update:Android auto include explicit indicator #611 2023-09-12 17:14:28 -05:00
advplyr 259c9cef20 Update:Podcast items show number of incomplete episodes #315 2023-09-11 17:55:59 -05:00
advplyr 6d61b2acaf Fix:Switch to library of item when clicking on title of fullscreen audio player #803 2023-09-11 17:08:15 -05:00
advplyr 72f687b079 Fix:Android auto loading podcast episodes #832 2023-09-11 12:25:51 -05:00
advplyr 0ce3c8bcbd Fix:Remove local playback sessions started offline, refresh open player timestamps when device gains focus 2023-09-10 17:51:53 -05:00
advplyr f9621d2f60 Fix:Progress lost when app in background for period of time #828 2023-09-10 15:10:26 -05:00
advplyr e634132a39 Fix:Android sync server progress to local progress #808 2023-09-10 13:37:41 -05:00
advplyr 59b428100b Sync local sessions when network reconnects 2023-09-10 12:46:57 -05:00
advplyrandGitHub 36e0c91e27 Merge pull request #851 from lkiesow/author-separator
Fix separator between authors
2023-09-10 08:39:20 -05:00
advplyr 03225957aa Update initial server connection to not block the home page 2023-09-10 08:38:11 -05:00
Lars Kiesow 77f70e48de Fix separator between authors
This patch fixes a small problem with the separator between several
authors which is `Author A , Author B`, but should be instead
`Author A, Author B` like with the other metadata fields.
2023-08-30 23:36:18 +02:00
advplyr cbdc3513c7 Fix:Comic reader not detecting file sort order when number is more than 5-digit 2023-08-29 16:06:20 -05:00
advplyr 394479bea0 Update readme, add matrix link 2023-07-27 18:19:49 -05:00
advplyrandGitHub b9592e46a7 Merge pull request #813 from casrou/master
Add missing space for authors as well #777
2023-07-26 18:02:38 -05:00
advplyr 5810a83c66 Fix:Android internal storage downloads audio files multiple times #791 2023-07-26 18:02:25 -05:00
Casper Roursgaard Christensen 389c7d23ca Revert to index as key 2023-07-27 00:56:47 +02:00
Casper Roursgaard Christensen defc7d216a Add missing space for authors as well #777 2023-07-26 23:29:54 +02:00
advplyr 852702112a Update:Enable CapacitorHttp to patch XMLHttpRequest to use native libraries, fixes cors #781 2023-07-12 17:13:57 -05:00
advplyr dd91bc6667 Fix:Item page comma separated lists space before comma #777 2023-07-12 16:56:23 -05:00
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
165 changed files with 19601 additions and 14919 deletions
+6
View File
@@ -21,6 +21,12 @@ jobs:
with: with:
node-version: 16 node-version: 16
- name: Set up Java
uses: actions/setup-java@v2
with:
distribution: "temurin"
java-version: 17
- name: install dependencies - name: install dependencies
run: npm ci run: npm ci
+6
View File
@@ -20,6 +20,12 @@ jobs:
with: with:
node-version: 16 node-version: 16
- name: Set up Java
uses: actions/setup-java@v2
with:
distribution: "temurin"
java-version: 17
- name: install dependencies - name: install dependencies
run: npm ci run: npm ci
+7 -14
View File
@@ -21,8 +21,6 @@ kotlin {
} }
android { android {
namespace 'com.audiobookshelf.app' namespace 'com.audiobookshelf.app'
buildFeatures { buildFeatures {
viewBinding true viewBinding true
@@ -35,9 +33,12 @@ 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 95 versionCode 99
versionName "0.9.64-beta" versionName "0.9.68-beta"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
manifestPlaceholders = [
"appAuthRedirectScheme": "com.audiobookshelf.app"
]
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.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61 // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
@@ -65,12 +66,11 @@ repositories {
mavenCentral() mavenCentral()
} }
configurations.all { configurations.configureEach {
resolutionStrategy { resolutionStrategy {
force("com.google.android.gms:play-services-base:18.0.1") force("com.google.android.gms:play-services-base:18.0.1")
force("androidx.appcompat:appcompat:$androidxAppCompatVersion") force("androidx.appcompat:appcompat:$androidxAppCompatVersion")
force("androidx.core:core-ktx:$androidx_core_ktx_version") force("androidx.core:core-ktx:$androidx_core_ktx_version")
force("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version")
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")
@@ -83,12 +83,9 @@ dependencies {
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation project(':capacitor-android') implementation project(':capacitor-android')
implementation 'androidx.constraintlayout:constraintlayout:2.1.3' implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
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"
@@ -116,7 +113,6 @@ dependencies {
// Glide for images // Glide for images
implementation "com.github.bumptech.glide:glide:$glide_version" implementation "com.github.bumptech.glide:glide:$glide_version"
kapt "com.github.bumptech.glide:compiler:$glide_version"
// Paper NoSQL Db // Paper NoSQL Db
implementation 'io.github.pilgr:paperdb:2.7.2' implementation 'io.github.pilgr:paperdb:2.7.2'
@@ -129,9 +125,6 @@ dependencies {
// Jackson for JSON // Jackson for JSON
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.12.2' implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.12.2'
// FFMPEG-Kit
implementation 'com.arthenica:ffmpeg-kit-min:4.5.1'
} }
apply from: 'capacitor.build.gradle' apply from: 'capacitor.build.gradle'
+4 -2
View File
@@ -2,14 +2,16 @@
android { android {
compileOptions { compileOptions {
sourceCompatibility JavaVersion.VERSION_11 sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_11 targetCompatibility JavaVersion.VERSION_17
} }
} }
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-browser')
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')
@@ -2,4 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/> <background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/> <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon> <monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -2,4 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/> <background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/> <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon> <monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,14 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">Audiobookshelf</string>
<string name="title_activity_main">Audiobookshelf</string>
<string name="package_name">com.audiobookshelf.app</string>
<string name="custom_url_scheme">audiobookshelf</string>
<string name="add_widget">Add widget</string>
<string name="app_widget_description">Simple widget for audiobookshelf playback</string>
<string name="action_jump_forward">Jump Forward</string>
<string name="action_jump_backward">Jump Backward</string>
<string name="action_skip_forward">Skip Forward</string>
<string name="action_skip_backward">Skip Backward</string>
<string name="action_change_speed">Change Playback Speed</string>
</resources>
+11 -1
View File
@@ -15,10 +15,10 @@
<application <application
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name" android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config" android:networkSecurityConfig="@xml/network_security_config"
android:requestLegacyExternalStorage="true" android:requestLegacyExternalStorage="true"
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"
@@ -92,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>
@@ -2,5 +2,10 @@
"appId": "com.audiobookshelf.app", "appId": "com.audiobookshelf.app",
"appName": "audiobookshelf-app", "appName": "audiobookshelf-app",
"webDir": "dist", "webDir": "dist",
"bundledWebRuntime": false "bundledWebRuntime": false,
"plugins": {
"CapacitorHttp": {
"enabled": false
}
}
} }
@@ -3,6 +3,14 @@
"pkg": "@capacitor/app", "pkg": "@capacitor/app",
"classpath": "com.capacitorjs.plugins.app.AppPlugin" "classpath": "com.capacitorjs.plugins.app.AppPlugin"
}, },
{
"pkg": "@capacitor/browser",
"classpath": "com.capacitorjs.plugins.browser.BrowserPlugin"
},
{
"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
@@ -15,9 +15,7 @@ import android.widget.RemoteViews
import androidx.media.session.MediaButtonReceiver import androidx.media.session.MediaButtonReceiver
import com.audiobookshelf.app.data.PlaybackSession import com.audiobookshelf.app.data.PlaybackSession
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.device.WidgetEventEmitter
import com.audiobookshelf.app.managers.DbManager import com.audiobookshelf.app.managers.DbManager
import com.audiobookshelf.app.player.PlayerNotificationService
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.request.target.AppWidgetTarget import com.bumptech.glide.request.target.AppWidgetTarget
@@ -28,12 +26,9 @@ import com.bumptech.glide.request.transition.Transition
*/ */
class MediaPlayerWidget : AppWidgetProvider() { class MediaPlayerWidget : AppWidgetProvider() {
val tag = "MediaPlayerWidget" val tag = "MediaPlayerWidget"
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
// There may be multiple widgets active, so update all of them Log.d(tag, "onUpdate $appWidgetIds")
for (appWidgetId in appWidgetIds) { }
updateAppWidget(context, appWidgetManager, appWidgetId, null, false, PlayerNotificationService.isClosed)
}
}
override fun onEnabled(context: Context) { override fun onEnabled(context: Context) {
Log.i(tag, "onEnabled check context ${context.packageName}") Log.i(tag, "onEnabled check context ${context.packageName}")
@@ -51,32 +46,14 @@ class MediaPlayerWidget : AppWidgetProvider() {
} }
// Enter relevant functionality for when the first widget is created // Enter relevant functionality for when the first widget is created
DeviceManager.widgetUpdater = (object : WidgetEventEmitter { DeviceManager.initializeWidgetUpdater(context)
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 onDisabled(context: Context) {
// Enter relevant functionality for when the last widget is disabled
}
} }
internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, playbackSession: PlaybackSession?, isPlaying:Boolean, isAppClosed:Boolean) { internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, playbackSession: PlaybackSession?, isPlaying:Boolean, isAppClosed:Boolean) {
val tag = "MediaPlayerWidget" val tag = "MediaPlayerWidget"
val views = RemoteViews(context.packageName, R.layout.media_player_widget) 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) val wholeWidgetClickI = Intent(context, MainActivity::class.java)
wholeWidgetClickI.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK wholeWidgetClickI.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
val wholeWidgetClickPI = PendingIntent.getActivity( val wholeWidgetClickPI = PendingIntent.getActivity(
@@ -100,7 +77,7 @@ internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManage
views.setOnClickPendingIntent(R.id.widgetBackground, wholeWidgetClickPI) views.setOnClickPendingIntent(R.id.widgetBackground, wholeWidgetClickPI)
val imageUri = playbackSession?.getCoverUri() ?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon) val imageUri = playbackSession?.getCoverUri(context) ?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
val awt: AppWidgetTarget = object : AppWidgetTarget(context.applicationContext, R.id.widgetAlbumArt, views, appWidgetId) { val awt: AppWidgetTarget = object : AppWidgetTarget(context.applicationContext, R.id.widgetAlbumArt, views, appWidgetId) {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) { override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
super.onResourceReady(resource, transition) super.onResourceReady(resource, transition)
@@ -118,7 +95,7 @@ internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManage
Log.i(tag, "Update App Widget | Is Playing=$isPlaying | isAppClosed=$isAppClosed") 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 val playPauseResource = if (isPlaying) androidx.mediarouter.R.drawable.ic_media_pause_dark else androidx.mediarouter.R.drawable.ic_media_play_dark
views.setImageViewResource(R.id.widgetPlayPauseButton, playPauseResource) views.setImageViewResource(R.id.widgetPlayPauseButton, playPauseResource)
// Instruct the widget manager to update the widget // Instruct the widget manager to update the widget
@@ -24,7 +24,7 @@ open class MediaType(var metadata:MediaTypeMetadata, var coverPath:String?) {
@JsonIgnore @JsonIgnore
open fun removeAudioTrack(localFileId:String) { } open fun removeAudioTrack(localFileId:String) { }
@JsonIgnore @JsonIgnore
open fun getLocalCopy():MediaType { return MediaType(MediaTypeMetadata(""),null) } open fun getLocalCopy():MediaType { return MediaType(MediaTypeMetadata("", false),null) }
} }
@@ -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
@@ -190,7 +190,7 @@ class Book(
JsonSubTypes.Type(BookMetadata::class), JsonSubTypes.Type(BookMetadata::class),
JsonSubTypes.Type(PodcastMetadata::class) JsonSubTypes.Type(PodcastMetadata::class)
) )
open class MediaTypeMetadata(var title:String) { open class MediaTypeMetadata(var title:String, var explicit:Boolean) {
@JsonIgnore @JsonIgnore
open fun getAuthorDisplayName():String { return "Unknown" } open fun getAuthorDisplayName():String { return "Unknown" }
} }
@@ -209,13 +209,13 @@ class BookMetadata(
var isbn:String?, var isbn:String?,
var asin:String?, var asin:String?,
var language:String?, var language:String?,
var explicit:Boolean, explicit:Boolean,
// In toJSONExpanded // In toJSONExpanded
var authorName:String?, var authorName:String?,
var authorNameLF:String?, var authorNameLF:String?,
var narratorName:String?, var narratorName:String?,
var seriesName:String? var seriesName:String?
) : MediaTypeMetadata(title) { ) : MediaTypeMetadata(title, explicit) {
@JsonIgnore @JsonIgnore
override fun getAuthorDisplayName():String { return authorName ?: "Unknown" } override fun getAuthorDisplayName():String { return authorName ?: "Unknown" }
} }
@@ -225,8 +225,9 @@ class PodcastMetadata(
title:String, title:String,
var author:String?, var author:String?,
var feedUrl:String?, var feedUrl:String?,
var genres:MutableList<String> var genres:MutableList<String>,
) : MediaTypeMetadata(title) { explicit:Boolean
) : MediaTypeMetadata(title, explicit) {
@JsonIgnore @JsonIgnore
override fun getAuthorDisplayName():String { return author ?: "Unknown" } override fun getAuthorDisplayName():String { return author ?: "Unknown" }
} }
@@ -251,15 +252,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()
} }
@@ -83,7 +83,7 @@ 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)
@@ -91,7 +91,6 @@ data class DeviceInfo(
var deviceId:String, 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
) )
@@ -110,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,
@@ -131,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,
@@ -58,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) {
@@ -90,6 +90,10 @@ class LibraryItem(
) )
} }
if (media.metadata.explicit) {
extras.putLong(MediaConstants.METADATA_KEY_IS_EXPLICIT, MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT)
}
val mediaId = localLibraryItemId ?: id val mediaId = localLibraryItemId ?: id
return MediaDescriptionCompat.Builder() return MediaDescriptionCompat.Builder()
.setMediaId(mediaId) .setMediaId(mediaId)
@@ -9,6 +9,8 @@ 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.BuildConfig
import com.audiobookshelf.app.R import com.audiobookshelf.app.R
@@ -46,7 +48,10 @@ class LocalLibraryItem(
val isPodcast get() = mediaType == "podcast" val isPodcast get() = mediaType == "podcast"
@JsonIgnore @JsonIgnore
fun getCoverUri(): Uri { fun getCoverUri(ctx:Context): Uri {
if (coverContentUrl?.startsWith("file:") == true) {
return FileProvider.getUriForFile(ctx, "${BuildConfig.APPLICATION_ID}.fileprovider", Uri.parse(coverContentUrl).toFile())
}
return if (coverContentUrl != null) Uri.parse(coverContentUrl) else Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon) return if (coverContentUrl != null) Uri.parse(coverContentUrl) else Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
} }
@@ -74,7 +79,7 @@ class LocalLibraryItem(
} }
@JsonIgnore @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()}"
@@ -85,19 +90,20 @@ class LocalLibraryItem(
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 val displayTitle = episode?.title ?: mediaMetadata.title
var duration = getDuration() 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 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(), displayTitle, authorName,null,duration,PLAYMETHOD_LOCAL,dateNow,0L,0L, audioTracks,currentTime,null,this,localEpisodeId,serverConnectionConfigId, serverAddress, "exo-player") return PlaybackSession(sessionId,serverUserId,libraryItemId,episode?.serverEpisodeId, mediaType, mediaMetadata, deviceInfo,chapters ?: mutableListOf(), displayTitle, authorName,null,duration,PLAYMETHOD_LOCAL,dateNow,0L,0L, audioTracks,currentTime,null,this,localEpisodeId,serverConnectionConfigId, serverAddress, "exo-player")
} }
@JsonIgnore @JsonIgnore
@@ -106,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)
}
} }
} }
@@ -148,6 +152,10 @@ class LocalLibraryItem(
) )
} }
if (media.metadata.explicit) {
extras.putLong(MediaConstants.METADATA_KEY_IS_EXPLICIT, MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT)
}
val mediaDescriptionBuilder = MediaDescriptionCompat.Builder() val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
.setMediaId(id) .setMediaId(id)
.setTitle(title) .setTitle(title)
@@ -43,7 +43,7 @@ data class LocalMediaItem(
return if (mediaType == "book") { return if (mediaType == "book") {
BookMetadata(name,null, mutableListOf(), mutableListOf(), mutableListOf(),null,null,null,null,null,null,null,false,null,null,null,null) BookMetadata(name,null, mutableListOf(), mutableListOf(), mutableListOf(),null,null,null,null,null,null,null,false,null,null,null,null)
} else { } else {
PodcastMetadata(name,null,null, mutableListOf()) PodcastMetadata(name,null,null, mutableListOf(), false)
} }
} }
@@ -6,6 +6,8 @@ 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.BuildConfig
import com.audiobookshelf.app.R import com.audiobookshelf.app.R
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
@@ -27,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?,
@@ -137,8 +140,15 @@ 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://${BuildConfig.APPLICATION_ID}/" + 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())
}
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) 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}")
@@ -152,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)
@@ -162,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)
@@ -182,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)
@@ -191,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
@@ -252,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
@@ -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 {
@@ -76,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)
}
}
})
}
} }
@@ -5,16 +5,11 @@ import android.net.Uri
import android.util.Log import android.util.Log
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.anggrayudi.storage.file.* import com.anggrayudi.storage.file.*
import com.arthenica.ffmpegkit.FFmpegKitConfig
import com.arthenica.ffmpegkit.FFprobeKit
import com.arthenica.ffmpegkit.Level
import com.audiobookshelf.app.data.* import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.models.DownloadItem import com.audiobookshelf.app.models.DownloadItem
import com.fasterxml.jackson.core.json.JsonReadFeature import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue import java.io.File
import com.getcapacitor.JSObject
import org.json.JSONException
class FolderScanner(var ctx: Context) { class FolderScanner(var ctx: Context) {
private val tag = "FolderScanner" private val tag = "FolderScanner"
@@ -26,213 +21,189 @@ class FolderScanner(var ctx: Context) {
return "local_" + DeviceManager.getBase64Id(mediaItemId) return "local_" + DeviceManager.getBase64Id(mediaItemId)
} }
enum class ItemScanResult { private fun scanInternalDownloadItem(downloadItem:DownloadItem, cb: (DownloadItemScanResult?) -> Unit) {
ADDED, REMOVED, UPDATED, UPTODATE val localLibraryItemId = "local_${downloadItem.libraryItemId}"
}
// TODO: CLEAN this monster! Divide into bite-size methods var localEpisodeId:String? = null
fun scanForMediaItems(localFolder:LocalFolder, forceAudioProbe:Boolean):FolderScanResult? { var localLibraryItem:LocalLibraryItem?
FFmpegKitConfig.enableLogCallback { log -> if (downloadItem.mediaType == "book") {
if (log.level != Level.AV_LOG_STDERR) { // STDERR is filled with junk 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)
Log.d(tag, "FFmpeg-Kit Log: (${log.level}) ${log.message}")
}
}
val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(localFolder.contentUrl))
if (df == null) {
Log.e(tag, "Folder Doc File Invalid $localFolder.contentUrl")
return null
}
var mediaItemsUpdated = 0
var mediaItemsAdded = 0
var mediaItemsRemoved = 0
var mediaItemsUpToDate = 0
// Search for files in media item folder
val foldersFound = df.search(true, DocumentFileType.FOLDER)
// Match folders found with local library items already saved in db
var existingLocalLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(localFolder.id)
// Remove existing items no longer there
existingLocalLibraryItems = existingLocalLibraryItems.filter { lli ->
Log.d(tag, "scanForMediaItems Checking Existing LLI ${lli.id}")
val fileFound = foldersFound.find { f -> lli.id == getLocalLibraryItemId(f.id) }
if (fileFound == null) {
Log.d(tag, "Existing local library item is no longer in file system ${lli.media.metadata.title}")
DeviceManager.dbManager.removeLocalLibraryItem(lli.id)
mediaItemsRemoved++
}
fileFound != null
}
foldersFound.forEach { itemFolder ->
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 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
if (existingItem != null || filesInFolder.isNotEmpty()) {
when (scanLibraryItemFolder(itemFolder, filesInFolder, localFolder, existingItem, forceAudioProbe)) {
ItemScanResult.REMOVED -> mediaItemsRemoved++
ItemScanResult.UPDATED -> mediaItemsUpdated++
ItemScanResult.ADDED -> mediaItemsAdded++
else -> mediaItemsUpToDate++
}
}
}
Log.d(tag, "Folder $${localFolder.name} scan Results: $mediaItemsAdded Added | $mediaItemsUpdated Updated | $mediaItemsRemoved Removed | $mediaItemsUpToDate Up-to-date")
return if (mediaItemsAdded > 0 || mediaItemsUpdated > 0 || mediaItemsRemoved > 0) {
val folderLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(localFolder.id) // Get all local media items
FolderScanResult(mediaItemsAdded, mediaItemsUpdated, mediaItemsRemoved, mediaItemsUpToDate, localFolder, folderLibraryItems)
} else {
Log.d(tag, "No Media Items to save")
FolderScanResult(mediaItemsAdded, mediaItemsUpdated, mediaItemsRemoved, mediaItemsUpToDate, localFolder, mutableListOf())
}
}
private fun scanLibraryItemFolder(itemFolder:DocumentFile, filesInFolder:List<DocumentFile>, localFolder:LocalFolder, existingItem:LocalLibraryItem?, forceAudioProbe:Boolean):ItemScanResult {
val itemFolderName = itemFolder.name ?: ""
val itemId = getLocalLibraryItemId(itemFolder.id)
val existingLocalFiles = existingItem?.localFiles ?: mutableListOf()
val existingAudioTracks = existingItem?.media?.getAudioTracks() ?: mutableListOf()
var isNewOrUpdated = existingItem == null
val audioTracks = mutableListOf<AudioTrack>()
val localFiles = mutableListOf<LocalFile>()
var index = 1
var startOffset = 0.0
var coverContentUrl:String? = null
var coverAbsolutePath:String? = null
var hasEBookFile = false
var newEBookFile:EBookFile? = null
val existingLocalFilesRemoved = existingLocalFiles.filter { elf ->
filesInFolder.find { fif -> DeviceManager.getBase64Id(fif.id) == elf.id } == null // File was not found in media item folder
}
if (existingLocalFilesRemoved.isNotEmpty()) {
Log.d(tag, "${existingLocalFilesRemoved.size} Local files were removed from local media item ${existingItem?.media?.metadata?.title}")
isNewOrUpdated = true
}
filesInFolder.forEach { file ->
val mimeType = file.mimeType ?: ""
val filename = file.name ?: ""
Log.d(tag, "Found $mimeType file $filename in folder $itemFolderName")
val localFileId = DeviceManager.getBase64Id(file.id)
val localFile = LocalFile(localFileId,filename,file.uri.toString(),file.getBasePath(ctx), file.getAbsolutePath(ctx),file.getSimplePath(ctx),mimeType,file.length())
localFiles.add(localFile)
Log.d(tag, "File attributes Id:${localFileId}|ContentUrl:${localFile.contentUrl}|isDownloadsDocument:${file.isDownloadsDocument}")
if (localFile.isAudioFile()) {
val audioTrackToAdd:AudioTrack?
val existingAudioTrack = existingAudioTracks.find { eat -> eat.localFileId == localFileId }
if (existingAudioTrack != null) { // Update existing audio track
if (existingAudioTrack.index != index) {
Log.d(tag, "scanLibraryItemFolder Updating Audio track index from ${existingAudioTrack.index} to $index")
existingAudioTrack.index = index
isNewOrUpdated = true
}
if (existingAudioTrack.startOffset != startOffset) {
Log.d(tag, "scanLibraryItemFolder Updating Audio track startOffset ${existingAudioTrack.startOffset} to $startOffset")
existingAudioTrack.startOffset = startOffset
isNewOrUpdated = true
}
}
if (existingAudioTrack == null || forceAudioProbe) {
Log.d(tag, "scanLibraryItemFolder Scanning Audio File Path ${localFile.absolutePath} | ForceAudioProbe=${forceAudioProbe}")
// TODO: Make asynchronous
val audioProbeResult = probeAudioFile(localFile.absolutePath)
if (existingAudioTrack != null) {
// Update audio probe data on existing audio track
existingAudioTrack.audioProbeResult = audioProbeResult
audioTrackToAdd = existingAudioTrack
} else {
// Create new audio track
val track = AudioTrack(index, startOffset, audioProbeResult?.duration ?: 0.0, filename, localFile.contentUrl, mimeType, null, true, localFileId, audioProbeResult, null)
audioTrackToAdd = track
}
startOffset += audioProbeResult?.duration ?: 0.0
isNewOrUpdated = true
} else {
audioTrackToAdd = existingAudioTrack
}
startOffset += audioTrackToAdd.duration
index++
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 {
val existingLocalFile = existingLocalFiles.find { elf -> elf.id == localFileId }
if (existingLocalFile == null) {
Log.d(tag, "scanLibraryItemFolder new local file found ${localFile.absolutePath}")
isNewOrUpdated = true
}
if (existingItem != null && existingItem.coverContentUrl == null) {
// Existing media item did not have a cover - cover found on scan
Log.d(tag, "scanLibraryItemFolder setting cover ${localFile.absolutePath}")
isNewOrUpdated = true
existingItem.coverAbsolutePath = localFile.absolutePath
existingItem.coverContentUrl = localFile.contentUrl
existingItem.media.coverPath = localFile.absolutePath
}
// First image file use as cover path
if (coverContentUrl == null) {
coverContentUrl = localFile.contentUrl
coverAbsolutePath = localFile.absolutePath
}
}
}
if (existingItem != null && audioTracks.isEmpty() && !hasEBookFile) {
Log.d(tag, "Local library item ${existingItem.media.metadata.title} no longer has audio tracks - removing item")
DeviceManager.dbManager.removeLocalLibraryItem(existingItem.id)
return ItemScanResult.REMOVED
} else if (existingItem != null && !isNewOrUpdated) {
Log.d(tag, "Local library item ${existingItem.media.metadata.title} has no updates")
return ItemScanResult.UPTODATE
} else if (existingItem != null) {
Log.d(tag, "Updating local library item ${existingItem.media.metadata.title}")
existingItem.updateFromScan(audioTracks,localFiles)
DeviceManager.dbManager.saveLocalLibraryItem(existingItem)
return ItemScanResult.UPDATED
} else if (audioTracks.isNotEmpty() || newEBookFile != null) {
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,newEBookFile,localFiles,coverContentUrl,coverAbsolutePath)
val localLibraryItem = localMediaItem.getLocalLibraryItem()
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
return ItemScanResult.ADDED
} else { } else {
return ItemScanResult.UPTODATE // Lookup or create podcast local library item
localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
if (localLibraryItem == null) {
Log.d(tag, "[FolderScanner] Podcast local library item not created yet for ${downloadItem.media.metadata.title}")
localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, downloadItem.itemFolderPath, downloadItem.itemFolderPath, "", false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true,downloadItem.serverConnectionConfigId,downloadItem.serverAddress,downloadItem.serverUserId,downloadItem.libraryItemId)
}
} }
val audioTracks:MutableList<AudioTrack> = mutableListOf()
var foundEBookFile = false
downloadItem.downloadItemParts.forEach { downloadItemPart ->
Log.d(tag, "Scan internal storage item with finalDestinationUri=${downloadItemPart.finalDestinationUri}")
val file = File(downloadItemPart.finalDestinationPath)
Log.d(tag, "Scan internal storage item created file ${file.name}")
if (file == null) {
Log.e(tag, "scanInternalDownloadItem: Null docFile for path ${downloadItemPart.finalDestinationPath}")
} else {
if (downloadItemPart.audioTrack != null) {
val audioTrackFromServer = downloadItemPart.audioTrack
Log.d(
tag,
"scanInternalDownloadItem: Audio Track from Server index = ${audioTrackFromServer.index}"
)
val localFileId = DeviceManager.getBase64Id(file.name)
Log.d(tag, "Scan internal file localFileId=$localFileId")
val localFile = LocalFile(
localFileId,
file.name,
downloadItemPart.finalDestinationUri.toString(),
file.getBasePath(ctx),
file.absolutePath,
file.getSimplePath(ctx),
file.mimeType,
file.length()
)
localLibraryItem.localFiles.add(localFile)
// Create new audio track
val track = AudioTrack(
audioTrackFromServer.index,
audioTrackFromServer.startOffset,
audioTrackFromServer.duration,
localFile.filename ?: "",
localFile.contentUrl,
localFile.mimeType ?: "",
null,
true,
localFileId,
null,
audioTrackFromServer.index
)
audioTracks.add(track)
Log.d(
tag,
"scanInternalDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}"
)
// Add podcast episodes to library
downloadItemPart.episode?.let { podcastEpisode ->
val podcast = localLibraryItem.media as Podcast
val newEpisode = podcast.addEpisode(track, podcastEpisode)
localEpisodeId = newEpisode.id
Log.d(
tag,
"scanInternalDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}"
)
}
} else if (downloadItemPart.ebookFile != null) {
foundEBookFile = true
Log.d(tag, "scanInternalDownloadItem: Ebook file found with mimetype=${file.mimeType}")
val localFileId = DeviceManager.getBase64Id(file.name)
val localFile = LocalFile(
localFileId,
file.name,
Uri.fromFile(file).toString(),
file.getBasePath(ctx),
file.absolutePath,
file.getSimplePath(ctx),
file.mimeType,
file.length()
)
localLibraryItem.localFiles.add(localFile)
val ebookFile = EBookFile(
downloadItemPart.ebookFile.ino,
downloadItemPart.ebookFile.metadata,
downloadItemPart.ebookFile.ebookFormat,
true,
localFileId,
localFile.contentUrl
)
(localLibraryItem.media as Book).ebookFile = ebookFile
Log.d(tag, "scanInternalDownloadItem: Ebook file added to lli ${localFile.contentUrl}")
} else {
val localFileId = DeviceManager.getBase64Id(file.name)
val localFile = LocalFile(localFileId,file.name,Uri.fromFile(file).toString(),file.getBasePath(ctx),file.absolutePath,file.getSimplePath(ctx),file.mimeType,file.length())
localLibraryItem.coverAbsolutePath = localFile.absolutePath
localLibraryItem.coverContentUrl = localFile.contentUrl
localLibraryItem.localFiles.add(localFile)
}
}
}
if (audioTracks.isEmpty() && !foundEBookFile) {
Log.d(tag, "scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}")
return cb(null)
}
// For books sort audio tracks then set
if (downloadItem.mediaType == "book") {
audioTracks.sortBy { it.index }
var indexCheck = 1
var startOffset = 0.0
audioTracks.forEach { audioTrack ->
if (audioTrack.index != indexCheck || audioTrack.startOffset != startOffset) {
audioTrack.index = indexCheck
audioTrack.startOffset = startOffset
}
indexCheck++
startOffset += audioTrack.duration
}
localLibraryItem.media.setAudioTracks(audioTracks)
}
val downloadItemScanResult = DownloadItemScanResult(localLibraryItem,null)
// If library item had media progress then make local media progress and save
downloadItem.userMediaProgress?.let { mediaProgress ->
val localMediaProgressId = if (downloadItem.episodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
val newLocalMediaProgress = LocalMediaProgress(
id = localMediaProgressId,
localLibraryItemId = localLibraryItemId,
localEpisodeId = localEpisodeId,
duration = mediaProgress.duration,
progress = mediaProgress.progress,
currentTime = mediaProgress.currentTime,
isFinished = mediaProgress.isFinished,
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 // Scan item after download and create local library item
fun scanDownloadItem(downloadItem: DownloadItem, cb: (DownloadItemScanResult?) -> Unit) { 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 folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl))
val foldersFound = folderDf?.search(true, DocumentFileType.FOLDER) ?: mutableListOf() val foldersFound = folderDf?.search(true, DocumentFileType.FOLDER) ?: mutableListOf()
@@ -370,7 +341,7 @@ class FolderScanner(var ctx: Context) {
duration = mediaProgress.duration, duration = mediaProgress.duration,
progress = mediaProgress.progress, progress = mediaProgress.progress,
currentTime = mediaProgress.currentTime, currentTime = mediaProgress.currentTime,
isFinished = false, isFinished = mediaProgress.isFinished,
ebookLocation = mediaProgress.ebookLocation, ebookLocation = mediaProgress.ebookLocation,
ebookProgress = mediaProgress.ebookProgress, ebookProgress = mediaProgress.ebookProgress,
lastUpdate = mediaProgress.lastUpdate, lastUpdate = mediaProgress.lastUpdate,
@@ -382,6 +353,7 @@ class FolderScanner(var ctx: Context) {
libraryItemId = downloadItem.libraryItemId, libraryItemId = downloadItem.libraryItemId,
episodeId = downloadItem.episodeId) episodeId = downloadItem.episodeId)
Log.d(tag, "scanLibraryItemFolder: Saving local media progress ${newLocalMediaProgress.id} at progress ${newLocalMediaProgress.progress}") Log.d(tag, "scanLibraryItemFolder: Saving local media progress ${newLocalMediaProgress.id} at progress ${newLocalMediaProgress.progress}")
DeviceManager.dbManager.saveLocalMediaProgress(newLocalMediaProgress) DeviceManager.dbManager.saveLocalMediaProgress(newLocalMediaProgress)
downloadItemScanResult.localMediaProgress = newLocalMediaProgress downloadItemScanResult.localMediaProgress = newLocalMediaProgress
@@ -391,132 +363,4 @@ class FolderScanner(var ctx: Context) {
cb(downloadItemScanResult) cb(downloadItemScanResult)
} }
fun scanLocalLibraryItem(localLibraryItem:LocalLibraryItem, forceAudioProbe:Boolean):LocalLibraryItemScanResult? {
val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(localLibraryItem.contentUrl))
if (df == null) {
Log.e(tag, "Item Folder Doc File Invalid ${localLibraryItem.absolutePath}")
return null
}
Log.d(tag, "scanLocalLibraryItem starting for ${localLibraryItem.absolutePath} | ${df.uri}")
var wasUpdated = false
// Search for files in media item folder
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/*"))
Log.d(tag, "scanLocalLibraryItem ${filesFound.size} files found in ${localLibraryItem.absolutePath}")
filesFound.forEach {
try {
Log.d(tag, "Checking file found ${it.name} | ${it.id}")
}catch(e:Exception) {
Log.d(tag, "Check file found exception", e)
}
}
val existingAudioTracks = localLibraryItem.media.getAudioTracks()
// Remove any files no longer found in library item folder
val existingLocalFileIds = localLibraryItem.localFiles.map { it.id }
existingLocalFileIds.forEach { localFileId ->
Log.d(tag, "Checking local file id is there $localFileId")
if (filesFound.find { DeviceManager.getBase64Id(it.id) == localFileId } == null) {
Log.d(tag, "scanLocalLibraryItem file $localFileId was removed from ${localLibraryItem.absolutePath}")
localLibraryItem.localFiles.removeIf { it.id == localFileId }
if (existingAudioTracks.find { it.localFileId == localFileId } != null) {
Log.d(tag, "scanLocalLibraryItem audio track file $localFileId was removed from ${localLibraryItem.absolutePath}")
localLibraryItem.media.removeAudioTrack(localFileId)
}
wasUpdated = true
}
}
filesFound.forEach { docFile ->
val localFileId = DeviceManager.getBase64Id(docFile.id)
val existingLocalFile = localLibraryItem.localFiles.find { it.id == localFileId }
if (existingLocalFile == null || (existingLocalFile.isAudioFile() && forceAudioProbe)) {
val localFile = existingLocalFile ?: LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx), docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
if (existingLocalFile == null) {
localLibraryItem.localFiles.add(localFile)
Log.d(tag, "scanLocalLibraryItem new file found ${localFile.filename}")
}
if (localFile.isAudioFile()) {
// TODO: Make asynchronous
val audioProbeResult = probeAudioFile(localFile.absolutePath)
val existingTrack = existingAudioTracks.find { audioTrack ->
audioTrack.localFileId == localFileId
}
if (existingTrack == null) {
// Create new audio track
val lastTrack = existingAudioTracks.lastOrNull()
val startOffset = (lastTrack?.startOffset ?: 0.0) + (lastTrack?.duration ?: 0.0)
val track = AudioTrack(existingAudioTracks.size, startOffset, audioProbeResult?.duration ?: 0.0, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, null)
localLibraryItem.media.addAudioTrack(track)
Log.d(tag, "Added New Audio Track ${track.title}")
wasUpdated = true
} else {
existingTrack.audioProbeResult = audioProbeResult
// TODO: Update data found from probe
Log.d(tag, "Updated Audio Track Probe Data ${existingTrack.title}")
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
if (localLibraryItem.coverContentUrl == null) {
Log.d(tag, "scanLocalLibraryItem setting cover for ${localLibraryItem.media.metadata.title}")
localLibraryItem.coverContentUrl = localFile.contentUrl
localLibraryItem.coverAbsolutePath = localFile.absolutePath
wasUpdated = true
}
}
}
}
if (wasUpdated) {
Log.d(tag, "Local library item was updated - saving it")
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
} else {
Log.d(tag, "Local library item was up-to-date")
}
return LocalLibraryItemScanResult(wasUpdated, localLibraryItem)
}
private fun probeAudioFile(absolutePath:String):AudioProbeResult? {
val session = FFprobeKit.execute("-i \"${absolutePath}\" -print_format json -show_format -show_streams -select_streams a -show_chapters -loglevel quiet")
var probeObject:JSObject? = null
try {
probeObject = JSObject(session.output)
} catch(error:JSONException) {
Log.e(tag, "Failed to parse probe result $error")
}
Log.d(tag, "FFprobe output $probeObject")
return if (probeObject == null || !probeObject.has("streams")) { // Check if output is empty
Log.d(tag, "probeAudioFile Probe audio file $absolutePath failed or invalid")
null
} else {
val audioProbeResult = jacksonMapper.readValue<AudioProbeResult>(session.output)
Log.d(tag, "Probe Result DATA ${audioProbeResult.duration} | ${audioProbeResult.size} | ${audioProbeResult.title} | ${audioProbeResult.artist}")
audioProbeResult
}
}
} }
@@ -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,33 @@ 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)
it.downloadId = 1
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 +118,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 +206,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
}
}
@@ -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 }
@@ -73,6 +73,12 @@ data class DownloadItemPart(
} }
} }
@get:JsonIgnore
val isInternalStorage get() = localFolderId.startsWith("internal-")
@get:JsonIgnore
val serverUrl get() = "${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}"
@JsonIgnore @JsonIgnore
fun getDownloadRequest(): DownloadManager.Request { fun getDownloadRequest(): DownloadManager.Request {
val dlRequest = DownloadManager.Request(uri) val dlRequest = DownloadManager.Request(uri)
@@ -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 {
@@ -86,7 +86,8 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
} }
override fun onSeekTo(pos: Long) { override fun onSeekTo(pos: Long) {
playerNotificationService.seekPlayer(pos) val currentTrackStartOffset = playerNotificationService.getCurrentTrackStartOffsetMs()
playerNotificationService.seekPlayer(currentTrackStartOffset + pos)
} }
private fun onChangeSpeed() { private fun onChangeSpeed() {
@@ -115,15 +116,16 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
if (mediaId.isNullOrEmpty()) { if (mediaId.isNullOrEmpty()) {
libraryItemWrapper = playerNotificationService.mediaManager.getFirstItem() libraryItemWrapper = playerNotificationService.mediaManager.getFirstItem()
} else if (mediaId.startsWith("ep_") || mediaId.startsWith("local_ep_")) { // Playing podcast episode
val libraryItemWithEpisode = playerNotificationService.mediaManager.getPodcastWithEpisodeByEpisodeId(mediaId)
libraryItemWrapper = libraryItemWithEpisode?.libraryItemWrapper
podcastEpisode = libraryItemWithEpisode?.episode
} else { } else {
libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId) val libraryItemWithEpisode = playerNotificationService.mediaManager.getPodcastWithEpisodeByEpisodeId(mediaId)
if (libraryItemWithEpisode != null) {
if (libraryItemWrapper == null) { libraryItemWrapper = libraryItemWithEpisode.libraryItemWrapper
Log.e(tag, "onPlayFromMediaId: Media item not found $mediaId") podcastEpisode = libraryItemWithEpisode.episode
} else {
libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId)
if (libraryItemWrapper == null) {
Log.e(tag, "onPlayFromMediaId: Media item not found $mediaId")
}
} }
} }
@@ -49,10 +49,10 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
val libraryItemWrapper: LibraryItemWrapper? val libraryItemWrapper: LibraryItemWrapper?
var podcastEpisode: PodcastEpisode? = null var podcastEpisode: PodcastEpisode? = null
if (mediaId.startsWith("ep_") || mediaId.startsWith("local_ep_")) { // Playing podcast episode val libraryItemWithEpisode = playerNotificationService.mediaManager.getPodcastWithEpisodeByEpisodeId(mediaId)
val libraryItemWithEpisode = playerNotificationService.mediaManager.getPodcastWithEpisodeByEpisodeId(mediaId) if (libraryItemWithEpisode != null) {
libraryItemWrapper = libraryItemWithEpisode?.libraryItemWrapper libraryItemWrapper = libraryItemWithEpisode.libraryItemWrapper
podcastEpisode = libraryItemWithEpisode?.episode podcastEpisode = libraryItemWithEpisode.episode
} else { } else {
libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId) libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId)
} }
@@ -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) {
@@ -109,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")
@@ -2,8 +2,6 @@ package com.audiobookshelf.app.player
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.* import android.app.*
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
@@ -11,10 +9,7 @@ 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.provider.Settings
@@ -25,14 +20,11 @@ import android.support.v4.media.session.MediaControllerCompat
import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat import android.support.v4.media.session.PlaybackStateCompat
import android.util.Log import android.util.Log
import android.view.View
import android.widget.RemoteViews
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.media.MediaBrowserServiceCompat import androidx.media.MediaBrowserServiceCompat
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.data.* import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.data.DeviceInfo import com.audiobookshelf.app.data.DeviceInfo
@@ -47,6 +39,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
@@ -70,7 +64,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
var isSwitchingPlayer = false // Used when switching between cast player and exoplayer var isSwitchingPlayer = false // Used when switching between cast player and exoplayer
} }
private val tag = "PlayerNotificationService" private val tag = "PlayerNotificationServ"
interface ClientEventEmitter { interface ClientEventEmitter {
fun onPlaybackSession(playbackSession:PlaybackSession) fun onPlaybackSession(playbackSession:PlaybackSession)
@@ -203,6 +197,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)
@@ -264,7 +264,7 @@ 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)
@@ -288,10 +288,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)
@@ -362,7 +362,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
@@ -394,7 +394,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
@@ -438,12 +438,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()
@@ -483,7 +497,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(),
@@ -638,16 +652,20 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
} }
fun getCurrentTime() : Long { fun getCurrentTrackStartOffsetMs() : Long {
return if (currentPlayer.mediaItemCount > 1) { return if (currentPlayer.mediaItemCount > 1) {
val windowIndex = currentPlayer.currentMediaItemIndex val windowIndex = currentPlayer.currentMediaItemIndex
val currentTrackStartOffset = currentPlaybackSession?.getTrackStartOffsetMs(windowIndex) ?: 0L val currentTrackStartOffset = currentPlaybackSession?.getTrackStartOffsetMs(windowIndex) ?: 0L
currentPlayer.currentPosition + currentTrackStartOffset currentTrackStartOffset
} else { } else {
currentPlayer.currentPosition 0
} }
} }
fun getCurrentTime() : Long {
return currentPlayer.currentPosition + getCurrentTrackStartOffsetMs()
}
fun getCurrentTimeSeconds() : Double { fun getCurrentTimeSeconds() : Double {
return getCurrentTime() / 1000.0 return getCurrentTime() / 1000.0
} }
@@ -863,10 +881,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")
}
} }
} }
@@ -880,8 +914,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()
} }
@@ -896,7 +932,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
@SuppressLint("HardwareIds") @SuppressLint("HardwareIds")
private fun getDeviceInfo(): DeviceInfo { fun getDeviceInfo(): DeviceInfo {
/* EXAMPLE /* EXAMPLE
manufacturer: Google manufacturer: Google
model: Pixel 6 model: Pixel 6
@@ -905,7 +941,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
appVersion: 0.9.46-beta appVersion: 0.9.46-beta
*/ */
val deviceId = Settings.Secure.getString(ctx.contentResolver, Settings.Secure.ANDROID_ID) val deviceId = Settings.Secure.getString(ctx.contentResolver, Settings.Secure.ANDROID_ID)
return DeviceInfo(deviceId, Build.MANUFACTURER, Build.MODEL, Build.BRAND, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME) return DeviceInfo(deviceId, Build.MANUFACTURER, Build.MODEL, Build.VERSION.SDK_INT, Build.VERSION.RELEASE)
} }
private val deviceSettings get() = DeviceManager.deviceData.deviceSettings ?: DeviceSettings.default() private val deviceSettings get() = DeviceManager.deviceData.deviceSettings ?: DeviceSettings.default()
@@ -962,13 +998,16 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
val extras = Bundle() val extras = Bundle()
extras.putBoolean( extras.putBoolean(
MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true) MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true
)
extras.putInt( extras.putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE, MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM) MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM
)
extras.putInt( extras.putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE, MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM) MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM
)
BrowserRoot(AUTO_MEDIA_ROOT, extras) BrowserRoot(AUTO_MEDIA_ROOT, extras)
} }
@@ -979,39 +1018,19 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
result.detach() result.detach()
if (parentMediaId.startsWith("li_") || parentMediaId.startsWith("local_")) { // Show podcast episodes if (parentMediaId == DOWNLOADS_ROOT) { // Load downloads
Log.d(tag, "Loading podcast episodes")
mediaManager.loadPodcastEpisodeMediaBrowserItems(parentMediaId, ctx) {
result.sendResult(it)
}
} else if (::browseTree.isInitialized && browseTree[parentMediaId] == null && mediaManager.getIsLibrary(parentMediaId)) { // Load library items for library
mediaManager.loadLibraryItemsWithAudio(parentMediaId) { libraryItems ->
val children = libraryItems.map { libraryItem ->
if (libraryItem.mediaType == "podcast") { // Podcasts are browseable
val mediaDescription = libraryItem.getMediaDescription(null, ctx)
MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
} else {
val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.id }
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id)
libraryItem.localLibraryItemId = localLibraryItem?.id
val description = libraryItem.getMediaDescription(progress, ctx)
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
}
}
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
}
} else if (parentMediaId == DOWNLOADS_ROOT) { // Load downloads
val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book") val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast") val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast")
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 ->
@@ -1027,19 +1046,19 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
val progress: MediaProgressWrapper? val progress: MediaProgressWrapper?
val mediaDescription:MediaDescriptionCompat val mediaDescription:MediaDescriptionCompat
if (itemInProgress.episode != null) { if (itemInProgress.episode != null) {
if (itemInProgress.isLocal) { if (itemInProgress.isLocal) {
progress = DeviceManager.dbManager.getLocalMediaProgress("${itemInProgress.libraryItemWrapper.id}-${itemInProgress.episode.id}") progress = DeviceManager.dbManager.getLocalMediaProgress("${itemInProgress.libraryItemWrapper.id}-${itemInProgress.episode.id}")
} else { } else {
progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == itemInProgress.libraryItemWrapper.id && it.episodeId == itemInProgress.episode.id } progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == itemInProgress.libraryItemWrapper.id && it.episodeId == itemInProgress.episode.id }
// to show download icon // to show download icon
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(itemInProgress.libraryItemWrapper.id) val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(itemInProgress.libraryItemWrapper.id)
localLibraryItem?.let { lli -> localLibraryItem?.let { lli ->
val localEpisode = (lli.media as Podcast).episodes?.find { it.serverEpisodeId == itemInProgress.episode.id } val localEpisode = (lli.media as Podcast).episodes?.find { it.serverEpisodeId == itemInProgress.episode.id }
itemInProgress.episode.localEpisodeId = localEpisode?.id itemInProgress.episode.localEpisodeId = localEpisode?.id
} }
} }
mediaDescription = itemInProgress.episode.getMediaDescription(itemInProgress.libraryItemWrapper, progress, ctx) mediaDescription = itemInProgress.episode.getMediaDescription(itemInProgress.libraryItemWrapper, progress, ctx)
} else { } else {
if (itemInProgress.isLocal) { if (itemInProgress.isLocal) {
@@ -1055,16 +1074,38 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
localBrowseItems += MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) localBrowseItems += MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
} }
result.sendResult(localBrowseItems) result.sendResult(localBrowseItems)
} else { } else if (parentMediaId == LIBRARIES_ROOT || parentMediaId == AUTO_MEDIA_ROOT) {
mediaManager.loadAndroidAutoItems { mediaManager.loadAndroidAutoItems {
browseTree = BrowseTree(this, mediaManager.serverItemsInProgress, mediaManager.serverLibraries) browseTree = BrowseTree(this, mediaManager.serverItemsInProgress, mediaManager.serverLibraries)
val children = browseTree[parentMediaId]?.map { item -> val children = browseTree[parentMediaId]?.map { item ->
Log.d(tag, "Loading Browser Media Item ${item.description.title}") Log.d(tag, "Loading Browser Media Item ${item.description.title}")
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
} }
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?) result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
} }
} else if (mediaManager.getIsLibrary(parentMediaId)) { // Load library items for library
Log.d(tag, "Loading items for library $parentMediaId")
mediaManager.loadLibraryItemsWithAudio(parentMediaId) { libraryItems ->
val children = libraryItems.map { libraryItem ->
if (libraryItem.mediaType == "podcast") { // Podcasts are browseable
val mediaDescription = libraryItem.getMediaDescription(null, ctx)
MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
} else {
val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.id }
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id)
libraryItem.localLibraryItemId = localLibraryItem?.id
val description = libraryItem.getMediaDescription(progress, ctx)
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
}
}
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
}
} else {
Log.d(tag, "Loading podcast episodes for podcast $parentMediaId")
mediaManager.loadPodcastEpisodeMediaBrowserItems(parentMediaId, ctx) {
result.sendResult(it)
}
} }
} }
@@ -201,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
@@ -231,9 +231,15 @@ class AbsDatabase : Plugin() {
if (!success) { if (!success) {
call.resolve(JSObject("{\"error\":\"$errorMsg\"}")) call.resolve(JSObject("{\"error\":\"$errorMsg\"}"))
} else { } else {
// Remove all local sessions
savedSessions.forEach {
DeviceManager.dbManager.removePlaybackSession(it.id)
}
call.resolve() call.resolve()
} }
} }
} else {
call.resolve()
} }
} }
} }
@@ -57,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"
@@ -72,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\"}"))
@@ -100,12 +109,6 @@ class AbsDownloader : Plugin() {
} }
} }
// Clean folder path so it can be used in URL
private fun cleanRelPath(relPath: String): String {
val cleanedRelPath = relPath.replace("\\", "/").replace("%", "%25").replace("#", "%23")
return if (cleanedRelPath.startsWith("/")) cleanedRelPath.substring(1) else cleanedRelPath
}
// Item filenames could be the same if they are in sub-folders, this will make them unique // 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("/", "_")
@@ -127,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")
@@ -138,13 +143,13 @@ 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 val book = libraryItem.media as Book
book.ebookFile?.let { ebookFile -> book.ebookFile?.let { ebookFile ->
val fileSize = ebookFile.metadata?.size ?: 0 val fileSize = ebookFile.metadata?.size ?: 0
val serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(ebookFile.metadata?.relPath ?: "")}" val serverPath = "/api/items/${libraryItem.id}/file/${ebookFile.ino}/download"
val destinationFilename = getFilenameFromRelPath(ebookFile.metadata?.relPath ?: "") val destinationFilename = getFilenameFromRelPath(ebookFile.metadata?.relPath ?: "")
val finalDestinationFile = File("$itemFolderPath/$destinationFilename") val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
val destinationFile = File("$tempFolderPath/$destinationFilename") val destinationFile = File("$tempFolderPath/$destinationFilename")
@@ -164,9 +169,14 @@ class AbsDownloader : Plugin() {
} }
// 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")
@@ -219,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")
@@ -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
@@ -13,16 +12,11 @@ import com.anggrayudi.storage.callback.StorageAccessCallback
import com.anggrayudi.storage.file.* import com.anggrayudi.storage.file.*
import com.audiobookshelf.app.MainActivity import com.audiobookshelf.app.MainActivity
import com.audiobookshelf.app.data.LocalFolder import com.audiobookshelf.app.data.LocalFolder
import com.audiobookshelf.app.data.LocalLibraryItem
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.device.FolderScanner
import com.fasterxml.jackson.core.json.JsonReadFeature import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.getcapacitor.* import com.getcapacitor.*
import com.getcapacitor.annotation.CapacitorPlugin import com.getcapacitor.annotation.CapacitorPlugin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.io.File import java.io.File
@CapacitorPlugin(name = "AbsFileSystem") @CapacitorPlugin(name = "AbsFileSystem")
@@ -166,23 +160,10 @@ class AbsFileSystem : Plugin() {
} }
@PluginMethod @PluginMethod
fun scanFolder(call: PluginCall) { fun getSDKVersion(call: PluginCall) {
val folderId = call.data.getString("folderId", "").toString() val jsObject = JSObject()
val forceAudioProbe = call.data.getBoolean("forceAudioProbe") jsObject.put("version", Build.VERSION.SDK_INT)
Log.d(TAG, "Scan Folder $folderId | Force Audio Probe $forceAudioProbe") call.resolve(jsObject)
val folder: LocalFolder? = DeviceManager.dbManager.getLocalFolder(folderId)
folder?.let {
val folderScanner = FolderScanner(context)
val folderScanResult = folderScanner.scanForMediaItems(it, forceAudioProbe)
if (folderScanResult == null) {
Log.d(TAG, "NO Scan DATA")
return call.resolve(JSObject())
} else {
Log.d(TAG, "Scan DATA ${jacksonMapper.writeValueAsString(folderScanResult)}")
return call.resolve(JSObject(jacksonMapper.writeValueAsString(folderScanResult)))
}
} ?: call.resolve(JSObject())
} }
@PluginMethod @PluginMethod
@@ -199,27 +180,6 @@ class AbsFileSystem : Plugin() {
call.resolve() call.resolve()
} }
@PluginMethod
fun scanLocalLibraryItem(call: PluginCall) {
val localLibraryItemId = call.data.getString("localLibraryItemId", "").toString()
val forceAudioProbe = call.data.getBoolean("forceAudioProbe")
Log.d(TAG, "Scan Local library item $localLibraryItemId | Force Audio Probe $forceAudioProbe")
GlobalScope.launch(Dispatchers.IO) {
val localLibraryItem: LocalLibraryItem? = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
localLibraryItem?.let {
val folderScanner = FolderScanner(context)
val scanResult = folderScanner.scanLocalLibraryItem(it, forceAudioProbe)
if (scanResult == null) {
Log.d(TAG, "NO Scan DATA")
call.resolve(JSObject())
} else {
Log.d(TAG, "Scan DATA ${jacksonMapper.writeValueAsString(scanResult)}")
call.resolve(JSObject(jacksonMapper.writeValueAsString(scanResult)))
}
} ?: call.resolve(JSObject())
}
}
@PluginMethod @PluginMethod
fun deleteItem(call: PluginCall) { fun deleteItem(call: PluginCall) {
val localLibraryItemId = call.data.getString("id", "").toString() val localLibraryItemId = call.data.getString("id", "").toString()
@@ -227,35 +187,44 @@ 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")
var subfolderPathToDelete = ""
// Check if should delete subfolder // Check if should delete subfolder
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId) val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
localLibraryItem?.folderId?.let { folderId ->
val folder = DeviceManager.dbManager.getLocalFolder(folderId) val success: Boolean
folder?.absolutePath?.let { folderPath ->
val splitAbsolutePath = absolutePath.split("/") // If internal library item use File to delete
val fullSubDir = splitAbsolutePath.subList(0, splitAbsolutePath.size - 1).joinToString("/") if (localLibraryItem?.folderId?.startsWith("internal-") == true) {
if (fullSubDir != folderPath) { Log.d(tag, "Deleting internal library item at absolutePath $absolutePath")
val subdirHasAnItem = DeviceManager.dbManager.getLocalLibraryItems().any { _localLibraryItem -> val file = File(absolutePath)
if (_localLibraryItem.id == localLibraryItemId) { success = file.deleteRecursively()
false } else {
} else { var subfolderPathToDelete = ""
_localLibraryItem.absolutePath.startsWith(fullSubDir) localLibraryItem?.folderId?.let { folderId ->
val folder = DeviceManager.dbManager.getLocalFolder(folderId)
folder?.absolutePath?.let { folderPath ->
val splitAbsolutePath = absolutePath.split("/")
val fullSubDir = splitAbsolutePath.subList(0, splitAbsolutePath.size - 1).joinToString("/")
if (fullSubDir != folderPath) {
val subdirHasAnItem = DeviceManager.dbManager.getLocalLibraryItems().any { _localLibraryItem ->
if (_localLibraryItem.id == localLibraryItemId) {
false
} else {
_localLibraryItem.absolutePath.startsWith(fullSubDir)
}
} }
subfolderPathToDelete = if (subdirHasAnItem) "" else fullSubDir
} }
subfolderPathToDelete = if (subdirHasAnItem) "" else fullSubDir
} }
} }
}
val docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl)) val docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
val success = docfile?.delete() == true success = docfile?.delete() == true
if (subfolderPathToDelete != "") { if (subfolderPathToDelete != "") {
Log.d(tag, "Deleting empty subfolder at $subfolderPathToDelete") Log.d(tag, "Deleting empty subfolder at $subfolderPathToDelete")
val docfilesub = DocumentFileCompat.fromFullPath(mainActivity, subfolderPathToDelete) val docfilesub = DocumentFileCompat.fromFullPath(mainActivity, subfolderPathToDelete)
docfilesub?.delete() docfilesub?.delete()
}
} }
if (success) { if (success) {
@@ -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) {
@@ -315,18 +323,19 @@ class ApiHandler(var ctx:Context) {
fun sendSyncLocalSessions(playbackSessions:List<PlaybackSession>, cb: (Boolean, String?) -> Unit) { fun sendSyncLocalSessions(playbackSessions:List<PlaybackSession>, cb: (Boolean, String?) -> Unit) {
val payload = JSObject(jacksonMapper.writeValueAsString(LocalSessionsSyncRequestPayload(playbackSessions))) val payload = JSObject(jacksonMapper.writeValueAsString(LocalSessionsSyncRequestPayload(playbackSessions)))
Log.d(tag, "Sending ${playbackSessions.size} saved local playback sessions to server")
postRequest("/api/session/local-all", payload, null) { postRequest("/api/session/local-all", payload, null) {
if (!it.getString("error").isNullOrEmpty()) { if (!it.getString("error").isNullOrEmpty()) {
Log.e(tag, "Failed to sync local sessions")
cb(false, it.getString("error")) cb(false, it.getString("error"))
} else { } else {
val response = jacksonMapper.readValue<LocalSessionsSyncResponsePayload>(it.toString()) val response = jacksonMapper.readValue<LocalSessionsSyncResponsePayload>(it.toString())
response.results.forEach { localSessionSyncResult -> response.results.forEach { localSessionSyncResult ->
Log.d(tag, "Synced session result ${localSessionSyncResult.id}|${localSessionSyncResult.progressSynced}|${localSessionSyncResult.success}")
playbackSessions.find { ps -> ps.id == localSessionSyncResult.id }?.let { session -> playbackSessions.find { ps -> ps.id == localSessionSyncResult.id }?.let { session ->
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}")
@@ -356,6 +365,7 @@ 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")
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
} else if (localMediaProgress.lastUpdate > mediaProgress.lastUpdate && localMediaProgress.ebookLocation != null && localMediaProgress.ebookLocation != mediaProgress.ebookLocation) { } else if (localMediaProgress.lastUpdate > mediaProgress.lastUpdate && localMediaProgress.ebookLocation != null && localMediaProgress.ebookLocation != mediaProgress.ebookLocation) {
// Patch ebook progress to server // Patch ebook progress to server
val endpoint = "/api/me/progress/${localMediaProgress.libraryItemId}" val endpoint = "/api/me/progress/${localMediaProgress.libraryItemId}"
@@ -2,4 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/> <background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/> <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon> <monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -2,4 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/> <background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/> <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon> <monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
+3 -3
View File
@@ -1,9 +1,9 @@
<?xml version='1.0' encoding='utf-8'?> <?xml version='1.0' encoding='utf-8'?>
<resources> <resources>
<string name="app_name">audiobookshelf</string> <string name="app_name">Audiobookshelf</string>
<string name="title_activity_main">audiobookshelf</string> <string name="title_activity_main">Audiobookshelf</string>
<string name="package_name">com.audiobookshelf.app</string> <string name="package_name">com.audiobookshelf.app</string>
<string name="custom_url_scheme">com.audiobookshelf.app</string> <string name="custom_url_scheme">audiobookshelf</string>
<string name="add_widget">Add widget</string> <string name="add_widget">Add widget</string>
<string name="app_widget_description">Simple widget for audiobookshelf playback</string> <string name="app_widget_description">Simple widget for audiobookshelf playback</string>
<string name="action_jump_forward">Jump Forward</string> <string name="action_jump_forward">Jump Forward</string>
+3 -4
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>
+2 -2
View File
@@ -8,8 +8,8 @@ buildscript {
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath 'com.google.gms:google-services:4.3.13' classpath 'com.google.gms:google-services:4.4.0'
classpath 'com.android.tools.build:gradle:7.4.0' classpath 'com.android.tools.build:gradle:8.1.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// NOTE: Do not place your application dependencies here; they belong // NOTE: Do not place your application dependencies here; they belong
+6
View File
@@ -5,6 +5,12 @@ 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-browser'
project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/browser/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
@@ -28,7 +28,7 @@ org.gradle.jvmargs=-Dfile.encoding=UTF-8 \
# Android operating system, and which are packaged with your app's APK # Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn # https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true android.useAndroidX=true
# Automatically convert third-party libraries to use AndroidX
android.enableJetifier=true
kapt.use.worker.api=false kapt.use.worker.api=false
android.defaults.buildfeatures.buildconfig=true
Binary file not shown.
+3 -3
View File
@@ -1,6 +1,6 @@
#Sun Apr 17 13:28:55 CDT 2022
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStorePath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.2-all.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+172 -113
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env sh #!/bin/sh
# #
# Copyright 2015 the original author or authors. # Copyright © 2015-2021 the original authors.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -17,67 +17,101 @@
# #
############################################################################## ##############################################################################
## #
## Gradle start up script for UN*X # Gradle start up script for POSIX generated by Gradle.
## #
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
############################################################################## ##############################################################################
# Attempt to set APP_HOME # Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle" # Resolve links: $0 may be a link
APP_BASE_NAME=`basename "$0"` app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value. # Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum" MAX_FD=maximum
warn () { warn () {
echo "$*" echo "$*"
} } >&2
die () { die () {
echo echo
echo "$*" echo "$*"
echo echo
exit 1 exit 1
} } >&2
# OS specific support (must be 'true' or 'false'). # OS specific support (must be 'true' or 'false').
cygwin=false cygwin=false
msys=false msys=false
darwin=false darwin=false
nonstop=false nonstop=false
case "`uname`" in case "$( uname )" in #(
CYGWIN* ) CYGWIN* ) cygwin=true ;; #(
cygwin=true Darwin* ) darwin=true ;; #(
;; MSYS* | MINGW* ) msys=true ;; #(
Darwin* ) NONSTOP* ) nonstop=true ;;
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
@@ -87,9 +121,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
if [ -n "$JAVA_HOME" ] ; then if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables # IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java" JAVACMD=$JAVA_HOME/jre/sh/java
else else
JAVACMD="$JAVA_HOME/bin/java" JAVACMD=$JAVA_HOME/bin/java
fi fi
if [ ! -x "$JAVACMD" ] ; then if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
@@ -98,7 +132,7 @@ Please set the JAVA_HOME variable in your environment to match the
location of your Java installation." location of your Java installation."
fi fi
else else
JAVACMD="java" JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the Please set the JAVA_HOME variable in your environment to match the
@@ -106,80 +140,105 @@ location of your Java installation."
fi fi
# Increase the maximum file descriptors if we can. # Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
MAX_FD_LIMIT=`ulimit -H -n` case $MAX_FD in #(
if [ $? -eq 0 ] ; then max*)
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
MAX_FD="$MAX_FD_LIMIT" # shellcheck disable=SC3045
fi MAX_FD=$( ulimit -H -n ) ||
ulimit -n $MAX_FD warn "Could not query maximum file descriptor limit"
if [ $? -ne 0 ] ; then esac
warn "Could not set maximum file descriptor limit: $MAX_FD" case $MAX_FD in #(
fi '' | soft) :;; #(
else *)
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
fi # shellcheck disable=SC3045
fi ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac esac
fi fi
# Escape application args # Collect all arguments for the java command, stacking in reverse order:
save () { # * args from the command line
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done # * the main class name
echo " " # * -classpath
} # * -D...appname settings
APP_ARGS=`save "$@"` # * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# Collect all arguments for the java command, following the shell quoting and substitution rules # For Cygwin or MSYS, switch paths to Windows format before running java
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@" exec "$JAVACMD" "$@"
+9 -6
View File
@@ -14,7 +14,7 @@
@rem limitations under the License. @rem limitations under the License.
@rem @rem
@if "%DEBUG%" == "" @echo off @if "%DEBUG%"=="" @echo off
@rem ########################################################################## @rem ##########################################################################
@rem @rem
@rem Gradle startup script for Windows @rem Gradle startup script for Windows
@@ -25,7 +25,8 @@
if "%OS%"=="Windows_NT" setlocal if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0 set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=. if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0 set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME% set APP_HOME=%DIRNAME%
@@ -40,7 +41,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1 %JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute if %ERRORLEVEL% equ 0 goto execute
echo. echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
@@ -75,13 +76,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
:end :end
@rem End local scope for the variables with windows NT shell @rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd if %ERRORLEVEL% equ 0 goto mainEnd
:fail :fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code! rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 set EXIT_CODE=%ERRORLEVEL%
exit /b 1 if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd :mainEnd
if "%OS%"=="Windows_NT" endlocal if "%OS%"=="Windows_NT" endlocal
+14 -14
View File
@@ -1,39 +1,39 @@
ext { ext {
minSdkVersion = 24 minSdkVersion = 24
compileSdkVersion = 33 compileSdkVersion = 34
targetSdkVersion = 32 targetSdkVersion = 33
androidxActivityVersion = '1.4.0' androidxActivityVersion = '1.7.0'
androidxAppCompatVersion = '1.4.2' androidxAppCompatVersion = '1.6.1'
androidxCoordinatorLayoutVersion = '1.2.0' androidxCoordinatorLayoutVersion = '1.2.0'
androidxCoreVersion = '1.8.0' androidxCoreVersion = '1.10.0'
androidPlayCore = '1.9.0' androidPlayCore = '1.9.0'
androidxFragmentVersion = '1.4.1' androidxFragmentVersion = '1.5.6'
junitVersion = '4.13.2' junitVersion = '4.13.2'
androidxJunitVersion = '1.1.3' androidxJunitVersion = '1.1.5'
androidxEspressoCoreVersion = '3.4.0' androidxEspressoCoreVersion = '3.5.1'
cordovaAndroidVersion = '10.1.1' cordovaAndroidVersion = '10.1.1'
androidx_car_version = '1.0.0-alpha7' androidx_car_version = '1.0.0-alpha7'
androidx_core_ktx_version = '1.7.0' androidx_core_ktx_version = '1.12.0'
androidx_media_version = '1.6.0' androidx_media_version = '1.6.0'
androidx_preference_version = '1.1.1' androidx_preference_version = '1.1.1'
androidx_test_runner_version = '1.3.0' androidx_test_runner_version = '1.3.0'
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'
gradle_version = '3.1.4' gradle_version = '3.1.4'
gson_version = '2.8.5' gson_version = '2.8.5'
junit_version = '4.13' junit_version = '4.13'
kotlin_version = '1.7.20' kotlin_version = '1.8.10'
kotlin_coroutines_version = '1.1.0' kotlin_coroutines_version = '1.6.4'
multidex_version = '1.0.3' multidex_version = '1.0.3'
play_services_auth_version = '18.1.0' play_services_auth_version = '18.1.0'
recycler_view_version = '1.1.0' recycler_view_version = '1.1.0'
robolectric_version = '4.2' robolectric_version = '4.2'
test_runner_version = '1.1.0' test_runner_version = '1.1.0'
coreSplashScreenVersion = '1.0.0-rc01' coreSplashScreenVersion = '1.0.1'
androidxWebkitVersion = '1.4.0' androidxWebkitVersion = '1.6.1'
} }
+23
View File
@@ -143,4 +143,27 @@ Bookshelf Label
display: -webkit-box; display: -webkit-box;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
-webkit-line-clamp: 4; -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;
} }
+6 -1
View File
@@ -2,5 +2,10 @@
"appId": "com.audiobookshelf.app", "appId": "com.audiobookshelf.app",
"appName": "audiobookshelf-app", "appName": "audiobookshelf-app",
"webDir": "dist", "webDir": "dist",
"bundledWebRuntime": false "bundledWebRuntime": false,
"plugins": {
"CapacitorHttp": {
"enabled": false
}
}
} }
+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
} }
+16 -11
View File
@@ -48,7 +48,7 @@
<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 pb-4 pt-2 mx-auto px-6" style="max-width: 414px"> <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 && serverLibraryItemId && networkConnected" class="material-icons text-3xl text-white text-opacity-75 cursor-pointer" @click="$emit('showBookmarks')">{{ bookmarks.length ? 'bookmark' : 'bookmark_border' }}</span>
<!-- hidden for podcasts but still using this as a placeholder --> <!-- hidden for podcasts but still using this as a placeholder -->
<span v-else class="material-icons text-3xl text-white text-opacity-0">bookmark</span> <span v-else class="material-icons text-3xl text-white text-opacity-0">bookmark</span>
@@ -90,12 +90,14 @@
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 rounded-full pointer-events-none" /> <div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 rounded-full pointer-events-none" />
<div ref="bufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 rounded-full pointer-events-none" /> <div ref="bufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 rounded-full pointer-events-none" />
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 rounded-full pointer-events-none" /> <div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 rounded-full pointer-events-none" />
<div ref="trackCursor" class="h-3.5 w-3.5 -top-1 rounded-full bg-gray-200 absolute pointer-events-auto" :class="{ 'opacity-0': lockUi || !showFullscreen }" @touchstart="touchstartCursor" /> <div ref="trackCursor" class="h-7 w-7 rounded-full absolute pointer-events-auto flex items-center justify-center" :style="{ top: '-11px' }" :class="{ 'opacity-0': lockUi || !showFullscreen }" @touchstart="touchstartCursor">
<div class="bg-gray-200 rounded-full w-3.5 h-3.5 pointer-events-none" />
</div>
</div> </div>
</div> </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>
@@ -113,7 +115,7 @@ export default {
}, },
sleepTimerRunning: Boolean, sleepTimerRunning: Boolean,
sleepTimeRemaining: Number, sleepTimeRemaining: Number,
isServerItem: Boolean serverLibraryItemId: String
}, },
data() { data() {
return { return {
@@ -272,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
}, },
@@ -389,7 +391,7 @@ export default {
}, },
clickTitleAndAuthor() { clickTitleAndAuthor() {
if (!this.showFullscreen) return if (!this.showFullscreen) return
const llid = this.libraryItem ? this.libraryItem.id : this.localLibraryItem ? this.localLibraryItem.id : null const llid = this.serverLibraryItemId || this.libraryItem?.id || this.localLibraryItem?.id
if (llid) { if (llid) {
this.$router.push(`/item/${llid}`) this.$router.push(`/item/${llid}`)
this.showFullscreen = false this.showFullscreen = false
@@ -551,7 +553,7 @@ export default {
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 - 7 + 'px' this.$refs.trackCursor.style.left = ptWidth - 14 + 'px'
} }
if (this.useChapterTrack) { if (this.useChapterTrack) {
@@ -572,8 +574,8 @@ 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')
@@ -693,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
@@ -702,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()
} }
@@ -717,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
@@ -763,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(() => {
@@ -790,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)
+59 -6
View File
@@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<app-audio-player ref="audioPlayer" :bookmarks="bookmarks" :sleep-timer-running="isSleepTimerRunning" :sleep-time-remaining="sleepTimeRemaining" :is-server-item="!!serverLibraryItemId" @selectPlaybackSpeed="showPlaybackSpeedModal = true" @updateTime="(t) => (currentTime = t)" @showSleepTimer="showSleepTimer" @showBookmarks="showBookmarks" /> <app-audio-player ref="audioPlayer" :bookmarks="bookmarks" :sleep-timer-running="isSleepTimerRunning" :sleep-time-remaining="sleepTimeRemaining" :serverLibraryItemId="serverLibraryItemId" @selectPlaybackSpeed="showPlaybackSpeedModal = true" @updateTime="(t) => (currentTime = t)" @showSleepTimer="showSleepTimer" @showBookmarks="showBookmarks" />
<modals-playback-speed-modal v-model="showPlaybackSpeedModal" :playback-rate.sync="playbackSpeed" @update:playbackRate="updatePlaybackSpeed" @change="changePlaybackSpeed" /> <modals-playback-speed-modal v-model="showPlaybackSpeedModal" :playback-rate.sync="playbackSpeed" @update:playbackRate="updatePlaybackSpeed" @change="changePlaybackSpeed" />
<modals-sleep-timer-modal v-model="showSleepTimerModal" :current-time="sleepTimeRemaining" :sleep-timer-running="isSleepTimerRunning" :current-end-of-chapter-time="currentEndOfChapterTime" :is-auto="isAutoSleepTimer" @change="selectSleepTimeout" @cancel="cancelSleepTimer" @increase="increaseSleepTimer" @decrease="decreaseSleepTimer" /> <modals-sleep-timer-modal v-model="showSleepTimerModal" :current-time="sleepTimeRemaining" :sleep-timer-running="isSleepTimerRunning" :current-end-of-chapter-time="currentEndOfChapterTime" :is-auto="isAutoSleepTimer" @change="selectSleepTimeout" @cancel="cancelSleepTimer" @increase="increaseSleepTimer" @decrease="decreaseSleepTimer" />
@@ -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)
} }
@@ -256,7 +256,7 @@ export default {
}) })
}, },
pauseItem() { pauseItem() {
if (this.$refs.audioPlayer && !this.$refs.audioPlayer.isPaused) { if (this.$refs.audioPlayer && this.$refs.audioPlayer.isPlaying) {
this.$refs.audioPlayer.pause() this.$refs.audioPlayer.pause()
} }
}, },
@@ -265,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
@@ -274,7 +273,7 @@ export default {
this.notifyOnReady() this.notifyOnReady()
}, },
notifyOnReady() { notifyOnReady() {
// TODO: iOS opens last active playback session on app launch. Should be consistent with Android // TODO: was used on iOS to open last played media. May be removed
if (!this.isIos) return if (!this.isIos) return
// If settings aren't loaded yet, native player will receive incorrect settings // If settings aren't loaded yet, native player will receive incorrect settings
@@ -283,6 +282,56 @@ 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)
},
/**
* When device gains focus then refresh the timestamps in the audio player
*/
deviceFocused(hasFocus) {
if (!this.$store.state.currentPlaybackSession) return
if (hasFocus) {
if (!this.$refs.audioPlayer?.isPlaying) {
const playbackSession = this.$store.state.currentPlaybackSession
if (this.$refs.audioPlayer.isLocalPlayMethod) {
const localLibraryItemId = playbackSession.localLibraryItem?.id
const localEpisodeId = playbackSession.localEpisodeId
if (!localLibraryItemId) {
console.error('[AudioPlayerContainer] device visibility: no local library item for session', JSON.stringify(playbackSession))
return
}
const localMediaProgress = this.$store.state.globals.localMediaProgress.find((mp) => {
if (localEpisodeId) return mp.localEpisodeId === localEpisodeId
return mp.localLibraryItemId === localLibraryItemId
})
if (localMediaProgress) {
console.log('[AudioPlayerContainer] device visibility: found local media progress', localMediaProgress.currentTime, 'last time in player is', this.currentTime)
this.$refs.audioPlayer.currentTime = localMediaProgress.currentTime
this.$refs.audioPlayer.timeupdate()
} else {
console.error('[AudioPlayerContainer] device visibility: Local media progress not found')
}
} else {
const libraryItemId = playbackSession.libraryItemId
const episodeId = playbackSession.episodeId
const url = episodeId ? `/api/me/progress/${libraryItemId}/${episodeId}` : `/api/me/progress/${libraryItemId}`
this.$nativeHttp
.get(url)
.then((data) => {
if (!this.$refs.audioPlayer?.isPlaying && data.libraryItemId === libraryItemId) {
console.log('[AudioPlayerContainer] device visibility: got server media progress', data.currentTime, 'last time in player is', this.currentTime)
this.$refs.audioPlayer.currentTime = data.currentTime
this.$refs.audioPlayer.timeupdate()
}
})
.catch((error) => {
console.error('[AudioPlayerContainer] device visibility: Failed to get progress', error)
})
}
}
}
} }
}, },
mounted() { mounted() {
@@ -300,6 +349,8 @@ 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)
this.$eventBus.$on('device-focus-update', this.deviceFocused)
}, },
beforeDestroy() { beforeDestroy() {
if (this.onLocalMediaProgressUpdateListener) this.onLocalMediaProgressUpdateListener.remove() if (this.onLocalMediaProgressUpdateListener) this.onLocalMediaProgressUpdateListener.remove()
@@ -313,6 +364,8 @@ 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)
this.$eventBus.$off('device-focus-update', this.deviceFocused)
} }
} }
</script> </script>
+37 -8
View File
@@ -11,7 +11,11 @@
<div class="w-full overflow-y-auto"> <div class="w-full overflow-y-auto">
<template v-for="item in navItems"> <template v-for="item in navItems">
<nuxt-link :to="item.to" :key="item.text" class="w-full hover:bg-bg hover:bg-opacity-60 flex items-center py-3 px-6 text-gray-300" :class="currentRoutePath.startsWith(item.to) ? 'bg-bg bg-opacity-60' : ''"> <button v-if="item.action" :key="item.text" class="w-full hover:bg-bg hover:bg-opacity-60 flex items-center py-3 px-6 text-gray-300" @click="clickAction(item.action)">
<span class="text-lg" :class="item.iconOutlined ? 'material-icons-outlined' : 'material-icons'">{{ item.icon }}</span>
<p class="pl-4">{{ item.text }}</p>
</button>
<nuxt-link v-else :to="item.to" :key="item.text" class="w-full hover:bg-bg hover:bg-opacity-60 flex items-center py-3 px-6 text-gray-300" :class="currentRoutePath.startsWith(item.to) ? 'bg-bg bg-opacity-60' : ''">
<span class="text-lg" :class="item.iconOutlined ? 'material-icons-outlined' : 'material-icons'">{{ item.icon }}</span> <span class="text-lg" :class="item.iconOutlined ? 'material-icons-outlined' : 'material-icons'">{{ item.icon }}</span>
<p class="pl-4">{{ item.text }}</p> <p class="pl-4">{{ item.text }}</p>
</nuxt-link> </nuxt-link>
@@ -24,9 +28,9 @@
<div class="flex items-center"> <div class="flex items-center">
<p class="text-xs">{{ $config.version }}</p> <p class="text-xs">{{ $config.version }}</p>
<div class="flex-grow" /> <div class="flex-grow" />
<div v-if="user" class="flex items-center" @click="logout"> <div v-if="user" class="flex items-center" @click="disconnect">
<p class="text-xs pr-2">Logout</p> <p class="text-xs pr-2">Disconnect</p>
<span class="material-icons text-sm">logout</span> <i class="material-icons text-sm -mb-0.5">cloud_off</i>
</div> </div>
</div> </div>
</div> </div>
@@ -126,6 +130,15 @@ export default {
text: 'Settings', text: 'Settings',
to: '/settings' to: '/settings'
}) })
if (this.serverConnectionConfig) {
items.push({
icon: 'login',
text: 'Switch Server/User',
action: 'logout'
})
}
return items return items
}, },
currentRoutePath() { currentRoutePath() {
@@ -133,14 +146,20 @@ export default {
} }
}, },
methods: { methods: {
async clickAction(action) {
await this.$hapticsImpact()
if (action === 'logout') {
await this.logout()
this.$router.push('/connect')
}
},
clickBackground() { clickBackground() {
this.show = false this.show = false
}, },
async logout() { async logout() {
await this.$hapticsImpact()
if (this.user) { if (this.user) {
await this.$axios.$post('/logout').catch((error) => { await this.$nativeHttp.post('/logout').catch((error) => {
console.error(error) console.error('Failed to logout', error)
}) })
} }
@@ -148,7 +167,17 @@ export default {
await this.$db.logout() await this.$db.logout()
this.$localStore.removeLastLibraryId() this.$localStore.removeLastLibraryId()
this.$store.commit('user/logout') this.$store.commit('user/logout')
this.$router.push('/connect') this.$store.commit('libraries/setCurrentLibrary', null)
},
async disconnect() {
await this.$hapticsImpact()
await this.logout()
if (this.$route.name !== 'bookshelf') {
this.$router.replace('/bookshelf')
} else {
location.reload()
}
}, },
touchstart(e) { touchstart(e) {
this.touchEvent = new TouchEvent(e) this.touchEvent = new TouchEvent(e)
+18 -3
View File
@@ -72,6 +72,9 @@ export default {
showBookshelfListView() { showBookshelfListView() {
return this.isBookEntity && this.bookshelfListView return this.isBookEntity && this.bookshelfListView
}, },
sortingIgnorePrefix() {
return this.$store.getters['getServerSetting']('sortingIgnorePrefix')
},
entityName() { entityName() {
return this.page return this.page
}, },
@@ -164,9 +167,9 @@ 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,numEpisodesIncomplete`
const payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => { const payload = await this.$nativeHttp.get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
console.error('failed to fetch books', error) console.error('failed to fetch books', error)
return null return null
}) })
@@ -332,6 +335,12 @@ export default {
}, },
async init() { async init() {
if (this.isFirstInit) return if (this.isFirstInit) return
if (!this.user) {
// Offline support not available
await this.resetEntities()
this.$eventBus.$emit('bookshelf-total-entities', 0)
return
}
this.localLibraryItems = await this.$db.getLocalLibraryItems(this.currentLibraryMediaType) this.localLibraryItems = await this.$db.getLocalLibraryItems(this.currentLibraryMediaType)
console.log('Local library items loaded for lazy bookshelf', this.localLibraryItems.length) console.log('Local library items loaded for lazy bookshelf', this.localLibraryItems.length)
@@ -358,8 +367,14 @@ export default {
this.handleScroll(scrollTop) this.handleScroll(scrollTop)
}, },
buildSearchParams() { buildSearchParams() {
if (this.page === 'search' || this.page === 'series' || this.page === 'collections') { if (this.page === 'search' || this.page === 'collections') {
return '' return ''
} else if (this.page === 'series') {
// Sort by name ascending
let searchParams = new URLSearchParams()
searchParams.set('sort', 'name')
searchParams.set('desc', 0)
return searchParams.toString()
} }
let searchParams = new URLSearchParams() let searchParams = new URLSearchParams()
+1 -18
View File
@@ -58,24 +58,7 @@ export default {
return this._author.numBooks || 0 return this._author.numBooks || 0
} }
}, },
methods: { methods: {},
async searchAuthor() {
this.searching = true
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, { q: this.name }).catch((error) => {
console.error('Failed', error)
return null
})
if (!response) {
this.$toast.error('Author not found')
} else if (response.updated) {
if (response.author.imagePath) this.$toast.success('Author was updated')
else this.$toast.success('Author was updated (no image found)')
} else {
this.$toast.info('No updates were made for Author')
}
this.searching = false
}
},
mounted() {} mounted() {}
} }
</script> </script>
+55 -81
View File
@@ -37,6 +37,12 @@
<div v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem', bottom: authorBottom + 'rem' }"> <div v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem', bottom: authorBottom + 'rem' }">
<p class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p> <p class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
</div> </div>
<div v-if="showPlayButton" class="absolute -bottom-16 -right-16 rotate-45 w-32 h-32 p-2 bg-gradient-to-r from-transparent to-black to-40% inline-flex justify-start items-center">
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto -rotate-45" @click.stop.prevent="play">
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">{{ streamIsPlaying ? 'pause_circle' : 'play_circle_filled' }}</span>
</div>
</div>
</div> </div>
<!-- Play/pause button for podcast episode --> <!-- Play/pause button for podcast episode -->
@@ -47,6 +53,7 @@
<!-- 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>
@@ -56,6 +63,11 @@
<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>
@@ -69,9 +81,14 @@
</div> </div>
<!-- Podcast Num Episodes --> <!-- Podcast Num Episodes -->
<div v-else-if="numEpisodes && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }"> <div v-else-if="numEpisodes && !numEpisodesIncomplete && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodes }}</p> <p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodes }}</p>
</div> </div>
<!-- Podcast Num Episodes Incomplete -->
<div v-else-if="numEpisodesIncomplete && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodesIncomplete }}</p>
</div>
</div> </div>
</template> </template>
@@ -106,7 +123,6 @@ export default {
isProcessingReadUpdate: false, isProcessingReadUpdate: false,
libraryItem: null, libraryItem: null,
imageReady: false, imageReady: false,
rescanning: false,
selected: false, selected: false,
isSelectionMode: false, isSelectionMode: false,
showCoverBg: false, showCoverBg: false,
@@ -123,9 +139,6 @@ export default {
} }
}, },
computed: { computed: {
showExperimentalFeatures() {
return this.store.state.showExperimentalFeatures
},
_libraryItem() { _libraryItem() {
return this.libraryItem || {} return this.libraryItem || {}
}, },
@@ -174,6 +187,10 @@ export default {
if (this.isLocal && this.isPodcast && this.media.episodes) return this.media.episodes.length if (this.isLocal && this.isPodcast && this.media.episodes) return this.media.episodes.length
return this.media.numEpisodes return this.media.numEpisodes
}, },
numEpisodesIncomplete() {
if (this.isLocal) return 0
return this._libraryItem.numEpisodesIncomplete || 0
},
processingBatch() { processingBatch() {
return this.store.state.processingBatch return this.store.state.processingBatch
}, },
@@ -212,7 +229,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
@@ -231,14 +248,14 @@ 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() { seriesSequenceList() {
return this.collapsedSeries ? this.collapsedSeries.seriesSequenceList : null return this.collapsedSeries?.seriesSequenceList || null
}, },
libraryItemIdsInSeries() { libraryItemIdsInSeries() {
// Only added to item object when collapseSeries is enabled // Only added to item object when collapseSeries is enabled
return this.collapsedSeries ? this.collapsedSeries.libraryItemIds || [] : [] return this.collapsedSeries?.libraryItemIds || []
}, },
displayTitle() { displayTitle() {
if (this.recentEpisode) return this.recentEpisode.title if (this.recentEpisode) return this.recentEpisode.title
@@ -282,34 +299,28 @@ export default {
}, },
userProgressPercent() { userProgressPercent() {
if (this.useEBookProgress) return Math.max(Math.min(1, this.userProgress.ebookProgress), 0) if (this.useEBookProgress) return Math.max(Math.min(1, this.userProgress.ebookProgress), 0)
return this.userProgress ? Math.max(Math.min(1, this.userProgress.progress), 0) || 0 : 0 return Math.max(Math.min(1, this.userProgress?.progress || 0), 0) || 0
}, },
itemIsFinished() { 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) { return this.store.getters['getIsMediaStreaming'](this.libraryItemId, this.recentEpisode?.id)
if (this.playerIsLocal) {
// Check is streaming local version of this episode
return false // episode cards not implemented for local yet
}
return this.$store.getters['getIsEpisodeStreaming'](this.libraryItemId, this.recentEpisode.id)
} else {
return false // not yet necessary for books
}
}, },
streamIsPlaying() { streamIsPlaying() {
return this.$store.state.playerIsPlaying && this.isStreaming return this.store.state.playerIsPlaying && this.isStreaming
}, },
isMissing() { isMissing() {
return this._libraryItem.isMissing return this._libraryItem.isMissing
@@ -379,13 +390,20 @@ 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
},
showPlayButton() {
return false
// return !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode)
} }
}, },
methods: { methods: {
@@ -427,6 +445,7 @@ 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 play() {},
async playEpisode() { async playEpisode() {
await this.$hapticsImpact() await this.$hapticsImpact()
const eventBus = this.$eventBus || this.$nuxt.$eventBus const eventBus = this.$eventBus || this.$nuxt.$eventBus
@@ -435,18 +454,15 @@ export default {
return return
} }
if (this.localLibraryItem) { if (this.localEpisode) {
const localEpisode = this.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId === this.recentEpisode.id) // Play episode locally
if (localEpisode) { eventBus.$emit('play-item', {
// Play episode locally libraryItemId: this.localLibraryItemId,
eventBus.$emit('play-item', { episodeId: this.localEpisode.id,
libraryItemId: this.localLibraryItemId, serverLibraryItemId: this.libraryItemId,
episodeId: localEpisode.id, serverEpisodeId: this.recentEpisode.id
serverLibraryItemId: this.libraryItemId, })
serverEpisodeId: this.recentEpisode.id return
})
return
}
} }
eventBus.$emit('play-item', { libraryItemId: this.libraryItemId, episodeId: this.recentEpisode.id }) eventBus.$emit('play-item', { libraryItemId: this.libraryItemId, episodeId: this.recentEpisode.id })
@@ -468,48 +484,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() {
this.rescanning = true
this.$axios
.$get(`/api/items/${this.libraryItemId}/scan`)
.then((data) => {
this.rescanning = false
var result = data.result
if (!result) {
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
} else if (result === 'UPDATED') {
this.$toast.success(`Re-Scan complete item was updated`)
} else if (result === 'UPTODATE') {
this.$toast.success(`Re-Scan complete item was up to date`)
} else if (result === 'REMOVED') {
this.$toast.error(`Re-Scan complete item was removed`)
}
})
.catch((error) => {
console.error('Failed to scan library item', error)
this.$toast.error('Failed to scan library item')
this.rescanning = false
})
},
showEditModalTracks() { showEditModalTracks() {
// More menu func // More menu func
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'tracks' }) this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'tracks' })
+14 -131
View File
@@ -18,7 +18,7 @@
<p class="whitespace-normal" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }"> <p class="whitespace-normal" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">
<span v-if="seriesSequence">#{{ seriesSequence }}&nbsp;</span>{{ displayTitle }} <span v-if="seriesSequence">#{{ seriesSequence }}&nbsp;</span>{{ displayTitle }}
</p> </p>
<p class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">by {{ displayAuthor }}</p> <p class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ displayAuthor }}</p>
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p> <p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
<p v-if="duration" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ $elapsedPretty(duration) }}</p> <p v-if="duration" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ $elapsedPretty(duration) }}</p>
<p v-if="episodes" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ episodes }}</p> <p v-if="episodes" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ episodes }}</p>
@@ -62,7 +62,6 @@ export default {
isProcessingReadUpdate: false, isProcessingReadUpdate: false,
libraryItem: null, libraryItem: null,
imageReady: false, imageReady: false,
rescanning: false,
selected: false, selected: false,
isSelectionMode: false, isSelectionMode: false,
showCoverBg: false, showCoverBg: false,
@@ -82,9 +81,6 @@ export default {
} }
}, },
computed: { computed: {
showExperimentalFeatures() {
return this.store.state.showExperimentalFeatures
},
_libraryItem() { _libraryItem() {
return this.libraryItem || {} return this.libraryItem || {}
}, },
@@ -188,20 +184,21 @@ 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
}, },
displayTitle() { displayTitle() {
if (this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix && this.title.toLowerCase().startsWith('the ')) { const ignorePrefix = this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix
return this.title.substr(4) + ', The' if (this.collapsedSeries) return ignorePrefix ? this.collapsedSeries.nameIgnorePrefix : this.collapsedSeries.name
} return ignorePrefix ? this.mediaMetadata.titleIgnorePrefix : this.title
return this.title
}, },
displayAuthor() { displayAuthor() {
if (this.isPodcast) return this.author if (this.isPodcast) return this.author
if (this.collapsedSeries) return `${this.booksInSeries} books in series`
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)
@@ -218,19 +215,19 @@ export default {
return this.userProgress ? !!this.userProgress.isFinished : false return this.userProgress ? !!this.userProgress.isFinished : false
}, },
showError() { showError() {
return this.hasMissingParts || this.hasInvalidParts || this.isMissing || this.isInvalid return this.numMissingParts || this.isMissing || this.isInvalid
}, },
isStreaming() { isStreaming() {
return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId
}, },
showReadButton() { showReadButton() {
return !this.isSelectionMode && this.showExperimentalFeatures && !this.showPlayButton && this.hasEbook return !this.isSelectionMode && !this.showPlayButton && this.hasEbook
}, },
showPlayButton() { showPlayButton() {
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && this.numTracks && !this.isStreaming return !this.isSelectionMode && !this.isMissing && !this.isInvalid && this.numTracks && !this.isStreaming
}, },
showSmallEBookIcon() { showSmallEBookIcon() {
return !this.isSelectionMode && this.showExperimentalFeatures && this.hasEbook return !this.isSelectionMode && this.hasEbook
}, },
isMissing() { isMissing() {
return this._libraryItem.isMissing return this._libraryItem.isMissing
@@ -238,85 +235,13 @@ export default {
isInvalid() { isInvalid() {
return this._libraryItem.isInvalid return this._libraryItem.isInvalid
}, },
hasMissingParts() { numMissingParts() {
return this._libraryItem.hasMissingParts if (this.isPodcast) return 0
}, return this.media.numMissingParts
hasInvalidParts() {
return this._libraryItem.hasInvalidParts
},
errorText() {
if (this.isMissing) return 'Item directory is missing!'
else if (this.isInvalid) return 'Item has no media files'
var txt = ''
if (this.hasMissingParts) {
txt = `${this.hasMissingParts} missing parts.`
}
if (this.hasInvalidParts) {
if (this.hasMissingParts) txt += ' '
txt += `${this.hasInvalidParts} invalid parts.`
}
return txt || 'Unknown Error'
},
overlayWrapperClasslist() {
var classes = []
if (this.isSelectionMode) classes.push('bg-opacity-60')
else classes.push('bg-opacity-40')
if (this.selected) {
classes.push('border-2 border-yellow-400')
}
return classes
}, },
store() { store() {
return this.$store || this.$nuxt.$store return this.$store || this.$nuxt.$store
}, },
userCanUpdate() {
return this.store.getters['user/getUserCanUpdate']
},
userCanDelete() {
return this.store.getters['user/getUserCanDelete']
},
userCanDownload() {
return this.store.getters['user/getUserCanDownload']
},
userIsRoot() {
return this.store.getters['user/getIsRoot']
},
titleFontSize() {
return 0.75 * this.sizeMultiplier
},
authorFontSize() {
return 0.6 * this.sizeMultiplier
},
placeholderCoverPadding() {
return 0.8 * this.sizeMultiplier
},
authorBottom() {
return 0.75 * this.sizeMultiplier
},
titleCleaned() {
if (!this.title) return ''
if (this.title.length > 60) {
return this.title.slice(0, 57) + '...'
}
return this.title
},
authorCleaned() {
if (!this.author) return ''
if (this.author.length > 30) {
return this.author.slice(0, 27) + '...'
}
return this.author
},
isAlternativeBookshelfView() {
return false
// var constants = this.$constants || this.$nuxt.$constants
// return this.bookshelfView === constants.BookshelfView.TITLES
},
titleDisplayBottomOffset() {
if (!this.isAlternativeBookshelfView) return 0
else if (!this.displaySortLine) return 3 * this.sizeMultiplier
return 4.25 * this.sizeMultiplier
},
coverWidth() { coverWidth() {
return 80 / this.bookCoverAspectRatio return 80 / this.bookCoverAspectRatio
} }
@@ -341,7 +266,7 @@ export default {
} else { } else {
var router = this.$router || this.$nuxt.$router var router = this.$router || this.$nuxt.$router
if (router) { if (router) {
if (this.collapsedSeries) router.push(`/library/${this.libraryId}/series/${this.collapsedSeries.id}`) if (this.collapsedSeries) router.push(`/bookshelf/series/${this.collapsedSeries.id}`)
else router.push(`/item/${this.libraryItemId}`) else router.push(`/item/${this.libraryItemId}`)
} }
} }
@@ -349,48 +274,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() {
this.rescanning = true
this.$axios
.$get(`/api/items/${this.libraryItemId}/scan`)
.then((data) => {
this.rescanning = false
var result = data.result
if (!result) {
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
} else if (result === 'UPDATED') {
this.$toast.success(`Re-Scan complete item was updated`)
} else if (result === 'UPTODATE') {
this.$toast.success(`Re-Scan complete item was up to date`)
} else if (result === 'REMOVED') {
this.$toast.error(`Re-Scan complete item was removed`)
}
})
.catch((error) => {
console.error('Failed to scan library item', error)
this.$toast.error('Failed to scan library item')
this.rescanning = false
})
},
showEditModalTracks() { showEditModalTracks() {
// More menu func // More menu func
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'tracks' }) this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'tracks' })
+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">local_offer</span>
</div>
<div class="flex-grow px-2 tagSearchCardContent h-full">
<p class="truncate text-sm">{{ tag }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
tag: String
},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
<style>
.tagSearchCardContent {
width: calc(100% - 40px);
height: 40px;
display: flex;
flex-direction: column;
justify-content: center;
}
</style>
+550 -50
View File
@@ -1,6 +1,7 @@
<template> <template>
<div class="w-full max-w-md mx-auto px-2 sm:px-4 lg:px-8 z-10"> <div class="w-full max-w-md mx-auto px-2 sm:px-4 lg:px-8 z-10">
<div v-show="!loggedIn" class="mt-8 bg-primary overflow-hidden shadow rounded-lg px-4 py-6 w-full"> <div v-show="!loggedIn" class="mt-8 bg-primary overflow-hidden shadow rounded-lg px-4 py-6 w-full">
<!-- list of server connection configs -->
<template v-if="!showForm"> <template v-if="!showForm">
<div v-for="config in serverConnectionConfigs" :key="config.id" class="flex items-center py-4 my-1 border-b border-white border-opacity-10 relative" @click="connectToServer(config)"> <div v-for="config in serverConnectionConfigs" :key="config.id" class="flex items-center py-4 my-1 border-b border-white border-opacity-10 relative" @click="connectToServer(config)">
<span class="material-icons-outlined text-xl text-gray-300">dns</span> <span class="material-icons-outlined text-xl text-gray-300">dns</span>
@@ -14,23 +15,21 @@
<ui-btn class="w-full" @click="newServerConfigClick">Add New Server</ui-btn> <ui-btn class="w-full" @click="newServerConfigClick">Add New Server</ui-btn>
</div> </div>
</template> </template>
<!-- form to add a new server connection config -->
<div v-else class="w-full"> <div v-else class="w-full">
<form v-show="!showAuth" @submit.prevent="submit" novalidate class="w-full"> <!-- server address input -->
<form v-if="!showAuth" @submit.prevent="submit" novalidate class="w-full">
<div v-if="serverConnectionConfigs.length" class="flex items-center mb-4" @click="showServerList"> <div v-if="serverConnectionConfigs.length" class="flex items-center mb-4" @click="showServerList">
<span class="material-icons text-gray-300">arrow_back</span> <span class="material-icons text-gray-300">arrow_back</span>
</div> </div>
<h2 class="text-lg leading-7 mb-2">Server address</h2> <h2 class="text-lg leading-7 mb-2">Server address</h2>
<ui-text-input v-model="serverConfig.address" :disabled="processing || !networkConnected || !!serverConfig.id" placeholder="http://55.55.55.55:13378" type="url" class="w-full h-10" /> <ui-text-input v-model="serverConfig.address" :disabled="processing || !networkConnected || !!serverConfig.id" placeholder="http://55.55.55.55:13378" type="url" class="w-full h-10" />
<div class="flex justify-end items-center mt-6"> <div class="flex justify-end items-center mt-6">
<!-- <div class="relative flex">
<button class="outline-none uppercase tracking-wide font-semibold text-xs text-gray-300" type="button" @click="addCustomHeaders">Add Custom Headers</button>
<div v-if="numCustomHeaders" class="rounded-full h-5 w-5 flex items-center justify-center text-xs bg-success bg-opacity-40 leading-3 font-semibold font-mono ml-1">{{ numCustomHeaders }}</div>
</div> -->
<ui-btn :disabled="processing || !networkConnected" type="submit" :padding-x="3" class="h-10">{{ networkConnected ? 'Submit' : 'No Internet' }}</ui-btn> <ui-btn :disabled="processing || !networkConnected" type="submit" :padding-x="3" class="h-10">{{ networkConnected ? 'Submit' : 'No Internet' }}</ui-btn>
</div> </div>
</form> </form>
<template v-if="showAuth"> <!-- username/password and auth methods -->
<template v-else>
<div v-if="serverConfig.id" class="flex items-center mb-4" @click="showServerList"> <div v-if="serverConfig.id" class="flex items-center mb-4" @click="showServerList">
<span class="material-icons text-gray-300">arrow_back</span> <span class="material-icons text-gray-300">arrow_back</span>
</div> </div>
@@ -41,7 +40,7 @@
<span v-if="!serverConfig.id" class="material-icons" style="font-size: 1.1rem" @click="editServerAddress">edit</span> <span v-if="!serverConfig.id" class="material-icons" style="font-size: 1.1rem" @click="editServerAddress">edit</span>
</div> </div>
<div class="w-full h-px bg-white bg-opacity-10 my-2" /> <div class="w-full h-px bg-white bg-opacity-10 my-2" />
<form @submit.prevent="submitAuth" class="pt-3"> <form v-if="isLocalAuthEnabled" @submit.prevent="submitAuth" class="pt-3">
<ui-text-input v-model="serverConfig.username" :disabled="processing" placeholder="username" class="w-full mb-2 text-lg" /> <ui-text-input v-model="serverConfig.username" :disabled="processing" placeholder="username" class="w-full mb-2 text-lg" />
<ui-text-input v-model="password" type="password" :disabled="processing" placeholder="password" class="w-full mb-2 text-lg" /> <ui-text-input v-model="password" type="password" :disabled="processing" placeholder="password" class="w-full mb-2 text-lg" />
@@ -51,9 +50,12 @@
<ui-btn :disabled="processing || !networkConnected" type="submit" class="mt-1 h-10">{{ networkConnected ? 'Submit' : 'No Internet' }}</ui-btn> <ui-btn :disabled="processing || !networkConnected" type="submit" class="mt-1 h-10">{{ networkConnected ? 'Submit' : 'No Internet' }}</ui-btn>
</div> </div>
</form> </form>
<div v-if="isLocalAuthEnabled && isOpenIDAuthEnabled" class="w-full h-px bg-white bg-opacity-10 my-4" />
<ui-btn v-if="isOpenIDAuthEnabled" :disabled="processing" class="h-10 w-full" @click="clickLoginWithOpenId">{{ oauth.buttonText }}</ui-btn>
</template> </template>
</div> </div>
<!-- auth error message -->
<div v-show="error" class="w-full rounded-lg bg-red-600 bg-opacity-10 border border-error border-opacity-50 py-3 px-2 flex items-center mt-4"> <div v-show="error" class="w-full rounded-lg bg-red-600 bg-opacity-10 border border-error border-opacity-50 py-3 px-2 flex items-center mt-4">
<span class="material-icons mr-2 text-error" style="font-size: 1.1rem">warning</span> <span class="material-icons mr-2 text-error" style="font-size: 1.1rem">warning</span>
<p class="text-error">{{ error }}</p> <p class="text-error">{{ error }}</p>
@@ -71,13 +73,20 @@
</div> </div>
</div> </div>
<p v-if="!serverConnectionConfigs.length" class="mt-2 text-center text-error"><strong>Important!</strong> This app is designed to work with an Audiobookshelf server that you or someone you know is hosting. This app does not provide any content.</p>
<modals-custom-headers-modal v-model="showAddCustomHeaders" :custom-headers.sync="serverConfig.customHeaders" /> <modals-custom-headers-modal v-model="showAddCustomHeaders" :custom-headers.sync="serverConfig.customHeaders" />
</div> </div>
</template> </template>
<script> <script>
import { Browser } from '@capacitor/browser'
import { CapacitorHttp } from '@capacitor/core'
import { Dialog } from '@capacitor/dialog' import { Dialog } from '@capacitor/dialog'
// TODO: when backend ready. See validateLoginFormResponse()
//const requiredServerVersion = '2.5.0'
export default { export default {
data() { data() {
return { return {
@@ -92,7 +101,14 @@ export default {
password: null, password: null,
error: null, error: null,
showForm: false, showForm: false,
showAddCustomHeaders: false showAddCustomHeaders: false,
authMethods: [],
oauth: {
state: null,
verifier: null,
challenge: null,
buttonText: 'Login with OpenID'
}
} }
}, },
computed: { computed: {
@@ -103,21 +119,261 @@ export default {
return this.$store.state.networkConnected return this.$store.state.networkConnected
}, },
serverConnectionConfigs() { serverConnectionConfigs() {
return this.deviceData ? this.deviceData.serverConnectionConfigs || [] : [] return this.deviceData?.serverConnectionConfigs || []
}, },
lastServerConnectionConfigId() { lastServerConnectionConfigId() {
return this.deviceData ? this.deviceData.lastServerConnectionConfigId : null return this.deviceData?.lastServerConnectionConfigId || null
}, },
lastServerConnectionConfig() { lastServerConnectionConfig() {
if (!this.lastServerConnectionConfigId || !this.serverConnectionConfigs.length) return null if (!this.lastServerConnectionConfigId || !this.serverConnectionConfigs.length) return null
return this.serverConnectionConfigs.find((s) => s.id == this.lastServerConnectionConfigId) return this.serverConnectionConfigs.find((s) => s.id == this.lastServerConnectionConfigId)
}, },
numCustomHeaders() { isLocalAuthEnabled() {
if (!this.serverConfig.customHeaders) return 0 return this.authMethods.includes('local') || !this.authMethods.length
return Object.keys(this.serverConfig.customHeaders).length },
isOpenIDAuthEnabled() {
return this.authMethods.includes('openid')
} }
}, },
methods: { methods: {
/**
* Initiates the login process using OpenID via OAuth2.0.
* 1. Verifying the server's address
* 2. Calling oauthRequest() to obtain the special OpenID redirect URL
* including a challenge and specying audiobookshelf://oauth as redirect URL
* 3. Open this redirect URL in browser (which is a website of the SSO provider)
*
* When the browser is open, the following flow is expected:
* a. The user authenticates and the provider redirects back to custom URL audiobookshelf://oauth
* b. The app calls appUrlOpen() when `audiobookshelf://oauth` is called
* b. appUrlOpen() handles the incoming URL and extracts the authorization code from GET parameter
* c. oauthExchangeCodeForToken() exchanges the authorization code for an access token
*
*
* @async
* @throws Will log a console error if the browser fails to open the URL and display errors via this.error to the user.
*/
async clickLoginWithOpenId() {
// oauth standard requires https explicitly
if (!this.serverConfig.address.startsWith('https')) {
console.warn(`[SSO] Oauth2 requires HTTPS`)
this.$toast.error(`SSO: The URL to the server must be https:// secured`)
return
}
// First request that we want to do oauth/openid and get the URL which a browser window should open
const redirectUrl = await this.oauthRequest(this.serverConfig.address)
if (!redirectUrl) {
// error message handled by oauthRequest
return
}
// Actually we should be able to use the redirectUrl directly for Browser.open below
// However it seems that when directly using it there is a malformation and leads to the error
// Unhandled Promise Rejection: DataCloneError: The object can not be cloned.
// (On calling Browser.open)
// Which is hard to debug
// So we simply extract the important elements and build the required URL ourselves
// which also has the advantage that we can replace the callbackurl with the app url
const client_id = redirectUrl.searchParams.get('client_id')
const scope = redirectUrl.searchParams.get('scope')
const state = redirectUrl.searchParams.get('state')
if (!client_id || !scope || !state) {
console.warn(`[SSO] Invalid OpenID URL - client_id scope or state missing: ${redirectUrl}`)
this.$toast.error(`SSO: Invalid answer`)
return
}
if (redirectUrl.protocol !== 'https:') {
console.warn(`[SSO] Insecure Redirection by SSO provider: ${redirectUrl.protocol} is not allowed. Use HTTPS`)
this.$toast.error(`SSO: The SSO provider must return a HTTPS secured URL`)
return
}
// We need to verify if the state is the same later
this.oauth.state = state
const host = `https://${redirectUrl.host}`
const buildUrl = `${host}${redirectUrl.pathname}?response_type=code` + `&client_id=${encodeURIComponent(client_id)}&scope=${encodeURIComponent(scope)}&state=${encodeURIComponent(state)}` + `&redirect_uri=${encodeURIComponent('audiobookshelf://oauth')}` + `&code_challenge=${encodeURIComponent(this.oauth.challenge)}&code_challenge_method=S256`
// example url for authentik
// const authURL = "https://authentik/application/o/authorize/?response_type=code&client_id=41cd96f...&redirect_uri=audiobookshelf%3A%2F%2Foauth&scope=openid%20openid%20email%20profile&state=asdds..."
// Open the browser. The browser/identity provider in turn will redirect to an in-app link supplementing a code
try {
await Browser.open({ url: buildUrl })
} catch (error) {
console.error('Error opening browser', error)
}
},
/**
* Requests the OAuth/OpenID URL from the backend server to open in browser
*
* @async
* @param {string} url - The base URL of the server to append the OAuth request parameters to.
* @return {Promise<URL|null>} OAuth URL which should be opened in a browser
* @throws Logs an error and displays a toast notification if the token exchange fails.
*/
async oauthRequest(url) {
// Generate oauth2 PKCE challenge
// In accordance to RFC 7636 Section 4
function base64URLEncode(arrayBuffer) {
let base64String = btoa(String.fromCharCode.apply(null, new Uint8Array(arrayBuffer)))
return base64String.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
}
async function sha256(plain) {
const encoder = new TextEncoder()
const data = encoder.encode(plain)
return await window.crypto.subtle.digest('SHA-256', data)
}
function generateRandomString() {
var array = new Uint32Array(42)
window.crypto.getRandomValues(array)
return Array.from(array, (dec) => ('0' + dec.toString(16)).slice(-2)).join('') // hex
}
const verifier = generateRandomString()
const challenge = base64URLEncode(await sha256(verifier))
this.oauth.verifier = verifier
this.oauth.challenge = challenge
// set parameter isRest to true, so the backend wont attempt a redirect after we call backend:/callback in exchangeCodeForToken
const backendEndpoint = `${url}/auth/openid?code_challenge=${challenge}&code_challenge_method=S256&isRest=true`
try {
const response = await CapacitorHttp.get({
url: backendEndpoint,
disableRedirects: true,
webFetchExtra: {
redirect: 'manual'
}
})
// Every kind of redirection is allowed [RFC6749 - 1.7]
if (!(response.status >= 300 && response.status < 400)) {
throw new Error(`Unexpected response from server: ${response.status}`)
}
// Depending on iOS or Android, it can be location or Location...
const locationHeader = response.headers[Object.keys(response.headers).find((key) => key.toLowerCase() === 'location')]
if (!locationHeader) {
throw new Error(`No location header in SSO answer`)
}
const url = new URL(locationHeader)
return url
} catch (error) {
console.error(`[SSO] ${error.message}`)
this.$toast.error(`SSO Error: ${error.message}`)
}
},
/**
* Handles the callback received from the OAuth/OpenID provider.
*
* @async
* @function appUrlOpen
* @param {string} url - The callback URL received from the OAuth/OpenID provider.
* @throws Logs a warning and displays a toast notification if the URL is invalid or the state doesn't match.
*/
async appUrlOpen(url) {
if (!url) return
// Handle the OAuth callback
const urlObj = new URL(url)
// audiobookshelf://oauth?code...
// urlObj.hostname for iOS and urlObj.pathname for android
if (url.startsWith('audiobookshelf://oauth')) {
// Extract possible errors thrown by the SSO provider
const authError = urlObj.searchParams.get('error')
if (authError) {
console.warn(`[SSO] Received the following error: ${authError}`)
this.$toast.error(`SSO: Received the following error: ${authError}`)
return
}
// Extract oauth2 code to be exchanged for a token
const authCode = urlObj.searchParams.get('code')
// Extract the state variable
const state = urlObj.searchParams.get('state')
if (this.oauth.state !== state) {
console.warn(`[SSO] Wrong state returned by SSO Provider`)
this.$toast.error(`SSO: The response from the SSO Provider was invalid (wrong state)`)
return
}
// Clear the state variable from the component config
this.oauth.state = null
if (authCode) {
await this.oauthExchangeCodeForToken(authCode, state)
} else {
console.warn(`[SSO] No code received`)
this.$toast.error(`SSO: The response from the SSO Provider did not include a code (authentication error?)`)
}
} else {
console.warn(`[ServerConnectForm] appUrlOpen: Unknown url: ${url} - host: ${urlObj.hostname} - path: ${urlObj.pathname}`)
}
},
/**
* Exchanges an oauth2 authorization code for a JWT token.
* And uses that token to finalise the log in process using authenticateToken()
*
* @async
* @function oauthExchangeCodeForToken
* @param {string} code - The authorization code provided by the OpenID provider.
* @param {string} state - The state value used to associate a client session with an ID token.
* @throws Logs an error and displays a toast notification if the token exchange fails.
*/
async oauthExchangeCodeForToken(code, state) {
// We need to read the url directly from this.serverConfig.address as the callback which is called via the external browser does not pass us that info
const backendEndpoint = `${this.serverConfig.address}/auth/openid/callback?state=${encodeURIComponent(state)}&code=${encodeURIComponent(code)}&code_verifier=${encodeURIComponent(this.oauth.verifier)}`
try {
// We can close the browser at this point (does not work on Android)
if (this.$platform === 'ios' || this.$platform === 'web') {
await Browser.close()
}
} catch (error) {} // No Error handling needed
try {
const response = await CapacitorHttp.get({
url: backendEndpoint
})
if (!response.data || !response.data.user || !response.data.user.token) {
throw new Error('Token data is missing in the response.')
}
this.serverConfig.token = response.data.user.token
const payload = await this.authenticateToken()
if (!payload) {
throw new Error('Authentication failed with the provided token.')
}
const duplicateConfig = this.serverConnectionConfigs.find((scc) => scc.address === this.serverConfig.address && scc.username === payload.user.username)
if (duplicateConfig) {
throw new Error('Config already exists for this address and username.')
}
this.setUserAndConnection(payload)
} catch (error) {
console.error('[SSO] Error in exchangeCodeForToken: ', error)
this.$toast.error(`SSO error: ${error.message || error}`)
} finally {
// We don't need the oauth verifier any more
this.oauth.verifier = null
this.oauth.challenge = null
}
},
addCustomHeaders() { addCustomHeaders() {
this.showAddCustomHeaders = true this.showAddCustomHeaders = true
}, },
@@ -206,10 +462,18 @@ export default {
this.error = null this.error = null
this.showAuth = false this.showAuth = false
}, },
validateServerUrl(url) { /**
* Validates a URL and reconstructs it with an optional protocol override.
* If the URL is invalid, null is returned.
*
* @param {string} url - The URL to validate.
* @param {string|null} [protocolOverride=null] - (Optional) Protocol to override the URL's original protocol.
* @returns {string|null} The validated URL with the original or overridden protocol, or null if invalid.
*/
validateServerUrl(url, protocolOverride = null) {
try { try {
var urlObject = new URL(url) var urlObject = new URL(url)
var address = `${urlObject.protocol}//${urlObject.hostname}` var address = `${protocolOverride ? protocolOverride : urlObject.protocol}//${urlObject.hostname}`
if (urlObject.port) address += ':' + urlObject.port if (urlObject.port) address += ':' + urlObject.port
return address return address
} catch (error) { } catch (error) {
@@ -217,27 +481,94 @@ export default {
return null return null
} }
}, },
pingServerAddress(address, customHeaders) { /**
const options = { timeout: 3000 } * Sends a GET request to the specified URL with the provided headers and timeout.
if (customHeaders) { * If the response is successful (HTTP 200), the response object is returned.
options.headers = customHeaders * Otherwise, throws an error object containing code.
* code can be either a number, which is then a HTTP status code or
* a string, which is then a keyword like NSURLErrorBadURL when the TCP connection could not be established.
* When code is a string, error.message contains the human readable error by the OS or
* the http body of the non-200 answer.
*
* @async
* @param {string} url - The URL to which the GET request will be sent.
* @param {Object} headers - HTTP headers to be included in the request.
* @param {number} [connectTimeout=6000] - Timeout for the request in milliseconds.
* @returns {Promise<HttpResponse>} The HTTP response object if the request is successful.
* @throws {Error} An error with 'code' property set to the HTTP status code if the response is not successful.
* @throws {Error} An error with 'code' property set to the error code if the request fails.
*/
async getRequest(url, headers, connectTimeout = 6000) {
const options = {
url,
headers,
connectTimeout
} }
return this.$axios try {
.$get(`${address}/ping`, options) const response = await CapacitorHttp.get(options)
.then((data) => data.success) console.log('[ServerConnectForm] GET request response', response)
if (response.status == 200) {
return response
} else {
// Put the HTTP error code inside the cause
let errorObj = new Error(response.data)
errorObj.code = response.status
throw errorObj
}
} catch (error) {
// Put the error name inside the cause (a string)
let errorObj = new Error(error.message)
errorObj.code = error.code
throw errorObj
}
},
async postRequest(url, data, headers, connectTimeout = 6000) {
if (!headers) headers = {}
if (!headers['Content-Type'] && data) {
headers['Content-Type'] = 'application/json'
}
const options = {
url,
headers,
data,
connectTimeout
}
const response = await CapacitorHttp.post(options)
console.log('[ServerConnectForm] POST request response', response)
if (response.status >= 400) {
throw new Error(response.data)
} else {
return response.data
}
},
/**
* Get request to server /status api endpoint
*
* @param {string} address
* @returns {Promise<HttpResponse>}
* HttpResponse.data is {isInit:boolean, language:string, authMethods:string[]}>
*/
async getServerAddressStatus(address) {
return this.getRequest(`${address}/status`)
},
pingServerAddress(address, customHeaders) {
return this.getRequest(`${address}/ping`, customHeaders)
.then((response) => {
return response.data.success
})
.catch((error) => { .catch((error) => {
console.error('Server check failed', error) console.error('Server ping failed', error)
const errorMsg = error.message || error
this.error = 'Failed to ping server' this.error = 'Failed to ping server'
if (typeof errorMsg === 'string') {
this.error += ` (${errorMsg})`
}
return false return false
}) })
}, },
requestServerLogin() { requestServerLogin() {
const options = {} return this.postRequest(`${this.serverConfig.address}/login`, { username: this.serverConfig.username, password: this.password || '' }, this.serverConfig.customHeaders, 20000)
if (this.serverConfig.customHeaders) {
options.headers = this.serverConfig.customHeaders
}
return this.$axios
.$post(`${this.serverConfig.address}/login`, { username: this.serverConfig.username, password: this.password }, options)
.then((data) => { .then((data) => {
if (!data.user) { if (!data.user) {
console.error(data.error) console.error(data.error)
@@ -248,30 +579,186 @@ export default {
}) })
.catch((error) => { .catch((error) => {
console.error('Server auth failed', error) console.error('Server auth failed', error)
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error' const errorMsg = error.message || error
this.error = errorMsg this.error = 'Failed to login'
if (typeof errorMsg === 'string') {
this.error += ` (${errorMsg})`
}
return false return false
}) })
}, },
async submit() { async submit() {
if (!this.networkConnected) return if (!this.networkConnected) return
if (!this.serverConfig.address) return if (!this.serverConfig.address) return
if (!this.serverConfig.address.startsWith('http')) {
this.serverConfig.address = 'http://' + this.serverConfig.address
}
var validServerAddress = this.validateServerUrl(this.serverConfig.address)
if (!validServerAddress) {
this.error = 'Invalid server address'
return
}
this.serverConfig.address = validServerAddress const initialAddress = this.serverConfig.address
// Did the user specify a protocol?
const protocolProvided = initialAddress.startsWith('http://') || initialAddress.startsWith('https://')
// Add https:// if not provided
this.serverConfig.address = this.prependProtocolIfNeeded(initialAddress)
this.processing = true this.processing = true
this.error = null this.error = null
this.authMethods = []
var success = await this.pingServerAddress(this.serverConfig.address, this.serverConfig.customHeaders) try {
this.processing = false // Try the server URL. If it fails and the protocol was not provided, try with http instead of https
if (success) this.showAuth = true const statusData = await this.tryServerUrl(this.serverConfig.address, !protocolProvided)
if (this.validateLoginFormResponse(statusData, this.serverConfig.address, protocolProvided)) {
this.showAuth = true
this.authMethods = statusData.data.authMethods || []
this.oauth.buttonText = statusData.data.authFormData?.authOpenIDButtonText || 'Login with OpenID'
if (statusData.data.authFormData?.authOpenIDAutoLaunch) {
this.clickLoginWithOpenId()
}
}
} catch (error) {
this.handleLoginFormError(error)
} finally {
this.processing = false
}
},
/** Validates the login form response from the server.
*
* Ensure the request has not been redirected to an unexpected hostname and check if it is Audiobookshelf
*
* @param {object} statusData - The data received from the server's response, including data and url.
* @param {string} initialAddressWithProtocol - The initial server address including the protocol used for the request.
* @param {boolean} protocolProvided - Indicates whether the protocol was explicitly provided in the initial address.
*
* @returns {boolean} - Returns `true` if the response is valid, otherwise `false` and sets this.error.
*/
validateLoginFormResponse(statusData, initialAddressWithProtocol, protocolProvided) {
// We have a 200 status code at this point
// Check if we got redirected to a different hostname, we don't allow this
const initialAddressUrl = new URL(initialAddressWithProtocol)
const currentAddressUrl = new URL(statusData.url)
if (initialAddressUrl.hostname !== currentAddressUrl.hostname) {
this.error = `Server redirected somewhere else (to ${currentAddressUrl.hostname})`
console.error(`[ServerConnectForm] Server redirected somewhere else (to ${currentAddressUrl.hostname})`)
return false
} // We don't allow a redirection back from https to http if the user used https:// explicitly
else if (protocolProvided && initialAddressWithProtocol.startsWith('https://') && currentAddressUrl.protocol === 'http') {
this.error = `You specified https:// but the Server redirected back to plain http`
console.error(`[ServerConnectForm] User specified https:// but server redirected to http`)
return false
}
// Check content of response now
if (!statusData || !statusData.data || Object.keys(statusData).length === 0) {
this.error = 'Response from server was empty' // Usually some kind of config error on server side
console.error('[ServerConnectForm] Received empty response')
return false
} else if (!('isInit' in statusData.data) || !('language' in statusData.data)) {
this.error = 'This does not seem to be a Audiobookshelf server'
console.error('[ServerConnectForm] Received as response from Server:\n', statusData)
return false
// TODO: delete the if above and comment the ones below out, as soon as the backend is ready to introduce a version check
// } else if (!('app' in statusData.data) || statusData.data.app.toLowerCase() !== 'audiobookshelf') {
// this.error = 'This does not seem to be a Audiobookshelf server'
// console.error('[ServerConnectForm] Received as response from Server:\n', statusData)
// return false
// } else if (!this.isValidVersion(statusData.data.serverVersion, requiredServerVersion)) {
// this.error = `Server version is below minimum required version of ${requiredServerVersion} (${statusData.data.serverVersion})`
// console.error('[ServerConnectForm] Server version is too low: ', statusData.data.serverVersion)
// return false
} else if (!statusData.data.isInit) {
this.error = 'Server is not initialized'
return false
}
// If we got redirected from http to https, we allow this
// Also there is the possibility that https was tried (with protocolProvided false) but only http was successfull
// So set the correct protocol for the config
const configUrl = new URL(this.serverConfig.address)
configUrl.protocol = currentAddressUrl.protocol
// Remove trailing slash
this.serverConfig.address = configUrl.toString().replace(/\/$/, '')
return true
},
/**
* Handles errors received during the login form process, providing user-friendly error messages.
*
* @param {Object} error - The error object received from a failed login attempt.
*/
handleLoginFormError(error) {
console.error('[ServerConnectForm] Received invalid status', error)
if (error.code === 404) {
this.error = `This does not seem to be an Audiobookshelf server. (Error: 404 querying /status)`
} else if (typeof error.code === 'number') {
// Error with HTTP Code
this.error = `Failed to retrieve status of server: ${error.code}`
} else {
// error is usually a meaningful error like "Server timed out"
this.error = `Failed to contact server. (${error})`
}
},
/**
* Attempts to retrieve the server address status for the given URL.
* If the initial attempt fails, it retries with HTTP if allowed.
*
* @param {string} address - The URL address to validate and check.
* @param {boolean} shouldRetryWithHttp - Flag to indicate if the function should retry with HTTP on failure.
* @returns {Promise<HttpResponse>}
* HttpResponse.data is {isInit:boolean, language:string, authMethods:string[]}>
* @throws Will throw an error if the URL has a wrong format or if both HTTPS and HTTP (if retried) requests fail.
*/
async tryServerUrl(address, shouldRetryWithHttp) {
const validatedUrl = this.validateServerUrl(address)
if (!validatedUrl) {
throw new Error('URL has wrong format')
}
try {
return await this.getServerAddressStatus(validatedUrl)
} catch (error) {
// We only retry when the user did not specify a protocol
// Also for security reasons, we only retry when the https request did not
// return a http status code (so only retry when the TCP connection could not be established)
if (shouldRetryWithHttp && typeof error.code !== 'number') {
console.log('[ServerConnectForm] https failed, trying to connect with http...')
const validatedHttpUrl = this.validateServerUrl(address, 'http:')
if (validatedHttpUrl) {
return await this.getServerAddressStatus(validatedHttpUrl)
}
// else if validatedHttpUrl is false return the original error below
}
// rethrow original error
throw error
}
},
/**
* Ensures that a protocol is prepended to the given address if it does not already start with http:// or https://.
*
* @param {string} address - The server address that may or may not have a protocol.
* @returns {string} The address with a protocol prepended if it was missing.
*/
prependProtocolIfNeeded(address) {
return address.startsWith('http://') || address.startsWith('https://') ? address : `https://${address}`
},
/**
* Compares two semantic versioning strings to determine if the current version meets
* or exceeds the minimum version requirement.
*
* @param {string} currentVersion - The current version string to compare, e.g., "1.2.3".
* @param {string} minVersion - The minimum version string required, e.g., "1.0.0".
* @returns {boolean} - Returns true if the current version is greater than or equal
* to the minimum version, false otherwise.
*/
isValidVersion(currentVersion, minVersion) {
const currentParts = currentVersion.split('.').map(Number)
const minParts = minVersion.split('.').map(Number)
for (let i = 0; i < minParts.length; i++) {
if (currentParts[i] > minParts[i]) return true
if (currentParts[i] < minParts[i]) return false
}
return true
}, },
async submitAuth() { async submitAuth() {
if (!this.networkConnected) return if (!this.networkConnected) return
@@ -295,12 +782,13 @@ export default {
this.setUserAndConnection(payload) this.setUserAndConnection(payload)
} }
}, },
async setUserAndConnection({ user, userDefaultLibraryId, serverSettings }) { async setUserAndConnection({ user, userDefaultLibraryId, serverSettings, ereaderDevices }) {
if (!user) return if (!user) return
console.log('Successfully logged in', JSON.stringify(user)) console.log('Successfully logged in', JSON.stringify(user))
this.$store.commit('setServerSettings', serverSettings) this.$store.commit('setServerSettings', serverSettings)
this.$store.commit('libraries/setEReaderDevices', ereaderDevices)
// Set library - Use last library if set and available fallback to default user library // Set library - Use last library if set and available fallback to default user library
var lastLibraryId = await this.$localStore.getLastLibraryId() var lastLibraryId = await this.$localStore.getLastLibraryId()
@@ -312,6 +800,7 @@ export default {
this.serverConfig.userId = user.id this.serverConfig.userId = user.id
this.serverConfig.token = user.token this.serverConfig.token = user.token
this.serverConfig.username = user.username
var serverConnectionConfig = await this.$db.setServerConnectionConfig(this.serverConfig) var serverConnectionConfig = await this.$db.setServerConnectionConfig(this.serverConfig)
@@ -330,16 +819,23 @@ export default {
this.error = null this.error = null
this.processing = true this.processing = true
var authRes = await this.$axios.$post(`${this.serverConfig.address}/api/authorize`, null, { headers: { Authorization: `Bearer ${this.serverConfig.token}` } }).catch((error) => {
console.error('[Server] Server auth failed', error) const authRes = await this.postRequest(`${this.serverConfig.address}/api/authorize`, null, { Authorization: `Bearer ${this.serverConfig.token}` }).catch((error) => {
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error' console.error('[ServerConnectForm] Server auth failed', error)
this.error = errorMsg const errorMsg = error.message || error
this.error = 'Failed to authorize'
if (typeof errorMsg === 'string') {
this.error += ` (${errorMsg})`
}
return false return false
}) })
console.log('[ServerConnectForm] authRes=', authRes)
this.processing = false this.processing = false
return authRes return authRes
}, },
async init() { init() {
if (this.lastServerConnectionConfig) { if (this.lastServerConnectionConfig) {
this.connectToServer(this.lastServerConnectionConfig) this.connectToServer(this.lastServerConnectionConfig)
} else { } else {
@@ -348,7 +844,11 @@ export default {
} }
}, },
mounted() { mounted() {
this.$eventBus.$on('url-open', this.appUrlOpen)
this.init() this.init()
},
beforeDestroy() {
this.$eventBus.$off('url-open', this.appUrlOpen)
} }
} }
</script> </script>
+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
-3
View File
@@ -51,9 +51,6 @@ export default {
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2) if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
return this.width / 240 return this.width / 240
}, },
showExperimentalFeatures() {
return this.store.state.showExperimentalFeatures
},
store() { store() {
return this.$store || this.$nuxt.$store return this.$store || this.$nuxt.$store
}, },
+2 -2
View File
@@ -140,8 +140,8 @@ export default {
console.log('Podcast payload', podcastPayload) console.log('Podcast payload', podcastPayload)
this._processing = true this._processing = true
this.$axios this.$nativeHttp
.$post('/api/podcasts', podcastPayload) .post('/api/podcasts', podcastPayload)
.then((libraryItem) => { .then((libraryItem) => {
this._processing = false this._processing = false
this.$toast.success('Podcast added') this.$toast.success('Podcast added')
+1 -1
View File
@@ -65,7 +65,7 @@ export default {
iconPack: 'material-icons', iconPack: 'material-icons',
icon: 'podcasts', icon: 'podcasts',
iconClass: 'text-xl', iconClass: 'text-xl',
text: 'Search' text: 'Add'
}) })
} }
} else { } else {
+12 -14
View File
@@ -1,5 +1,5 @@
<template> <template>
<modals-modal v-model="show" :width="300" height="100%"> <modals-modal v-model="show" :width="400" height="100%">
<template #outer> <template #outer>
<div class="absolute top-8 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>
@@ -102,44 +102,42 @@ export default {
}) })
if (!value) return if (!value) return
this.$axios this.$nativeHttp
.$delete(`/api/me/item/${this.libraryItemId}/bookmark/${bm.time}`) .delete(`/api/me/item/${this.libraryItemId}/bookmark/${bm.time}`)
.then(() => { .then(() => {
this.$toast.success('Bookmark removed') this.$store.commit('user/deleteBookmark', { libraryItemId: this.libraryItemId, time: bm.time })
}) })
.catch((error) => { .catch((error) => {
this.$toast.error(`Failed to remove bookmark`) this.$toast.error(`Failed to remove bookmark`)
console.error(error) console.error(error)
}) })
this.show = false
}, },
async clickBookmark(bm) { async clickBookmark(bm) {
await this.$hapticsImpact() await this.$hapticsImpact()
this.$emit('select', bm) this.$emit('select', bm)
}, },
submitUpdateBookmark(updatedBookmark) { submitUpdateBookmark(updatedBookmark) {
var bookmark = { ...updatedBookmark } this.$nativeHttp
this.$axios .patch(`/api/me/item/${this.libraryItemId}/bookmark`, updatedBookmark)
.$patch(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark) .then((bookmark) => {
.then(() => { this.$store.commit('user/updateBookmark', bookmark)
this.$toast.success('Bookmark updated') this.showBookmarkTitleInput = false
}) })
.catch((error) => { .catch((error) => {
this.$toast.error(`Failed to update bookmark`) this.$toast.error(`Failed to update bookmark`)
console.error(error) console.error(error)
}) })
this.show = false
}, },
submitCreateBookmark() { submitCreateBookmark() {
if (!this.newBookmarkTitle) { if (!this.newBookmarkTitle) {
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm') this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
} }
var bookmark = { const bookmark = {
title: this.newBookmarkTitle, title: this.newBookmarkTitle,
time: Math.floor(this.currentTime) time: Math.floor(this.currentTime)
} }
this.$axios this.$nativeHttp
.$post(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark) .post(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
.then(() => { .then(() => {
this.$toast.success('Bookmark added') this.$toast.success('Bookmark added')
}) })
+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-8 left-4 z-40 pt-0.5" 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 })
} }
} }
+43 -6
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-8 left-4 z-40" style="max-width: 80%"> <div v-if="title" class="absolute top-10 left-4 z-40 pt-1 pb-1.5" style="max-width: 80%">
<p class="text-white text-xl 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() {}
+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>
+473
View File
@@ -0,0 +1,473 @@
<template>
<div>
<modals-dialog v-model="show" :items="moreMenuItems" @action="moreMenuAction" />
<modals-item-details-modal v-model="showDetailsModal" :library-item="libraryItem" />
<modals-dialog v-model="showSendEbookDevicesModal" title="Select a device" :items="ereaderDeviceItems" @action="sendEbookToDeviceAction" />
</div>
</template>
<script>
import { Dialog } from '@capacitor/dialog'
import { AbsFileSystem } from '@/plugins/capacitor'
export default {
props: {
value: Boolean,
processing: Boolean,
libraryItem: {
type: Object,
default: () => {}
},
episode: {
type: Object,
default: () => {}
},
rssFeed: {
type: Object,
default: () => null
},
hideRssFeedOption: Boolean
},
data() {
return {
showDetailsModal: false,
showSendEbookDevicesModal: false
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
moreMenuItems() {
const items = []
// TODO: Implement on iOS
if (this.$platform !== 'ios' && !this.isPodcast) {
items.push({
text: 'History',
value: 'history',
icon: 'history'
})
}
if (!this.isPodcast || this.episode) {
if (!this.userIsFinished) {
items.push({
text: 'Mark as Finished',
value: 'markFinished',
icon: 'beenhere'
})
}
if (this.progressPercent > 0) {
items.push({
text: 'Discard Progress',
value: 'discardProgress',
icon: 'backspace'
})
}
}
if ((!this.isPodcast && this.serverLibraryItemId) || (this.episode && this.serverEpisodeId)) {
items.push({
text: 'Add to Playlist',
value: 'playlist',
icon: 'playlist_add'
})
if (this.ereaderDeviceItems.length) {
items.push({
text: 'Send ebook to device',
value: 'sendEbook',
icon: 'send'
})
}
}
if (this.showRSSFeedOption) {
items.push({
text: this.rssFeed ? 'RSS Feed' : 'Open RSS Feed',
value: 'rssFeed',
icon: 'rss_feed'
})
}
if (this.localLibraryItemId) {
items.push({
text: 'Manage Local Files',
value: 'manageLocal',
icon: 'folder'
})
if (!this.isPodcast) {
items.push({
text: 'Delete Local Item',
value: 'deleteLocal',
icon: 'delete'
})
} else if (this.localEpisodeId) {
items.push({
text: 'Delete Local Episode',
value: 'deleteLocalEpisode',
icon: 'delete'
})
}
}
if (!this.episode) {
items.push({
text: 'More Info',
value: 'details',
icon: 'info'
})
}
return items
},
ereaderDeviceItems() {
if (!this.ebookFile || !this.$store.state.libraries.ereaderDevices?.length) return []
return this.$store.state.libraries.ereaderDevices.map((d) => {
return {
text: d.name,
value: d.name
}
})
},
isConnectedToServer() {
if (!this.isLocal) return true
if (!this.libraryItem?.serverAddress) return false
return this.$store.getters['user/getServerAddress'] === this.libraryItem.serverAddress
},
isLocal() {
return !!this.libraryItem?.isLocal
},
localLibraryItem() {
if (this.isLocal) return this.libraryItem
return this.libraryItem?.localLibraryItem || null
},
localLibraryItemId() {
return this.localLibraryItem?.id || null
},
serverLibraryItemId() {
if (!this.isLocal) return this.libraryItem?.id
if (this.isConnectedToServer) {
return this.libraryItem.libraryItemId
}
return null
},
localEpisode() {
if (this.isLocal) return this.episode
return this.episode?.localEpisode
},
localEpisodeId() {
return this.localEpisode?.id || null
},
serverEpisodeId() {
if (!this.isLocal) return this.episode?.id
if (this.isConnectedToServer) {
return this.episode.serverEpisodeId
}
return null
},
mediaType() {
return this.libraryItem?.mediaType
},
isPodcast() {
return this.mediaType == 'podcast'
},
media() {
return this.libraryItem?.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
},
title() {
return this.mediaMetadata.title
},
tracks() {
return this.media.tracks || []
},
episodes() {
return this.media.episodes || []
},
ebookFile() {
return this.media.ebookFile
},
localItemProgress() {
if (this.isPodcast) {
if (!this.localEpisodeId) return null
return this.$store.getters['globals/getLocalMediaProgressById'](this.localLibraryItemId, this.localEpisodeId)
}
return this.$store.getters['globals/getLocalMediaProgressById'](this.localLibraryItemId)
},
serverItemProgress() {
if (this.isPodcast) {
if (!this.serverEpisodeId) return null
return this.$store.getters['user/getUserMediaProgress'](this.serverLibraryItemId, this.serverEpisodeId)
}
return this.$store.getters['user/getUserMediaProgress'](this.serverLibraryItemId)
},
userItemProgress() {
if (this.isLocal) return this.localItemProgress
return this.serverItemProgress
},
userIsFinished() {
return !!this.userItemProgress?.isFinished
},
useEBookProgress() {
if (!this.userItemProgress || this.userItemProgress.progress) return false
return this.userItemProgress.ebookProgress > 0
},
progressPercent() {
if (this.useEBookProgress) return Math.max(Math.min(1, this.userItemProgress.ebookProgress), 0)
return Math.max(Math.min(1, this.userItemProgress?.progress || 0), 0)
},
showRSSFeedOption() {
if (this.hideRssFeedOption) return false
if (!this.serverLibraryItemId) return false
if (!this.rssFeed && !this.episodes.length && !this.tracks.length) return false // Cannot open RSS feed with no episodes/tracks
// If rss feed is open then show feed url to users otherwise just show to admins
return this.userIsAdminOrUp || this.rssFeed
},
mediaId() {
if (this.isPodcast) return null
return this.serverLibraryItemId || this.localLibraryItemId
}
},
methods: {
moreMenuAction(action) {
this.show = false
if (action === 'manageLocal') {
this.$nextTick(() => {
this.$router.push(`/localMedia/item/${this.localLibraryItemId}`)
})
} else if (action === 'details') {
this.showDetailsModal = true
} else if (action === 'playlist') {
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: this.episode }])
this.$store.commit('globals/setShowPlaylistsAddCreateModal', true)
} else if (action === 'markFinished') {
if (this.episode) this.toggleEpisodeFinished()
else this.toggleFinished()
} else if (action === 'history') {
this.$router.push(`/media/${this.mediaId}/history?title=${this.title}`)
} else if (action === 'discardProgress') {
this.clearProgressClick()
} else if (action === 'deleteLocal') {
this.deleteLocalItem()
} else if (action === 'deleteLocalEpisode') {
this.deleteLocalEpisode()
} else if (action === 'rssFeed') {
this.clickRSSFeed()
} else if (action === 'sendEbook') {
this.showSendEbookDevicesModal = true
}
},
async toggleFinished() {
await this.$hapticsImpact()
// Show confirm if item has progress since it will reset
if (this.userItemProgress && this.userItemProgress.progress > 0 && !this.userIsFinished) {
const { value } = await Dialog.confirm({
title: 'Confirm',
message: 'Are you sure you want to mark this item as Finished?'
})
if (!value) return
}
this.$emit('update:processing', true)
if (this.isLocal) {
const isFinished = !this.userIsFinished
const payload = await this.$db.updateLocalMediaProgressFinished({ localLibraryItemId: this.localLibraryItemId, isFinished })
console.log('toggleFinished payload', JSON.stringify(payload))
if (payload?.error) {
this.$toast.error(payload?.error || 'Unknown error')
} else {
const localMediaProgress = payload.localMediaProgress
console.log('toggleFinished localMediaProgress', JSON.stringify(localMediaProgress))
if (localMediaProgress) {
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
}
}
} else {
const updatePayload = {
isFinished: !this.userIsFinished
}
await this.$nativeHttp.patch(`/api/me/progress/${this.serverLibraryItemId}`, updatePayload).catch((error) => {
console.error('Failed', error)
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
})
}
this.$emit('update:processing', false)
},
async toggleEpisodeFinished() {
await this.$hapticsImpact()
this.$emit('update:processing', true)
if (this.isLocal || this.localEpisode) {
const isFinished = !this.userIsFinished
const localLibraryItemId = this.localLibraryItemId
const localEpisodeId = this.localEpisodeId
const payload = await this.$db.updateLocalMediaProgressFinished({ localLibraryItemId, localEpisodeId, isFinished })
console.log('toggleFinished payload', JSON.stringify(payload))
if (payload?.error) {
this.$toast.error(payload?.error || 'Unknown error')
} else {
const localMediaProgress = payload.localMediaProgress
console.log('toggleFinished localMediaProgress', JSON.stringify(localMediaProgress))
if (localMediaProgress) {
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
}
}
} else {
const updatePayload = {
isFinished: !this.userIsFinished
}
await this.$nativeHttp.patch(`/api/me/progress/${this.serverLibraryItemId}/${this.serverEpisodeId}`, updatePayload).catch((error) => {
console.error('Failed', error)
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
})
}
this.$emit('update:processing', false)
},
async clearProgressClick() {
await this.$hapticsImpact()
const { value } = await Dialog.confirm({
title: 'Confirm',
message: 'Are you sure you want to reset your progress?'
})
if (value) {
this.$emit('update:processing', true)
const serverMediaProgressId = this.serverItemProgress?.id
if (this.localItemProgress) {
await this.$db.removeLocalMediaProgress(this.localItemProgress.id)
this.$store.commit('globals/removeLocalMediaProgressForItem', this.localItemProgress.id)
}
if (serverMediaProgressId) {
await this.$nativeHttp
.delete(`/api/me/progress/${serverMediaProgressId}`)
.then(() => {
console.log('Progress reset complete')
this.$toast.success(`Your progress was reset`)
this.$store.commit('user/removeMediaProgress', serverMediaProgressId)
})
.catch((error) => {
console.error('Progress reset failed', error)
})
}
this.$emit('update:processing', false)
}
},
async deleteLocalEpisode() {
await this.$hapticsImpact()
const localEpisodeAudioTrack = this.localEpisode.audioTrack
const localFile = this.localLibraryItem.localFiles.find((lf) => lf.id === localEpisodeAudioTrack.localFileId)
if (!localFile) {
this.$toast.error('Audio track does not have matching local file..')
return
}
let confirmMessage = `Remove local episode "${localFile.basePath}" from your device?`
if (this.serverLibraryItemId) {
confirmMessage += ' The file on the server will be unaffected.'
}
const { value } = await Dialog.confirm({
title: 'Confirm',
message: confirmMessage
})
if (value) {
const res = await AbsFileSystem.deleteTrackFromItem({ id: this.localLibraryItemId, trackLocalFileId: localFile.id, trackContentUrl: localEpisodeAudioTrack.contentUrl })
if (res?.id) {
this.$toast.success('Deleted episode successfully')
if (this.isLocal) {
// If this is local episode then redirect to server episode when available
if (this.serverEpisodeId) {
this.$router.replace(`/item/${this.serverLibraryItemId}/${this.serverEpisodeId}`)
} else {
this.$router.replace(`/item/${this.localLibraryItemId}`)
}
} else {
// Update local library item and local episode
this.libraryItem.localLibraryItem = res
this.$delete(this.episode, 'localEpisode')
}
} else this.$toast.error('Failed to delete')
}
},
async deleteLocalItem() {
await this.$hapticsImpact()
let confirmMessage = 'Remove local files of this item from your device?'
if (this.serverLibraryItemId) {
confirmMessage += ' The files on the server and your progress will be unaffected.'
}
const { value } = await Dialog.confirm({
title: 'Confirm',
message: confirmMessage
})
if (value) {
const res = await AbsFileSystem.deleteItem(this.localLibraryItem)
if (res?.success) {
this.$toast.success('Deleted successfully')
if (this.isLocal) {
// If local then redirect to server version when available
if (this.serverLibraryItemId) {
this.$router.replace(`/item/${this.serverLibraryItemId}`)
} else {
this.$router.replace('/bookshelf')
}
} else {
// Remove localLibraryItem
this.$delete(this.libraryItem, 'localLibraryItem')
}
} else this.$toast.error('Failed to delete')
}
},
clickRSSFeed() {
this.$store.commit('globals/setRSSFeedOpenCloseModal', {
id: this.serverLibraryItemId,
name: this.title,
type: 'item',
feed: this.rssFeed,
hasEpisodesWithoutPubDate: this.episodes.some((ep) => !ep.pubDate)
})
},
sendEbookToDeviceAction(deviceName) {
this.showSendEbookDevicesModal = false
const payload = {
libraryItemId: this.serverLibraryItemId,
deviceName
}
this.$emit('update:processing', true)
this.$nativeHttp
.post(`/api/emails/send-ebook-to-device`, payload)
.then(() => {
this.$toast.success('Ebook sent successfully')
})
.catch((error) => {
console.error('Failed to send ebook to device', error)
this.$toast.error('Failed to send ebook to device')
})
.finally(() => {
this.$emit('update:processing', false)
})
}
},
mounted() {}
}
</script>
+2 -2
View File
@@ -7,10 +7,10 @@
</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" />
+1 -1
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-10 right-4 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="show = false"> <div class="absolute z-40 top-10 right-4 h-10 w-10 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="show = false">
<span class="material-icons text-4xl">close</span> <span class="material-icons text-4xl">close</span>
</div> </div>
<slot name="outer" /> <slot name="outer" />
@@ -96,8 +96,8 @@ export default {
} }
this.processing = true this.processing = true
this.$axios this.$nativeHttp
.$post(`/api/podcasts/${this.libraryItem.id}/download-episodes`, episodesToDownload) .post(`/api/podcasts/${this.libraryItem.id}/download-episodes`, episodesToDownload)
.then(() => { .then(() => {
this.processing = false this.processing = false
this.$toast.success('Started downloading episodes on server') this.$toast.success('Started downloading episodes on server')
+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-8 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)
} }
}, },
+12 -10
View File
@@ -1,15 +1,17 @@
<template> <template>
<div :key="bookmark.id" :id="`bookmark-row-${bookmark.id}`" class="flex items-center px-3 py-4 justify-start cursor-pointer hover:bg-bg relative" :class="highlight ? 'bg-bg bg-opacity-60' : ' bg-opacity-20'" @click="click"> <div :key="bookmark.id" :id="`bookmark-row-${bookmark.id}`" class="flex items-center px-1 py-4 justify-start cursor-pointer hover:bg-bg relative" :class="highlight ? 'bg-bg bg-opacity-60' : ' bg-opacity-20'" @click="click">
<span class="material-icons text-xl" :class="highlight ? 'text-success' : 'text-white text-opacity-60'">{{ highlight ? 'bookmark' : 'bookmark_border' }}</span> <div class="flex-grow overflow-hidden px-2">
<div class="flex-grow overflow-hidden"> <div class="flex items-center mb-0.5">
<p class="pl-2 pr-2 truncate text-sm">{{ bookmark.title }}</p> <i class="material-icons text-lg pr-1 -mb-1" :class="highlight ? 'text-success' : 'text-white/60'">{{ highlight ? 'bookmark' : 'bookmark_border' }}</i>
<p class="truncate text-sm">
{{ bookmark.title }}
</p>
</div>
<p class="text-sm font-mono text-gray-300 flex items-center"><span class="material-icons text-base text-white/60 pl-px pr-1">schedule</span>{{ $secondsToTimestamp(bookmark.time) }}</p>
</div> </div>
<div class="h-full flex items-center w-14 justify-end"> <div class="h-full flex items-center justify-end transform w-16 pr-2" @click.stop>
<span class="font-mono text-xs text-gray-300">{{ $secondsToTimestamp(bookmark.time) }}</span> <span class="material-icons text-2xl mr-2 text-gray-200 hover:text-yellow-400" @click.stop="editClick">edit</span>
</div> <span class="material-icons text-2xl text-gray-200 hover:text-error cursor-pointer" @click.stop="deleteClick">delete</span>
<div class="h-full flex items-center justify-end transform w-16">
<span class="material-icons text-lg mr-2 text-gray-200 hover:text-yellow-400" @click.stop="editClick">edit</span>
<span class="material-icons text-lg text-gray-200 hover:text-error cursor-pointer" @click.stop="deleteClick">delete</span>
</div> </div>
</div> </div>
</template> </template>
+47 -42
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-8 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>
@@ -101,8 +109,8 @@ export default {
}, },
loadPlaylists() { loadPlaylists() {
this.loading = true this.loading = true
this.$axios this.$nativeHttp
.$get(`/api/libraries/${this.currentLibraryId}/playlists`) .get(`/api/libraries/${this.currentLibraryId}/playlists`)
.then((data) => { .then((data) => {
this.playlists = data.results || [] this.playlists = data.results || []
}) })
@@ -127,11 +135,10 @@ export default {
this.processing = true this.processing = true
const itemObjects = this.selectedPlaylistItems.map((pi) => ({ libraryItemId: pi.libraryItem.id, episodeId: pi.episode ? pi.episode.id : null })) const itemObjects = this.selectedPlaylistItems.map((pi) => ({ libraryItemId: pi.libraryItem.id, episodeId: pi.episode ? pi.episode.id : null }))
this.$axios this.$nativeHttp
.$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)
@@ -146,11 +153,10 @@ export default {
this.processing = true this.processing = true
const itemObjects = this.selectedPlaylistItems.map((pi) => ({ libraryItemId: pi.libraryItem.id, episodeId: pi.episode ? pi.episode.id : null })) const itemObjects = this.selectedPlaylistItems.map((pi) => ({ libraryItemId: pi.libraryItem.id, episodeId: pi.episode ? pi.episode.id : null }))
this.$axios this.$nativeHttp
.$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)
@@ -178,11 +184,10 @@ export default {
name: this.newPlaylistName name: this.newPlaylistName
} }
this.$axios this.$nativeHttp
.$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.$nativeHttp
.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.$nativeHttp
.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>
+168 -50
View File
@@ -1,28 +1,17 @@
<template> <template>
<div id="comic-reader" class="w-full h-full relative"> <div id="comic-reader" class="w-full h-full relative">
<div v-show="showPageMenu" v-click-outside="clickOutsideObj" class="pagemenu absolute top-12 right-16 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-52"> <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.stop="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="clickedOutsideInfoMenu" class="pagemenu absolute top-12 right-0 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-full" @click.stop.prevent>
<div v-for="key in comicMetadataKeys" :key="key" class="w-full px-2 py-1">
<p class="text-xs">
<strong>{{ key }}</strong>
: {{ comicMetadata[key] }}
</p>
</div>
</div>
<div v-if="comicMetadata" class="absolute top-0 right-3 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="clickShowInfoMenu">
<span class="material-icons text-lg">more</span>
</div>
<div class="absolute top-0 right-16 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @mouseup.prevent @click.stop.prevent="clickShowPageMenu">
<span class="material-icons text-lg">menu</span>
</div>
<div class="absolute top-0 left-3 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20" @click.stop>
<p class="font-mono">{{ page + 1 }} / {{ numPages }}</p>
</div>
<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 {
@@ -62,11 +64,7 @@ export default {
loadTimeout: null, loadTimeout: null,
loadedFirstPage: false, loadedFirstPage: false,
comicMetadata: null, comicMetadata: null,
clickOutsideObj: { pageMenuWidth: 256
handler: this.clickedOutside,
events: ['mousedown'],
isActive: true
}
} }
}, },
watch: { watch: {
@@ -78,31 +76,127 @@ 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: {
async updateProgress() {
if (!this.keepProgress) return
if (!this.numPages) {
console.error('Num pages not loaded')
return
}
if (this.savedPage === this.page) {
return
}
const payload = {
ebookLocation: String(this.page),
ebookProgress: Math.max(0, Math.min(1, (Number(this.page) - 1) / Number(this.numPages)))
}
// Update local item
if (this.localLibraryItemId) {
const localPayload = {
localLibraryItemId: this.localLibraryItemId,
...payload
}
const localResponse = await this.$db.updateLocalEbookProgress(localPayload)
if (localResponse.localMediaProgress) {
this.$store.commit('globals/updateLocalMediaProgress', localResponse.localMediaProgress)
}
}
// Update server item
if (this.serverLibraryItemId) {
this.$nativeHttp.patch(`/api/me/progress/${this.serverLibraryItemId}`, payload).catch((error) => {
console.error('ComicReader.updateProgress failed:', error)
})
}
},
clickShowInfoMenu() { clickShowInfoMenu() {
this.showInfoMenu = !this.showInfoMenu this.showInfoMenu = !this.showInfoMenu
this.showPageMenu = false this.showPageMenu = false
}, },
clickShowPageMenu() { clickShowPageMenu() {
if (!this.numPages) return
this.showPageMenu = !this.showPageMenu this.showPageMenu = !this.showPageMenu
this.showInfoMenu = false this.showInfoMenu = false
}, },
clickedOutside() {
this.showPageMenu = false
},
clickedOutsideInfoMenu() {
this.showInfoMenu = false
},
next() { next() {
if (!this.canGoNext) return if (!this.canGoNext) return
this.setPage(this.page + 1) this.setPage(this.page + 1)
@@ -111,13 +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
} }
this.showPageMenu = false this.showPageMenu = false
const filename = this.pages[index] const filename = this.pages[page - 1]
this.page = index this.page = page
this.updateProgress()
return this.extractFile(filename) return this.extractFile(filename)
}, },
setLoadTimeout() { setLoadTimeout() {
@@ -147,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
@@ -166,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
@@ -196,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()
@@ -214,8 +335,8 @@ export default {
}, },
parseImageFilename(filename) { parseImageFilename(filename) {
var basename = Path.basename(filename, Path.extname(filename)) var basename = Path.basename(filename, Path.extname(filename))
var numbersinpath = basename.match(/\d{1,5}/g) var numbersinpath = basename.match(/\d+/g)
if (!numbersinpath || !numbersinpath.length) { if (!numbersinpath?.length) {
return { return {
index: -1, index: -1,
filename filename
@@ -261,9 +382,6 @@ export default {
padding-top: 36px; padding-top: 36px;
} }
.pagemenu {
max-height: calc(100% - 80px);
}
.comicimg { .comicimg {
height: 100%; height: 100%;
margin: auto; margin: auto;
+137 -22
View File
@@ -2,11 +2,10 @@
<div id="epub-frame" class="w-full"> <div id="epub-frame" class="w-full">
<div id="viewer" class="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' }"> <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' }">
<p class="text-xs">epub</p> <p v-if="totalLocations" class="text-xs text-slate-600">Location {{ currentLocationNum }} of {{ totalLocations }}</p>
<div class="flex-grow" /> <div class="flex-grow" />
<p class="text-xs">{{ progress }}%</p>
<p class="text-sm">{{ progress }}%</p>
</div> </div>
</div> </div>
</template> </template>
@@ -21,7 +20,8 @@ export default {
type: Object, type: Object,
default: () => {} default: () => {}
}, },
isLocal: Boolean isLocal: Boolean,
keepProgress: Boolean
}, },
data() { data() {
return { return {
@@ -29,15 +29,27 @@ export default {
book: null, book: null,
/** @type {ePub.Rendition} */ /** @type {ePub.Rendition} */
rendition: null, rendition: null,
progress: 0 progress: 0,
totalLocations: 0,
currentLocationNum: 0,
currentLocationCfi: null,
inittingDisplay: true,
ereaderSettings: {
theme: 'dark',
fontScale: 100,
lineSpacing: 115
}
} }
}, },
watch: { watch: {
playerLibraryItemId() { isPlayerOpen() {
this.updateHeight() this.updateHeight()
} }
}, },
computed: { computed: {
userToken() {
return this.$store.getters['user/getToken']
},
/** @returns {string} */ /** @returns {string} */
libraryItemId() { libraryItemId() {
return this.libraryItem?.id return this.libraryItem?.id
@@ -58,15 +70,15 @@ export default {
} }
return null return null
}, },
playerLibraryItemId() { isPlayerOpen() {
return this.$store.state.playerLibraryItemId return this.$store.getters['getIsPlayerOpen']
}, },
readerHeightOffset() { readerHeightOffset() {
return this.playerLibraryItemId ? 196 : 96 return this.isPlayerOpen ? 204 : 104
}, },
/** @returns {Array<ePub.NavItem>} */ /** @returns {Array<ePub.NavItem>} */
chapters() { chapters() {
return this.book ? this.book.navigation.toc : [] return this.book?.navigation?.toc || []
}, },
userItemProgress() { userItemProgress() {
if (this.isLocal) return this.localItemProgress if (this.isLocal) return this.localItemProgress
@@ -80,9 +92,49 @@ export default {
}, },
localStorageLocationsKey() { localStorageLocationsKey() {
return `ebookLocations-${this.libraryItemId}` 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)'
return {
'*': {
color: `${fontColor}!important`,
'background-color': `${backgroundColor}!important`,
'line-height': this.ereaderSettings.lineSpacing + '%!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)
@@ -104,6 +156,8 @@ export default {
* @param {string} payload.ebookProgress - eBook Progress Percentage * @param {string} payload.ebookProgress - eBook Progress Percentage
*/ */
async updateProgress(payload) { async updateProgress(payload) {
if (!this.keepProgress) return
// Update local item // Update local item
if (this.localLibraryItemId) { if (this.localLibraryItemId) {
const localPayload = { const localPayload = {
@@ -118,7 +172,7 @@ export default {
// Update server item // Update server item
if (this.serverLibraryItemId) { if (this.serverLibraryItemId) {
this.$axios.$patch(`/api/me/progress/${this.serverLibraryItemId}`, payload).catch((error) => { this.$nativeHttp.patch(`/api/me/progress/${this.serverLibraryItemId}`, payload).catch((error) => {
console.error('EpubReader.updateProgress failed:', error) console.error('EpubReader.updateProgress failed:', error)
}) })
} }
@@ -210,9 +264,20 @@ export default {
}, },
/** @param {string} location - CFI of the new location */ /** @param {string} location - CFI of the new location */
relocated(location) { relocated(location) {
if (this.userItemProgress?.ebookLocation === location.start.cfi) { console.log(`[EpubReader] relocated ${location.start.cfi}`)
if (this.inittingDisplay) {
console.log(`[EpubReader] relocated but initting display ${location.start.cfi}`)
return return
} }
this.currentLocationNum = location.start.location
if (this.currentLocationCfi === location.start.cfi) {
console.log(`[EpubReader] location already saved`, location.start.cfi)
return
}
console.log(`[EpubReader] Saving new location ${location.start.cfi}`)
this.currentLocationCfi = location.start.cfi
if (location.end.percentage) { if (location.end.percentage) {
this.updateProgress({ this.updateProgress({
@@ -231,11 +296,15 @@ export default {
/** @type {EpubReader} */ /** @type {EpubReader} */
const reader = this const reader = this
console.log('[EpubReader] initEpub', reader.url)
/** @type {ePub.Book} */ /** @type {ePub.Book} */
reader.book = new ePub(reader.url, { reader.book = new ePub(reader.url, {
width: window.innerWidth, width: window.innerWidth,
height: window.innerHeight - this.readerHeightOffset height: window.innerHeight - this.readerHeightOffset,
openAs: 'epub',
requestHeaders: {
Authorization: `Bearer ${this.userToken}`
}
}) })
/** @type {ePub.Rendition} */ /** @type {ePub.Rendition} */
@@ -247,25 +316,71 @@ export default {
flow: 'paginated' flow: 'paginated'
}) })
// load saved progress
reader.rendition.display(this.userItemProgress?.ebookLocation || reader.book.locations.start)
// load style
reader.rendition.themes.default({ '*': { color: '#fff!important' } })
reader.book.ready.then(() => { reader.book.ready.then(() => {
console.log('%c [EpubReader] Book ready', 'color:cyan;')
let displayCfi = reader.book.locations.start
if (this.savedEbookLocation && reader.book.spine.get(this.savedEbookLocation)) {
displayCfi = this.savedEbookLocation
}
reader.rendition.on('displayed', async () => {
console.log('%c [EpubReader] Rendition displayed', 'color:blue;')
// Overriding the needsSnap function in epubjs `snap.js` to fix a bug with scrollLeft being a decimal
reader.rendition.manager.snapper.needsSnap = function () {
let left = Math.round(this.scrollLeft)
let snapWidth = this.layout.pageWidth * this.layout.divisor
return left % snapWidth !== 0
}
})
reader.rendition.on('rendered', (section, view) => {
this.applyTheme()
console.log('%c [EpubReader] Rendition rendered', 'color:red;', section, view)
})
// set up event listeners // set up event listeners
reader.rendition.on('relocated', reader.relocated) reader.rendition.on('relocated', reader.relocated)
reader.rendition.on('displayError', (err) => {
console.log('[EpubReader] Display error', err)
})
reader.rendition.on('touchstart', (event) => {
this.$emit('touchstart', event)
})
reader.rendition.on('touchend', (event) => {
this.$emit('touchend', event)
})
// load ebook cfi locations // load ebook cfi locations
const savedLocations = this.loadLocations() const savedLocations = this.loadLocations()
if (savedLocations) { if (savedLocations) {
reader.book.locations.load(savedLocations) reader.book.locations.load(savedLocations)
this.totalLocations = reader.book.locations.length()
} else { } else {
reader.book.locations.generate().then(() => { reader.book.locations.generate(100).then(() => {
this.totalLocations = reader.book.locations.length()
this.currentLocationNum = reader.rendition.currentLocation()?.start.location || 0
this.checkSaveLocations(reader.book.locations.save()) this.checkSaveLocations(reader.book.locations.save())
}) })
} }
// TODO: To get the correct page need to render twice. On book ready and after first display. Figure out why
console.log(`[EpubReader] Displaying cfi ${displayCfi}`)
this.currentLocationCfi = displayCfi
reader.rendition.display(displayCfi).then(() => {
reader.rendition.display(displayCfi).then(() => {
this.inittingDisplay = false
})
})
})
},
applyTheme() {
if (!this.rendition) return
this.rendition.getContents().forEach((c) => {
c.addStylesheetRules(this.themeRules)
}) })
} }
}, },
+16 -13
View File
@@ -1,12 +1,8 @@
<template> <template>
<div class="mobi-ebook-viewer w-full relative"> <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 ? '120px' : '0px' }">
<p class="text-xs">mobi</p>
<div class="flex-grow" />
</div>
</div> </div>
</template> </template>
@@ -17,14 +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']
} }
}, },
methods: { methods: {
@@ -85,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) => {
@@ -119,14 +122,14 @@ export default {
<style> <style>
.mobi-ebook-viewer { .mobi-ebook-viewer {
height: calc(100% - 52px); height: calc(100% - 32px);
max-height: calc(100% - 52px); max-height: calc(100% - 32px);
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
} }
.reader-player-open .mobi-ebook-viewer { .reader-player-open .mobi-ebook-viewer {
height: calc(100% - 152px); height: calc(100% - 132px);
max-height: calc(100% - 152px); max-height: calc(100% - 132px);
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
} }
+139 -16
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="w-full h-full pt-6 relative"> <div class="w-full h-full relative">
<div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent> <div 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.$nativeHttp.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>
+233 -41
View File
@@ -1,11 +1,89 @@
<template> <template>
<div v-if="show" class="absolute top-0 left-0 w-full h-full bg-bg z-40 pt-16" :class="{ 'reader-player-open': !!playerLibraryItemId }"> <div 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-16 pt-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" :library-item="selectedLibraryItem" :is-local="isLocal" />
<!-- ereader -->
<component v-if="readerComponentName" ref="readerComponent" :is="readerComponentName" :url="ebookUrl" :library-item="selectedLibraryItem" :is-local="isLocal" :keep-progress="keepProgress" @touchstart="touchstart" @touchend="touchend" @loaded="readerLoaded" @hook:mounted="readerMounted" />
<!-- table of contents modal -->
<modals-fullscreen-modal v-model="showTOCModal" :theme="ereaderTheme">
<div class="flex items-end justify-between h-20 px-4 pb-2">
<h1 class="text-lg">Table of Contents</h1>
<button class="flex" @click.stop="showTOCModal = false">
<span class="material-icons">close</span>
</button>
</div>
<!-- chapters list -->
<div class="w-full overflow-y-auto overflow-x-hidden h-full max-h-[calc(100vh-85px)]">
<div class="w-full h-full px-4">
<ul>
<li v-for="chapter in chapters" :key="chapter.id" class="py-1">
<a :href="chapter.href" class="opacity-80 hover:opacity-100" @click.prevent="goToChapter(chapter.href)">{{ chapter.label }}</a>
<ul v-if="chapter.subitems.length">
<li v-for="subchapter in chapter.subitems" :key="subchapter.id" class="py-1 pl-4">
<a :href="subchapter.href" class="opacity-80 hover:opacity-100" @click.prevent="goToChapter(subchapter.href)">{{ subchapter.label }}</a>
</li>
</ul>
</li>
</ul>
<div v-if="!chapters.length" class="flex h-full items-center justify-center">
<p class="text-xl">{{ 'No Chapters' }}</p>
</div>
</div>
</div>
</modals-fullscreen-modal>
<!-- ereader settings modal -->
<modals-fullscreen-modal v-model="showSettingsModal" :theme="ereaderTheme" half-screen>
<div class="flex items-end justify-between h-20 px-4 pb-2 mb-8">
<h1 class="text-lg">Ereader Settings</h1>
<button class="flex" @click="showSettingsModal = false">
<span class="material-icons">close</span>
</button>
</div>
<div class="w-full overflow-y-auto overflow-x-hidden h-full max-h-[calc(100vh-85px)]">
<div class="w-full h-full px-4">
<div class="flex items-center mb-8">
<div class="w-32">
<p class="text-base">Theme:</p>
</div>
<ui-toggle-btns v-model="ereaderSettings.theme" :items="themeItems" @input="settingsUpdated" />
</div>
<div class="flex items-center mb-8">
<div class="w-32">
<p class="text-base">Font scale:</p>
</div>
<ui-range-input v-model="ereaderSettings.fontScale" :min="5" :max="300" :step="5" input-width="180px" @input="settingsUpdated" />
</div>
<div class="flex items-center mb-8">
<div class="w-32">
<p class="text-base">Line spacing:</p>
</div>
<ui-range-input v-model="ereaderSettings.lineSpacing" :min="100" :max="300" :step="5" input-width="180px" @input="settingsUpdated" />
</div>
</div>
</div>
</modals-fullscreen-modal>
</div> </div>
</template> </template>
@@ -19,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)
} }
} }
} }
@@ -49,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'
@@ -61,17 +171,20 @@ 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?.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() {
return this.ebookFile?.ebookFormat || null if (!this.ebookFile) return null
// Use file extension for supplementary ebook
if (!this.ebookFile.ebookFormat) {
return this.ebookFile.metadata.ext.toLowerCase().slice(1)
}
return this.ebookFile.ebookFormat
}, },
ebookType() { ebookType() {
if (this.isMobi) return 'mobi' if (this.isMobi) return 'mobi'
@@ -93,7 +206,7 @@ export default {
return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr' return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr'
}, },
isLocal() { isLocal() {
return !!this.ebookFile?.isLocal return !!this.ebookFile?.isLocal || !!this.ebookFile?.localFileId
}, },
localContentUrl() { localContentUrl() {
return this.ebookFile?.contentUrl return this.ebookFile?.contentUrl
@@ -103,25 +216,57 @@ export default {
if (this.localContentUrl) { if (this.localContentUrl) {
return Capacitor.convertFileSrc(this.localContentUrl) return Capacitor.convertFileSrc(this.localContentUrl)
} }
let filepath = ''
if (this.selectedLibraryItem.isFile) {
filepath = this.$encodeUriPath(this.ebookFile.metadata.filename)
} else {
const itemRelPath = this.selectedLibraryItem.relPath
if (itemRelPath.startsWith('/')) itemRelPath = itemRelPath.slice(1)
const relPath = this.ebookFile.metadata.relPath
if (relPath.startsWith('/')) relPath = relPath.slice(1)
filepath = this.$encodeUriPath(`${itemRelPath}/${relPath}`)
}
const serverAddress = this.$store.getters['user/getServerAddress'] 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()
@@ -133,43 +278,90 @@ 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)
return return
} }
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 < 60 || touchDistanceY > touchDistanceX) { const touchDistance = Math.sqrt(Math.pow(this.touchstartX - this.touchendX, 2) + Math.pow(this.touchstartY - this.touchendY, 2))
if (touchDistance < 30) {
if (this.showSettingsModal) {
this.showSettingsModal = false
} else {
this.toggleToolbar()
}
return return
} }
if (this.touchendX < this.touchstartX) { if (touchDistanceX < 60 || touchDistanceY > touchDistanceX) {
console.log('swiped left') return
this.next()
} }
if (this.touchendX > this.touchstartX) { this.hideToolbar()
console.log('swiped right') if (!this.isEpub) {
this.prev() if (this.touchendX < this.touchstartX) {
this.next()
}
if (this.touchendX > this.touchstartX) {
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) {
// Ignore rapid touch
if (this.touchstartTime && Date.now() - this.touchstartTime < 250) {
return
}
this.touchstartX = e.touches[0].screenX this.touchstartX = e.touches[0].screenX
this.touchstartY = e.touches[0].screenY 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)
} }
-25
View File
@@ -70,28 +70,3 @@ export default {
mounted() {} mounted() {}
} }
</script> </script>
<style scoped>
.tracksTable {
border-collapse: collapse;
width: 100%;
border: 1px solid #474747;
}
.tracksTable tr:nth-child(even) {
background-color: #2e2e2e;
}
.tracksTable tr {
background-color: #373838;
}
.tracksTable td {
padding: 8px 8px;
}
.tracksTable th {
padding: 4px 8px;
font-size: 0.75rem;
}
</style>
-25
View File
@@ -58,28 +58,3 @@ export default {
mounted() {} mounted() {}
} }
</script> </script>
<style scoped>
.tracksTable {
border-collapse: collapse;
width: 100%;
border: 1px solid #474747;
}
.tracksTable tr:nth-child(even) {
background-color: #2e2e2e;
}
.tracksTable tr {
background-color: #373838;
}
.tracksTable td {
padding: 8px 8px;
}
.tracksTable th {
padding: 4px 8px;
font-size: 0.75rem;
}
</style>
@@ -8,7 +8,7 @@
<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 v-if="media.duration" class="text-xxs text-gray-500">{{ bookDuration }}</p>
</div> </div>
</div> </div>
<div class="w-8 min-w-8 flex justify-center"> <div class="w-8 min-w-8 flex justify-center">
@@ -74,7 +74,7 @@ export default {
return !this.isMissing && !this.isInvalid && this.tracks.length return !this.isMissing && !this.isInvalid && this.tracks.length
}, },
isStreaming() { isStreaming() {
return this.$store.getters['getIsEpisodeStreaming'](this.libraryItemId) return this.$store.getters['getIsMediaStreaming'](this.libraryItemId)
}, },
streamIsPlaying() { streamIsPlaying() {
return this.$store.state.playerIsPlaying && this.isStreaming return this.$store.state.playerIsPlaying && this.isStreaming
+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.$nativeHttp
.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>
+80 -31
View File
@@ -1,22 +1,30 @@
<template> <template>
<div class="w-full px-2 py-2 overflow-hidden relative"> <div class="w-full px-1.5 pb-1.5">
<nuxt-link v-if="libraryItem" :to="`/item/${libraryItem.id}`" class="flex items-center w-full"> <div class="w-full h-full p-2 rounded-lg relative bg-bg overflow-hidden">
<div class="h-full relative" :style="{ width: '50px' }"> <nuxt-link v-if="libraryItem" :to="itemUrl" class="flex items-center w-full">
<covers-book-cover :library-item="libraryItem" :width="50" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <div class="h-full relative" :style="{ width: '50px' }">
</div> <covers-book-cover :library-item="libraryItem" :width="50" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div class="item-table-content h-full px-2 flex items-center">
<div class="max-w-full">
<p class="truncate block text-sm">{{ itemTitle }}</p>
<p class="truncate block text-gray-400 text-xs">{{ bookAuthorName }}</p>
<p class="text-xxs text-gray-500">{{ itemDuration }}</p>
</div> </div>
</div> <div class="item-table-content h-full px-2 flex items-center">
<div class="w-8 min-w-8 flex justify-center"> <div class="max-w-full">
<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"> <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>
<span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span> <p v-if="authorName" class="truncate block text-gray-300 text-xs">{{ authorName }}</p>
</button> <p class="text-xxs text-gray-400">{{ itemDuration }}</p>
</div> </div>
</nuxt-link> </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>
<div class="w-8 min-w-8 flex justify-center">
<button class="w-8 h-8 rounded-full flex items-center justify-center" @click.stop.prevent="showMore">
<span class="material-icons">more_vert</span>
</button>
</div>
</nuxt-link>
<div class="absolute bottom-0 left-0 h-0.5 shadow-sm z-10" :class="userIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: progressPercent * 100 + '%' }"></div>
</div>
</div> </div>
</template> </template>
@@ -30,20 +38,27 @@ export default {
} }
}, },
data() { data() {
return { return {}
isProcessingReadUpdate: false,
processingRemove: false
}
}, },
computed: { computed: {
itemUrl() {
if (this.episodeId) return `/item/${this.libraryItem.id}/${this.episodeId}`
return `/item/${this.libraryItem.id}`
},
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 || {}
@@ -51,6 +66,12 @@ export default {
mediaMetadata() { mediaMetadata() {
return this.media.metadata || {} return this.media.metadata || {}
}, },
mediaType() {
return this.libraryItem.mediaType
},
isPodcast() {
return this.mediaType === 'podcast'
},
tracks() { tracks() {
if (this.episode) return [] if (this.episode) return []
return this.media.tracks || [] return this.media.tracks || []
@@ -66,6 +87,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)
@@ -82,27 +107,51 @@ export default {
coverWidth() { coverWidth() {
return 50 return 50
}, },
isMissing() {
return this.libraryItem.isMissing
},
isInvalid() {
return this.libraryItem.isInvalid
},
showPlayBtn() { showPlayBtn() {
return !this.isMissing && !this.isInvalid && (this.tracks.length || this.episode) return !this.isMissing && !this.isInvalid && (this.tracks.length || this.episode)
}, },
isStreaming() { isStreaming() {
return this.$store.getters['getIsEpisodeStreaming'](this.libraryItem.id, this.episodeId) if (this.localLibraryItem && this.localEpisode && this.$store.getters['getIsMediaStreaming'](this.localLibraryItem.id, this.localEpisode.id)) return true
return this.$store.getters['getIsMediaStreaming'](this.libraryItem.id, this.episodeId)
}, },
streamIsPlaying() { streamIsPlaying() {
return this.$store.state.playerIsPlaying && this.isStreaming return this.$store.state.playerIsPlaying && this.isStreaming
},
userItemProgress() {
return this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, this.episodeId)
},
userIsFinished() {
return !!this.userItemProgress?.isFinished
},
progressPercent() {
return Math.max(Math.min(1, this.userItemProgress?.progress || 0), 0)
} }
}, },
methods: { methods: {
showMore() {
const playlistItem = {
libraryItem: this.libraryItem,
episode: this.episode
}
if (this.localLibraryItem) {
playlistItem.libraryItem.localLibraryItem = this.localLibraryItem
}
if (this.localEpisode && playlistItem.episode) {
playlistItem.episode.localEpisode = this.localEpisode
}
this.$emit('showMore', playlistItem)
},
async playClick() { async playClick() {
await this.$hapticsImpact() await this.$hapticsImpact()
if (this.streamIsPlaying) { if (this.streamIsPlaying) {
this.$eventBus.$emit('pause-item') 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 { } else {
this.$eventBus.$emit('play-item', { this.$eventBus.$emit('play-item', {
libraryItemId: this.libraryItem.id, libraryItemId: this.libraryItem.id,
@@ -117,7 +166,7 @@ export default {
<style> <style>
.item-table-content { .item-table-content {
width: calc(100% - 82px); width: calc(100% - 114px);
max-width: calc(100% - 82px); max-width: calc(100% - 114px);
} }
</style> </style>
@@ -1,7 +1,7 @@
<template> <template>
<div class="w-full bg-primary bg-opacity-40"> <div class="w-full bg-primary/50 rounded-lg">
<div class="w-full h-14 flex items-center px-4 bg-primary"> <div class="w-full h-14 flex items-center px-3">
<p class="pr-4">Playlist Items</p> <p class="pr-2">Playlist Items</p>
<div class="w-6 h-6 md:w-7 md:h-7 bg-white bg-opacity-10 rounded-full flex items-center justify-center"> <div class="w-6 h-6 md:w-7 md:h-7 bg-white bg-opacity-10 rounded-full flex items-center justify-center">
<span class="text-xs md:text-sm font-mono leading-none">{{ items.length }}</span> <span class="text-xs md:text-sm font-mono leading-none">{{ items.length }}</span>
@@ -11,7 +11,7 @@
<p v-if="totalDuration" class="text-sm text-gray-200">{{ totalDurationPretty }}</p> <p v-if="totalDuration" class="text-sm text-gray-200">{{ totalDurationPretty }}</p>
</div> </div>
<template v-for="item in items"> <template v-for="item in items">
<tables-playlist-item-table-row :key="item.id" :item="item" :playlist-id="playlistId" /> <tables-playlist-item-table-row :key="item.id" :item="item" :playlist-id="playlistId" @showMore="showMore" />
</template> </template>
</div> </div>
</template> </template>
@@ -41,7 +41,11 @@ export default {
return this.$elapsedPrettyExtended(this.totalDuration) return this.$elapsedPrettyExtended(this.totalDuration)
} }
}, },
methods: {}, methods: {
showMore(playlistItem) {
this.$emit('showMore', playlistItem)
}
},
mounted() {} mounted() {}
} }
</script> </script>
+35 -46
View File
@@ -114,14 +114,7 @@ export default {
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
@@ -139,10 +132,10 @@ export default {
} }
}, },
itemProgressPercent() { itemProgressPercent() {
return this.itemProgress ? this.itemProgress.progress : 0 return this.itemProgress?.progress || 0
}, },
userIsFinished() { userIsFinished() {
return this.itemProgress ? !!this.itemProgress.isFinished : false return !!this.itemProgress?.isFinished
}, },
timeRemaining() { timeRemaining() {
if (this.streamIsPlaying) return 'Playing' if (this.streamIsPlaying) return 'Playing'
@@ -158,7 +151,7 @@ export default {
return this.$store.getters['globals/getDownloadItem'](this.libraryItemId, this.episode.id) return this.$store.getters['globals/getDownloadItem'](this.libraryItemId, this.episode.id)
}, },
localEpisodeId() { localEpisodeId() {
return this.localEpisode ? this.localEpisode.id : null return this.localEpisode?.id || null
} }
}, },
methods: { methods: {
@@ -186,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))
@@ -261,45 +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)
} }
if (payload.server) {
this.$toast.success(`Local & Server Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
} else {
this.$toast.success(`Local Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
}
} }
this.isProcessingReadUpdate = false this.isProcessingReadUpdate = false
} else { } else {
var updatePayload = { const updatePayload = {
isFinished: !this.userIsFinished isFinished: !this.userIsFinished
} }
this.$axios this.$nativeHttp
.$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
})
} }
} }
} }
+17 -7
View File
@@ -116,6 +116,10 @@ export default {
{ {
text: 'Complete', text: 'Complete',
value: 'complete' value: 'complete'
},
{
text: 'Downloaded',
value: 'downloaded'
} }
], ],
fetchingRSSFeed: false, fetchingRSSFeed: false,
@@ -141,10 +145,10 @@ export default {
return this.$store.state.networkConnected return this.$store.state.networkConnected
}, },
libraryItemId() { libraryItemId() {
return this.libraryItem ? this.libraryItem.id : null return this.libraryItem?.id || null
}, },
media() { media() {
return this.libraryItem ? this.libraryItem.media || {} : {} return this.libraryItem?.media || {}
}, },
mediaMetadata() { mediaMetadata() {
return this.media.metadata || {} return this.media.metadata || {}
@@ -154,11 +158,14 @@ export default {
}, },
episodesFiltered() { episodesFiltered() {
return this.episodesCopy.filter((ep) => { return this.episodesCopy.filter((ep) => {
if (this.filterKey === 'downloaded') {
return !!this.localEpisodeMap[ep.id]
}
var mediaProgress = this.getEpisodeProgress(ep) var mediaProgress = this.getEpisodeProgress(ep)
if (this.filterKey === 'incomplete') { if (this.filterKey === 'incomplete') {
return !mediaProgress || !mediaProgress.isFinished return !mediaProgress?.isFinished
} else if (this.filterKey === 'complete') { } else if (this.filterKey === 'complete') {
return mediaProgress && mediaProgress.isFinished return mediaProgress?.isFinished
} else if (this.filterKey === 'inProgress') { } else if (this.filterKey === 'inProgress') {
return mediaProgress && !mediaProgress.isFinished return mediaProgress && !mediaProgress.isFinished
} else if (this.filterKey === 'all') { } else if (this.filterKey === 'all') {
@@ -209,8 +216,8 @@ export default {
}) })
if (value) { if (value) {
this.$axios this.$nativeHttp
.$get(`/api/podcasts/${this.libraryItemId}/clear-queue`) .get(`/api/podcasts/${this.libraryItemId}/clear-queue`)
.then(() => { .then(() => {
this.$toast.success('Episode download queue cleared') this.$toast.success('Episode download queue cleared')
this.episodeDownloadQueued = [] this.episodeDownloadQueued = []
@@ -230,7 +237,7 @@ export default {
return this.$toast.error('Podcast does not have an RSS Feed') return this.$toast.error('Podcast does not have an RSS Feed')
} }
this.fetchingRSSFeed = true this.fetchingRSSFeed = true
const payload = await this.$axios.$post(`/api/podcasts/feed`, { rssFeed: this.mediaMetadata.feedUrl }).catch((error) => { const payload = await this.$nativeHttp.post(`/api/podcasts/feed`, { rssFeed: this.mediaMetadata.feedUrl }).catch((error) => {
console.error('Failed to get feed', error) console.error('Failed to get feed', error)
this.$toast.error('Failed to get podcast feed') this.$toast.error('Failed to get podcast feed')
return null return null
@@ -290,6 +297,9 @@ export default {
} }
}, },
mounted() { mounted() {
if (this.$route.query['episodefilter'] === 'downloaded') {
this.filterKey = 'downloaded'
}
this.$socket.$on('episode_download_queued', this.episodeDownloadQueued) this.$socket.$on('episode_download_queued', this.episodeDownloadQueued)
this.$socket.$on('episode_download_started', this.episodeDownloadStarted) this.$socket.$on('episode_download_started', this.episodeDownloadStarted)
this.$socket.$on('episode_download_finished', this.episodeDownloadFinished) this.$socket.$on('episode_download_finished', this.episodeDownloadFinished)
+32 -43
View File
@@ -117,14 +117,7 @@ export default {
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
@@ -192,29 +185,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))
@@ -267,45 +264,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)
} }
if (payload.server) {
this.$toast.success(`Local & Server Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
} else {
this.$toast.success(`Local Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
}
} }
this.isProcessingReadUpdate = false this.isProcessingReadUpdate = false
} else { } else {
var updatePayload = { const updatePayload = {
isFinished: !this.userIsFinished isFinished: !this.userIsFinished
} }
this.$axios this.$nativeHttp
.$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
})
} }
} }
} }
+90
View File
@@ -0,0 +1,90 @@
<template>
<div class="inline-flex">
<input v-model="input" type="range" :min="min" :max="max" :step="step" :style="{ width: inputWidth }" />
<p class="text-xs ml-2">{{ input }}%</p>
</div>
</template>
<script>
export default {
props: {
value: [String, Number],
min: Number,
max: Number,
step: Number,
inputWidth: {
type: String,
default: 'unset'
}
},
data() {
return {}
},
computed: {
input: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
}
},
methods: {},
mounted() {}
}
</script>
<style scoped>
input[type='range'] {
-webkit-appearance: none;
appearance: none;
background: transparent;
cursor: pointer;
}
input[type='range']:focus {
outline: none;
}
/* chromium */
input[type='range']::-webkit-slider-runnable-track {
background-color: rgb(0 0 0 / 0.25);
border-radius: 9999px;
height: 0.75rem;
}
input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
margin-top: -0.25rem;
border-radius: 9999px;
background-color: rgb(255 255 255 / 0.7);
height: 1.25rem;
width: 1.25rem;
}
input[type='range']:focus::-webkit-slider-thumb {
border: 1px solid #6b6b6b;
outline: 3px solid #6b6b6b;
outline-offset: 0.125rem;
}
/* firefox */
input[type='range']::-moz-range-track {
background-color: rgb(0 0 0 / 0.25);
border-radius: 9999px;
height: 0.75rem;
}
input[type='range']::-moz-range-thumb {
border: none;
border-radius: 9999px;
margin-top: -0.25rem;
background-color: rgb(255 255 255 / 0.7);
height: 1.25rem;
width: 1.25rem;
}
input[type='range']:focus::-moz-range-thumb {
border: 1px solid #6b6b6b;
outline: 3px solid #6b6b6b;
outline-offset: 0.125rem;
}
</style>
+85
View File
@@ -0,0 +1,85 @@
<template>
<div class="inline-flex toggle-btn-wrapper shadow-md">
<button v-for="item in items" :key="item.value" type="button" class="toggle-btn outline-none relative border border-gray-600 px-4 py-1" :class="{ selected: item.value === value }" @click.stop="clickBtn(item.value)">
{{ item.text }}
</button>
</div>
</template>
<script>
export default {
props: {
value: String,
/**
* [{ "text", "", "value": "" }]
*/
items: {
type: Array,
default: Object
}
},
data() {
return {}
},
computed: {},
methods: {
clickBtn(value) {
this.$emit('input', value)
}
},
mounted() {}
}
</script>
<style scoped>
.toggle-btn-wrapper .toggle-btn:first-child {
border-top-left-radius: 0.375rem /* 6px */;
border-bottom-left-radius: 0.375rem /* 6px */;
}
.toggle-btn-wrapper .toggle-btn:last-child {
border-top-right-radius: 0.375rem /* 6px */;
border-bottom-right-radius: 0.375rem /* 6px */;
}
.toggle-btn-wrapper .toggle-btn:first-child::before {
border-top-left-radius: 0.375rem /* 6px */;
border-bottom-left-radius: 0.375rem /* 6px */;
}
.toggle-btn-wrapper .toggle-btn:last-child::before {
border-top-right-radius: 0.375rem /* 6px */;
border-bottom-right-radius: 0.375rem /* 6px */;
}
.toggle-btn-wrapper .toggle-btn:not(:first-child) {
margin-left: -1px;
}
.toggle-btn::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0);
transition: all 0.1s ease-in-out;
}
.toggle-btn:hover:not(:disabled)::before {
background-color: rgba(255, 255, 255, 0.1);
}
.toggle-btn:hover:not(:disabled) {
color: white;
}
.toggle-btn {
color: rgba(255, 255, 255, 0.75);
}
.toggle-btn.selected {
color: white;
}
.toggle-btn.selected::before {
background-color: rgba(255, 255, 255, 0.1);
}
button.toggle-btn:disabled::before {
background-color: rgba(0, 0, 0, 0.2);
}
</style>
+92
View File
@@ -0,0 +1,92 @@
<template>
<div ref="box" class="inline-block" @click.stop="click">
<slot />
</div>
</template>
<script>
export default {
props: {
text: {
type: [String, Number],
required: true
},
direction: {
type: String,
default: 'right'
},
disabled: Boolean
},
data() {
return {
tooltip: null,
tooltipTextEl: null,
tooltipId: null,
isShowing: false,
hideTimeout: null
}
},
watch: {
text() {
this.updateText()
},
disabled(newVal) {
if (newVal && this.isShowing) {
this.hideTooltip()
}
}
},
methods: {
updateText() {
if (this.tooltipTextEl) {
this.tooltipTextEl.innerHTML = this.text
}
},
createTooltip() {
if (!this.$refs.box) return
const tooltip = document.createElement('div')
this.tooltipId = String(Math.floor(Math.random() * 10000))
tooltip.id = this.tooltipId
tooltip.className = 'fixed inset-0 w-screen h-screen bg-black/25 text-xs flex items-center justify-center p-2'
tooltip.style.zIndex = 100
tooltip.style.backgroundColor = 'rgba(0,0,0,0.85)'
tooltip.addEventListener('click', this.hideTooltip)
const innerDiv = document.createElement('div')
innerDiv.className = 'w-full p-2 border border-white/20 pointer-events-none text-white bg-primary'
innerDiv.innerHTML = this.text
tooltip.appendChild(innerDiv)
this.tooltipTextEl = innerDiv
this.tooltip = tooltip
},
showTooltip() {
if (this.disabled) return
if (!this.tooltip) {
this.createTooltip()
if (!this.tooltip) return
}
if (!this.$refs.box) return // Ensure element is not destroyed
try {
document.body.appendChild(this.tooltip)
} catch (error) {
console.error(error)
}
this.isShowing = true
},
hideTooltip() {
if (!this.tooltip) return
this.tooltip.remove()
this.isShowing = false
},
click() {
if (!this.isShowing) this.showTooltip()
}
},
beforeDestroy() {
this.hideTooltip()
}
}
</script>
+10 -3
View File
@@ -30,10 +30,15 @@ export default {
isCellular() { isCellular() {
return this.networkConnectionType === 'cellular' return this.networkConnectionType === 'cellular'
}, },
attemptingConnection() {
return this.$store.state.attemptingConnection
},
icon() { icon() {
if (!this.user) return null // hide when not connected to server if (!this.user && !this.attemptingConnection) return null // hide when not connected to server
if (!this.networkConnected) { if (this.attemptingConnection) {
return 'cloud_sync'
} else if (!this.networkConnected) {
return 'wifi_off' return 'wifi_off'
} else if (!this.socketConnected) { } else if (!this.socketConnected) {
return 'cloud_off' return 'cloud_off'
@@ -55,7 +60,9 @@ export default {
showAlertDialog() { showAlertDialog() {
var msg = '' var msg = ''
var meteredString = this.isNetworkUnmetered ? 'unmetered' : 'metered' var meteredString = this.isNetworkUnmetered ? 'unmetered' : 'metered'
if (!this.networkConnected) { if (this.attemptingConnection) {
msg = 'Attempting server connection'
} else if (!this.networkConnected) {
msg = 'No internet' msg = 'No internet'
} else if (!this.socketConnected) { } else if (!this.socketConnected) {
msg = 'Socket not connected' msg = 'Socket not connected'

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