Compare commits

...
Author SHA1 Message Date
advplyr 793f0c05f7 iOS version bump 0.9.70 2023-12-18 17:15:22 -06:00
advplyr cf7271dc57 Version bump 0.9.70 2023-12-18 17:10:07 -06:00
advplyr 54664b0c32 Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2023-12-18 17:05:40 -06:00
advplyr ecf910dc55 Fix:Play button loading indicator when offline #990 2023-12-18 17:05:34 -06:00
advplyr 460daec72e iOS version bump 0.9.69 2023-12-16 17:21:43 -06:00
advplyr 4dd67adcab Fix:Prevent pressing download buttons twice causing crash in iOS 2023-12-16 17:21:19 -06:00
advplyr 023c83ee78 Version bump 0.9.69-beta 2023-12-16 16:24:07 -06:00
advplyr 2b8ed16616 Update:UI for playlist & rss feed modal to add more padding on top and bottom to prevent iOS cutoff 2023-12-16 15:06:49 -06:00
advplyr 6ae4d72b68 Update:Settings UI cleanup and light theme updates 2023-12-16 14:43:48 -06:00
advplyr c6bd93ef5c Light theme updates 2023-12-16 14:37:05 -06:00
advplyr e1c02ce74c Update:Show loading indicator on play buttons when starting playback 2023-12-15 17:35:37 -06:00
advplyr efc6d68403 Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2023-12-15 16:05:53 -06:00
advplyr 7412d0899f Fix:Syncing when media is open in audio player and playing on another device #984
- Local playback session IDs have been updated to UUIDs instead of prefixing local_play_
2023-12-15 16:05:19 -06:00
advplyrandGitHub 42eb0a8384 Merge pull request #988 from burghy86/patch-2
Update it.json
2023-12-15 15:03:25 -06:00
burghy86andGitHub 27e83e8ed9 Update it.json 2023-12-15 22:02:26 +01:00
advplyr dd66042766 Update:Dont show chapter number infront of chapter title #981 2023-12-14 16:59:47 -06:00
advplyr dc790d5450 Fix light mode for new podcast page 2023-12-14 16:55:17 -06:00
advplyr 01ac844024 Fix light theme on add podcast page 2023-12-14 16:39:55 -06:00
advplyr c8064ac7bc Rename page name from search to add-podcast 2023-12-14 16:25:59 -06:00
advplyrandGitHub f5da44dc6f Merge pull request #986 from Hallo951/master
Update de.json
2023-12-14 08:15:26 -06:00
Hallo951andGitHub cbdff29684 Update de.json 2023-12-14 08:19:27 +01:00
advplyr 19d6c84113 Update:Light theme colors for track progress bar and ereader settings #916 2023-12-13 16:57:44 -06:00
advplyrandGitHub d9f4ed3d54 Merge pull request #983 from Nab0y/master
Fix Russian localization
2023-12-12 17:50:06 -06:00
advplyr fdb26b7700 Update:Set device language to match the first server connected to #448 2023-12-12 17:50:04 -06:00
Dmitry Naboychenko 94cb7a65a2 Fix Russian localization 2023-12-12 22:34:53 +03:00
advplyr 9298065934 Add:Theme option setting #916 2023-12-11 18:11:17 -06:00
advplyr 1cf01a5d12 Add home page shelf translation strings #448 2023-12-11 17:42:29 -06:00
advplyr e3aa96cbd5 Fix:Setting device language code 2023-12-11 17:27:26 -06:00
advplyr c7678da664 Update:Setup css colors for themes, add light theme colors #916 2023-12-10 17:53:27 -06:00
advplyrandGitHub 52701048ad Merge pull request #967 from tonyedwardspz/master
Feature - Add a low feedback mode
2023-12-09 15:14:52 -06:00
advplyr 776c02828f Fix latest page to show download indicator, update download button to be responsive right away 2023-12-09 14:57:44 -06:00
advplyr 69308413f6 Fix bad merge with master 2023-12-08 17:04:03 -06:00
advplyr 84e376a7cf Remove commented out toasts 2023-12-08 17:03:08 -06:00
advplyr 1822b21af8 Remove socketConnected check on local items alert 2023-12-08 16:49:12 -06:00
advplyr 8b222435e6 Fix:Load local settings before attempting server connection #976 2023-12-08 16:10:57 -06:00
advplyrandGitHub fdd251ec2c Merge pull request #975 from Jafeyyu/master
Improve Chinese translation
2023-12-08 16:01:24 -06:00
Tony Edwards 78170d4e10 Potential toasts for removal 2023-12-08 09:59:22 +00:00
Tony Edwards 837981b8b6 Removes download prompt on podcast ep page 2023-12-08 09:56:07 +00:00
Tony Edwards be23c6703f Shows media link status when connected to a server 2023-12-08 09:08:51 +00:00
Tony Edwards 0c72889917 Remove successfully connected media link message 2023-12-08 07:56:45 +00:00
Tony Edwards 02746bc35f Fixes my introduced errors 2023-12-08 07:53:35 +00:00
Qi, Xiaoqiang 85a5c33bd8 Improve Chinese translation 2023-12-08 15:33:37 +08:00
Tony Edwards 5067c0f6d7 Removes download confirmation prompt 2023-12-08 07:30:54 +00:00
Tony Edwards 89938ab120 Removes download success toast 2023-12-08 07:25:51 +00:00
Tony Edwards 9a32396daa Removes low feedback toggle 2023-12-08 07:19:23 +00:00
advplyr ddb1796891 Update serverconfig to remove version before saving 2023-12-07 17:02:50 -06:00
advplyrandGitHub 76253b7b9d Merge pull request #969 from Sapd/sso-redirect-ri
SSO: Add redirect_uri to oAuth auth request
2023-12-07 16:08:19 -06:00
advplyr 74fef1d2f4 Make backwards compatible with server v2.6.0 2023-12-07 11:07:37 -06:00
advplyrandGitHub 7306ffa709 Merge pull request #972 from JannikGohr/german_translation
Add German Translations
2023-12-06 17:45:41 -06:00
JannikGohr 6f289293cc Continue german translation in strings/de.json 2023-12-07 00:09:34 +01:00
advplyr 783f924bb9 Add:Ebook library filters #971 2023-12-05 16:14:20 -06:00
Denis Arnst 044a35aacf SSO: Removed isRest as requirement
Removed in the backend to increase compatibility with possible 3rd party oauth libraries people might use
2023-12-05 09:44:20 +01:00
Tony Edwards 78448894d2 Adds hiding audio book feedback promt/toast 2023-12-05 07:31:03 +00:00
Tony Edwards aca348e783 Adds low feedback mode to podcast downloads 2023-12-05 07:31:03 +00:00
Tony Edwards e6c1874cbb Adds download success toasts to low feedback mode 2023-12-05 07:31:03 +00:00
Tony Edwards 1b06dbd78c Adds basic low feedback mode implementation 2023-12-05 07:31:03 +00:00
advplyr a26f37aa49 Add more translation strings, clean out unused strings #448 2023-12-04 17:53:36 -06:00
Denis Arnst 8af3c09458 SSO: Add redirect_uri to oAuth auth request
Also:
- Add client_id with the name of the app (for possible future use)
- Add response_type simply to provide all required oauth2 parameters for reference
- For the URL to be opened in Browser: Forward the callback/redirect_uri URL provided by the server
- Use a hardcoded variable for HTTPS enforcement, for easier debugging
2023-12-04 22:43:14 +01:00
advplyr fed6579e08 Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2023-12-03 17:37:14 -06:00
advplyr 6621f8b2ee Add:Language code setting and translations #448 2023-12-03 17:37:01 -06:00
advplyrandGitHub 737b6d7c26 Merge pull request #964 from Sapd/seek-fix
Fix track skipping on iOS (Fixes #686)
2023-11-30 17:06:33 -06:00
Denis Arnst 59c71b71fb Fix track skipping on iOS (Fixes #686)
When we seek into a different chapter, we will have to reset the progress of the current one before seeking to the new one.
2023-11-29 17:30:34 +01:00
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
215 changed files with 24651 additions and 15529 deletions
+6
View File
@@ -21,6 +21,12 @@ jobs:
with:
node-version: 16
- name: Set up Java
uses: actions/setup-java@v2
with:
distribution: "temurin"
java-version: 17
- name: install dependencies
run: npm ci
+6
View File
@@ -20,6 +20,12 @@ jobs:
with:
node-version: 16
- name: Set up Java
uses: actions/setup-java@v2
with:
distribution: "temurin"
java-version: 17
- name: install dependencies
run: npm ci
+7 -14
View File
@@ -21,8 +21,6 @@ kotlin {
}
android {
namespace 'com.audiobookshelf.app'
buildFeatures {
viewBinding true
@@ -35,9 +33,12 @@ android {
applicationId "com.audiobookshelf.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 95
versionName "0.9.64-beta"
versionCode 101
versionName "0.9.70-beta"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
manifestPlaceholders = [
"appAuthRedirectScheme": "com.audiobookshelf.app"
]
aaptOptions {
// 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
@@ -65,12 +66,11 @@ repositories {
mavenCentral()
}
configurations.all {
configurations.configureEach {
resolutionStrategy {
force("com.google.android.gms:play-services-base:18.0.1")
force("androidx.appcompat:appcompat:$androidxAppCompatVersion")
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("org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version")
force("org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version")
@@ -83,12 +83,9 @@ dependencies {
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation project(':capacitor-android')
implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
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 "androidx.core:core-ktx:$androidx_core_ktx_version"
@@ -116,7 +113,6 @@ dependencies {
// Glide for images
implementation "com.github.bumptech.glide:glide:$glide_version"
kapt "com.github.bumptech.glide:compiler:$glide_version"
// Paper NoSQL Db
implementation 'io.github.pilgr:paperdb:2.7.2'
@@ -129,9 +125,6 @@ dependencies {
// Jackson for JSON
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'
+4 -2
View File
@@ -2,14 +2,16 @@
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-browser')
implementation project(':capacitor-clipboard')
implementation project(':capacitor-dialog')
implementation project(':capacitor-haptics')
implementation project(':capacitor-network')
@@ -2,4 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -2,4 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
<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
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true"
@@ -92,6 +92,16 @@
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</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>
</manifest>
@@ -2,5 +2,10 @@
"appId": "com.audiobookshelf.app",
"appName": "audiobookshelf-app",
"webDir": "dist",
"bundledWebRuntime": false
"bundledWebRuntime": false,
"plugins": {
"CapacitorHttp": {
"enabled": false
}
}
}
@@ -3,6 +3,14 @@
"pkg": "@capacitor/app",
"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",
"classpath": "com.capacitorjs.plugins.dialog.DialogPlugin"
@@ -12,10 +12,10 @@ import android.util.Log
import androidx.core.app.ActivityCompat
import com.anggrayudi.storage.SimpleStorage
import com.anggrayudi.storage.SimpleStorageHelper
import com.audiobookshelf.app.plugins.AbsDatabase
import com.audiobookshelf.app.managers.DbManager
import com.audiobookshelf.app.player.PlayerNotificationService
import com.audiobookshelf.app.plugins.AbsAudioPlayer
import com.audiobookshelf.app.plugins.AbsDatabase
import com.audiobookshelf.app.plugins.AbsDownloader
import com.audiobookshelf.app.plugins.AbsFileSystem
import com.getcapacitor.BridgeActivity
@@ -15,9 +15,7 @@ import android.widget.RemoteViews
import androidx.media.session.MediaButtonReceiver
import com.audiobookshelf.app.data.PlaybackSession
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.device.WidgetEventEmitter
import com.audiobookshelf.app.managers.DbManager
import com.audiobookshelf.app.player.PlayerNotificationService
import com.bumptech.glide.Glide
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.request.target.AppWidgetTarget
@@ -28,12 +26,9 @@ import com.bumptech.glide.request.transition.Transition
*/
class MediaPlayerWidget : AppWidgetProvider() {
val tag = "MediaPlayerWidget"
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
// There may be multiple widgets active, so update all of them
for (appWidgetId in appWidgetIds) {
updateAppWidget(context, appWidgetManager, appWidgetId, null, false, PlayerNotificationService.isClosed)
}
}
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
Log.d(tag, "onUpdate $appWidgetIds")
}
override fun onEnabled(context: Context) {
Log.i(tag, "onEnabled check context ${context.packageName}")
@@ -51,32 +46,14 @@ class MediaPlayerWidget : AppWidgetProvider() {
}
// Enter relevant functionality for when the first widget is created
DeviceManager.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)
}
}
})
DeviceManager.initializeWidgetUpdater(context)
}
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) {
val tag = "MediaPlayerWidget"
val views = RemoteViews(context.packageName, R.layout.media_player_widget)
Log.i(tag, "updateAppWidget ${playbackSession?.displayTitle ?: "No Title"} isPlaying=$isPlaying isAppClosed=$isAppClosed")
val wholeWidgetClickI = Intent(context, MainActivity::class.java)
wholeWidgetClickI.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
val wholeWidgetClickPI = PendingIntent.getActivity(
@@ -100,7 +77,7 @@ internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManage
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) {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
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")
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)
// Instruct the widget manager to update the widget
@@ -24,7 +24,7 @@ open class MediaType(var metadata:MediaTypeMetadata, var coverPath:String?) {
@JsonIgnore
open fun removeAudioTrack(localFileId:String) { }
@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 ->
if (episodes?.find{ it.audioTrack?.localFileId == at.localFileId } == null) {
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)
}
}
@@ -67,7 +67,7 @@ class Podcast(
@JsonIgnore
override fun addAudioTrack(audioTrack:AudioTrack) {
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)
var index = 1
@@ -96,7 +96,7 @@ class Podcast(
@JsonIgnore
fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode {
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)
var index = 1
@@ -190,7 +190,7 @@ class Book(
JsonSubTypes.Type(BookMetadata::class),
JsonSubTypes.Type(PodcastMetadata::class)
)
open class MediaTypeMetadata(var title:String) {
open class MediaTypeMetadata(var title:String, var explicit:Boolean) {
@JsonIgnore
open fun getAuthorDisplayName():String { return "Unknown" }
}
@@ -209,13 +209,13 @@ class BookMetadata(
var isbn:String?,
var asin:String?,
var language:String?,
var explicit:Boolean,
explicit:Boolean,
// In toJSONExpanded
var authorName:String?,
var authorNameLF:String?,
var narratorName:String?,
var seriesName:String?
) : MediaTypeMetadata(title) {
) : MediaTypeMetadata(title, explicit) {
@JsonIgnore
override fun getAuthorDisplayName():String { return authorName ?: "Unknown" }
}
@@ -225,8 +225,9 @@ class PodcastMetadata(
title:String,
var author:String?,
var feedUrl:String?,
var genres:MutableList<String>
) : MediaTypeMetadata(title) {
var genres:MutableList<String>,
explicit:Boolean
) : MediaTypeMetadata(title, explicit) {
@JsonIgnore
override fun getAuthorDisplayName():String { return author ?: "Unknown" }
}
@@ -251,15 +252,16 @@ data class PodcastEpisode(
var publishedAt:Long?,
var audioFile:AudioFile?,
var audioTrack:AudioTrack?,
var chapters:List<BookChapter>?,
var duration:Double?,
var size:Long?,
var serverEpisodeId:String?, // For local podcasts to match with server podcasts
var localEpisodeId:String? // For Android Auto server episodes with local copy
) {
@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) {
libraryItem.getCoverUri()
libraryItem.getCoverUri(ctx)
} else {
(libraryItem as LibraryItem).getCoverUri()
}
@@ -83,7 +83,7 @@ data class LocalFolder(
)
open class LibraryItemWrapper(var id:String) {
@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)
@@ -91,7 +91,6 @@ data class DeviceInfo(
var deviceId:String,
var manufacturer:String,
var model:String,
var brand:String,
var sdkVersion:Int,
var clientVersion: String
)
@@ -110,6 +109,7 @@ data class DeviceSettings(
var enableAltView:Boolean,
var jumpBackwardsTime:Int,
var jumpForwardTime:Int,
var enableMp3IndexSeeking:Boolean,
var disableShakeToResetSleepTimer:Boolean,
var shakeSensitivity: ShakeSensitivitySetting,
var lockOrientation: LockOrientationSetting,
@@ -121,7 +121,8 @@ data class DeviceSettings(
var autoSleepTimerAutoRewindTime: Long, //Time in milliseconds
var sleepTimerLength: Long, // Time in milliseconds
var disableSleepTimerFadeOut: Boolean,
var disableSleepTimerResetFeedback: Boolean
var disableSleepTimerResetFeedback: Boolean,
var languageCode: String
) {
companion object {
// Static method to get default device settings
@@ -131,6 +132,7 @@ data class DeviceSettings(
enableAltView = true,
jumpBackwardsTime = 10,
jumpForwardTime = 10,
enableMp3IndexSeeking = false,
disableShakeToResetSleepTimer = false,
shakeSensitivity = ShakeSensitivitySetting.MEDIUM,
lockOrientation = LockOrientationSetting.NONE,
@@ -142,7 +144,8 @@ data class DeviceSettings(
autoSleepTimerAutoRewind = false,
autoSleepTimerAutoRewindTime = 300000L, // 5 minutes
disableSleepTimerFadeOut = false,
disableSleepTimerResetFeedback = false
disableSleepTimerResetFeedback = false,
languageCode = "en-us"
)
}
}
@@ -58,7 +58,7 @@ class LibraryItem(
}
@JsonIgnore
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat {
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat {
val extras = Bundle()
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
return MediaDescriptionCompat.Builder()
.setMediaId(mediaId)
@@ -9,6 +9,8 @@ import android.os.Bundle
import android.provider.MediaStore
import android.support.v4.media.MediaDescriptionCompat
import android.util.Log
import androidx.core.content.FileProvider
import androidx.core.net.toFile
import androidx.media.utils.MediaConstants
import com.audiobookshelf.app.BuildConfig
import com.audiobookshelf.app.R
@@ -46,7 +48,10 @@ class LocalLibraryItem(
val isPodcast get() = mediaType == "podcast"
@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)
}
@@ -74,9 +79,9 @@ class LocalLibraryItem(
}
@JsonIgnore
fun getPlaybackSession(episode:PodcastEpisode?):PlaybackSession {
fun getPlaybackSession(episode:PodcastEpisode?, deviceInfo:DeviceInfo):PlaybackSession {
val localEpisodeId = episode?.id
val sessionId = "play_local_${UUID.randomUUID()}"
val sessionId = "${UUID.randomUUID()}"
// Get current progress for local media
val mediaProgressId = if (localEpisodeId.isNullOrEmpty()) id else "$id-$localEpisodeId"
@@ -85,19 +90,20 @@ class LocalLibraryItem(
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>
val authorName = mediaMetadata.getAuthorDisplayName()
val displayTitle = episode?.title ?: mediaMetadata.title
var duration = getDuration()
if (episode != null) { // Get podcast episode audio track
episode.audioTrack?.let { at -> mutableListOf(at) }?.let { tracks -> audioTracks = tracks }
chapters = episode.chapters
duration = episode.audioTrack?.duration ?: 0.0
Log.d("LocalLibraryItem", "getPlaybackSession: Got podcast episode audio track ${audioTracks.size}")
}
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
@@ -106,18 +112,16 @@ class LocalLibraryItem(
}
@JsonIgnore
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx:Context?): MediaDescriptionCompat {
val coverUri = getCoverUri()
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx:Context): MediaDescriptionCompat {
val coverUri = getCoverUri(ctx)
var bitmap:Bitmap? = null
if (coverContentUrl != null) {
ctx?.let {
bitmap = if (Build.VERSION.SDK_INT < 28) {
MediaStore.Images.Media.getBitmap(it.contentResolver, coverUri)
} else {
val source: ImageDecoder.Source = ImageDecoder.createSource(it.contentResolver, coverUri)
ImageDecoder.decodeBitmap(source)
}
bitmap = if (Build.VERSION.SDK_INT < 28) {
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
} else {
val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, coverUri)
ImageDecoder.decodeBitmap(source)
}
}
@@ -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()
.setMediaId(id)
.setTitle(title)
@@ -43,7 +43,7 @@ data class LocalMediaItem(
return if (mediaType == "book") {
BookMetadata(name,null, mutableListOf(), mutableListOf(), mutableListOf(),null,null,null,null,null,null,null,false,null,null,null,null)
} 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.provider.MediaStore
import android.support.v4.media.MediaMetadataCompat
import androidx.core.content.FileProvider
import androidx.core.net.toFile
import com.audiobookshelf.app.BuildConfig
import com.audiobookshelf.app.R
import com.audiobookshelf.app.device.DeviceManager
@@ -27,6 +29,7 @@ class PlaybackSession(
var episodeId:String?,
var mediaType:String,
var mediaMetadata:MediaTypeMetadata,
var deviceInfo:DeviceInfo,
var chapters:List<BookChapter>,
var displayTitle: String?,
var displayAuthor: String?,
@@ -137,8 +140,15 @@ class PlaybackSession(
}
@JsonIgnore
fun getCoverUri(): Uri {
if (localLibraryItem?.coverContentUrl != null) return Uri.parse(localLibraryItem?.coverContentUrl) ?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
fun getCoverUri(ctx:Context): Uri {
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)
return Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")
@@ -152,6 +162,8 @@ class PlaybackSession(
@JsonIgnore
fun getMediaMetadataCompat(ctx: Context): MediaMetadataCompat {
val coverUri = getCoverUri(ctx)
val metadataBuilder = MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_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_DISPLAY_DESCRIPTION, displayAuthor)
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getCoverUri().toString())
.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, getCoverUri().toString())
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, getCoverUri().toString())
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString())
.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, coverUri.toString())
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString())
// Local covers get bitmap
if (localLibraryItem?.coverContentUrl != null) {
val bitmap = if (Build.VERSION.SDK_INT < 28) {
MediaStore.Images.Media.getBitmap(ctx.contentResolver, getCoverUri())
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
} else {
val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, getCoverUri())
val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, coverUri)
ImageDecoder.decodeBitmap(source)
}
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
@@ -182,7 +194,9 @@ class PlaybackSession(
}
@JsonIgnore
fun getExoMediaMetadata(audioTrack:AudioTrack): MediaMetadata {
fun getExoMediaMetadata(ctx:Context): MediaMetadata {
val coverUri = getCoverUri(ctx)
val metadataBuilder = MediaMetadata.Builder()
.setTitle(displayTitle)
.setDisplayTitle(displayTitle)
@@ -191,20 +205,18 @@ class PlaybackSession(
.setSubtitle(displayAuthor)
.setAlbumTitle(displayAuthor)
.setDescription(displayAuthor)
.setArtworkUri(getCoverUri())
val contentUri = this.getContentUri(audioTrack)
metadataBuilder.setMediaUri(contentUri)
.setArtworkUri(coverUri)
.setMediaType(MediaMetadata.MEDIA_TYPE_AUDIO_BOOK)
return metadataBuilder.build()
}
@JsonIgnore
fun getMediaItems():List<MediaItem> {
fun getMediaItems(ctx:Context):List<MediaItem> {
val mediaItems:MutableList<MediaItem> = mutableListOf()
for (audioTrack in audioTracks) {
val mediaMetadata = this.getExoMediaMetadata(audioTrack)
val mediaMetadata = this.getExoMediaMetadata(ctx)
val mediaUri = this.getContentUri(audioTrack)
val mimeType = audioTrack.mimeType
@@ -252,7 +264,7 @@ class PlaybackSession(
@JsonIgnore
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
@@ -1,15 +1,20 @@
package com.audiobookshelf.app.device
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.util.Log
import com.audiobookshelf.app.MediaPlayerWidget
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.managers.DbManager
import com.audiobookshelf.app.player.PlayerNotificationService
import com.audiobookshelf.app.updateAppWidget
interface WidgetEventEmitter {
fun onPlayerChanged(pns:PlayerNotificationService)
fun onPlayerClosed()
}
object DeviceManager {
@@ -43,6 +48,11 @@ object DeviceManager {
if (deviceData.deviceSettings?.autoSleepTimerAutoRewindTime == null) {
deviceData.deviceSettings?.autoSleepTimerAutoRewindTime = 300000L // 5 minutes
}
// Language added in v0.9.69
if (deviceData.deviceSettings?.languageCode == null) {
deviceData.deviceSettings?.languageCode = "en-us"
}
}
fun getBase64Id(id:String):String {
@@ -76,4 +86,31 @@ object DeviceManager {
deviceData.lastPlaybackSession = playbackSession
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 androidx.documentfile.provider.DocumentFile
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.models.DownloadItem
import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.getcapacitor.JSObject
import org.json.JSONException
import java.io.File
class FolderScanner(var ctx: Context) {
private val tag = "FolderScanner"
@@ -26,213 +21,189 @@ class FolderScanner(var ctx: Context) {
return "local_" + DeviceManager.getBase64Id(mediaItemId)
}
enum class ItemScanResult {
ADDED, REMOVED, UPDATED, UPTODATE
}
private fun scanInternalDownloadItem(downloadItem:DownloadItem, cb: (DownloadItemScanResult?) -> Unit) {
val localLibraryItemId = "local_${downloadItem.libraryItemId}"
// TODO: CLEAN this monster! Divide into bite-size methods
fun scanForMediaItems(localFolder:LocalFolder, forceAudioProbe:Boolean):FolderScanResult? {
FFmpegKitConfig.enableLogCallback { log ->
if (log.level != Level.AV_LOG_STDERR) { // STDERR is filled with junk
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
var localEpisodeId:String? = null
var localLibraryItem:LocalLibraryItem?
if (downloadItem.mediaType == "book") {
localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, downloadItem.itemFolderPath, downloadItem.itemFolderPath, "", false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true, downloadItem.serverConnectionConfigId, downloadItem.serverAddress, downloadItem.serverUserId, downloadItem.libraryItemId)
} else {
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
fun scanDownloadItem(downloadItem: DownloadItem, cb: (DownloadItemScanResult?) -> Unit) {
// If downloading to internal storage handle separately
if (downloadItem.isInternalStorage) {
scanInternalDownloadItem(downloadItem, cb)
return
}
val folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl))
val foldersFound = folderDf?.search(true, DocumentFileType.FOLDER) ?: mutableListOf()
@@ -370,7 +341,7 @@ class FolderScanner(var ctx: Context) {
duration = mediaProgress.duration,
progress = mediaProgress.progress,
currentTime = mediaProgress.currentTime,
isFinished = false,
isFinished = mediaProgress.isFinished,
ebookLocation = mediaProgress.ebookLocation,
ebookProgress = mediaProgress.ebookProgress,
lastUpdate = mediaProgress.lastUpdate,
@@ -382,6 +353,7 @@ class FolderScanner(var ctx: Context) {
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
@@ -391,132 +363,4 @@ class FolderScanner(var ctx: Context) {
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.delay
import kotlinx.coroutines.launch
import java.io.File
import java.io.FileOutputStream
import java.util.*
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)
}
interface InternalProgressCallback {
fun onProgress(totalBytesWritten:Long, progress: Long)
fun onComplete(failed: Boolean)
}
companion object {
var isDownloading:Boolean = false
}
@@ -65,11 +72,33 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
if (nextDownloadItemParts.size > 0) {
nextDownloadItemParts.forEach {
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)
if (it.isInternalStorage) {
val file = File(it.finalDestinationPath)
file.parentFile?.mkdirs()
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
while (currentDownloadItemParts.size > 0) {
val itemParts = currentDownloadItemParts.filter { !it.isMoving }.map { it }
for (downloadItemPart in itemParts) {
val downloadCheckStatus = checkDownloadItemPart(downloadItemPart)
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
if (downloadItemPart.isInternalStorage) {
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
// Will move to final destination, remove current item parts, and check if download item is finished
handleDownloadItemPartCheck(downloadCheckStatus, downloadItemPart)
if (downloadItemPart.completed) {
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)
@@ -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}")
currentDownloadItemParts.remove(downloadItemPart)
} else if (downloadCheckStatus == DownloadCheckStatus.Successful) {
val file = DocumentFileCompat.fromUri(mainActivity, 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)
} else {
apiHandler.getAllItemsInProgress { itemsInProgress ->
serverItemsInProgress = itemsInProgress
serverItemsInProgress = itemsInProgress.filter {
val libraryItem = it.libraryItemWrapper as LibraryItem
libraryItem.checkHasTracks()
}
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) {
if (libraryItemWrapper is LocalLibraryItem) {
cb(libraryItemWrapper.getPlaybackSession(episode))
cb(libraryItemWrapper.getPlaybackSession(episode, playItemRequestPayload.deviceInfo))
} else {
val libraryItem = libraryItemWrapper as LibraryItem
apiHandler.playLibraryItem(libraryItem.id,episode?.id ?: "", playItemRequestPayload) {
@@ -38,8 +38,8 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
var currentLocalMediaProgress: LocalMediaProgress? = null
private val currentDisplayTitle get() = currentPlaybackSession?.displayTitle ?: "Unset"
private val currentIsLocal get() = currentPlaybackSession?.isLocal == true
private val currentSessionId get() = currentPlaybackSession?.id ?: ""
val currentIsLocal get() = currentPlaybackSession?.isLocal == true
val currentSessionId get() = currentPlaybackSession?.id ?: ""
private val currentPlaybackDuration get() = currentPlaybackSession?.duration ?: 0.0
fun start(playbackSession:PlaybackSession) {
@@ -215,7 +215,6 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
val listeningTimeToAdd = diffSinceLastSync / 1000L
val syncData = MediaProgressSyncData(listeningTimeToAdd,currentPlaybackDuration,currentTime)
currentPlaybackSession?.syncData(syncData)
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
if (hasNetworkConnection && shouldSyncServer && !it.libraryItemId.isNullOrEmpty() && it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
apiHandler.sendLocalProgressSync(it) { syncSuccess, errorMsg ->
Log.d(
tag,
"Local progress sync data sent to server $currentDisplayTitle for time $currentTime"
)
if (syncSuccess) {
failedSyncs = 0
playerNotificationService.alertSyncSuccess()
@@ -21,6 +21,9 @@ data class DownloadItem(
val media: MediaType,
val downloadItemParts: MutableList<DownloadItemPart>
) {
@get:JsonIgnore
val isInternalStorage get() = localFolder.id.startsWith("internal-")
@get:JsonIgnore
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
fun getDownloadRequest(): DownloadManager.Request {
val dlRequest = DownloadManager.Request(uri)
@@ -123,7 +123,7 @@ class CastManager constructor(val mainActivity:Activity) {
}
fun startRouteScan(connListener:ChromecastListener) {
var callback = object : ScanCallback() {
val callback = object : ScanCallback() {
override fun onRouteUpdate(routes: List<MediaRouter.RouteInfo>?) {
Log.d(tag, "CAST On ROUTE UPDATED ${routes?.size} | ${getContext().castState}")
// 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
connListener.onReceiverAvailableUpdate(true)
// 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 (session != null) {
// Let the client know
@@ -246,15 +246,15 @@ class CastManager constructor(val mainActivity:Activity) {
onRouteUpdate(outRoutes)
}
override fun onRouteAdded(router: MediaRouter?, route: MediaRouter.RouteInfo?) {
override fun onRouteAdded(router: MediaRouter, route: MediaRouter.RouteInfo) {
onFilteredRouteUpdate()
}
override fun onRouteChanged(router: MediaRouter?, route: MediaRouter.RouteInfo?) {
override fun onRouteChanged(router: MediaRouter, route: MediaRouter.RouteInfo) {
onFilteredRouteUpdate()
}
override fun onRouteRemoved(router: MediaRouter?, route: MediaRouter.RouteInfo?) {
override fun onRouteRemoved(router: MediaRouter, route: MediaRouter.RouteInfo) {
onFilteredRouteUpdate()
}
}
@@ -11,12 +11,12 @@ import com.google.android.exoplayer2.*
import com.google.android.exoplayer2.C.TrackType
import com.google.android.exoplayer2.MediaMetadata
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.ext.cast.SessionAvailabilityListener
import com.google.android.exoplayer2.source.TrackGroup
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.TrackSelectionArray
import com.google.android.exoplayer2.trackselection.TrackSelectionParameters
@@ -49,7 +49,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
private var myPlayWhenReady:Boolean = false
private var playbackParameters:PlaybackParameters = PlaybackParameters.DEFAULT
private var myAvailableCommands: Commands
private var myCurrentTracksInfo: TracksInfo
private var myCurrentTracksInfo: Tracks
private var myCurrentTrackGroups: TrackGroupArray
private var myCurrentTrackSelections: TrackSelectionArray
@@ -76,7 +76,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
COMMAND_GET_MEDIA_ITEMS_METADATA,
COMMAND_SET_MEDIA_ITEMS_METADATA,
COMMAND_CHANGE_MEDIA_ITEMS,
COMMAND_GET_TRACK_INFOS)
COMMAND_GET_TRACKS)
.build()
var currentPlaybackState = Player.STATE_IDLE
@@ -95,7 +95,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
Log.d(tag, "Init CastPlayer")
myCurrentTrackGroups = TrackGroupArray.EMPTY
myCurrentTrackSelections = EMPTY_TRACK_SELECTION_ARRAY
myCurrentTracksInfo = TracksInfo.EMPTY
myCurrentTracksInfo = Tracks.EMPTY
statusListener = StatusListener()
timelineTracker = CastTimelineTracker()
myCurrentTimeline = CastTimeline.EMPTY_CAST_TIMELINE
@@ -385,7 +385,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
}
if (updateTracksAndSelectionsAndNotifyIfChanged()) {
listeners.queueEvent(
EVENT_TRACKS_CHANGED) { listener: Listener -> listener.onTracksInfoChanged(currentTracksInfo) }
EVENT_TRACKS_CHANGED) { listener: Listener -> listener.onTracksChanged(currentTracks) }
}
updateAvailableCommandsAndNotifyIfChanged()
listeners.flushEvents()
@@ -405,7 +405,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
val hasChanged = !myCurrentTrackGroups.isEmpty
myCurrentTrackGroups = TrackGroupArray.EMPTY
myCurrentTrackSelections = EMPTY_TRACK_SELECTION_ARRAY
myCurrentTracksInfo = TracksInfo.EMPTY
myCurrentTracksInfo = Tracks.EMPTY
return hasChanged
}
var activeTrackIds = mediaStatus.activeTrackIds
@@ -414,7 +414,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
}
val trackGroups = arrayOfNulls<TrackGroup>(castMediaTracks.size)
val trackSelections = arrayOfNulls<TrackSelection>(RENDERER_COUNT)
val trackGroupInfos = arrayOfNulls<TrackGroupInfo>(castMediaTracks.size)
val trackGroupInfos = arrayOfNulls<Group>(castMediaTracks.size)
for (i in castMediaTracks.indices) {
val mediaTrack = castMediaTracks[i]
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 trackSelected = booleanArrayOf(selected)
trackGroupInfos[i] = TrackGroupInfo(trackGroups[i]!!, trackSupport, trackType, trackSelected)
trackGroupInfos[i] = Group(trackGroups[i]!!, false, trackSupport, trackSelected)
}
val tg = trackGroups.filterNotNull().toTypedArray()
var ts = trackSelections.filterNotNull().toTypedArray()
var tgi = trackGroupInfos.filterNotNull().toMutableList()
val ts = trackSelections.filterNotNull().toTypedArray()
val tgi = trackGroupInfos.filterNotNull().toMutableList()
val newTrackGroups = TrackGroupArray(*tg)
val newTrackSelections = TrackSelectionArray(*ts)
val newTracksInfo = TracksInfo(tgi)
if (newTrackGroups != currentTrackGroups
|| newTrackSelections != currentTrackSelections
|| newTracksInfo != currentTracksInfo) {
val newTracksInfo = Tracks(tgi)
if (newTracksInfo != currentTracks) {
myCurrentTrackSelections = newTrackSelections
myCurrentTrackGroups = newTrackGroups
myCurrentTracksInfo = newTracksInfo
@@ -593,7 +591,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
listeners.flushEvents()
val pendingResult: PendingResult<RemoteMediaClient.MediaChannelResult> = if (playWhenReady) remoteMediaClient!!.play() else remoteMediaClient!!.pause()
var resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
val resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
updatePlayerStateAndNotifyIfChanged()
}
@@ -622,10 +620,10 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
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")
var resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
val resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
val statusCode: Int = it.status.statusCode
if (statusCode != CastStatusCodes.SUCCESS && statusCode != CastStatusCodes.REPLACED) {
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
// 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 (currentMediaItemIndex != 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)
} else {
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)
}
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)
var resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
val resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
updatePlaybackRateAndNotifyIfChanged()
listeners.flushEvents()
}
@@ -736,15 +734,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
sessionManager.endCurrentSession(false)
}
override fun getCurrentTrackGroups(): TrackGroupArray {
return this.myCurrentTrackGroups
}
override fun getCurrentTrackSelections(): TrackSelectionArray {
return this.myCurrentTrackSelections
}
override fun getCurrentTracksInfo(): TracksInfo {
override fun getCurrentTracks(): Tracks {
return this.myCurrentTracksInfo
}
@@ -858,8 +848,12 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
return VideoSize.UNKNOWN
}
override fun getCurrentCues(): MutableList<Cue> {
return mutableListOf()
override fun getSurfaceSize(): Size {
return Size.UNKNOWN
}
override fun getCurrentCues(): CueGroup {
return CueGroup.EMPTY_TIME_ZERO
}
override fun getDeviceInfo(): DeviceInfo {
@@ -86,7 +86,8 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
}
override fun onSeekTo(pos: Long) {
playerNotificationService.seekPlayer(pos)
val currentTrackStartOffset = playerNotificationService.getCurrentTrackStartOffsetMs()
playerNotificationService.seekPlayer(currentTrackStartOffset + pos)
}
private fun onChangeSpeed() {
@@ -115,15 +116,16 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
if (mediaId.isNullOrEmpty()) {
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 {
libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId)
if (libraryItemWrapper == null) {
Log.e(tag, "onPlayFromMediaId: Media item not found $mediaId")
val libraryItemWithEpisode = playerNotificationService.mediaManager.getPodcastWithEpisodeByEpisodeId(mediaId)
if (libraryItemWithEpisode != null) {
libraryItemWrapper = libraryItemWithEpisode.libraryItemWrapper
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?
var podcastEpisode: PodcastEpisode? = null
if (mediaId.startsWith("ep_") || mediaId.startsWith("local_ep_")) { // Playing podcast episode
val libraryItemWithEpisode = playerNotificationService.mediaManager.getPodcastWithEpisodeByEpisodeId(mediaId)
libraryItemWrapper = libraryItemWithEpisode?.libraryItemWrapper
podcastEpisode = libraryItemWithEpisode?.episode
val libraryItemWithEpisode = playerNotificationService.mediaManager.getPodcastWithEpisodeByEpisodeId(mediaId)
if (libraryItemWithEpisode != null) {
libraryItemWrapper = libraryItemWithEpisode.libraryItemWrapper
podcastEpisode = libraryItemWithEpisode.episode
} else {
libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId)
}
@@ -56,6 +56,9 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
lazyIsPlaying = isPlaying
// Update widget
DeviceManager.widgetUpdater?.onPlayerChanged(playerNotificationService)
if (isPlaying) {
Log.d(tag, "SeekBackTime: Player is playing")
if (lastPauseTime > 0 && DeviceManager.deviceData.deviceSettings?.disableAutoRewind != true) {
@@ -109,8 +112,6 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
}
playerNotificationService.clientEventEmitter?.onPlayingUpdate(isPlaying)
DeviceManager.widgetUpdater?.onPlayerChanged(playerNotificationService)
}
override fun onEvents(player: Player, events: Player.Events) {
@@ -44,11 +44,7 @@ class PlayerNotificationListener(var playerNotificationService:PlayerNotificatio
} else {
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.isClosed && !PlayerNotificationService.isSwitchingPlayer) {
Log.d(tag, "PNS is not closed - closing it now")
playerNotificationService.closePlayback()
} else if (PlayerNotificationService.isSwitchingPlayer) {
if (PlayerNotificationService.isSwitchingPlayer) {
// 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
Log.d(tag, "PNS is switching player")
@@ -2,8 +2,6 @@ package com.audiobookshelf.app.player
import android.annotation.SuppressLint
import android.app.*
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
@@ -11,10 +9,7 @@ import android.graphics.Color
import android.graphics.ImageDecoder
import android.hardware.Sensor
import android.hardware.SensorManager
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.net.*
import android.os.*
import android.provider.MediaStore
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.PlaybackStateCompat
import android.util.Log
import android.view.View
import android.widget.RemoteViews
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.media.MediaBrowserServiceCompat
import androidx.media.utils.MediaConstants
import com.audiobookshelf.app.BuildConfig
import com.audiobookshelf.app.R
import com.audiobookshelf.app.data.*
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.CustomActionProvider
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.ProgressiveMediaSource
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
}
private val tag = "PlayerNotificationService"
private val tag = "PlayerNotificationServ"
interface ClientEventEmitter {
fun onPlaybackSession(playbackSession:PlaybackSession)
@@ -203,6 +197,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
super.onCreate()
ctx = this
// Initialize Paper
DbManager.initialize(ctx)
// Initialize widget
DeviceManager.initializeWidgetUpdater(ctx)
// To listen for network change from metered to unmetered
val networkRequest = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
@@ -264,7 +264,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
playerNotificationManager.setUseNextAction(false)
playerNotificationManager.setUsePreviousAction(false)
playerNotificationManager.setUseChronometer(false)
playerNotificationManager.setUseStopAction(true)
playerNotificationManager.setUseStopAction(false)
playerNotificationManager.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX)
playerNotificationManager.setUseFastForwardActionInCompactView(true)
@@ -288,10 +288,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
return MediaDescriptionCompat.Builder().build()
}
val coverUri = currentPlaybackSession!!.getCoverUri()
val coverUri = currentPlaybackSession!!.getCoverUri(ctx)
var bitmap:Bitmap? = null
// Local covers get bitmap
var bitmap: Bitmap? = null
// Local covers get bitmap
if (currentPlaybackSession!!.localLibraryItem?.coverContentUrl != null) {
bitmap = if (Build.VERSION.SDK_INT < 28) {
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
@@ -362,7 +362,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
.build()
mPlayer.setHandleAudioBecomingNoisy(true)
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)
//attach player to playerNotificationManager
@@ -394,7 +394,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
val metadata = playbackSession.getMediaMetadataCompat(ctx)
mediaSession.setMetadata(metadata)
val mediaItems = playbackSession.getMediaItems()
val mediaItems = playbackSession.getMediaItems(ctx)
val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f
initialPlaybackRate = playbackRate
@@ -438,12 +438,26 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
if (playbackSession.isLocal) {
Log.d(tag, "Playing Local Item")
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) {
Log.d(tag, "Direct Playing Item")
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)
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0])
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory).createMediaSource(mediaItems[0])
} else {
Log.d(tag, "Playing HLS Item")
val dataSourceFactory = DefaultHttpDataSource.Factory()
@@ -483,7 +497,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
}
private fun setMediaSessionConnectorCustomActions(playbackSession:PlaybackSession) {
val mediaItems = playbackSession.getMediaItems()
val mediaItems = playbackSession.getMediaItems(ctx)
val customActionProviders = mutableListOf(
JumpBackwardCustomActionProvider(),
JumpForwardCustomActionProvider(),
@@ -638,16 +652,20 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
}
}
fun getCurrentTime() : Long {
fun getCurrentTrackStartOffsetMs() : Long {
return if (currentPlayer.mediaItemCount > 1) {
val windowIndex = currentPlayer.currentMediaItemIndex
val currentTrackStartOffset = currentPlaybackSession?.getTrackStartOffsetMs(windowIndex) ?: 0L
currentPlayer.currentPosition + currentTrackStartOffset
currentTrackStartOffset
} else {
currentPlayer.currentPosition
0
}
}
fun getCurrentTime() : Long {
return currentPlayer.currentPosition + getCurrentTrackStartOffsetMs()
}
fun getCurrentTimeSeconds() : Double {
return getCurrentTime() / 1000.0
}
@@ -863,10 +881,26 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
fun closePlayback(calledOnError:Boolean? = false) {
Log.d(tag, "closePlayback")
val isLocal = mediaProgressSyncer.currentIsLocal
val currentSessionId = mediaProgressSyncer.currentSessionId
if (mediaProgressSyncer.listeningTimerRunning) {
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)
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
mediaProgressSyncer.reset()
clientEventEmitter?.onPlaybackClosed()
PlayerListener.lastPauseTime = 0
isClosed = true
DeviceManager.widgetUpdater?.onPlayerClosed()
stopForeground(Service.STOP_FOREGROUND_REMOVE)
stopSelf()
}
@@ -896,7 +932,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
}
@SuppressLint("HardwareIds")
private fun getDeviceInfo(): DeviceInfo {
fun getDeviceInfo(): DeviceInfo {
/* EXAMPLE
manufacturer: Google
model: Pixel 6
@@ -905,7 +941,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
appVersion: 0.9.46-beta
*/
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()
@@ -962,13 +998,16 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
val extras = Bundle()
extras.putBoolean(
MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true)
MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true
)
extras.putInt(
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(
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)
}
@@ -979,39 +1018,19 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
result.detach()
if (parentMediaId.startsWith("li_") || parentMediaId.startsWith("local_")) { // Show podcast episodes
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
if (parentMediaId == DOWNLOADS_ROOT) { // Load downloads
val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast")
val localBrowseItems:MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
localBooks.forEach { localLibraryItem ->
val progress = DeviceManager.dbManager.getLocalMediaProgress(localLibraryItem.id)
val description = localLibraryItem.getMediaDescription(progress, ctx)
if (localLibraryItem.media.getAudioTracks().isNotEmpty()) {
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 ->
@@ -1027,19 +1046,19 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
val progress: MediaProgressWrapper?
val mediaDescription:MediaDescriptionCompat
if (itemInProgress.episode != null) {
if (itemInProgress.isLocal) {
progress = DeviceManager.dbManager.getLocalMediaProgress("${itemInProgress.libraryItemWrapper.id}-${itemInProgress.episode.id}")
} else {
progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == itemInProgress.libraryItemWrapper.id && it.episodeId == itemInProgress.episode.id }
if (itemInProgress.isLocal) {
progress = DeviceManager.dbManager.getLocalMediaProgress("${itemInProgress.libraryItemWrapper.id}-${itemInProgress.episode.id}")
} else {
progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == itemInProgress.libraryItemWrapper.id && it.episodeId == itemInProgress.episode.id }
// to show download icon
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(itemInProgress.libraryItemWrapper.id)
localLibraryItem?.let { lli ->
val localEpisode = (lli.media as Podcast).episodes?.find { it.serverEpisodeId == itemInProgress.episode.id }
itemInProgress.episode.localEpisodeId = localEpisode?.id
}
// to show download icon
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(itemInProgress.libraryItemWrapper.id)
localLibraryItem?.let { lli ->
val localEpisode = (lli.media as Podcast).episodes?.find { it.serverEpisodeId == itemInProgress.episode.id }
itemInProgress.episode.localEpisodeId = localEpisode?.id
}
}
}
mediaDescription = itemInProgress.episode.getMediaDescription(itemInProgress.libraryItemWrapper, progress, ctx)
} else {
if (itemInProgress.isLocal) {
@@ -1055,16 +1074,38 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
localBrowseItems += MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
}
result.sendResult(localBrowseItems)
} else {
} else if (parentMediaId == LIBRARIES_ROOT || parentMediaId == AUTO_MEDIA_ROOT) {
mediaManager.loadAndroidAutoItems {
browseTree = BrowseTree(this, mediaManager.serverItemsInProgress, mediaManager.serverLibraries)
val children = browseTree[parentMediaId]?.map { item ->
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>?)
}
} 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 {
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) {
Log.d(tag, "prepareLibraryItem: Using start time override $startTimeOverride")
playbackSession.currentTime = startTimeOverride
@@ -231,9 +231,15 @@ class AbsDatabase : Plugin() {
if (!success) {
call.resolve(JSObject("{\"error\":\"$errorMsg\"}"))
} else {
// Remove all local sessions
savedSessions.forEach {
DeviceManager.dbManager.removePlaybackSession(it.id)
}
call.resolve()
}
}
} else {
call.resolve()
}
}
}
@@ -57,7 +57,7 @@ class AbsDownloader : Plugin() {
val libraryItemId = call.data.getString("libraryItemId").toString()
var episodeId = call.data.getString("episodeId").toString()
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")
val downloadId = if (episodeId.isEmpty()) libraryItemId else "$libraryItemId-$episodeId"
@@ -72,9 +72,18 @@ class AbsDownloader : Plugin() {
} else {
Log.d(tag, "Got library item from server ${libraryItem.id}")
val localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
if (localFolder != null) {
if (localFolderId == "") {
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") {
Log.e(tag, "Library item is not a podcast but episode was requested")
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
private fun getFilenameFromRelPath(relPath: String): String {
var cleanedRelPath = relPath.replace("\\", "_").replace("/", "_")
@@ -127,7 +130,9 @@ class AbsDownloader : Plugin() {
}
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")
@@ -138,13 +143,13 @@ class AbsDownloader : Plugin() {
val tracks = libraryItem.media.getAudioTracks()
Log.d(tag, "Starting library item download with ${tracks.size} tracks")
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 book = libraryItem.media as Book
book.ebookFile?.let { ebookFile ->
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 finalDestinationFile = File("$itemFolderPath/$destinationFilename")
val destinationFile = File("$tempFolderPath/$destinationFilename")
@@ -164,9 +169,14 @@ class AbsDownloader : Plugin() {
}
// Create download item part for each audio track
val audioFiles = (libraryItem.media as Book).audioFiles ?: mutableListOf()
tracks.forEach { audioTrack ->
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)
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 audioTrack = episode?.audioTrack
val audioFileIno = episode?.audioFile?.ino
val fileSize = audioTrack?.metadata?.size ?: 0
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 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 ?: "")
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
import android.app.AlertDialog
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.util.Log
@@ -13,16 +12,11 @@ import com.anggrayudi.storage.callback.StorageAccessCallback
import com.anggrayudi.storage.file.*
import com.audiobookshelf.app.MainActivity
import com.audiobookshelf.app.data.LocalFolder
import com.audiobookshelf.app.data.LocalLibraryItem
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.device.FolderScanner
import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.getcapacitor.*
import com.getcapacitor.annotation.CapacitorPlugin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.io.File
@CapacitorPlugin(name = "AbsFileSystem")
@@ -166,23 +160,10 @@ class AbsFileSystem : Plugin() {
}
@PluginMethod
fun scanFolder(call: PluginCall) {
val folderId = call.data.getString("folderId", "").toString()
val forceAudioProbe = call.data.getBoolean("forceAudioProbe")
Log.d(TAG, "Scan Folder $folderId | Force Audio Probe $forceAudioProbe")
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())
fun getSDKVersion(call: PluginCall) {
val jsObject = JSObject()
jsObject.put("version", Build.VERSION.SDK_INT)
call.resolve(jsObject)
}
@PluginMethod
@@ -199,27 +180,6 @@ class AbsFileSystem : Plugin() {
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
fun deleteItem(call: PluginCall) {
val localLibraryItemId = call.data.getString("id", "").toString()
@@ -227,35 +187,44 @@ class AbsFileSystem : Plugin() {
val contentUrl = call.data.getString("contentUrl", "").toString()
Log.d(tag, "deleteItem $absolutePath | $contentUrl")
var subfolderPathToDelete = ""
// Check if should delete subfolder
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
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)
val success: Boolean
// If internal library item use File to delete
if (localLibraryItem?.folderId?.startsWith("internal-") == true) {
Log.d(tag, "Deleting internal library item at absolutePath $absolutePath")
val file = File(absolutePath)
success = file.deleteRecursively()
} else {
var subfolderPathToDelete = ""
localLibraryItem?.folderId?.let { folderId ->
val folder = DeviceManager.dbManager.getLocalFolder(folderId)
folder?.absolutePath?.let { folderPath ->
val splitAbsolutePath = absolutePath.split("/")
val fullSubDir = splitAbsolutePath.subList(0, splitAbsolutePath.size - 1).joinToString("/")
if (fullSubDir != folderPath) {
val subdirHasAnItem = DeviceManager.dbManager.getLocalLibraryItems().any { _localLibraryItem ->
if (_localLibraryItem.id == localLibraryItemId) {
false
} else {
_localLibraryItem.absolutePath.startsWith(fullSubDir)
}
}
subfolderPathToDelete = if (subdirHasAnItem) "" else fullSubDir
}
subfolderPathToDelete = if (subdirHasAnItem) "" else fullSubDir
}
}
}
val docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
val success = docfile?.delete() == true
val docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
success = docfile?.delete() == true
if (subfolderPathToDelete != "") {
Log.d(tag, "Deleting empty subfolder at $subfolderPathToDelete")
val docfilesub = DocumentFileCompat.fromFullPath(mainActivity, subfolderPathToDelete)
docfilesub?.delete()
if (subfolderPathToDelete != "") {
Log.d(tag, "Deleting empty subfolder at $subfolderPathToDelete")
val docfilesub = DocumentFileCompat.fromFullPath(mainActivity, subfolderPathToDelete)
docfilesub?.delete()
}
}
if (success) {
@@ -17,6 +17,7 @@ import com.getcapacitor.JSObject
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.internal.EMPTY_REQUEST
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
@@ -45,11 +46,11 @@ class ApiHandler(var ctx:Context) {
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 token = config?.token ?: DeviceManager.token
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"
Log.d(tag, "postRequest to $requestUrl")
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) {
Log.d(tag, "authorize: Authorizing ${config.address}")
postRequest("/api/authorize", JSObject(), config) {
@@ -315,18 +323,19 @@ class ApiHandler(var ctx:Context) {
fun sendSyncLocalSessions(playbackSessions:List<PlaybackSession>, cb: (Boolean, String?) -> Unit) {
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) {
if (!it.getString("error").isNullOrEmpty()) {
Log.e(tag, "Failed to sync local sessions")
cb(false, it.getString("error"))
} else {
val response = jacksonMapper.readValue<LocalSessionsSyncResponsePayload>(it.toString())
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 ->
if (localSessionSyncResult.progressSynced == true) {
val syncResult = SyncResult(true, true, "Progress synced on server")
MediaEventManager.saveEvent(session, syncResult)
DeviceManager.dbManager.removePlaybackSession(session.id)
Log.i(tag, "Successfully synced session ${session.displayTitle} with server")
} else if (!localSessionSyncResult.success) {
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}")
localMediaProgress.updateFromServerMediaProgress(mediaProgress)
MediaEventManager.syncEvent(mediaProgress, "Sync on server connection")
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
} else if (localMediaProgress.lastUpdate > mediaProgress.lastUpdate && localMediaProgress.ebookLocation != null && localMediaProgress.ebookLocation != mediaProgress.ebookLocation) {
// Patch ebook progress to server
val endpoint = "/api/me/progress/${localMediaProgress.libraryItemId}"
@@ -2,4 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -2,4 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
+3 -3
View File
@@ -1,9 +1,9 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">audiobookshelf</string>
<string name="title_activity_main">audiobookshelf</string>
<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">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>
+3 -4
View File
@@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>
<paths>
<files-path name="downloads" path="downloads/" />
</paths>
+2 -2
View File
@@ -8,8 +8,8 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.google.gms:google-services:4.3.13'
classpath 'com.android.tools.build:gradle:7.4.0'
classpath 'com.google.gms:google-services:4.4.0'
classpath 'com.android.tools.build:gradle:8.1.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// 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'
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'
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
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Automatically convert third-party libraries to use AndroidX
android.enableJetifier=true
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
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.2-all.zip
networkTimeout=10000
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");
# 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
# 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"
APP_BASE_NAME=`basename "$0"`
# Resolve links: $0 may be a link
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.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
MAX_FD=maximum
warn () {
echo "$*"
}
} >&2
die () {
echo
echo "$*"
echo
exit 1
}
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
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 [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
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."
fi
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.
Please set the JAVA_HOME variable in your environment to match the
@@ -106,80 +140,105 @@ location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# 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" ;;
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --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
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
# For Cygwin or MSYS, switch paths to Windows format before running java
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" "$@"
+9 -6
View File
@@ -14,7 +14,7 @@
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@@ -25,7 +25,8 @@
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@@ -40,7 +41,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
if %ERRORLEVEL% equ 0 goto execute
echo.
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
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
+14 -14
View File
@@ -1,39 +1,39 @@
ext {
minSdkVersion = 24
compileSdkVersion = 33
targetSdkVersion = 32
androidxActivityVersion = '1.4.0'
androidxAppCompatVersion = '1.4.2'
compileSdkVersion = 34
targetSdkVersion = 33
androidxActivityVersion = '1.7.0'
androidxAppCompatVersion = '1.6.1'
androidxCoordinatorLayoutVersion = '1.2.0'
androidxCoreVersion = '1.8.0'
androidxCoreVersion = '1.10.0'
androidPlayCore = '1.9.0'
androidxFragmentVersion = '1.4.1'
androidxFragmentVersion = '1.5.6'
junitVersion = '4.13.2'
androidxJunitVersion = '1.1.3'
androidxEspressoCoreVersion = '3.4.0'
androidxJunitVersion = '1.1.5'
androidxEspressoCoreVersion = '3.5.1'
cordovaAndroidVersion = '10.1.1'
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_preference_version = '1.1.1'
androidx_test_runner_version = '1.3.0'
arch_lifecycle_version = '2.2.0'
constraint_layout_version = '2.0.1'
espresso_version = '3.3.0'
exoplayer_version = '2.17.0'
exoplayer_version = '2.18.7'
fragment_version = '1.2.5'
glide_version = '4.11.0'
gms_strict_version_matcher_version = '1.0.3'
gradle_version = '3.1.4'
gson_version = '2.8.5'
junit_version = '4.13'
kotlin_version = '1.7.20'
kotlin_coroutines_version = '1.1.0'
kotlin_version = '1.8.10'
kotlin_coroutines_version = '1.6.4'
multidex_version = '1.0.3'
play_services_auth_version = '18.1.0'
recycler_view_version = '1.1.0'
robolectric_version = '4.2'
test_runner_version = '1.1.0'
coreSplashScreenVersion = '1.0.0-rc01'
androidxWebkitVersion = '1.4.0'
coreSplashScreenVersion = '1.0.1'
androidxWebkitVersion = '1.6.1'
}
+24
View File
@@ -1,3 +1,4 @@
@import "./tailwind.css";
@import "./fonts.css";
@import './defaultStyles.css';
@import './absicons.css';
@@ -143,4 +144,27 @@ Bookshelf Label
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
}
.tracksTable {
border-collapse: collapse;
width: 100%;
border: 1px solid rgb(var(--color-secondary));
}
.tracksTable tr:nth-child(even) {
background-color: rgb(var(--color-secondary));
}
.tracksTable tr {
background-color: rgb(var(--color-bg));
}
.tracksTable td {
padding: 8px 8px;
}
.tracksTable th {
padding: 4px 8px;
font-size: 0.75rem;
}
+43
View File
@@ -0,0 +1,43 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
color: white;
--color-bg: 56 56 56;
--color-bg-hover: 102 104 107;
--color-fg: 230 237 243;
--color-fg-muted: 142 147 153;
--color-primary: 35 35 35;
--color-secondary: 47 48 48;
--color-border: 75 85 89;
--color-bg-toggle: 56 56 56;
--color-bg-toggle-selected: 81 82 84;
--color-track-cursor: 229 231 235;
--color-track: 107 114 128;
--color-track-buffered: 75 85 99;
--gradient-item-page: linear-gradient(169deg, rgba(0, 0, 0, 0.4) 0%, rgba(55, 56, 56, 1) 80%);
--gradient-audio-player: linear-gradient(169deg, rgba(0, 0, 0, 0) 0%, rgba(38, 38, 38, 1) 80%);
--gradient-minimized-audio-player: linear-gradient(145deg, rgba(38, 38, 38, 0.5) 0%, rgba(38, 38, 38, 0.9) 20%, rgb(38, 38, 38) 60%);
}
html[data-theme='light'] {
color: black;
--color-bg: 255 255 255;
--color-bg-hover: 208 210 212;
--color-fg: 37 37 37;
--color-fg-muted: 101 109 118;
--color-primary: 222 222 222;
--color-secondary: 246 248 250;
--color-border: 189 191 191;
--color-bg-toggle: 222 222 222;
--color-bg-toggle-selected: 255 255 255;
--color-track-cursor: 101 109 118;
--color-track: 189 191 191;
--color-track-buffered: 129 131 131;
--gradient-item-page: linear-gradient(169deg, rgba(255, 255, 255, 0.4) 0%, rgba(255, 255, 255, 1) 80%);
--gradient-audio-player: linear-gradient(169deg, rgba(255, 255, 255, 0.4) 0%, rgba(255, 255, 255, 1) 80%);
--gradient-minimized-audio-player: linear-gradient(145deg, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 0.9) 20%, rgb(255, 255, 255) 60%);
}
}
+6 -1
View File
@@ -2,5 +2,10 @@
"appId": "com.audiobookshelf.app",
"appName": "audiobookshelf-app",
"webDir": "dist",
"bundledWebRuntime": false
"bundledWebRuntime": false,
"plugins": {
"CapacitorHttp": {
"enabled": false
}
}
}
+4 -4
View File
@@ -4,13 +4,13 @@
<nuxt-link v-show="!showBack" to="/" class="mr-3">
<img src="/Logo.png" class="h-10 w-10" />
</nuxt-link>
<a v-if="showBack" @click="back" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 mr-2 cursor-pointer">
<span class="material-icons text-3xl text-white">arrow_back</span>
<a v-if="showBack" @click="back" class="rounded-full h-10 w-10 flex items-center justify-center mr-2 cursor-pointer">
<span class="material-icons text-3xl text-fg">arrow_back</span>
</a>
<div v-if="user && currentLibrary">
<div class="pl-1.5 pr-2.5 py-2 bg-bg bg-opacity-30 rounded-md flex items-center" @click="clickShowLibraryModal">
<ui-library-icon :icon="currentLibraryIcon" :size="4" font-size="base" />
<p class="text-smleading-4 ml-2 mt-0.5 max-w-24 truncate">{{ currentLibraryName }}</p>
<p class="text-sm leading-4 ml-2 mt-0.5 max-w-24 truncate">{{ currentLibraryName }}</p>
</div>
</div>
@@ -79,7 +79,7 @@ export default {
},
methods: {
castClick() {
if (this.$store.state.playerIsLocal) {
if (this.$store.getters['getIsCurrentSessionLocal']) {
this.$eventBus.$emit('cast-local-item')
return
}
+44 -39
View File
@@ -1,7 +1,7 @@
<template>
<div v-if="playbackSession" id="streamContainer" class="fixed top-0 left-0 layout-wrapper right-0 z-50 pointer-events-none" :class="{ fullscreen: showFullscreen, 'ios-player': $platform === 'ios', 'web-player': $platform === 'web' }">
<div v-if="showFullscreen" class="w-full h-full z-10 absolute top-0 left-0 pointer-events-auto" :style="{ backgroundColor: coverRgb }">
<div class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: linear-gradient(169deg, rgba(0, 0, 0, 0) 0%, rgba(38, 38, 38, 1) 80%)" />
<div class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: var(--gradient-audio-player)" />
<div class="top-4 left-4 absolute cursor-pointer">
<span class="material-icons text-5xl" :class="{ 'text-black text-opacity-75': coverBgIsLight }" @click="collapseFullscreen">expand_more</span>
@@ -12,20 +12,20 @@
<div class="top-6 right-4 absolute cursor-pointer">
<span class="material-icons text-3xl" :class="{ 'text-black text-opacity-75': coverBgIsLight }" @click="showMoreMenuDialog = true">more_vert</span>
</div>
<p class="top-4 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" :class="{ 'text-black text-opacity-75': coverBgIsLight }" style="font-size: 10px">{{ isDirectPlayMethod ? 'Direct' : isLocalPlayMethod ? 'Local' : 'Transcode' }}</p>
<p class="top-4 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" :class="{ 'text-black text-opacity-75': coverBgIsLight }" style="font-size: 10px">{{ isDirectPlayMethod ? $strings.LabelPlaybackDirect : isLocalPlayMethod ? $strings.LabelPlaybackLocal : $strings.LabelPlaybackTranscode }}</p>
</div>
<div v-if="useChapterTrack && useTotalTrack && showFullscreen" class="absolute total-track w-full z-30 px-6">
<div class="flex">
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem">{{ currentTimePretty }}</p>
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ currentTimePretty }}</p>
<div class="flex-grow" />
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem">{{ totalTimeRemainingPretty }}</p>
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ totalTimeRemainingPretty }}</p>
</div>
<div class="w-full">
<div class="h-1 w-full bg-gray-500 bg-opacity-50 relative rounded-full">
<div ref="totalReadyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none rounded-full" />
<div ref="totalBufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 pointer-events-none rounded-full" />
<div ref="totalPlayedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none rounded-full" />
<div class="h-1 w-full bg-track/50 relative rounded-full">
<div ref="totalReadyTrack" class="h-full bg-track-buffered absolute top-0 left-0 pointer-events-none rounded-full" />
<div ref="totalBufferedTrack" class="h-full bg-track absolute top-0 left-0 pointer-events-none rounded-full" />
<div ref="totalPlayedTrack" class="h-full bg-track-cursor absolute top-0 left-0 pointer-events-none rounded-full" />
</div>
</div>
</div>
@@ -42,60 +42,62 @@
<div class="title-author-texts absolute z-30 left-0 right-0 overflow-hidden" @click="clickTitleAndAuthor">
<p class="title-text truncate">{{ title }}</p>
<p class="author-text text-white text-opacity-75 truncate">{{ authorName }}</p>
<p class="author-text text-fg text-opacity-75 truncate">{{ authorName }}</p>
</div>
<div id="playerContent" class="playerContainer w-full z-20 absolute bottom-0 left-0 right-0 p-2 pointer-events-auto transition-all" :style="{ backgroundColor: showFullscreen ? '' : coverRgb }" @click="clickContainer">
<div 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">
<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-fg-muted cursor-pointer" @click="$emit('showBookmarks')">{{ bookmarks.length ? 'bookmark' : 'bookmark_border' }}</span>
<!-- hidden for podcasts but still using this as a placeholder -->
<span v-else class="material-icons text-3xl text-white text-opacity-0">bookmark</span>
<span class="font-mono text-white text-opacity-75 cursor-pointer" style="font-size: 1.35rem" @click="$emit('selectPlaybackSpeed')">{{ currentPlaybackRate }}x</span>
<svg v-if="!sleepTimerRunning" xmlns="http://www.w3.org/2000/svg" class="h-7 w-7 text-white text-opacity-75 cursor-pointer" fill="none" viewBox="0 0 24 24" stroke="currentColor" @click.stop="$emit('showSleepTimer')">
<span class="font-mono text-fg-muted cursor-pointer" style="font-size: 1.35rem" @click="$emit('selectPlaybackSpeed')">{{ currentPlaybackRate }}x</span>
<svg v-if="!sleepTimerRunning" xmlns="http://www.w3.org/2000/svg" class="h-7 w-7 text-fg-muted cursor-pointer" fill="none" viewBox="0 0 24 24" stroke="currentColor" @click.stop="$emit('showSleepTimer')">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
<div v-else class="h-7 w-7 flex items-center justify-around cursor-pointer" @click.stop="$emit('showSleepTimer')">
<p class="text-xl font-mono text-success">{{ sleepTimeRemainingPretty }}</p>
</div>
<span class="material-icons text-3xl text-white cursor-pointer" :class="chapters.length ? 'text-opacity-75' : 'text-opacity-10'" @click="clickChaptersBtn">format_list_bulleted</span>
<span class="material-icons text-3xl text-fg cursor-pointer" :class="chapters.length ? 'text-opacity-75' : 'text-opacity-10'" @click="clickChaptersBtn">format_list_bulleted</span>
</div>
</div>
<div v-else class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: linear-gradient(145deg, rgba(38, 38, 38, 0.5) 0%, rgba(38, 38, 38, 0.9) 20%, rgb(38, 38, 38) 60%)" />
<div v-else class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: var(--gradient-minimized-audio-player)" />
<div id="playerControls" class="absolute right-0 bottom-0 mx-auto" style="max-width: 414px">
<div class="flex items-center max-w-full" :class="lockUi ? 'justify-center' : 'justify-between'">
<span v-show="showFullscreen && !lockUi" class="material-icons next-icon text-white text-opacity-75 cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
<span v-show="!lockUi" class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span>
<span v-show="showFullscreen && !lockUi" class="material-icons next-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
<span v-show="!lockUi" class="material-icons jump-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span>
<div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4 relative overflow-hidden" :style="{ backgroundColor: coverRgb }" :class="{ 'animate-spin': seekLoading }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
<div v-if="!coverBgIsLight" class="absolute top-0 left-0 w-full h-full bg-white bg-opacity-20 pointer-events-none" />
<span v-if="!isLoading" class="material-icons" :class="{ 'text-white': coverRgb && !coverBgIsLight }">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
<widgets-spinner-icon v-else class="h-8 w-8" />
</div>
<span v-show="!lockUi" class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">{{ jumpForwardIcon }}</span>
<span v-show="showFullscreen && !lockUi" class="material-icons next-icon text-white cursor-pointer" :class="nextChapter && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
<span v-show="!lockUi" class="material-icons jump-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">{{ jumpForwardIcon }}</span>
<span v-show="showFullscreen && !lockUi" class="material-icons next-icon text-fg cursor-pointer" :class="nextChapter && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
</div>
</div>
<div id="playerTrack" class="absolute left-0 w-full px-6">
<div class="flex pointer-events-none">
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem" ref="currentTimestamp">0:00</p>
<p class="font-mono text-fg" style="font-size: 0.8rem" ref="currentTimestamp">0:00</p>
<div class="flex-grow" />
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem">{{ timeRemainingPretty }}</p>
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ timeRemainingPretty }}</p>
</div>
<div ref="track" class="h-1.5 w-full bg-gray-500 bg-opacity-50 relative rounded-full" :class="{ 'animate-pulse': isLoading }" @click.stop>
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 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="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="track" class="h-1.5 w-full bg-track/50 relative rounded-full" :class="{ 'animate-pulse': isLoading }" @click.stop>
<div ref="readyTrack" class="h-full bg-track-buffered absolute top-0 left-0 rounded-full pointer-events-none" />
<div ref="bufferedTrack" class="h-full bg-track absolute top-0 left-0 rounded-full pointer-events-none" />
<div ref="playedTrack" class="h-full bg-track-cursor absolute top-0 left-0 rounded-full pointer-events-none" />
<div ref="trackCursor" class="h-7 w-7 rounded-full absolute pointer-events-auto flex items-center justify-center" :style="{ top: '-11px' }" :class="{ 'opacity-0': lockUi || !showFullscreen }" @touchstart="touchstartCursor">
<div class="bg-track-cursor rounded-full w-3.5 h-3.5 pointer-events-none" />
</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" />
</div>
</template>
@@ -113,7 +115,7 @@ export default {
},
sleepTimerRunning: Boolean,
sleepTimeRemaining: Number,
isServerItem: Boolean
serverLibraryItemId: String
},
data() {
return {
@@ -173,7 +175,7 @@ export default {
// TODO: Implement on iOS
if (this.$platform !== 'ios' && !this.isPodcast && this.mediaId) {
items.push({
text: 'History',
text: this.$strings.ButtonHistory,
value: 'history',
icon: 'history'
})
@@ -182,22 +184,22 @@ export default {
items.push(
...[
{
text: 'Total Track',
text: this.$strings.LabelTotalTrack,
value: 'total_track',
icon: this.useTotalTrack ? 'check_box' : 'check_box_outline_blank'
},
{
text: 'Chapter Track',
text: this.$strings.LabelChapterTrack,
value: 'chapter_track',
icon: this.useChapterTrack ? 'check_box' : 'check_box_outline_blank'
},
{
text: this.lockUi ? 'Unlock Player' : 'Lock Player',
text: this.lockUi ? this.$strings.LabelUnlockPlayer : this.$strings.LabelLockPlayer,
value: 'lock',
icon: this.lockUi ? 'lock' : 'lock_open'
},
{
text: 'Close Player',
text: this.$strings.LabelClosePlayer,
value: 'close',
icon: 'close'
}
@@ -272,7 +274,7 @@ export default {
return this.playbackSession ? this.playbackSession.localLibraryItem || null : null
},
localLibraryItemCoverSrc() {
var localItemCover = this.localLibraryItem ? this.localLibraryItem.coverContentUrl : null
var localItemCover = this.localLibraryItem?.coverContentUrl || null
if (localItemCover) return Capacitor.convertFileSrc(localItemCover)
return null
},
@@ -389,7 +391,7 @@ export default {
},
clickTitleAndAuthor() {
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) {
this.$router.push(`/item/${llid}`)
this.showFullscreen = false
@@ -551,7 +553,7 @@ export default {
this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px'
if (this.$refs.trackCursor) {
this.$refs.trackCursor.style.left = ptWidth - 7 + 'px'
this.$refs.trackCursor.style.left = ptWidth - 14 + 'px'
}
if (this.useChapterTrack) {
@@ -572,8 +574,8 @@ export default {
AbsAudioPlayer.seek({ value: Math.floor(time) })
if (this.$refs.playedTrack) {
var perc = time / this.totalDuration
var ptWidth = Math.round(perc * this.trackWidth)
const perc = time / this.totalDuration
const ptWidth = Math.round(perc * this.trackWidth)
this.$refs.playedTrack.style.width = ptWidth + 'px'
this.$refs.playedTrack.classList.remove('bg-gray-200')
@@ -693,6 +695,7 @@ export default {
this.updateTimestamp()
this.updateTrack()
this.updateReadyTrack()
this.$localStore.setUseTotalTrack(this.useTotalTrack)
this.$localStore.setUseChapterTrack(this.useChapterTrack)
} else if (action === 'total_track') {
this.useTotalTrack = !this.useTotalTrack
@@ -702,6 +705,7 @@ export default {
this.updateTrack()
this.updateReadyTrack()
this.$localStore.setUseTotalTrack(this.useTotalTrack)
this.$localStore.setUseChapterTrack(this.useChapterTrack)
} else if (action === 'close') {
this.closePlayback()
}
@@ -717,7 +721,7 @@ export default {
AbsAudioPlayer.closePlayback()
},
endPlayback() {
this.$store.commit('setPlayerItem', null)
this.$store.commit('setPlaybackSession', null)
this.showFullscreen = false
this.isEnded = false
this.isLoading = false
@@ -763,7 +767,7 @@ export default {
this.isEnded = false
this.isLoading = true
this.syncStatus = 0
this.$store.commit('setPlayerItem', this.playbackSession)
this.$store.commit('setPlaybackSession', this.playbackSession)
// Set track width
this.$nextTick(() => {
@@ -790,6 +794,7 @@ export default {
},
async init() {
this.useChapterTrack = await this.$localStore.getUseChapterTrack()
this.useTotalTrack = await this.$localStore.getUseTotalTrack()
this.lockUi = await this.$localStore.getPlayerLock()
this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession)
+64 -6
View File
@@ -1,6 +1,6 @@
<template>
<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-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) {
if (!data.numSegments) return
var chunks = data.chunks
const chunks = data.chunks
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
}
@@ -204,6 +204,7 @@ export default {
message: `Cannot cast downloaded media items. Confirm to close cast and play on your device.`
})
if (!value) {
this.$store.commit('setPlayerDoneStartingPlayback')
return
}
}
@@ -217,6 +218,7 @@ export default {
} else if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.play()
}
this.$store.commit('setPlayerDoneStartingPlayback')
return
}
@@ -254,9 +256,12 @@ export default {
console.error('Failed', error)
this.$toast.error('Failed to play')
})
.finally(() => {
this.$store.commit('setPlayerDoneStartingPlayback')
})
},
pauseItem() {
if (this.$refs.audioPlayer && !this.$refs.audioPlayer.isPaused) {
if (this.$refs.audioPlayer && this.$refs.audioPlayer.isPlaying) {
this.$refs.audioPlayer.pause()
}
},
@@ -265,8 +270,7 @@ export default {
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
},
onMediaPlayerChanged(data) {
var mediaPlayer = data.value
this.$store.commit('setMediaPlayer', mediaPlayer)
this.$store.commit('setMediaPlayer', data.value)
},
onReady() {
// The UI is reporting elsewhere we are ready
@@ -274,7 +278,7 @@ export default {
this.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 settings aren't loaded yet, native player will receive incorrect settings
@@ -283,6 +287,56 @@ export default {
this.$store.commit('setIsFirstAudioLoad', false) // Only run this once on app launch
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() {
@@ -300,6 +354,8 @@ export default {
this.$eventBus.$on('close-stream', this.closeStreamOnly)
this.$eventBus.$on('cast-local-item', this.castLocalItem)
this.$eventBus.$on('user-settings', this.settingsUpdated)
this.$eventBus.$on('playback-time-update', this.playbackTimeUpdate)
this.$eventBus.$on('device-focus-update', this.deviceFocused)
},
beforeDestroy() {
if (this.onLocalMediaProgressUpdateListener) this.onLocalMediaProgressUpdateListener.remove()
@@ -313,6 +369,8 @@ export default {
this.$eventBus.$off('close-stream', this.closeStreamOnly)
this.$eventBus.$off('cast-local-item', this.castLocalItem)
this.$eventBus.$off('user-settings', this.settingsUpdated)
this.$eventBus.$off('playback-time-update', this.playbackTimeUpdate)
this.$eventBus.$off('device-focus-update', this.deviceFocused)
}
}
</script>
+47 -18
View File
@@ -1,7 +1,7 @@
<template>
<div class="fixed top-0 left-0 right-0 layout-wrapper w-full z-50 overflow-hidden pointer-events-none">
<div class="absolute top-0 left-0 w-full h-full bg-black transition-opacity duration-200" :class="show ? 'bg-opacity-60 pointer-events-auto' : 'bg-opacity-0'" @click="clickBackground" />
<div class="absolute top-0 right-0 w-64 h-full bg-primary transform transition-transform py-6 pointer-events-auto" :class="show ? '' : 'translate-x-64'" @click.stop>
<div class="absolute top-0 right-0 w-64 h-full bg-bg transform transition-transform py-6 pointer-events-auto" :class="show ? '' : 'translate-x-64'" @click.stop>
<div class="px-6 mb-4">
<p v-if="user" class="text-base">
Welcome,
@@ -11,22 +11,26 @@
<div class="w-full overflow-y-auto">
<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/60 flex items-center py-3 px-6 text-fg-muted" @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/60 flex items-center py-3 px-6 text-fg" :class="currentRoutePath.startsWith(item.to) ? 'bg-bg-hover/50' : 'text-fg-muted'">
<span class="text-lg" :class="item.iconOutlined ? 'material-icons-outlined' : 'material-icons'">{{ item.icon }}</span>
<p class="pl-4">{{ item.text }}</p>
</nuxt-link>
</template>
</div>
<div class="absolute bottom-0 left-0 w-full py-6 px-6 text-gray-300">
<div class="absolute bottom-0 left-0 w-full py-6 px-6 text-fg">
<div v-if="serverConnectionConfig" class="mb-4 flex justify-center">
<p class="text-xs text-gray-400" style="word-break: break-word">{{ serverConnectionConfig.address }} (v{{ serverSettings.version }})</p>
<p class="text-xs text-fg-muted" style="word-break: break-word">{{ serverConnectionConfig.address }} (v{{ serverSettings.version }})</p>
</div>
<div class="flex items-center">
<p class="text-xs">{{ $config.version }}</p>
<div class="flex-grow" />
<div v-if="user" class="flex items-center" @click="logout">
<p class="text-xs pr-2">Logout</p>
<span class="material-icons text-sm">logout</span>
<div v-if="user" class="flex items-center" @click="disconnect">
<p class="text-xs pr-2">{{ $strings.ButtonDisconnect }}</p>
<i class="material-icons text-sm -mb-0.5">cloud_off</i>
</div>
</div>
</div>
@@ -81,7 +85,7 @@ export default {
var items = [
{
icon: 'home',
text: 'Home',
text: this.$strings.ButtonHome,
to: '/bookshelf'
}
]
@@ -89,19 +93,19 @@ export default {
items = [
{
icon: 'cloud_off',
text: 'Connect to Server',
text: this.$strings.ButtonConnectToServer,
to: '/connect'
}
].concat(items)
} else {
items.push({
icon: 'person',
text: 'Account',
text: this.$strings.HeaderAccount,
to: '/account'
})
items.push({
icon: 'equalizer',
text: 'User Stats',
text: this.$strings.ButtonUserStats,
to: '/stats'
})
}
@@ -110,22 +114,31 @@ export default {
items.push({
icon: 'folder',
iconOutlined: true,
text: 'Local Media',
text: this.$strings.ButtonLocalMedia,
to: '/localMedia/folders'
})
} else {
items.push({
icon: 'download',
iconOutlined: false,
text: 'Downloads',
text: this.$strings.HeaderDownloads,
to: '/downloads'
})
}
items.push({
icon: 'settings',
text: 'Settings',
text: this.$strings.HeaderSettings,
to: '/settings'
})
if (this.serverConnectionConfig) {
items.push({
icon: 'login',
text: this.$strings.ButtonSwitchServerUser,
action: 'logout'
})
}
return items
},
currentRoutePath() {
@@ -133,14 +146,20 @@ export default {
}
},
methods: {
async clickAction(action) {
await this.$hapticsImpact()
if (action === 'logout') {
await this.logout()
this.$router.push('/connect')
}
},
clickBackground() {
this.show = false
},
async logout() {
await this.$hapticsImpact()
if (this.user) {
await this.$axios.$post('/logout').catch((error) => {
console.error(error)
await this.$nativeHttp.post('/logout').catch((error) => {
console.error('Failed to logout', error)
})
}
@@ -148,7 +167,17 @@ export default {
await this.$db.logout()
this.$localStore.removeLastLibraryId()
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) {
this.touchEvent = new TouchEvent(e)
+19 -4
View File
@@ -9,7 +9,7 @@
<div v-show="!entities.length && initialized" class="w-full py-16 text-center text-xl">
<div class="py-4 capitalize">No {{ entityName }}</div>
<ui-btn v-if="hasFilter" @click="clearFilter">Clear Filter</ui-btn>
<ui-btn v-if="hasFilter" @click="clearFilter">{{ $strings.ButtonClearFilter }}</ui-btn>
</div>
</div>
</template>
@@ -72,6 +72,9 @@ export default {
showBookshelfListView() {
return this.isBookEntity && this.bookshelfListView
},
sortingIgnorePrefix() {
return this.$store.getters['getServerSetting']('sortingIgnorePrefix')
},
entityName() {
return this.page
},
@@ -164,9 +167,9 @@ export default {
const entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `items` : this.entityName
const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1`
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)
return null
})
@@ -332,6 +335,12 @@ export default {
},
async init() {
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)
console.log('Local library items loaded for lazy bookshelf', this.localLibraryItems.length)
@@ -358,8 +367,14 @@ export default {
this.handleScroll(scrollTop)
},
buildSearchParams() {
if (this.page === 'search' || this.page === 'series' || this.page === 'collections') {
if (this.page === 'search' || this.page === 'collections') {
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()
+1 -1
View File
@@ -1,7 +1,7 @@
<template>
<div class="w-full relative">
<div v-if="altViewEnabled" class="px-5 pb-3 pt-4">
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ label }}</p>
<p class="font-semibold" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ label }}</p>
</div>
<div class="flex items-end px-3 max-w-full overflow-x-auto" :class="altViewEnabled ? '' : 'bookshelfRow'" :style="{ height: shelfHeight + 'px', paddingBottom: entityPaddingBottom + 'px' }">
+3 -20
View File
@@ -6,8 +6,8 @@
<!-- Author name & num books overlay -->
<div v-show="!searching && !nameBelow" class="absolute bottom-0 left-0 w-full py-1 bg-black bg-opacity-60 px-2">
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
<p class="text-center font-semibold truncate text-white" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} {{ $strings.LabelBooks }}</p>
</div>
<!-- Loading spinner -->
@@ -58,24 +58,7 @@ export default {
return this._author.numBooks || 0
}
},
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
}
},
methods: {},
mounted() {}
}
</script>
+3 -3
View File
@@ -5,10 +5,10 @@
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
<p v-else class="truncate text-sm" v-html="matchHtml" />
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-gray-300">{{ matchHtml }}</p>
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-fg-muted">{{ matchHtml }}</p>
<p v-if="matchKey !== 'authors'" class="text-xs text-gray-200 truncate">by {{ authorName }}</p>
<p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
<p v-if="matchKey !== 'authors'" class="text-xs text-fg truncate">by {{ authorName }}</p>
<p v-else class="truncate text-xs text-fg" v-html="matchHtml" />
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin' || matchKey === 'episode' || matchKey === 'narrators'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
</div>
+80 -90
View File
@@ -10,20 +10,20 @@
<p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
{{ displayTitle }}
</p>
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayLineTwo || '&nbsp;' }}</p>
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
<p class="truncate text-fg-muted" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayLineTwo || '&nbsp;' }}</p>
<p v-if="displaySortLine" class="truncate text-fg-muted" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
</div>
<div v-if="seriesSequenceList" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20 text-right" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }" style="background-color: #78350f">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequenceList }}</p>
<p class="text-white" :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequenceList }}</p>
</div>
<div v-else-if="booksInSeries" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }" style="background-color: #cd9d49dd">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ booksInSeries }}</p>
<p class="text-white" :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ booksInSeries }}</p>
</div>
<div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10">
<div v-show="libraryItem && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: sizeMultiplier * 0.5 + 'rem' }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }" class="text-gray-300 text-center">{{ title }}</p>
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }" class="text-fg-muted text-center">{{ title }}</p>
</div>
<img v-show="libraryItem" ref="cover" :src="bookCoverSrc" class="w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
@@ -37,16 +37,28 @@
<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>
</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>
<!-- Play/pause button for podcast episode -->
<div v-if="recentEpisode" class="absolute z-10 top-0 left-0 bottom-0 right-0 m-auto flex items-center justify-center w-12 h-12 rounded-full bg-white bg-opacity-70" @click.stop="playEpisode">
<span class="material-icons text-6xl text-black text-opacity-80">{{ streamIsPlaying ? 'pause_circle' : 'play_circle_filled' }}</span>
<div v-if="recentEpisode" class="absolute z-10 top-0 left-0 bottom-0 right-0 m-auto flex items-center justify-center w-12 h-12 rounded-full" :class="{ 'bg-white/70': !playerIsStartingForThisMedia }" @click.stop="playEpisode">
<span v-if="!playerIsStartingForThisMedia" class="material-icons text-6xl text-black/80">{{ streamIsPlaying ? 'pause_circle' : 'play_circle_filled' }}</span>
<div v-else class="text-fg absolute top-0 left-0 w-full h-full flex items-center justify-center bg-black/80 rounded-full overflow-hidden">
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>
</div>
</div>
<!-- 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>
<!-- 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` }">
<span class="material-icons text-2xl text-success">{{ isLocalOnly ? 'task' : 'download_done' }}</span>
</div>
@@ -56,6 +68,11 @@
<span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span>
</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 -->
<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>
@@ -63,14 +80,19 @@
<!-- Podcast Episode # -->
<div v-if="recentEpisodeNumber !== null && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">
<p class="text-white" :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">
Episode<span v-if="recentEpisodeNumber"> #{{ recentEpisodeNumber }}</span>
</p>
</div>
<!-- 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' }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodes }}</p>
<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 class="text-white" :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodes }}</p>
</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 class="text-white" :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodesIncomplete }}</p>
</div>
</div>
</template>
@@ -106,7 +128,6 @@ export default {
isProcessingReadUpdate: false,
libraryItem: null,
imageReady: false,
rescanning: false,
selected: false,
isSelectionMode: false,
showCoverBg: false,
@@ -123,9 +144,6 @@ export default {
}
},
computed: {
showExperimentalFeatures() {
return this.store.state.showExperimentalFeatures
},
_libraryItem() {
return this.libraryItem || {}
},
@@ -174,6 +192,10 @@ export default {
if (this.isLocal && this.isPodcast && this.media.episodes) return this.media.episodes.length
return this.media.numEpisodes
},
numEpisodesIncomplete() {
if (this.isLocal) return 0
return this._libraryItem.numEpisodesIncomplete || 0
},
processingBatch() {
return this.store.state.processingBatch
},
@@ -212,7 +234,7 @@ export default {
return this.mediaMetadata.series
},
seriesSequence() {
return this.series ? this.series.sequence : null
return this.series?.sequence || null
},
recentEpisode() {
// Only added to item when getting currently listening podcasts
@@ -231,14 +253,14 @@ export default {
},
booksInSeries() {
// Only added to item object when collapseSeries is enabled
return this.collapsedSeries ? this.collapsedSeries.numBooks : 0
return this.collapsedSeries?.numBooks || 0
},
seriesSequenceList() {
return this.collapsedSeries ? this.collapsedSeries.seriesSequenceList : null
return this.collapsedSeries?.seriesSequenceList || null
},
libraryItemIdsInSeries() {
// Only added to item object when collapseSeries is enabled
return this.collapsedSeries ? this.collapsedSeries.libraryItemIds || [] : []
return this.collapsedSeries?.libraryItemIds || []
},
displayTitle() {
if (this.recentEpisode) return this.recentEpisode.title
@@ -282,34 +304,36 @@ export default {
},
userProgressPercent() {
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() {
return this.userProgress ? !!this.userProgress.isFinished : false
return !!this.userProgress?.isFinished
},
showError() {
return this.numMissingParts || this.isMissing || this.isInvalid
},
playerIsLocal() {
return !!this.$store.state.playerIsLocal
},
localLibraryItemId() {
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() {
if (this.isPodcast) {
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
}
return this.store.getters['getIsMediaStreaming'](this.libraryItemId, this.recentEpisode?.id)
},
streamIsPlaying() {
return this.$store.state.playerIsPlaying && this.isStreaming
return this.store.state.playerIsPlaying && this.isStreaming
},
playerIsStartingPlayback() {
// Play has been pressed and waiting for native play response
return this.store.state.playerIsStartingPlayback
},
playerIsStartingForThisMedia() {
const mediaId = this.store.state.playerStartingPlaybackMediaId
return mediaId === this.recentEpisode?.id
},
isMissing() {
return this._libraryItem.isMissing
@@ -379,13 +403,20 @@ export default {
showHasLocalDownload() {
if (this.localLibraryItem || this.isLocal) {
if (this.recentEpisode && !this.isLocal) {
const localEpisode = this.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId === this.recentEpisode.id)
return !!localEpisode
return !!this.localEpisode
} else {
return true
}
}
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: {
@@ -427,7 +458,10 @@ export default {
// Server books may have a local library item
this.localLibraryItem = localLibraryItem
},
async play() {},
async playEpisode() {
if (this.playerIsStartingPlayback) return
await this.$hapticsImpact()
const eventBus = this.$eventBus || this.$nuxt.$eventBus
if (this.streamIsPlaying) {
@@ -435,18 +469,16 @@ export default {
return
}
if (this.localLibraryItem) {
const localEpisode = this.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId === this.recentEpisode.id)
if (localEpisode) {
// Play episode locally
eventBus.$emit('play-item', {
libraryItemId: this.localLibraryItemId,
episodeId: localEpisode.id,
serverLibraryItemId: this.libraryItemId,
serverEpisodeId: this.recentEpisode.id
})
return
}
this.store.commit('setPlayerIsStartingPlayback', this.recentEpisode.id)
if (this.localEpisode) {
// Play episode locally
eventBus.$emit('play-item', {
libraryItemId: this.localLibraryItemId,
episodeId: this.localEpisode.id,
serverLibraryItemId: this.libraryItemId,
serverEpisodeId: this.recentEpisode.id
})
return
}
eventBus.$emit('play-item', { libraryItemId: this.libraryItemId, episodeId: this.recentEpisode.id })
@@ -468,48 +500,6 @@ export default {
editClick() {
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() {
// More menu func
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'tracks' })
+1 -1
View File
@@ -5,7 +5,7 @@
<covers-collection-cover ref="cover" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-mdtext-center" :style="{ width: Math.min(240, width) + 'px' }">
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md text-center" :style="{ width: Math.min(240, width) + 'px' }">
<div class="w-full h-full flex items-center justify-center rounded-sm border" :class="isAltViewEnabled ? 'altBookshelfLabel' : 'shinyBlack'" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
</div>
+20 -137
View File
@@ -18,10 +18,10 @@
<p class="whitespace-normal" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">
<span v-if="seriesSequence">#{{ seriesSequence }}&nbsp;</span>{{ displayTitle }}
</p>
<p class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">by {{ displayAuthor }}</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="episodes" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ episodes }}</p>
<p class="truncate text-fg-muted" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ displayAuthor }}</p>
<p v-if="displaySortLine" class="truncate text-fg-muted" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
<p v-if="duration" class="truncate text-fg-muted" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ $elapsedPretty(duration) }}</p>
<p v-if="episodes" class="truncate text-fg-muted" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ episodes }}</p>
</div>
<div v-if="localLibraryItem || isLocal" class="absolute top-0 right-0 z-20" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
@@ -62,7 +62,6 @@ export default {
isProcessingReadUpdate: false,
libraryItem: null,
imageReady: false,
rescanning: false,
selected: false,
isSelectionMode: false,
showCoverBg: false,
@@ -82,9 +81,6 @@ export default {
}
},
computed: {
showExperimentalFeatures() {
return this.store.state.showExperimentalFeatures
},
_libraryItem() {
return this.libraryItem || {}
},
@@ -180,7 +176,7 @@ export default {
return this.mediaMetadata.series
},
seriesSequence() {
return this.series ? this.series.sequence : null
return this.series?.sequence || null
},
collapsedSeries() {
// Only added to item object when collapseSeries is enabled
@@ -188,20 +184,21 @@ export default {
},
booksInSeries() {
// Only added to item object when collapseSeries is enabled
return this.collapsedSeries ? this.collapsedSeries.numBooks : 0
return this.collapsedSeries?.numBooks || 0
},
displayTitle() {
if (this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix && this.title.toLowerCase().startsWith('the ')) {
return this.title.substr(4) + ', The'
}
return this.title
const ignorePrefix = this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix
if (this.collapsedSeries) return ignorePrefix ? this.collapsedSeries.nameIgnorePrefix : this.collapsedSeries.name
return ignorePrefix ? this.mediaMetadata.titleIgnorePrefix : this.title
},
displayAuthor() {
if (this.isPodcast) return this.author
if (this.collapsedSeries) return `${this.booksInSeries} books in series`
if (this.orderBy === 'media.metadata.authorNameLF') return this.authorLF
return this.author
},
displaySortLine() {
if (this.collapsedSeries) return null
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 === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt)
@@ -212,25 +209,25 @@ export default {
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
userProgressPercent() {
return this.userProgress ? this.userProgress.progress || 0 : 0
return this.userProgress?.progress || 0
},
itemIsFinished() {
return this.userProgress ? !!this.userProgress.isFinished : false
return !!this.userProgress?.isFinished
},
showError() {
return this.hasMissingParts || this.hasInvalidParts || this.isMissing || this.isInvalid
return this.numMissingParts || this.isMissing || this.isInvalid
},
isStreaming() {
return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId
},
showReadButton() {
return !this.isSelectionMode && this.showExperimentalFeatures && !this.showPlayButton && this.hasEbook
return !this.isSelectionMode && !this.showPlayButton && this.hasEbook
},
showPlayButton() {
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && this.numTracks && !this.isStreaming
},
showSmallEBookIcon() {
return !this.isSelectionMode && this.showExperimentalFeatures && this.hasEbook
return !this.isSelectionMode && this.hasEbook
},
isMissing() {
return this._libraryItem.isMissing
@@ -238,85 +235,13 @@ export default {
isInvalid() {
return this._libraryItem.isInvalid
},
hasMissingParts() {
return this._libraryItem.hasMissingParts
},
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
numMissingParts() {
if (this.isPodcast) return 0
return this.media.numMissingParts
},
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() {
return 80 / this.bookCoverAspectRatio
}
@@ -341,7 +266,7 @@ export default {
} else {
var router = this.$router || this.$nuxt.$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}`)
}
}
@@ -349,48 +274,6 @@ export default {
editClick() {
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() {
// More menu func
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'tracks' })
+1 -1
View File
@@ -4,7 +4,7 @@
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
<covers-playlist-cover ref="cover" :items="items" :width="width" :height="height" />
</div>
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-mdtext-center" :style="{ width: Math.min(160, width) + 'px' }">
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md text-center" :style="{ width: Math.min(160, width) + 'px' }">
<div class="w-full h-full flex items-center justify-center rounded-sm border" :class="isAltViewEnabled ? 'altBookshelfLabel' : 'shinyBlack'" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
</div>
+1 -1
View File
@@ -10,7 +10,7 @@
<div v-if="isAltViewEnabled && isCategorized" class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
</div>
<div v-if="!isCategorized" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-mdtext-center" :style="{ width: Math.min(240, width) + 'px' }">
<div v-if="!isCategorized" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md text-center" :style="{ width: Math.min(240, width) + 'px' }">
<div class="w-full h-full flex items-center justify-center rounded-sm border" :class="isAltViewEnabled ? 'altBookshelfLabel' : 'shinyBlack'" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
</div>
+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-fg">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>
+594 -65
View File
@@ -1,66 +1,68 @@
<template>
<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">
<!-- list of server connection configs -->
<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)">
<span class="material-icons-outlined text-xl text-gray-300">dns</span>
<p class="pl-3 pr-6 text-base text-gray-200">{{ config.name }}</p>
<div v-for="config in serverConnectionConfigs" :key="config.id" class="flex items-center py-4 my-1 border-b border-fg/10 relative" @click="connectToServer(config)">
<span class="material-icons-outlined text-xl text-fg-muted">dns</span>
<p class="pl-3 pr-6 text-base text-fg">{{ config.name }}</p>
<div class="absolute top-0 right-0 h-full px-4 flex items-center" @click.stop="editServerConfig(config)">
<span class="material-icons text-lg text-gray-300">more_vert</span>
<span class="material-icons text-lg text-fg-muted">more_vert</span>
</div>
</div>
<div class="my-1 py-4 w-full">
<ui-btn class="w-full" @click="newServerConfigClick">Add New Server</ui-btn>
<ui-btn class="w-full" @click="newServerConfigClick">{{ $strings.ButtonAddNewServer }}</ui-btn>
</div>
</template>
<!-- form to add a new server connection config -->
<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">
<span class="material-icons text-gray-300">arrow_back</span>
<span class="material-icons text-fg-muted">arrow_back</span>
</div>
<h2 class="text-lg leading-7 mb-2">Server address</h2>
<h2 class="text-lg leading-7 mb-2">{{ $strings.LabelServerAddress }}</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" />
<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 ? $strings.ButtonSubmit : $strings.MessageNoNetworkConnection }}</ui-btn>
</div>
</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">
<span class="material-icons text-gray-300">arrow_back</span>
<span class="material-icons text-fg-muted">arrow_back</span>
</div>
<div class="flex items-center">
<p class="text-gray-300">{{ serverConfig.address }}</p>
<p class="text-fg-muted">{{ serverConfig.address }}</p>
<div class="flex-grow" />
<span v-if="!serverConfig.id" class="material-icons" style="font-size: 1.1rem" @click="editServerAddress">edit</span>
</div>
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
<form @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="password" type="password" :disabled="processing" placeholder="password" class="w-full mb-2 text-lg" />
<div class="w-full h-px bg-fg/10 my-2" />
<form v-if="isLocalAuthEnabled" @submit.prevent="submitAuth" class="pt-3">
<ui-text-input v-model="serverConfig.username" :disabled="processing" :placeholder="$strings.LabelUsername" class="w-full mb-2 text-lg" />
<ui-text-input v-model="password" type="password" :disabled="processing" :placeholder="$strings.LabelPassword" class="w-full mb-2 text-lg" />
<div class="flex items-center pt-2">
<ui-icon-btn v-if="serverConfig.id" small bg-color="error" icon="delete" @click="removeServerConfigClick" />
<div class="flex-grow" />
<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 ? $strings.ButtonSubmit : $strings.MessageNoNetworkConnection }}</ui-btn>
</div>
</form>
<div v-if="isLocalAuthEnabled && isOpenIDAuthEnabled" class="w-full h-px bg-fg/10 my-4" />
<ui-btn v-if="isOpenIDAuthEnabled" :disabled="processing" class="h-10 w-full" @click="clickLoginWithOpenId">{{ oauth.buttonText }}</ui-btn>
</template>
</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">
<span class="material-icons mr-2 text-error" style="font-size: 1.1rem">warning</span>
<p class="text-error">{{ error }}</p>
</div>
</div>
<div :class="processing ? 'opacity-100' : 'opacity-0 pointer-events-none'" class="fixed w-full h-full top-0 left-0 bg-black bg-opacity-75 flex items-center justify-center z-30 transition-opacity duration-500">
<div :class="processing ? 'opacity-100' : 'opacity-0 pointer-events-none'" class="fixed w-full h-full top-0 left-0 bg-black/75 flex items-center justify-center z-30 transition-opacity duration-500">
<div>
<div class="absolute top-0 left-0 w-full p-6 flex items-center flex-col justify-center z-0 short:hidden">
<img src="/Logo.png" class="h-20 w-20 mb-2" />
@@ -71,13 +73,20 @@
</div>
</div>
<p v-if="!serverConnectionConfigs.length" class="mt-2 text-center text-error" v-html="$strings.MessageAudiobookshelfServerRequired" />
<modals-custom-headers-modal v-model="showAddCustomHeaders" :custom-headers.sync="serverConfig.customHeaders" />
</div>
</template>
<script>
import { Browser } from '@capacitor/browser'
import { CapacitorHttp } from '@capacitor/core'
import { Dialog } from '@capacitor/dialog'
// TODO: when backend ready. See validateLoginFormResponse()
//const requiredServerVersion = '2.5.0'
export default {
data() {
return {
@@ -86,38 +95,298 @@ export default {
processing: false,
serverConfig: {
address: null,
version: null,
username: null,
customHeaders: null
},
password: null,
error: null,
showForm: false,
showAddCustomHeaders: false
showAddCustomHeaders: false,
authMethods: [],
oauth: {
state: null,
verifier: null,
challenge: null,
buttonText: 'Login with OpenID',
enforceHTTPs: true // RFC 6749, Section 10.9 requires https
}
}
},
computed: {
deviceData() {
return this.$store.state.deviceData || {}
},
deviceSettings() {
return this.deviceData.deviceSettings || {}
},
networkConnected() {
return this.$store.state.networkConnected
},
serverConnectionConfigs() {
return this.deviceData ? this.deviceData.serverConnectionConfigs || [] : []
return this.deviceData?.serverConnectionConfigs || []
},
lastServerConnectionConfigId() {
return this.deviceData ? this.deviceData.lastServerConnectionConfigId : null
return this.deviceData?.lastServerConnectionConfigId || null
},
lastServerConnectionConfig() {
if (!this.lastServerConnectionConfigId || !this.serverConnectionConfigs.length) return null
return this.serverConnectionConfigs.find((s) => s.id == this.lastServerConnectionConfigId)
},
numCustomHeaders() {
if (!this.serverConfig.customHeaders) return 0
return Object.keys(this.serverConfig.customHeaders).length
isLocalAuthEnabled() {
return this.authMethods.includes('local') || !this.authMethods.length
},
isOpenIDAuthEnabled() {
return this.authMethods.includes('openid')
}
},
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') && this.oauth.enforceHTTPs) {
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')
let redirect_uri_param = redirectUrl.searchParams.get('redirect_uri')
// Backwards compatability with 2.6.0
if (this.serverConfig.version === '2.6.0') {
redirect_uri_param = 'audiobookshelf://oauth'
}
if (!client_id || !scope || !state || !redirect_uri_param) {
console.warn(`[SSO] Invalid OpenID URL - client_id scope state or redirect_uri missing: ${redirectUrl}`)
this.$toast.error(`SSO: Invalid answer`)
return
}
if (redirectUrl.protocol !== 'https:' && this.oauth.enforceHTTPs) {
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 = `${redirectUrl.protocol}//${redirectUrl.host}`
const buildUrl = `${host}${redirectUrl.pathname}?response_type=code` + `&client_id=${encodeURIComponent(client_id)}&scope=${encodeURIComponent(scope)}&state=${encodeURIComponent(state)}` + `&redirect_uri=${encodeURIComponent(redirect_uri_param)}` + `&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
let backendEndpoint = `${url}/auth/openid?code_challenge=${challenge}&code_challenge_method=S256&redirect_uri=${encodeURIComponent('audiobookshelf://oauth')}&client_id=${encodeURIComponent('Audiobookshelf-App')}&response_type=code`
// Backwards compatability with 2.6.0
if (this.serverConfig.version === '2.6.0') {
backendEndpoint += '&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() {
this.showAddCustomHeaders = true
},
@@ -206,10 +475,18 @@ export default {
this.error = null
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 {
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
return address
} catch (error) {
@@ -217,27 +494,94 @@ export default {
return null
}
},
pingServerAddress(address, customHeaders) {
const options = { timeout: 3000 }
if (customHeaders) {
options.headers = customHeaders
/**
* Sends a GET request to the specified URL with the provided headers and timeout.
* If the response is successful (HTTP 200), the response object is returned.
* 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
.$get(`${address}/ping`, options)
.then((data) => data.success)
try {
const response = await CapacitorHttp.get(options)
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) => {
console.error('Server check failed', error)
console.error('Server ping failed', error)
const errorMsg = error.message || error
this.error = 'Failed to ping server'
if (typeof errorMsg === 'string') {
this.error += ` (${errorMsg})`
}
return false
})
},
requestServerLogin() {
const options = {}
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)
return this.postRequest(`${this.serverConfig.address}/login`, { username: this.serverConfig.username, password: this.password || '' }, this.serverConfig.customHeaders, 20000)
.then((data) => {
if (!data.user) {
console.error(data.error)
@@ -248,30 +592,187 @@ export default {
})
.catch((error) => {
console.error('Server auth failed', error)
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
this.error = errorMsg
const errorMsg = error.message || error
this.error = 'Failed to login'
if (typeof errorMsg === 'string') {
this.error += ` (${errorMsg})`
}
return false
})
},
async submit() {
if (!this.networkConnected) 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.error = null
this.authMethods = []
var success = await this.pingServerAddress(this.serverConfig.address, this.serverConfig.customHeaders)
this.processing = false
if (success) this.showAuth = true
try {
// Try the server URL. If it fails and the protocol was not provided, try with http instead of https
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'
this.serverConfig.version = statusData.data.serverVersion
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() {
if (!this.networkConnected) return
@@ -295,12 +796,14 @@ export default {
this.setUserAndConnection(payload)
}
},
async setUserAndConnection({ user, userDefaultLibraryId, serverSettings }) {
async setUserAndConnection({ user, userDefaultLibraryId, serverSettings, ereaderDevices }) {
if (!user) return
console.log('Successfully logged in', JSON.stringify(user))
this.$store.commit('setServerSettings', serverSettings)
this.$store.commit('libraries/setEReaderDevices', ereaderDevices)
this.$setServerLanguageCode(serverSettings.language)
// Set library - Use last library if set and available fallback to default user library
var lastLibraryId = await this.$localStore.getLastLibraryId()
@@ -312,9 +815,24 @@ export default {
this.serverConfig.userId = user.id
this.serverConfig.token = user.token
this.serverConfig.username = user.username
delete this.serverConfig.version
var serverConnectionConfig = await this.$db.setServerConnectionConfig(this.serverConfig)
// Set the device language to match the servers if this is the first server connection
if (!this.serverConnectionConfigs.length && serverSettings.language !== 'en-us') {
const deviceSettings = {
...this.deviceSettings,
languageCode: serverSettings.language
}
const updatedDeviceData = await this.$db.updateDeviceSettings(deviceSettings)
if (updatedDeviceData) {
this.$store.commit('setDeviceData', updatedDeviceData)
this.$setLanguageCode(updatedDeviceData.deviceSettings?.languageCode || 'en-us')
}
}
this.$store.commit('user/setUser', user)
this.$store.commit('user/setServerConnectionConfig', serverConnectionConfig)
@@ -330,16 +848,23 @@ export default {
this.error = null
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)
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
this.error = errorMsg
const authRes = await this.postRequest(`${this.serverConfig.address}/api/authorize`, null, { Authorization: `Bearer ${this.serverConfig.token}` }).catch((error) => {
console.error('[ServerConnectForm] Server auth failed', error)
const errorMsg = error.message || error
this.error = 'Failed to authorize'
if (typeof errorMsg === 'string') {
this.error += ` (${errorMsg})`
}
return false
})
console.log('[ServerConnectForm] authRes=', authRes)
this.processing = false
return authRes
},
async init() {
init() {
if (this.lastServerConnectionConfig) {
this.connectToServer(this.lastServerConnectionConfig)
} else {
@@ -348,7 +873,11 @@ export default {
}
},
mounted() {
this.$eventBus.$on('url-open', this.appUrlOpen)
this.init()
},
beforeDestroy() {
this.$eventBus.$off('url-open', this.appUrlOpen)
}
}
</script>
+1 -1
View File
@@ -70,7 +70,7 @@ export default {
return this.libraryItem.isLocal
},
localCover() {
return this.libraryItem ? this.libraryItem.coverContentUrl : null
return this.libraryItem?.coverContentUrl || null
},
squareAspectRatio() {
return this.bookCoverAspectRatio === 1
+1 -1
View File
@@ -15,7 +15,7 @@
<div v-else class="relative w-full h-full flex items-center justify-center p-2 bg-primary rounded-sm">
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
<p class="text-white text-opacity-60 text-center" :style="{ fontSize: Math.min(1, sizeMultiplier) + 'rem' }">Empty Collection</p>
<p class="text-fg-muted text-center" :style="{ fontSize: Math.min(1, sizeMultiplier) + 'rem' }">Empty Collection</p>
</div>
</div>
</template>
-3
View File
@@ -51,9 +51,6 @@ export default {
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
return this.width / 240
},
showExperimentalFeatures() {
return this.store.state.showExperimentalFeatures
},
store() {
return this.$store || this.$nuxt.$store
},
+13 -13
View File
@@ -2,24 +2,24 @@
<div class="w-full px-2">
<img v-if="podcast.imageUrl" :src="podcast.imageUrl" class="h-36 w-36 object-contain mx-auto mb-2" />
<ui-text-input-with-label v-model="podcast.title" :label="'Title'" class="mb-2 text-sm" @input="titleUpdated" />
<ui-text-input-with-label v-model="podcast.title" :label="$strings.LabelTitle" class="mb-2 text-sm" @input="titleUpdated" />
<ui-text-input-with-label v-model="podcast.author" :label="'Author'" class="mb-2 text-sm" />
<ui-text-input-with-label v-model="podcast.author" :label="$strings.LabelAuthor" class="mb-2 text-sm" />
<ui-text-input-with-label v-model="podcast.feedUrl" :label="'Feed URL'" readonly class="mb-2 text-sm" />
<ui-text-input-with-label v-model="podcast.feedUrl" :label="$strings.LabelFeedURL" readonly class="mb-2 text-sm" />
<ui-multi-select v-model="podcast.genres" :items="podcast.genres" :label="'Genres'" class="mb-2 text-sm" />
<ui-multi-select v-model="podcast.genres" :items="podcast.genres" :label="$strings.LabelGenres" class="mb-2 text-sm" />
<ui-textarea-with-label v-model="podcast.description" :label="'Description'" :rows="3" class="mb-2 text-sm" />
<ui-textarea-with-label v-model="podcast.description" :label="$strings.LabelDescription" :rows="3" class="mb-2 text-sm" />
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="processing" :label="'Folder'" class="mb-2 text-sm" @input="folderUpdated" />
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="processing" :label="$strings.LabelFolder" class="mb-2 text-sm" @input="folderUpdated" />
<ui-text-input-with-label v-model="fullPath" :label="'Podcast Path'" input-class="h-10" readonly class="mb-2 text-sm" />
<ui-text-input-with-label v-model="fullPath" :label="$strings.LabelPath" input-class="h-10" readonly class="mb-2 text-sm" />
<div class="flex items-center py-4 px-2">
<ui-checkbox v-model="podcast.autoDownloadEpisodes" :label="'Auto Download Episodes'" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-sm font-semibold" />
<ui-checkbox v-model="podcast.autoDownloadEpisodes" :label="$strings.LabelAutoDownloadEpisodes" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-sm font-semibold" />
<div class="flex-grow" />
<ui-btn color="success" @click="submit">Submit</ui-btn>
<ui-btn color="success" @click="submit">{{ $strings.ButtonSubmit }}</ui-btn>
</div>
</div>
</template>
@@ -140,15 +140,15 @@ export default {
console.log('Podcast payload', podcastPayload)
this._processing = true
this.$axios
.$post('/api/podcasts', podcastPayload)
this.$nativeHttp
.post('/api/podcasts', podcastPayload)
.then((libraryItem) => {
this._processing = false
this.$toast.success('Podcast added')
this.$toast.success(this.$strings.ToastPodcastCreateSuccess)
this.$router.push(`/item/${libraryItem.id}`)
})
.catch((error) => {
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to add podcast'
var errorMsg = error.response && error.response.data ? error.response.data : this.$strings.ToastPodcastCreateFailed
console.error('Failed to create podcast', error)
this._processing = false
this.$toast.error(errorMsg)
+15 -15
View File
@@ -1,7 +1,7 @@
<template>
<div class="w-full h-9 bg-bg relative">
<div id="bookshelf-navbar" class="absolute z-10 top-0 left-0 w-full h-full flex bg-secondary text-gray-200">
<nuxt-link v-for="item in items" :key="item.to" :to="item.to" class="h-full flex-grow flex items-center justify-center" :class="routeName === item.routeName ? 'bg-primary' : 'text-gray-400'">
<div id="bookshelf-navbar" class="absolute z-10 top-0 left-0 w-full h-full flex bg-secondary">
<nuxt-link v-for="item in items" :key="item.to" :to="item.to" class="h-full flex-grow flex items-center justify-center" :class="routeName === item.routeName ? 'bg-primary' : 'text-fg-muted'">
<p v-if="routeName === item.routeName" class="text-sm font-semibold">{{ item.text }}</p>
<span v-else-if="item.iconPack === 'abs-icons'" class="abs-icons" :class="`icon-${item.icon} ${item.iconClass || ''}`"></span>
<span v-else :class="`${item.iconPack} ${item.iconClass || ''}`">{{ item.icon }}</span>
@@ -20,7 +20,7 @@ export default {
return this.$store.getters['libraries/getCurrentLibrary']
},
currentLibraryIcon() {
return this.currentLibrary ? this.currentLibrary.icon : 'database'
return this.currentLibrary?.icon || 'database'
},
userHasPlaylists() {
return this.$store.state.libraries.numUserPlaylists
@@ -38,7 +38,7 @@ export default {
iconPack: 'abs-icons',
icon: 'home',
iconClass: 'text-xl',
text: 'Home'
text: this.$strings.ButtonHome
},
{
to: '/bookshelf/latest',
@@ -46,7 +46,7 @@ export default {
iconPack: 'abs-icons',
icon: 'list',
iconClass: 'text-xl',
text: 'Latest'
text: this.$strings.ButtonLatest
},
{
to: '/bookshelf/library',
@@ -54,18 +54,18 @@ export default {
iconPack: 'abs-icons',
icon: this.currentLibraryIcon,
iconClass: 'text-lg',
text: 'Library'
text: this.$strings.ButtonLibrary
}
]
if (this.userIsAdminOrUp) {
items.push({
to: '/bookshelf/search',
routeName: 'bookshelf-search',
to: '/bookshelf/add-podcast',
routeName: 'bookshelf-add-podcast',
iconPack: 'material-icons',
icon: 'podcasts',
iconClass: 'text-xl',
text: 'Search'
text: this.$strings.ButtonAdd
})
}
} else {
@@ -76,7 +76,7 @@ export default {
iconPack: 'abs-icons',
icon: 'home',
iconClass: 'text-xl',
text: 'Home'
text: this.$strings.ButtonHome
},
{
to: '/bookshelf/library',
@@ -84,7 +84,7 @@ export default {
iconPack: 'abs-icons',
icon: this.currentLibraryIcon,
iconClass: 'text-lg',
text: 'Library'
text: this.$strings.ButtonLibrary
},
{
to: '/bookshelf/series',
@@ -92,7 +92,7 @@ export default {
iconPack: 'abs-icons',
icon: 'columns',
iconClass: 'text-lg pt-px',
text: 'Series'
text: this.$strings.ButtonSeries
},
{
to: '/bookshelf/collections',
@@ -100,7 +100,7 @@ export default {
iconPack: 'material-icons-outlined',
icon: 'collections_bookmark',
iconClass: 'text-xl',
text: 'Collections'
text: this.$strings.ButtonCollections
},
{
to: '/bookshelf/authors',
@@ -108,7 +108,7 @@ export default {
iconPack: 'abs-icons',
icon: 'authors',
iconClass: 'text-2xl',
text: 'Authors'
text: this.$strings.ButtonAuthors
}
]
}
@@ -119,7 +119,7 @@ export default {
routeName: 'bookshelf-playlists',
iconPack: 'material-icons',
icon: 'queue_music',
text: 'Playlists'
text: this.$strings.ButtonPlaylists
})
}
+8 -8
View File
@@ -64,16 +64,16 @@ export default {
return this.$route.query || {}
},
entityTitle() {
if (this.isPodcast) return 'Podcasts'
if (this.page === 'library') return 'Books'
if (this.isPodcast) return this.$strings.LabelPodcasts
if (this.page === 'library') return this.$strings.LabelBooks
else if (this.page === 'series') {
return 'Series'
return this.$strings.LabelSeries
} else if (this.page === 'collections') {
return 'Collections'
return this.$strings.ButtonCollections
} else if (this.page === 'playlists') {
return 'Playlists'
return this.$strings.ButtonPlaylists
} else if (this.page === 'authors') {
return 'Authors'
return this.$strings.LabelAuthors
}
return ''
},
@@ -92,7 +92,7 @@ export default {
if (this.seriesBookPage) {
return [
{
text: 'Collapse Sub-Series',
text: this.$strings.LabelCollapseSeries,
value: 'collapse_subseries',
icon: this.settings.collapseBookSeries ? 'check_box' : 'check_box_outline_blank'
}
@@ -100,7 +100,7 @@ export default {
} else {
return [
{
text: 'Collapse Series',
text: this.$strings.LabelCollapseSeries,
value: 'collapse_series',
icon: this.settings.collapseSeries ? 'check_box' : 'check_box_outline_blank'
}
@@ -2,7 +2,7 @@
<modals-modal v-model="show" :width="200" height="100%">
<template #outer>
<div class="absolute top-8 left-4 z-40">
<p class="text-white text-2xl truncate">Auto Rewind Time</p>
<p class="text-white text-2xl truncate capitalize">{{ $strings.LabelAutoRewindTime }}</p>
</div>
</template>
@@ -23,7 +23,7 @@
<p class="text-2xl font-mono text-center">{{ manualTimeoutMin }} min</p>
<ui-btn @click="increaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">add</span></ui-btn>
</div>
<ui-btn @click="clickedOption(manualTimeoutMin)" class="w-full">Set Timer</ui-btn>
<ui-btn @click="clickedOption(manualTimeoutMin)" class="w-full">{{ $strings.ButtonSetTimer }}</ui-btn>
</div>
<ul v-else class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="timeout in timeouts">
@@ -35,7 +35,7 @@
</template>
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="manualTimerModal = true">
<div class="flex items-center justify-center">
<span class="font-normal block truncate text-lg text-center">Custom time</span>
<span class="font-normal block truncate text-lg text-center">{{ $strings.LabelCustomTime }}</span>
</div>
</li>
</ul>
+21 -23
View File
@@ -1,12 +1,12 @@
<template>
<modals-modal v-model="show" :width="300" height="100%">
<modals-modal v-model="show" :width="400" height="100%">
<template #outer>
<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">{{ $strings.LabelYourBookmarks }}</p>
</div>
</template>
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
<div ref="container" class="w-full rounded-lg bg-primary border border-white border-opacity-20 overflow-y-auto overflow-x-hidden" style="max-height: 80vh" @click.stop.prevent>
<div ref="container" class="w-full rounded-lg bg-primary border border-border overflow-y-auto overflow-x-hidden" style="max-height: 80vh" @click.stop.prevent>
<div class="w-full h-full p-4" v-show="showBookmarkTitleInput">
<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="showBookmarkTitleInput = false">
@@ -29,11 +29,11 @@
<modals-bookmarks-bookmark-item :key="bookmark.id" :highlight="currentTime === bookmark.time" :bookmark="bookmark" @click="clickBookmark" @edit="editBookmark" @delete="deleteBookmark" />
</template>
<div v-if="!bookmarks.length" class="flex h-32 items-center justify-center">
<p class="text-xl">No Bookmarks</p>
<p class="text-xl">{{ $strings.MessageNoBookmarks }}</p>
</div>
<div v-show="canCreateBookmark" class="flex px-4 py-2 items-center text-center justify-between border-b border-white border-opacity-10 bg-blue-500 bg-opacity-20 cursor-pointer text-white text-opacity-80 hover:bg-opacity-40 hover:text-opacity-100" @click.stop="createBookmark">
<div v-show="canCreateBookmark" class="flex px-4 py-2 items-center text-center justify-between border-b border-fg/10 bg-success cursor-pointer text-white text-opacity-80" @click.stop="createBookmark">
<span class="material-icons">add</span>
<p class="text-base pl-2">Create Bookmark</p>
<p class="text-base pl-2">{{ $strings.ButtonCreateBookmark }}</p>
<p class="text-sm font-mono">
{{ this.$secondsToTimestamp(currentTime) }}
</p>
@@ -98,53 +98,51 @@ export default {
await this.$hapticsImpact()
const { value } = await Dialog.confirm({
title: 'Remove Bookmark',
message: `Are you sure you want to remove bookmark?`
message: this.$strings.MessageConfirmRemoveBookmark
})
if (!value) return
this.$axios
.$delete(`/api/me/item/${this.libraryItemId}/bookmark/${bm.time}`)
this.$nativeHttp
.delete(`/api/me/item/${this.libraryItemId}/bookmark/${bm.time}`)
.then(() => {
this.$toast.success('Bookmark removed')
this.$store.commit('user/deleteBookmark', { libraryItemId: this.libraryItemId, time: bm.time })
})
.catch((error) => {
this.$toast.error(`Failed to remove bookmark`)
this.$toast.error(this.$strings.ToastBookmarkRemoveFailed)
console.error(error)
})
this.show = false
},
async clickBookmark(bm) {
await this.$hapticsImpact()
this.$emit('select', bm)
},
submitUpdateBookmark(updatedBookmark) {
var bookmark = { ...updatedBookmark }
this.$axios
.$patch(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
.then(() => {
this.$toast.success('Bookmark updated')
this.$nativeHttp
.patch(`/api/me/item/${this.libraryItemId}/bookmark`, updatedBookmark)
.then((bookmark) => {
this.$store.commit('user/updateBookmark', bookmark)
this.showBookmarkTitleInput = false
})
.catch((error) => {
this.$toast.error(`Failed to update bookmark`)
this.$toast.error(this.$strings.ToastBookmarkUpdateFailed)
console.error(error)
})
this.show = false
},
submitCreateBookmark() {
if (!this.newBookmarkTitle) {
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
}
var bookmark = {
const bookmark = {
title: this.newBookmarkTitle,
time: Math.floor(this.currentTime)
}
this.$axios
.$post(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
this.$nativeHttp
.post(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
.then(() => {
this.$toast.success('Bookmark added')
})
.catch((error) => {
this.$toast.error(`Failed to create bookmark`)
this.$toast.error(this.$strings.ToastBookmarkCreateFailed)
console.error(error)
})
+23 -16
View File
@@ -1,20 +1,20 @@
<template>
<modals-modal v-model="show" :width="350" height="100%">
<modals-modal v-model="show" :width="400" height="100%">
<template #outer>
<div v-if="currentChapter" class="absolute top-8 left-4 z-40 pt-0.5" style="max-width: 80%">
<p class="text-white text-lg truncate">Current: {{ currentChapterTitle }}</p>
<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">{{ chapters.length }} {{ $strings.LabelChapters }}</p>
</div>
</template>
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
<div ref="container" class="w-full 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-fg/20" style="max-height: 75%" @click.stop>
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<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)">
<template v-for="chapter in chapters">
<li :key="chapter.id" :id="`chapter-row-${chapter.id}`" class="text-fg 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">
<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-fg/80">{{ chapter.title }}</p>
<div class="absolute top-0 right-3 -mt-0.5">
<span class="font-mono text-white text-opacity-90 leading-3 text-sm" style="letter-spacing: -0.5px">{{ $secondsToTimestamp(chapter.start) }}</span>
<span class="font-mono text-fg-muted leading-3 text-sm" style="letter-spacing: -0.5px">{{ $secondsToTimestamp(chapter.start / _playbackRate) }}</span>
</div>
</div>
@@ -38,14 +38,17 @@ export default {
currentChapter: {
type: Object,
default: () => null
}
},
playbackRate: Number
},
data() {
return {}
},
watch: {
value(newVal) {
this.$nextTick(this.scrollToChapter)
if (newVal) {
this.$nextTick(this.scrollToChapter)
}
}
},
computed: {
@@ -57,11 +60,15 @@ export default {
this.$emit('input', val)
}
},
_playbackRate() {
if (!this.playbackRate || isNaN(this.playbackRate)) return 1
return this.playbackRate
},
currentChapterId() {
return this.currentChapter ? this.currentChapter.id : null
return this.currentChapter?.id
},
currentChapterTitle() {
return this.currentChapter ? this.currentChapter.title : null
return this.currentChapter?.title || null
}
},
methods: {
@@ -71,12 +78,12 @@ export default {
scrollToChapter() {
if (!this.currentChapterId) return
var container = this.$refs.container
const container = this.$refs.container
if (container) {
var currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
const currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
if (currChapterEl) {
var offsetTop = currChapterEl.offsetTop
var containerHeight = container.clientHeight
const offsetTop = currChapterEl.offsetTop
const containerHeight = container.clientHeight
container.scrollTo({ top: offsetTop - containerHeight / 2 })
}
}
+45 -8
View File
@@ -1,20 +1,20 @@
<template>
<modals-modal v-model="show" :width="300" height="100%">
<modals-modal v-model="show" :width="width" height="100%">
<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>
</div>
</template>
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
<div ref="container" class="w-full 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-fg/20 p-2" style="max-height: 75%" @click.stop>
<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">
<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-fg 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">
<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>
<span v-if="item.icon" class="material-icons-outlined text-xl mr-2 text-fg text-opacity-80">{{ item.icon }}</span>
<p class="font-normal block truncate text-base text-fg text-opacity-80">{{ item.text }}</p>
</div>
</li>
</slot>
@@ -34,11 +34,27 @@ export default {
type: Array,
default: () => []
},
selected: String // optional
selected: [String, Number], // optional
itemPaddingY: {
type: String,
default: '16px'
},
width: {
type: Number,
default: 300
}
},
data() {
return {}
},
watch: {
show: {
immediate: true,
handler(newVal) {
if (newVal) this.$nextTick(this.init)
}
}
},
computed: {
show: {
get() {
@@ -47,11 +63,32 @@ export default {
set(val) {
this.$emit('input', val)
}
},
itemsToShow() {
return this.items.map((i) => {
if (typeof i === 'string') {
return {
text: i,
value: i
}
}
return i
})
}
},
methods: {
clickedOption(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() {}
+39 -22
View File
@@ -2,14 +2,14 @@
<modals-modal v-model="show" width="90%" height="100%">
<template #outer>
<div v-show="selected !== 'all'" class="absolute top-10 left-4 z-40">
<ui-btn class="text-xl border-yellow-400 border-opacity-40" @click="clearSelected">Clear</ui-btn>
<ui-btn class="text-lg border-yellow-400 border-opacity-40" @click="clearSelected">{{ $strings.ButtonClearFilter }}</ui-btn>
</div>
</template>
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
<div class="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-primary rounded-lg border border-fg/20" style="max-height: 75%" @click.stop>
<ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items">
<li :key="item.value" class="text-gray-50 select-none relative py-4 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-bg bg-opacity-50' : ''" role="option" @click="clickedOption(item)">
<li :key="item.value" class="text-fg select-none relative py-4 pr-9 cursor-pointer" :class="item.value === selected ? 'bg-bg bg-opacity-50' : ''" role="option" @click="clickedOption(item)">
<div class="flex items-center justify-between">
<span class="font-normal ml-3 block truncate text-lg">{{ item.text }}</span>
</div>
@@ -20,12 +20,12 @@
</template>
</ul>
<ul v-show="sublist" class="h-full w-full rounded-lg" role="listbox" aria-labelledby="listbox-label">
<li class="text-gray-50 select-none relative py-3 pl-9 cursor-pointer hover:bg-black-400" role="option" @click="sublist = null">
<li class="text-fg select-none relative py-3 pl-9 cursor-pointer" role="option" @click="sublist = null">
<div class="absolute left-1 top-0 bottom-0 h-full flex items-center">
<span class="material-icons text-2xl">arrow_left</span>
</div>
<div class="flex items-center justify-between">
<span class="font-normal ml-3 block truncate text-lg">Back</span>
<span class="font-normal ml-3 block truncate text-lg">{{ $strings.ButtonBack }}</span>
</div>
</li>
<li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="option">
@@ -34,7 +34,7 @@
</div>
</li>
<template v-for="item in sublistItems">
<li :key="item.value" class="text-gray-50 select-none relative px-4 cursor-pointer hover:bg-black-400" :class="`${sublist}.${item.value}` === selected ? 'bg-bg bg-opacity-50' : ''" role="option" @click="clickedSublistOption(item.value)">
<li :key="item.value" class="text-fg select-none relative px-4 cursor-pointer" :class="`${sublist}.${item.value}` === selected ? 'bg-bg bg-opacity-50' : ''" role="option" @click="clickedSublistOption(item.value)">
<div class="flex items-center">
<span class="font-normal truncate py-3 text-base">{{ item.text }}</span>
</div>
@@ -57,62 +57,67 @@ export default {
sublist: null,
bookItems: [
{
text: 'All',
text: this.$strings.LabelAll,
value: 'all'
},
{
text: 'Genre',
text: this.$strings.LabelGenre,
value: 'genres',
sublist: true
},
{
text: 'Tag',
text: this.$strings.LabelTag,
value: 'tags',
sublist: true
},
{
text: 'Series',
text: this.$strings.LabelSeries,
value: 'series',
sublist: true
},
{
text: 'Authors',
text: this.$strings.LabelAuthor,
value: 'authors',
sublist: true
},
{
text: 'Narrator',
text: this.$strings.LabelNarrator,
value: 'narrators',
sublist: true
},
{
text: 'Language',
text: this.$strings.LabelLanguage,
value: 'languages',
sublist: true
},
{
text: 'Progress',
text: this.$strings.LabelProgress,
value: 'progress',
sublist: true
},
{
text: 'Issues',
text: this.$strings.LabelEbooks,
value: 'ebooks',
sublist: true
},
{
text: this.$strings.ButtonIssues,
value: 'issues',
sublist: false
}
],
podcastItems: [
{
text: 'All',
text: this.$strings.LabelAll,
value: 'all'
},
{
text: 'Genre',
text: this.$strings.LabelGenre,
value: 'genres',
sublist: true
},
{
text: 'Tag',
text: this.$strings.LabelTag,
value: 'tags',
sublist: true
}
@@ -176,19 +181,31 @@ export default {
return [
{
id: 'finished',
name: 'Finished'
name: this.$strings.LabelFinished
},
{
id: 'in-progress',
name: 'In Progress'
name: this.$strings.LabelInProgress
},
{
id: 'not-started',
name: 'Not Started'
name: this.$strings.LabelNotStarted
},
{
id: 'not-finished',
name: 'Not Finished'
name: this.$strings.LabelNotFinished
}
]
},
ebooks() {
return [
{
id: 'ebook',
name: this.$strings.LabelHasEbook
},
{
id: 'supplementary',
name: this.$strings.LabelHasSupplementaryEbook
}
]
},
+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-fg 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>
+4 -4
View File
@@ -2,17 +2,17 @@
<modals-modal v-model="show" :width="400" height="100%">
<template #outer>
<div class="absolute top-8 left-4 z-40">
<p class="text-white text-2xl truncate">Details</p>
<p class="text-white text-2xl truncate">{{ $strings.HeaderDetails }}</p>
</div>
</template>
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20 p-2" style="max-height: 75%" @click.stop>
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-border p-2" style="max-height: 75%" @click.stop>
<p class="mb-2">{{ mediaMetadata.title }}</p>
<div v-if="size" class="text-sm mb-2">Size: {{ $bytesPretty(size) }}</div>
<div v-if="size" class="text-sm mb-2">{{ $strings.LabelSize }}: {{ $bytesPretty(size) }}</div>
<p class="mb-1 text-xs text-gray-200">ID: {{ _libraryItem.id }}</p>
<p class="mb-1 text-xs text-fg">ID: {{ _libraryItem.id }}</p>
</div>
</div>
</modals-modal>
+463
View File
@@ -0,0 +1,463 @@
<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="$strings.LabelSelectADevice" :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: this.$strings.ButtonHistory,
value: 'history',
icon: 'history'
})
}
if (!this.isPodcast || this.episode) {
if (!this.userIsFinished) {
items.push({
text: this.$strings.MessageMarkAsFinished,
value: 'markFinished',
icon: 'beenhere'
})
}
if (this.progressPercent > 0) {
items.push({
text: this.$strings.MessageDiscardProgress,
value: 'discardProgress',
icon: 'backspace'
})
}
}
if ((!this.isPodcast && this.serverLibraryItemId) || (this.episode && this.serverEpisodeId)) {
items.push({
text: this.$strings.LabelAddToPlaylist,
value: 'playlist',
icon: 'playlist_add'
})
if (this.ereaderDeviceItems.length) {
items.push({
text: this.$strings.ButtonSendEbookToDevice,
value: 'sendEbook',
icon: 'send'
})
}
}
if (this.showRSSFeedOption) {
items.push({
text: this.rssFeed ? this.$strings.HeaderRSSFeed : this.$strings.HeaderOpenRSSFeed,
value: 'rssFeed',
icon: 'rss_feed'
})
}
if (this.localLibraryItemId) {
items.push({
text: this.$strings.ButtonManageLocalFiles,
value: 'manageLocal',
icon: 'folder'
})
if (!this.isPodcast) {
items.push({
text: this.$strings.ButtonDeleteLocalItem,
value: 'deleteLocal',
icon: 'delete'
})
} else if (this.localEpisodeId) {
items.push({
text: this.$strings.ButtonDeleteLocalEpisode,
value: 'deleteLocalEpisode',
icon: 'delete'
})
}
}
if (!this.episode) {
items.push({
text: this.$strings.LabelMoreInfo,
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: this.$strings.MessageConfirmMarkAsFinished
})
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(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedFailed : this.$strings.ToastItemMarkedAsNotFinishedFailed)
})
}
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(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedFailed : this.$strings.ToastItemMarkedAsNotFinishedFailed)
})
}
this.$emit('update:processing', false)
},
async clearProgressClick() {
await this.$hapticsImpact()
const { value } = await Dialog.confirm({
title: 'Confirm',
message: this.$strings.MessageConfirmDiscardProgress
})
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
}
const { value } = await Dialog.confirm({
title: 'Confirm',
message: this.$getString('MessageConfirmDeleteLocalEpisode', [localFile.basePath])
})
if (value) {
const res = await AbsFileSystem.deleteTrackFromItem({ id: this.localLibraryItemId, trackLocalFileId: localFile.id, trackContentUrl: localEpisodeAudioTrack.contentUrl })
if (res?.id) {
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()
const { value } = await Dialog.confirm({
title: 'Confirm',
message: this.$strings.MessageConfirmDeleteLocalFiles
})
if (value) {
const res = await AbsFileSystem.deleteItem(this.localLibraryItem)
if (res?.success) {
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>
+3 -3
View File
@@ -2,15 +2,15 @@
<modals-modal v-model="show" :width="300" :processing="processing" height="100%">
<template #outer>
<div class="absolute top-8 left-4 z-40" style="max-width: 80%">
<p class="text-white text-2xl truncate">Libraries</p>
<p class="text-white text-2xl truncate">{{ $strings.HeaderLibraries }}</p>
</div>
</template>
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
<div class="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-border" style="max-height: 75%" @click.stop>
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<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-fg 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 class="flex items-center px-3">
<ui-library-icon :icon="library.icon" />
+2 -2
View File
@@ -2,11 +2,11 @@
<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 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>
</div>
<slot name="outer" />
<div ref="content" style="min-height: 200px" class="relative text-white max-h-screen" :style="{ height: modalHeight, width: modalWidth, maxWidth: maxWidth }" v-click-outside="clickBg">
<div ref="content" style="min-height: 200px" class="relative text-fg max-h-screen" :style="{ height: modalHeight, width: modalWidth, maxWidth: maxWidth }" v-click-outside="clickBg">
<slot />
</div>
</div>
+21 -21
View File
@@ -1,9 +1,9 @@
<template>
<modals-modal v-model="show" width="90%">
<div class="w-full h-full bg-primary rounded-lg border border-white border-opacity-20">
<div class="w-full h-full bg-primary rounded-lg border border-fg/20">
<ul class="w-full rounded-lg text-base" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items">
<li :key="item.value" class="text-gray-50 select-none relative py-4 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-bg bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
<li :key="item.value" class="text-fg select-none relative py-4 pr-9 cursor-pointer" :class="item.value === selected ? 'bg-bg bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
<div class="flex items-center">
<span class="font-normal ml-3 block truncate text-lg">{{ item.text }}</span>
</div>
@@ -29,83 +29,83 @@ export default {
return {
bookItems: [
{
text: 'Title',
text: this.$strings.LabelTitle,
value: 'media.metadata.title'
},
{
text: 'Author (First Last)',
text: this.$strings.LabelAuthorFirstLast,
value: 'media.metadata.authorName'
},
{
text: 'Author (Last, First)',
text: this.$strings.LabelAuthorLastFirst,
value: 'media.metadata.authorNameLF'
},
{
text: 'Published Year',
text: this.$strings.LabelPublishYear,
value: 'media.metadata.publishedYear'
},
{
text: 'Added At',
text: this.$strings.LabelAddedAt,
value: 'addedAt'
},
{
text: 'Size',
text: this.$strings.LabelSize,
value: 'size'
},
{
text: 'Duration',
text: this.$strings.LabelDuration,
value: 'media.duration'
},
{
text: 'File Birthtime',
text: this.$strings.LabelFileBirthtime,
value: 'birthtimeMs'
},
{
text: 'File Modified',
text: this.$strings.LabelFileModified,
value: 'mtimeMs'
}
],
podcastItems: [
{
text: 'Title',
text: this.$strings.LabelTitle,
value: 'media.metadata.title'
},
{
text: 'Author',
text: this.$strings.LabelAuthor,
value: 'media.metadata.author'
},
{
text: 'Added At',
text: this.$strings.LabelAddedAt,
value: 'addedAt'
},
{
text: 'Size',
text: this.$strings.LabelSize,
value: 'size'
},
{
text: 'File Birthtime',
text: this.$strings.LabelFileBirthtime,
value: 'birthtimeMs'
},
{
text: 'File Modified',
text: this.$strings.LabelFileModified,
value: 'mtimeMs'
}
],
episodeItems: [
{
text: 'Pub Date',
text: this.$strings.LabelPubDate,
value: 'publishedAt'
},
{
text: 'Title',
text: this.$strings.LabelTitle,
value: 'title'
},
{
text: 'Season',
text: this.$strings.LabelSeason,
value: 'season'
},
{
text: 'Episode',
text: this.$strings.LabelEpisode,
value: 'episode'
}
]
+6 -6
View File
@@ -2,29 +2,29 @@
<modals-modal v-model="show" @input="modalInput" :width="200" height="100%">
<template #outer>
<div class="absolute top-8 left-4 z-40">
<p class="text-white text-2xl truncate">Playback Speed</p>
<p class="text-white text-2xl truncate">{{ $strings.LabelPlaybackSpeed }}</p>
</div>
</template>
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center">
<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-primary rounded-lg border border-border" style="max-height: 75%" @click.stop>
<ul class="w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="rate in rates">
<li :key="rate" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="rate === selected ? 'bg-bg bg-opacity-80' : ''" role="option" @click="clickedOption(rate)">
<li :key="rate" class="text-fg select-none relative py-4" :class="rate === selected ? 'bg-bg-hover/50' : ''" role="option" @click="clickedOption(rate)">
<div class="flex items-center justify-center">
<span class="font-normal block truncate text-lg">{{ rate }}x</span>
</div>
</li>
</template>
</ul>
<div class="flex items-center justify-center py-3 border-t border-white border-opacity-10">
<button :disabled="!canDecrement" @click="decrement" class="icon-num-btn w-8 h-8 text-white text-opacity-75 rounded border border-white border-opacity-20 flex items-center justify-center">
<div class="flex items-center justify-center py-3 border-t border-fg/10">
<button :disabled="!canDecrement" @click="decrement" class="icon-num-btn w-8 h-8 text-fg-muted rounded border border-border flex items-center justify-center">
<span class="material-icons">remove</span>
</button>
<div class="w-24 text-center">
<p class="text-xl">{{ playbackRate }}<span class="text-lg"></span></p>
</div>
<button :disabled="!canIncrement" @click="increment" class="icon-num-btn w-8 h-8 text-white text-opacity-75 rounded border border-white border-opacity-20 flex items-center justify-center">
<button :disabled="!canIncrement" @click="increment" class="icon-num-btn w-8 h-8 text-fg-muted rounded border border-border flex items-center justify-center">
<span class="material-icons">add</span>
</button>
</div>
@@ -96,8 +96,8 @@ export default {
}
this.processing = true
this.$axios
.$post(`/api/podcasts/${this.libraryItem.id}/download-episodes`, episodesToDownload)
this.$nativeHttp
.post(`/api/podcasts/${this.libraryItem.id}/download-episodes`, episodesToDownload)
.then(() => {
this.processing = false
this.$toast.success('Started downloading episodes on server')
+34 -13
View File
@@ -1,8 +1,8 @@
<template>
<modals-modal v-model="show" :width="300" height="100%">
<template #outer>
<div class="absolute top-8 left-4 z-40" style="max-width: 80%">
<p class="text-white text-lg truncate">Select Local Folder</p>
<div class="absolute top-10 left-4 z-40" style="max-width: 80%">
<p class="text-white text-lg truncate">{{ $strings.HeaderSelectDownloadLocation }}</p>
</div>
</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>
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<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">
<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>
</div>
</li>
@@ -24,36 +25,56 @@
<script>
export default {
props: {
value: Boolean,
mediaType: String
},
data() {
return {
localFolders: []
}
},
watch: {
value(newVal) {
this.$nextTick(this.init)
show(newVal) {
if (newVal) {
this.$nextTick(this.init)
}
}
},
computed: {
show: {
get() {
return this.value
return this.$store.state.globals.showSelectLocalFolderModal
},
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: {
clickedOption(folder) {
this.$emit('select', folder)
this.show = false
if (!this.callback) {
console.error('Callback not set')
return
}
this.callback(folder)
},
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: this.$strings.LabelInternalAppStorage,
mediaType: this.mediaType
})
}
this.localFolders = localFolders.filter((lf) => lf.mediaType == this.mediaType)
}
},
+7 -7
View File
@@ -2,7 +2,7 @@
<modals-modal v-model="show" :width="200" height="100%">
<template #outer>
<div class="absolute top-8 left-4 z-40">
<p class="text-white text-2xl truncate">Sleep Timer</p>
<p class="text-white text-2xl truncate">{{ $strings.HeaderSleepTimer }}</p>
</div>
</template>
@@ -23,24 +23,24 @@
<p class="text-2xl font-mono text-center">{{ manualTimeoutMin }} min</p>
<ui-btn @click="increaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">add</span></ui-btn>
</div>
<ui-btn @click="clickedOption(manualTimeoutMin)" class="w-full">Set Timer</ui-btn>
<ui-btn @click="clickedOption(manualTimeoutMin)" class="w-full">{{ $strings.ButtonSetTimer }}</ui-btn>
</div>
<ul v-else class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="timeout in timeouts">
<li :key="timeout" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption(timeout)">
<li :key="timeout" class="text-fg select-none relative py-4" role="option" @click="clickedOption(timeout)">
<div class="flex items-center justify-center">
<span class="font-normal block truncate text-lg">{{ timeout }} min</span>
</div>
</li>
</template>
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickedChapterOption">
<li class="text-fg select-none relative py-4" role="option" @click="clickedChapterOption">
<div class="flex items-center justify-center">
<span class="font-normal block truncate text-lg text-center">End of Chapter</span>
<span class="font-normal block truncate text-lg text-center">{{ $strings.LabelEndOfChapter }}</span>
</div>
</li>
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="manualTimerModal = true">
<li class="text-fg select-none relative py-4" role="option" @click="manualTimerModal = true">
<div class="flex items-center justify-center">
<span class="font-normal block truncate text-lg text-center">Custom time</span>
<span class="font-normal block truncate text-lg text-center">{{ $strings.LabelCustomTime }}</span>
</div>
</li>
</ul>
+9 -9
View File
@@ -2,7 +2,7 @@
<modals-modal v-model="show" :width="200" height="100%">
<template #outer>
<div class="absolute top-8 left-4 z-40">
<p class="text-white text-2xl truncate">Sleep Timer</p>
<p class="text-white text-2xl truncate">{{ $strings.HeaderSleepTimer }}</p>
</div>
</template>
@@ -13,7 +13,7 @@
manualTimerModal = false
"
>
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-border" style="max-height: 75%" @click.stop>
<div v-if="manualTimerModal" class="p-4">
<div class="flex mb-4" @click="manualTimerModal = false">
<span class="material-icons text-3xl">arrow_back</span>
@@ -23,24 +23,24 @@
<p class="text-2xl font-mono text-center">{{ manualTimeoutMin }} min</p>
<ui-btn @click="increaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">add</span></ui-btn>
</div>
<ui-btn @click="clickedOption(manualTimeoutMin)" class="w-full">Set Timer</ui-btn>
<ui-btn @click="clickedOption(manualTimeoutMin)" class="w-full">{{ $strings.ButtonSetTimer }}</ui-btn>
</div>
<ul v-else-if="!sleepTimerRunning" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="timeout in timeouts">
<li :key="timeout" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption(timeout)">
<li :key="timeout" class="text-fg select-none relative py-4" role="option" @click="clickedOption(timeout)">
<div class="flex items-center justify-center">
<span class="font-normal block truncate text-lg">{{ timeout }} min</span>
</div>
</li>
</template>
<li v-if="currentEndOfChapterTime" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickedChapterOption(timeout)">
<li v-if="currentEndOfChapterTime" class="text-fg select-none relative py-4" role="option" @click="clickedChapterOption(timeout)">
<div class="flex items-center justify-center">
<span class="font-normal block truncate text-lg text-center">End of Chapter</span>
<span class="font-normal block truncate text-lg text-center">{{ $strings.LabelEndOfChapter }}</span>
</div>
</li>
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="manualTimerModal = true">
<li class="text-fg select-none relative py-4" role="option" @click="manualTimerModal = true">
<div class="flex items-center justify-center">
<span class="font-normal block truncate text-lg text-center">Custom time</span>
<span class="font-normal block truncate text-lg text-center">{{ $strings.LabelCustomTime }}</span>
</div>
</li>
</ul>
@@ -51,7 +51,7 @@
<ui-btn @click="increaseSleepTime" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">add</span></ui-btn>
</div>
<ui-btn @click="cancelSleepTimer" class="w-full">{{ isAuto ? 'Disable Auto Timer' : 'Cancel Timer' }}</ui-btn>
<ui-btn @click="cancelSleepTimer" class="w-full">{{ isAuto ? $strings.ButtonDisableAutoTimer : $strings.ButtonCancelTimer }}</ui-btn>
</div>
</div>
</div>
+12 -10
View File
@@ -1,15 +1,17 @@
<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">
<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">
<p class="pl-2 pr-2 truncate text-sm">{{ bookmark.title }}</p>
<div :key="bookmark.id" :id="`bookmark-row-${bookmark.id}`" class="flex items-center px-1 py-4 justify-start relative" :class="highlight ? 'bg-bg bg-opacity-60' : ' bg-opacity-20'" @click="click">
<div class="flex-grow overflow-hidden px-2">
<div class="flex items-center mb-0.5">
<i class="material-icons text-lg pr-1 -mb-1" :class="highlight ? 'text-success' : 'text-fg-muted'">{{ highlight ? 'bookmark' : 'bookmark_border' }}</i>
<p class="truncate text-sm">
{{ bookmark.title }}
</p>
</div>
<p class="text-sm font-mono text-fg-muted flex items-center"><span class="material-icons text-base pl-px pr-1">schedule</span>{{ $secondsToTimestamp(bookmark.time) }}</p>
</div>
<div class="h-full flex items-center w-14 justify-end">
<span class="font-mono text-xs text-gray-300">{{ $secondsToTimestamp(bookmark.time) }}</span>
</div>
<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 class="h-full flex items-center justify-end transform w-16 pr-2" @click.stop>
<span class="material-icons text-2xl mr-2 text-fg hover:text-yellow-400" @click.stop="editClick">edit</span>
<span class="material-icons text-2xl text-fg hover:text-error" @click.stop="deleteClick">delete</span>
</div>
</div>
</template>
+48 -44
View File
@@ -1,41 +1,49 @@
<template>
<modals-modal v-model="show" :width="360" height="100%" :processing="processing">
<template #outer>
<div class="absolute top-8 left-4 z-40">
<p class="text-white text-2xl truncate">Add to Playlist</p>
</div>
</template>
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
<div ref="container" class="w-full rounded-lg bg-primary border border-white border-opacity-20 overflow-y-auto overflow-x-hidden" style="max-height: 80vh" @click.stop.prevent>
<div class="w-full h-full p-4" v-show="showPlaylistNameInput">
<div class="flex mb-4 items-center">
<div class="w-9 h-9 flex items-center justify-center rounded-full hover:bg-white hover:bg-opacity-10 cursor-pointer" @click.stop="showPlaylistNameInput = false">
<span class="material-icons text-3xl">arrow_back</span>
</div>
<p class="text-xl pl-2">New Playlist</p>
<div class="flex-grow" />
</div>
<modals-fullscreen-modal v-model="show" :processing="processing">
<div class="flex items-end justify-between h-24 px-4 pb-2">
<h1 class="text-lg">{{ $strings.LabelAddToPlaylist }}</h1>
<button class="flex" @click="show = false">
<span class="material-icons">close</span>
</button>
</div>
<ui-text-input-with-label v-model="newPlaylistName" label="Name" />
<div class="flex justify-end mt-6">
<ui-btn color="success" :loading="processing" class="w-full" @click.stop="submitCreatePlaylist">Create</ui-btn>
<!-- create new playlist form -->
<div v-if="showPlaylistNameInput" class="w-full h-full max-h-[calc(100vh-176px)] flex items-center">
<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 cursor-pointer" @click.stop="showPlaylistNameInput = false">
<span class="material-icons text-3xl">arrow_back</span>
</div>
<p class="text-xl pl-2 leading-none">{{ $strings.HeaderNewPlaylist }}</p>
<div class="flex-grow" />
</div>
<div class="w-full h-full" v-show="!showPlaylistNameInput">
<template v-for="playlist in sortedPlaylists">
<modals-playlists-playlist-row :key="playlist.id" :in-playlist="playlist.isItemIncluded" :playlist="playlist" @click="clickPlaylist" />
</template>
<div v-if="!playlists.length" class="flex h-32 items-center justify-center">
<p class="text-xl">{{ loading ? 'Loading..' : 'No Playlists' }}</p>
</div>
<ui-btn :loading="processing" color="success" class="w-full flex items-center justify-center" @click.stop="createPlaylist">
<span class="material-icons text-xl">add</span>
<p class="text-base pl-2">New Playlist</p>
</ui-btn>
<ui-text-input-with-label v-model="newPlaylistName" :label="$strings.LabelName" />
<div class="flex justify-end mt-6">
<ui-btn color="success" :loading="processing" class="w-full" @click.stop="submitCreatePlaylist">{{ $strings.ButtonCreate }}</ui-btn>
</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-176px)]">
<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 ? $strings.MessageLoading : $strings.MessageNoUserPlaylists }}</p>
</div>
</div>
</div>
<!-- create playlist btn -->
<div v-if="!showPlaylistNameInput" class="flex items-start justify-between h-20 pt-2 absolute bottom-0 left-0 w-full">
<ui-btn :loading="processing" color="success" class="w-full h-14 flex items-center justify-center" @click.stop="createPlaylist">
<p class="text-base">{{ $strings.ButtonCreateNewPlaylist }}</p>
</ui-btn>
</div>
</modals-fullscreen-modal>
</template>
<script>
@@ -101,8 +109,8 @@ export default {
},
loadPlaylists() {
this.loading = true
this.$axios
.$get(`/api/libraries/${this.currentLibraryId}/playlists`)
this.$nativeHttp
.get(`/api/libraries/${this.currentLibraryId}/playlists`)
.then((data) => {
this.playlists = data.results || []
})
@@ -127,11 +135,10 @@ export default {
this.processing = true
const itemObjects = this.selectedPlaylistItems.map((pi) => ({ libraryItemId: pi.libraryItem.id, episodeId: pi.episode ? pi.episode.id : null }))
this.$axios
.$post(`/api/playlists/${playlist.id}/batch/remove`, { items: itemObjects })
this.$nativeHttp
.post(`/api/playlists/${playlist.id}/batch/remove`, { items: itemObjects })
.then((updatedPlaylist) => {
console.log(`Items removed from playlist`, updatedPlaylist)
this.$toast.success('Playlist item(s) removed')
})
.catch((error) => {
console.error('Failed to remove items from playlist', error)
@@ -146,11 +153,10 @@ export default {
this.processing = true
const itemObjects = this.selectedPlaylistItems.map((pi) => ({ libraryItemId: pi.libraryItem.id, episodeId: pi.episode ? pi.episode.id : null }))
this.$axios
.$post(`/api/playlists/${playlist.id}/batch/add`, { items: itemObjects })
this.$nativeHttp
.post(`/api/playlists/${playlist.id}/batch/add`, { items: itemObjects })
.then((updatedPlaylist) => {
console.log(`Items added to playlist`, updatedPlaylist)
this.$toast.success('Items added to playlist')
})
.catch((error) => {
console.error('Failed to add items to playlist', error)
@@ -178,18 +184,16 @@ export default {
name: this.newPlaylistName
}
this.$axios
.$post('/api/playlists', newPlaylist)
this.$nativeHttp
.post('/api/playlists', newPlaylist)
.then((data) => {
console.log('New playlist created', data)
this.$toast.success(`Playlist "${data.name}" created`)
this.newPlaylistName = ''
this.showPlaylistNameInput = false
})
.catch((error) => {
console.error('Failed to create playlist', error)
var errMsg = error.response ? error.response.data || '' : ''
this.$toast.error(`Failed to create playlist: ${errMsg}`)
this.$toast.error(this.$strings.ToastPlaylistCreateFailed)
})
.finally(() => {
this.processing = false
+10 -7
View File
@@ -1,16 +1,15 @@
<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 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" />
</div>
<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 class="absolute top-0 right-0 h-full flex items-center px-3" @click.stop="click">
<span v-if="inPlaylist" class="material-icons text-error">remove</span>
<span v-else class="material-icons text-success">add</span>
<div class="w-24 min-w-[96px] px-1">
<ui-btn v-if="inPlaylist" small class="w-full" @click.stop="click">{{ $strings.ButtonRemove }}</ui-btn>
<ui-btn v-else small class="w-full" @click.stop="click">{{ $strings.ButtonAdd }}</ui-btn>
</div>
</div>
</template>
@@ -35,6 +34,10 @@ export default {
methods: {
click() {
this.$emit('click', this.playlist)
},
clickCover() {
this.$emit('close')
this.$router.push(`/playlist/${this.playlist.id}`)
}
},
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-fg/10 flex items-center justify-center cursor-pointer" :class="!showAdvancedView ? 'text-fg bg-bg border-b-bg' : 'text-fg-muted 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-fg/10 flex items-center justify-center -ml-px cursor-pointer" :class="showAdvancedView ? 'text-fg bg-bg border-b-bg' : 'text-fg-muted 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-fg/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="border" 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="border" 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>
+187
View File
@@ -0,0 +1,187 @@
<template>
<modals-fullscreen-modal v-model="show" :processing="processing">
<div class="flex items-end justify-end h-24 pr-4 pb-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%-176px)] overflow-y-auto">
<div v-if="currentFeed" class="w-full">
<div class="w-full relative">
<h1 class="text-lg mb-4">{{ $strings.HeaderRSSFeedIsOpen }}</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-fg-muted'" @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-fg-muted uppercase text-sm">{{ $strings.LabelRSSFeedPreventIndexing }}</span>
</div>
<div>{{ currentFeed.meta.preventIndexing ? $strings.ButtonYes : $strings.LabelNo }}</div>
</div>
<div v-if="currentFeed.meta.ownerName" class="flex py-0.5">
<div class="w-48">
<span class="text-fg-muted 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-fg-muted 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-fg-muted 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-start pt-2 h-20">
<ui-btn v-if="currentFeed" color="error" class="w-full h-14" @click="closeFeed">{{ $strings.ButtonCloseFeed }}</ui-btn>
<ui-btn v-else color="success" class="w-full h-14" @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>
<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">
<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)">
<p class="text-sm truncate">{{ file }}</p>
<modals-modal v-model="showInfoMenu" height="90%">
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click.stop="showInfoMenu = false">
<div class="w-full overflow-x-hidden overflow-y-auto bg-bg rounded-lg border border-border text-fg" 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 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>
</modals-modal>
<div class="overflow-hidden m-auto comicwrapper relative">
<div class="h-full flex justify-center">
@@ -33,6 +22,13 @@
<ui-loading-indicator />
</div>
</div>
<div class="fixed left-0 h-8 w-full bg-bg px-4 flex items-center text-fg-muted" :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>
</template>
@@ -47,7 +43,13 @@ Archive.init({
export default {
props: {
url: String
url: String,
libraryItem: {
type: Object,
default: () => {}
},
isLocal: Boolean,
keepProgress: Boolean
},
data() {
return {
@@ -62,11 +64,7 @@ export default {
loadTimeout: null,
loadedFirstPage: false,
comicMetadata: null,
clickOutsideObj: {
handler: this.clickedOutside,
events: ['mousedown'],
isActive: true
}
pageMenuWidth: 256
}
},
watch: {
@@ -78,31 +76,127 @@ export default {
}
},
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() {
return this.comicMetadata ? Object.keys(this.comicMetadata) : []
},
canGoNext() {
return this.page < this.numPages - 1
return this.page < this.numPages
},
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: {
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() {
this.showInfoMenu = !this.showInfoMenu
this.showPageMenu = false
},
clickShowPageMenu() {
if (!this.numPages) return
this.showPageMenu = !this.showPageMenu
this.showInfoMenu = false
},
clickedOutside() {
this.showPageMenu = false
},
clickedOutsideInfoMenu() {
this.showInfoMenu = false
},
next() {
if (!this.canGoNext) return
this.setPage(this.page + 1)
@@ -111,13 +205,15 @@ export default {
if (!this.canGoPrev) return
this.setPage(this.page - 1)
},
setPage(index) {
if (index < 0 || index > this.numPages - 1) {
setPage(page) {
if (page <= 0 || page > this.numPages) {
return
}
this.showPageMenu = false
const filename = this.pages[index]
this.page = index
const filename = this.pages[page - 1]
this.page = page
this.updateProgress()
return this.extractFile(filename)
},
setLoadTimeout() {
@@ -147,11 +243,14 @@ export default {
},
async extract() {
this.loading = true
console.log('Extracting', this.url)
var buff = await this.$axios.$get(this.url, {
responseType: 'blob'
const buff = await this.$axios.$get(this.url, {
responseType: 'blob',
headers: {
Authorization: `Bearer ${this.userToken}`
}
})
const archive = await Archive.open(buff)
const originalFilesObject = await archive.getFilesObject()
// to support images in subfolders we need to flatten the object
@@ -166,10 +265,33 @@ export default {
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) {
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.$emit('loaded', {
hasMetadata: this.comicMetadata
})
} else {
this.$toast.error('Unable to extract pages')
this.loading = false
@@ -196,7 +318,6 @@ export default {
return flattenObject(filesObject)
},
async extractXmlFile(filename) {
console.log('extracting xml filename', filename)
try {
var file = await this.filesObject[filename].extract()
var reader = new FileReader()
@@ -214,8 +335,8 @@ export default {
},
parseImageFilename(filename) {
var basename = Path.basename(filename, Path.extname(filename))
var numbersinpath = basename.match(/\d{1,5}/g)
if (!numbersinpath || !numbersinpath.length) {
var numbersinpath = basename.match(/\d+/g)
if (!numbersinpath?.length) {
return {
index: -1,
filename
@@ -261,9 +382,6 @@ export default {
padding-top: 36px;
}
.pagemenu {
max-height: calc(100% - 80px);
}
.comicimg {
height: 100%;
margin: auto;
+137 -22
View File
@@ -2,11 +2,10 @@
<div id="epub-frame" class="w-full">
<div id="viewer" class="h-full w-full"></div>
<div class="fixed left-0 h-8 w-full bg-bg px-2 flex items-center" :style="{ bottom: playerLibraryItemId ? '120px' : '0px' }">
<p class="text-xs">epub</p>
<div class="fixed left-0 h-8 w-full px-4 flex items-center" :class="isLightTheme ? 'bg-white text-black' : 'bg-[#232323] text-white/80'" :style="{ bottom: isPlayerOpen ? '120px' : '0px' }">
<p v-if="totalLocations" class="text-xs text-slate-600">Location {{ currentLocationNum }} of {{ totalLocations }}</p>
<div class="flex-grow" />
<p class="text-sm">{{ progress }}%</p>
<p class="text-xs">{{ progress }}%</p>
</div>
</div>
</template>
@@ -21,7 +20,8 @@ export default {
type: Object,
default: () => {}
},
isLocal: Boolean
isLocal: Boolean,
keepProgress: Boolean
},
data() {
return {
@@ -29,15 +29,27 @@ export default {
book: null,
/** @type {ePub.Rendition} */
rendition: null,
progress: 0
progress: 0,
totalLocations: 0,
currentLocationNum: 0,
currentLocationCfi: null,
inittingDisplay: true,
ereaderSettings: {
theme: 'dark',
fontScale: 100,
lineSpacing: 115
}
}
},
watch: {
playerLibraryItemId() {
isPlayerOpen() {
this.updateHeight()
}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
/** @returns {string} */
libraryItemId() {
return this.libraryItem?.id
@@ -58,15 +70,15 @@ export default {
}
return null
},
playerLibraryItemId() {
return this.$store.state.playerLibraryItemId
isPlayerOpen() {
return this.$store.getters['getIsPlayerOpen']
},
readerHeightOffset() {
return this.playerLibraryItemId ? 196 : 96
return this.isPlayerOpen ? 204 : 104
},
/** @returns {Array<ePub.NavItem>} */
chapters() {
return this.book ? this.book.navigation.toc : []
return this.book?.navigation?.toc || []
},
userItemProgress() {
if (this.isLocal) return this.localItemProgress
@@ -80,9 +92,49 @@ export default {
},
localStorageLocationsKey() {
return `ebookLocations-${this.libraryItemId}`
},
savedEbookLocation() {
if (!this.keepProgress) return null
if (!this.userItemProgress?.ebookLocation) return null
// Validate ebookLocation is an epubcfi
if (!String(this.userItemProgress.ebookLocation).startsWith('epubcfi')) return null
return this.userItemProgress.ebookLocation
},
isLightTheme() {
return this.ereaderSettings.theme === 'light'
},
themeRules() {
const isDark = this.ereaderSettings.theme === 'dark'
const fontColor = isDark ? '#fff' : '#000'
const backgroundColor = isDark ? 'rgb(35 35 35)' : 'rgb(255, 255, 255)'
return {
'*': {
color: `${fontColor}!important`,
'background-color': `${backgroundColor}!important`,
'line-height': this.ereaderSettings.lineSpacing + '%!important'
},
a: {
color: `${fontColor}!important`
}
}
}
},
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() {
if (this.rendition && this.rendition.resize) {
this.rendition.resize(window.innerWidth, window.innerHeight - this.readerHeightOffset)
@@ -104,6 +156,8 @@ export default {
* @param {string} payload.ebookProgress - eBook Progress Percentage
*/
async updateProgress(payload) {
if (!this.keepProgress) return
// Update local item
if (this.localLibraryItemId) {
const localPayload = {
@@ -118,7 +172,7 @@ export default {
// Update server item
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)
})
}
@@ -210,9 +264,20 @@ export default {
},
/** @param {string} location - CFI of the new 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
}
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) {
this.updateProgress({
@@ -231,11 +296,15 @@ export default {
/** @type {EpubReader} */
const reader = this
console.log('[EpubReader] initEpub', reader.url)
/** @type {ePub.Book} */
reader.book = new ePub(reader.url, {
width: window.innerWidth,
height: window.innerHeight - this.readerHeightOffset
height: window.innerHeight - this.readerHeightOffset,
openAs: 'epub',
requestHeaders: {
Authorization: `Bearer ${this.userToken}`
}
})
/** @type {ePub.Rendition} */
@@ -247,25 +316,71 @@ export default {
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(() => {
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
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
const savedLocations = this.loadLocations()
if (savedLocations) {
reader.book.locations.load(savedLocations)
this.totalLocations = reader.book.locations.length()
} 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())
})
}
// 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>
<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>
</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>
</template>
@@ -17,14 +13,18 @@ import defaultCss from '@/assets/ebooks/basic.js'
export default {
props: {
url: String
url: String,
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {}
},
computed: {
playerLibraryItemId() {
return this.$store.state.playerLibraryItemId
userToken() {
return this.$store.getters['user/getToken']
}
},
methods: {
@@ -85,7 +85,10 @@ export default {
async initMobi() {
// Fetch mobi file as blob
var buff = await this.$axios.$get(this.url, {
responseType: 'blob'
responseType: 'blob',
headers: {
Authorization: `Bearer ${this.userToken}`
}
})
var reader = new FileReader()
reader.onload = async (event) => {
@@ -119,14 +122,14 @@ export default {
<style>
.mobi-ebook-viewer {
height: calc(100% - 52px);
max-height: calc(100% - 52px);
height: calc(100% - 32px);
max-height: calc(100% - 32px);
overflow-x: hidden;
overflow-y: auto;
}
.reader-player-open .mobi-ebook-viewer {
height: calc(100% - 152px);
max-height: calc(100% - 152px);
height: calc(100% - 132px);
max-height: calc(100% - 132px);
overflow-x: hidden;
overflow-y: auto;
}
+139 -16
View File
@@ -1,5 +1,5 @@
<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 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>
@@ -11,69 +11,192 @@
</div>
</div>
<div class="absolute top-0 right-1 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-6 flex items-center text-center">
<p class="font-mono text-xs">{{ page }} / {{ numPages }}</p>
<div class="h-full flex items-center justify-center">
<div :style="{ width: pdfWidth + 'px', height: pdfHeight + 'px' }" class="w-full h-full overflow-auto">
<div v-if="loadedRatio > 0 && loadedRatio < 1" style="background-color: green; color: white; text-align: center" :style="{ width: loadedRatio * 100 + '%' }">{{ Math.floor(loadedRatio * 100) }}%</div>
<pdf ref="pdf" class="m-auto z-10 border border-black border-opacity-20 shadow-md bg-white" :src="pdfDocInitParams" :page="page" :rotate="rotate" @progress="loadedRatio = $event" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event" @loaded="loadedEvt"></pdf>
</div>
</div>
<div :style="{ height: pdfHeight + 'px' }" class="overflow-hidden m-auto">
<div class="flex items-center justify-center">
<div :style="{ width: pdfWidth + 'px', height: pdfHeight + 'px' }" class="w-full h-full overflow-auto">
<div v-if="loadedRatio > 0 && loadedRatio < 1" style="background-color: green; color: white; text-align: center" :style="{ width: loadedRatio * 100 + '%' }">{{ Math.floor(loadedRatio * 100) }}%</div>
<pdf ref="pdf" class="m-auto z-10 border border-black border-opacity-20 shadow-md bg-white" :src="url" :page="page" :rotate="rotate" @progress="loadedRatio = $event" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event"></pdf>
</div>
</div>
<div class="fixed left-0 h-8 w-full bg-bg px-4 flex items-center text-fg-muted" :style="{ bottom: isPlayerOpen ? '120px' : '0px' }">
<div class="flex-grow" />
<p class="text-xs">{{ page }} / {{ numPages }}</p>
</div>
</div>
</template>
<script>
import pdf from 'vue-pdf'
import pdf from '@teckel/vue-pdf'
export default {
components: {
pdf
},
props: {
url: String
url: String,
libraryItem: {
type: Object,
default: () => {}
},
isLocal: Boolean,
keepProgress: Boolean
},
data() {
return {
rotate: 0,
loadedRatio: 0,
page: 1,
numPages: 0
numPages: 0,
windowWidth: 0,
windowHeight: 0
}
},
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() {
return this.pdfHeight * 0.6667
if (this.windowWidth > this.windowHeight) {
// Landscape
return this.windowHeight * 0.6
} else {
// Portrait
return this.windowWidth
}
},
pdfHeight() {
return window.innerHeight - 120
return this.pdfWidth * 1.667
},
canGoNext() {
return this.page < this.numPages
},
canGoPrev() {
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: {
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) {
this.numPages = e
},
prev() {
if (this.page <= 1) return
this.page--
this.updateProgress()
},
next() {
if (this.page >= this.numPages) return
this.page++
this.updateProgress()
},
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>
+235 -41
View File
@@ -1,11 +1,91 @@
<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 class="h-16 pt-8 w-full bg-primary flex items-center px-2 fixed top-0 left-0 z-30 box-shadow-sm">
<p class="w-5/6 truncate">{{ title }}</p>
<div class="flex-grow" />
<span class="material-icons text-xl text-white" @click.stop="show = false">close</span>
<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-[#232323] data-[theme=dark]:text-white data-[theme=light]:bg-white data-[theme=light]:text-black" :class="{ 'reader-player-open': isPlayerOpen }">
<!-- toolbar -->
<div class="h-32 pt-10 w-full px-2 fixed top-0 left-0 z-30 transition-transform bg-bg text-fg" :class="showingToolbar ? 'translate-y-0' : '-translate-y-32'" :style="{ boxShadow: showingToolbar ? '0px 8px 8px #11111155' : '' }" @touchstart.stop @mousedown.stop @touchend.stop @mouseup.stop>
<div class="flex items-center mb-2">
<button type="button" class="inline-flex mx-2" @click.stop="show = false">
<span class="material-icons-outlined text-3xl text-fg">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-fg">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-fg">settings</span>
</button>
<button v-if="comicHasMetadata" type="button" class="inline-flex mx-2" @click.stop="clickMetadataBtn">
<span class="material-icons text-2xl text-fg">more</span>
</button>
</div>
<p class="text-center truncate">{{ title }}</p>
</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">{{ $strings.HeaderTableOfContents }}</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">{{ $strings.MessageNoChapters }}</p>
</div>
</div>
</div>
</modals-fullscreen-modal>
<!-- ereader settings modal -->
<modals-fullscreen-modal v-model="showSettingsModal" :theme="ereaderTheme" half-screen>
<div style="box-shadow: 0px -8px 8px #11111155">
<div class="flex items-end justify-between h-20 px-4 pb-2 mb-8">
<h1 class="text-lg">{{ $strings.HeaderEreaderSettings }}</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">{{ $strings.LabelTheme }}:</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">{{ $strings.LabelFontScale }}:</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">{{ $strings.LabelLineSpacing }}:</p>
</div>
<ui-range-input v-model="ereaderSettings.lineSpacing" :min="100" :max="300" :step="5" input-width="180px" @input="settingsUpdated" />
</div>
</div>
</div>
</div>
</modals-fullscreen-modal>
</div>
</template>
@@ -19,16 +99,32 @@ export default {
touchstartY: 0,
touchendX: 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: {
show: {
handler(newVal) {
if (newVal) {
this.comicHasMetadata = false
this.registerListeners()
this.hideToolbar()
console.log('showReader for ebookFile', JSON.stringify(this.ebookFile))
} else {
this.unregisterListeners()
this.$showHideStatusBar(true)
}
}
}
@@ -49,10 +145,26 @@ export default {
return this.$store.state.selectedLibraryItem
},
media() {
return this.selectedLibraryItem ? this.selectedLibraryItem.media : null
return this.selectedLibraryItem?.media || null
},
mediaMetadata() {
return this.media ? this.media.metadata || {} : {}
return this.media?.metadata || {}
},
ereaderTheme() {
if (this.isEpub) return this.ereaderSettings.theme
return document.documentElement.dataset.theme || 'dark'
},
themeItems() {
return [
{
text: this.$strings.LabelThemeDark,
value: 'dark'
},
{
text: this.$strings.LabelThemeLight,
value: 'light'
}
]
},
readerComponentName() {
if (this.ebookType === 'epub') return 'readers-epub-reader'
@@ -61,17 +173,20 @@ export default {
else if (this.ebookType === 'pdf') return 'readers-pdf-reader'
return null
},
folderId() {
return this.selectedLibraryItem ? this.selectedLibraryItem.folderId : null
},
libraryId() {
return this.selectedLibraryItem ? this.selectedLibraryItem.libraryId : null
},
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() {
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() {
if (this.isMobi) return 'mobi'
@@ -93,7 +208,7 @@ export default {
return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr'
},
isLocal() {
return !!this.ebookFile?.isLocal
return !!this.ebookFile?.isLocal || !!this.ebookFile?.localFileId
},
localContentUrl() {
return this.ebookFile?.contentUrl
@@ -103,25 +218,57 @@ export default {
if (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']
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() {
return this.$store.state.playerLibraryItemId
isPlayerOpen() {
return this.$store.getters['getIsPlayerOpen']
},
keepProgress() {
return this.$store.state.ereaderKeepProgress
},
ebookFileId() {
return this.$store.state.ereaderFileId
}
},
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() {
if (this.$refs.readerComponent && this.$refs.readerComponent.next) {
this.$refs.readerComponent.next()
@@ -133,43 +280,90 @@ export default {
}
},
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
if (touchTimeMs >= 1000) {
console.log('Touch too long', touchTimeMs)
return
}
const touchDistanceX = Math.abs(this.touchendX - this.touchstartX)
const touchDistanceY = Math.abs(this.touchendY - this.touchstartY)
if (touchDistanceX < 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
}
if (this.touchendX < this.touchstartX) {
console.log('swiped left')
this.next()
if (touchDistanceX < 60 || touchDistanceY > touchDistanceX) {
return
}
if (this.touchendX > this.touchstartX) {
console.log('swiped right')
this.prev()
this.hideToolbar()
if (!this.isEpub) {
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) {
// Ignore rapid touch
if (this.touchstartTime && Date.now() - this.touchstartTime < 250) {
return
}
this.touchstartX = e.touches[0].screenX
this.touchstartY = e.touches[0].screenY
this.touchstartTime = Date.now()
this.touchIdentifier = e.touches[0].identifier
},
touchend(e) {
if (this.touchIdentifier !== e.changedTouches[0].identifier) {
return
}
this.touchendX = e.changedTouches[0].screenX
this.touchendY = e.changedTouches[0].screenY
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() {
this.$eventBus.$on('close-ebook', this.closeEvt)
document.body.addEventListener('touchstart', this.touchstart)
document.body.addEventListener('touchend', this.touchend)
},
unregisterListeners() {
this.$eventBus.$on('close-ebook', this.closeEvt)
document.body.removeEventListener('touchstart', this.touchstart)
document.body.removeEventListener('touchend', this.touchend)
}

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