Compare commits

...
Author SHA1 Message Date
advplyr 752e918d26 iOS version bump 0.9.73 & update dependencies 2024-03-03 16:28:55 -06:00
advplyr ad08cc69f4 Version bump 0.9.73-beta 2024-03-03 14:15:32 -06:00
advplyr da3882fd5e Fix:Playlist tab not shown after adding first playlist #1092 2024-03-03 13:29:58 -06:00
advplyr c04c7a55d9 Fix:iOS progress bar overflowing edge of screen 2024-03-03 12:52:17 -06:00
advplyr 3318846b43 Add:Remove from playlist option in more menu for items on playlist page #987 2024-03-02 15:25:18 -06:00
advplyrandGitHub e0860feeb9 Merge pull request #1109 from megamegax/hu
feat(i18n): add Hungarian localization
2024-03-01 15:39:15 -06:00
Hunyady Mihály c0253a69b9 feat(i18n): replace NoItemsFound translation to a more general one 2024-03-01 22:09:33 +01:00
Hunyady Mihály 9242c7e6e0 feat(i18n): small grammar adjustments 2024-03-01 22:04:09 +01:00
Hunyady Mihály 0bfc579ae4 feat(i18n): adjust translation 2024-03-01 21:39:06 +01:00
megamegax 5fad06756b feat(i18n): add Hungarian localization 2024-03-01 20:03:21 +01:00
advplyrandGitHub 5be6afa6c1 Merge pull request #1106 from pmangro/0.9.72.a-PT-BR
[PT-BR] Translated LabelAllowSeekingOnMediaControls
2024-02-27 15:58:36 -06:00
pmangroandGitHub 5ba3b8be7a [PT-BR] Translated LabelAllowSeekingOnMediaControls 2024-02-27 14:39:21 -03:00
advplyr 0eb62f541d Fix:Local media item page long titles overflowing screen pushing play & more menu button offscreen #1103 2024-02-25 15:35:23 -06:00
advplyrandGitHub 6baff881c7 Merge pull request #1094 from fidoriel/disable-widget-ios-scrubing
Disable ios/android scrubbing in MPRemoteCommandCenter/MediaBrowserServiceCompat
2024-02-25 15:29:52 -06:00
fidoriel 8c23c1e795 german translation for LabelAllowSeekingOnMediaControls 2024-02-25 22:25:17 +01:00
advplyr 9706c66a27 iOS Update Realm schema version for new setting, update remote transport controls on device settings updated 2024-02-25 15:04:54 -06:00
advplyr f4f7252537 Update wording for enable seeking on media controls settings, remove restart required help text, map translations 2024-02-25 14:44:40 -06:00
advplyr 430d200151 Update android disable media control seek setting to update in realtime 2024-02-25 14:11:28 -06:00
advplyr 7be82d03f3 Merge branch 'master' into disable-widget-ios-scrubing 2024-02-25 12:51:58 -06:00
advplyr 15aaef742d Fix:Ignore swiping for audio player when modal is open #1099 2024-02-24 15:00:29 -06:00
advplyrandGitHub e443ba09da Merge pull request #1095 from pmangro/0.9.72.a-PT-BR
Added PT-BR Strings
2024-02-24 14:49:35 -06:00
luca 8235d1f1f7 reorder setting and add info label 2024-02-22 20:27:33 +01:00
pmangroandGitHub 9a39dcceee Update i18n.js to add pt-br 2024-02-21 08:55:45 -03:00
pmangroandGitHub a707b7fc86 Typos corrected 2024-02-21 08:53:34 -03:00
fidoriel b6fea85ef4 fix compile android 2024-02-21 10:16:47 +01:00
advplyr 7e3b3f55ae Fix:Android auto not loading server items #1081 2024-02-20 17:05:07 -06:00
fidoriel d2d3bbe8a1 fix default false 2024-02-20 22:08:47 +01:00
fidoriel 41a65c0f9d add setting for widget scrubbing 2024-02-20 21:58:31 +01:00
pmangroandGitHub 165ddd58a6 Update pt-br.json - initial file 2024-02-20 17:29:03 -03:00
pmangroandGitHub 09dc04c0de Created pt-br.json 2024-02-20 15:40:29 -03:00
advplyrandGitHub 57d5dbc0d0 Merge pull request #1082 from arcao/patch-1
Translate cs.json
2024-02-14 18:42:25 -06:00
Martin SloupandGitHub 11af75e127 Translate cs.json
Translate untranslated texts to Czech and fix already translated texts.
2024-02-14 02:10:33 +01:00
advplyr f8d82a3020 iOS version bump 0.9.72 2024-02-11 08:54:54 -06:00
advplyr 81d7b1cfd7 Version bump 0.9.72-beta 2024-02-10 16:35:01 -06:00
advplyr e15c1bcb32 Fix:Continue listening shelves show on initial failed connection #1038 2024-02-09 17:52:24 -06:00
advplyr 826d414d9f Update:iOS do not resume playback after app suspended. Attempted fix for #973 2024-02-07 18:02:25 -06:00
advplyr 0da6fca727 Fix:iOS 0 second listening sessions #970 and include device info with offline sessions 2024-02-05 19:04:11 -06:00
advplyr 2327bc1705 Update playMethod to fallback to 0 2024-02-04 18:14:24 -06:00
advplyr 3bb76cecac Fix:Android respect offline mode on app restart 2024-02-04 17:46:56 -06:00
advplyr e3f82bb237 Fix:Include device info with sync local-all request 2024-02-04 17:27:57 -06:00
advplyr fcd6270f2d Fix:Hide library list button from app bar when no network connection 2024-02-04 15:53:05 -06:00
advplyr d5411703ca Update:Add 10 second timeout when loading home page shelves 2024-02-04 15:48:37 -06:00
advplyr 8fc2058db8 Update:Handle navigating to library item pages when server connection goes down
- Added loading indicator to library item page on initial request to server
- Redirect to downloaded item page if server item request fails
2024-02-04 15:46:26 -06:00
advplyr d03949f5d1 Update:Audio player title scrolls when overflow #740
- increase width of the title on minimized player to scale with screen width
- mini player shows media title and chapter title
2024-02-03 17:31:53 -06:00
advplyr ff01786063 Remove unused fastSort.js 2024-02-03 13:28:41 -06:00
advplyr bb117ab4da Update:Authors page increase size to fill width with 2 per row #1026 2024-02-02 18:33:23 -06:00
advplyr f5ec247b47 Update:Minimized audio player allow swipe up to fullscreen #1046 2024-02-01 18:20:10 -06:00
advplyr 6e6dd00523 Update:Allow swiping down anywhere on fullscreen audio player to minimize #1046 2024-02-01 17:51:10 -06:00
advplyr c9fb98f033 Fix:Android crash on startup due to SimpleStorage #1040
- Update SimpleStorage to 1.5.5-SNAPSHOT
2024-01-29 16:46:39 -06:00
advplyr 75ecff5e6d Remove @nuxtjs/tailwindcss dependency and replace with standalone tailwindcss 2024-01-25 17:39:22 -06:00
advplyrandGitHub 91a9f918fb Merge pull request #1052 from bloodscript/issue-1051-german-localization-improvement
[issue-1051] German Localization
2024-01-24 14:18:17 -06:00
Steffen Meiselbach 03305f90ca corrected ereader based on current Duden entry 2024-01-23 12:38:31 +01:00
Steffen Meiselbach 9428bc07c5 reverted to uniformly uncapitalized pronouns 2024-01-23 12:37:03 +01:00
Steffen Meiselbach f38f9c3502 ebook written as used in Duden and uniformly capitalized version of Du 2024-01-23 12:33:01 +01:00
Steffen Meiselbach 64a796aece standardized addressing (du), missing translations and uniformly starting with capital character 2024-01-23 07:24:40 +01:00
advplyr 63039fe8c9 Fix pl.json formatting 2024-01-22 17:34:59 -06:00
Steffen Meiselbach 3f2b723d0f (sleep) timer is typically used in german parlance 2024-01-22 22:55:28 +01:00
advplyr ffd7fb1cf9 Update workflows to use node 20 2024-01-21 15:56:24 -06:00
advplyrandGitHub b049b206cf Merge pull request #1049 from Bogumio/patch-2
Update pl.json
2024-01-21 15:31:27 -06:00
advplyr 35f56dc8a9 Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2024-01-21 15:32:13 -06:00
advplyr a71cd326cc Update readme node version to 20 2024-01-21 15:32:06 -06:00
BogumioandGitHub cc841d1c7c Update pl.json
The rest of the translations have been completed. Minor corrections and unifications.
2024-01-21 12:04:06 +01:00
advplyrandGitHub 16ecdcf96e Merge pull request #1042 from Bogumio/patch-1
Update pl.json
2024-01-14 11:15:48 -06:00
BogumioandGitHub 96ec56d1ad Update pl.json
Most of the missing phrases have been completed. I will try to translate the rest soon.
2024-01-14 18:07:55 +01:00
advplyr 9b15bafc9f Fix:Settings page button/toggle touch area #1021 2024-01-11 17:27:41 -06:00
advplyr aa02f2e465 Update readme for iOS beta full 2024-01-06 14:15:19 -06:00
advplyrandGitHub 15464617ef Merge pull request #1032 from burghy86/patch-3
Update it.json
2024-01-05 10:21:43 -06:00
burghy86andGitHub 60ecceb59d Update it.json
all string translate
2024-01-05 10:18:48 +01:00
advplyr 3fe943e989 Add:Player setting to scale elapsed time by playback speed #1028
- Moved player settings to new playerSettings key in storage preferences
2024-01-04 15:28:25 -06:00
advplyr 5e7d039454 iOS version bump 0.9.71 2024-01-01 13:55:13 -06:00
advplyr f928df146a Version bump 0.9.71-beta 2024-01-01 12:56:12 -06:00
advplyr 6e57cb49c4 Update:Move modal titles down to not overlap with ios status bar 2024-01-01 12:24:41 -06:00
advplyr 2f8494b013 Add:Tags to item page & allow wrapping to multiple lines #1014 2024-01-01 12:08:15 -06:00
advplyr 6f1b93bd9f Update:Default to alt view so that wooden shelf does not show on initial load 2024-01-01 11:55:57 -06:00
advplyr 19b928d7f6 Fix:Download marker overlapping series sequence on book cover #996 2024-01-01 11:53:28 -06:00
advplyr b849be6f5e Add:Epub reader setting to force single page #1018 2024-01-01 11:31:45 -06:00
advplyrandGitHub 86096df1a4 Merge pull request #1003 from benonymity/playerSwipeBack
Disable iOS swipeBack in fullscreen audio player
2024-01-01 10:52:35 -06:00
advplyrandGitHub 98a465c7b4 Update components/app/AudioPlayer.vue 2024-01-01 10:51:46 -06:00
advplyr 3315f61ae4 Merge branch 'master' into playerSwipeBack 2024-01-01 10:46:01 -06:00
advplyrandGitHub f977445d7a Merge pull request #1004 from benonymity/statusBarTweaks
iOS Status Bar Tweaks
2024-01-01 10:31:58 -06:00
advplyr 095d5e1cec Fix item page gradient for light mode 2024-01-01 10:26:06 -06:00
advplyr 69fbfa4b33 Update nuxtjs/tailwindcss and update tailwind config 2024-01-01 10:19:55 -06:00
advplyr 1c89d33726 Update item page gradient to be absolute and below cover image, add transitions 2024-01-01 09:57:51 -06:00
advplyr acee907932 Merge branch 'master' into statusBarTweaks 2024-01-01 09:00:47 -06:00
advplyrandGitHub 08ad216fe2 Merge pull request #1002 from benonymity/iosChapterTrack
iOS Now Playing Chapter Track (updated #372)
2024-01-01 08:51:59 -06:00
advplyr 3cc7a0937c Fix seeking in NowPlaying to adjust for chapter start time 2024-01-01 08:49:39 -06:00
advplyr 322cad5548 Move AbsAudioPlayer.setChapterTrack to AudioPlayer instead of localStore 2024-01-01 08:22:20 -06:00
advplyr 73d70dd480 Merge branch 'master' into iosChapterTrack 2024-01-01 08:11:59 -06:00
advplyrandGitHub 56bc569d4a Merge pull request #1016 from KaiStarkk/hotfix-pr-1005
[Fix] Prevent crash from force unwrapping nil value in PR-1005
2023-12-31 16:06:50 -06:00
advplyr 981ae58f6a Update:Podfile for capacitor filesharer 2023-12-31 16:02:29 -06:00
Kieran Hannigan b2bfa4c002 [Fix] Prevent crash from force unwrapping nil value in PR #1005 2023-12-30 11:16:10 +08:00
advplyrandGitHub 8b6003c5e3 Merge pull request #1005 from benonymity/playPauseFix
Add togglePlayPauseCommand route to iOS
2023-12-29 15:53:20 -06:00
advplyr 017b6237ca Fix:Filter dialog clear filter button overflow notch on ios #999 2023-12-27 17:23:55 -06:00
advplyr d5496c2e9a Fix:Library list view long titles overflowing, clamped to 2 lines #1013 2023-12-27 17:10:57 -06:00
advplyr 5ef8961c33 Fix:Comma separated links on item page 2023-12-27 17:03:36 -06:00
advplyr e8bea48577 Map shelf translation strings 2023-12-26 16:40:28 -06:00
advplyrandGitHub 6cae78f5c2 Merge pull request #1007 from Jafeyyu/master
Update zh-cn.json
2023-12-26 16:34:01 -06:00
JafeyyuandGitHub d9d31a0620 Update zh-cn.json 2023-12-26 18:09:17 +08:00
advplyr 3682c5fca2 Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2023-12-25 15:35:34 -06:00
advplyr 14b04a14b6 Add:Year in review cards to stats page 2023-12-25 15:35:29 -06:00
advplyrandGitHub f218d90bfb Merge pull request #997 from Jafeyyu/master
Update zh-cn.json
2023-12-24 12:02:17 -06:00
advplyrandGitHub 020f936ad4 Merge pull request #991 from Hallo951/master
Update de.json
2023-12-24 12:01:58 -06:00
benonymity 224e5efe48 add: togglePlayPause route 2023-12-23 01:51:37 -05:00
benonymity 439ab2c8c0 fix: item page background conflict 2023-12-23 01:45:40 -05:00
benonymity 26d0209763 fix: ios status bar mirror theme 2023-12-23 00:00:50 -05:00
benonymity 29086fc783 fix: no swipeback when audio player fullscreen 2023-12-22 22:34:24 -05:00
benonymity bb656a265f fix: broken isIos 2023-12-22 21:36:49 -05:00
benonymity b11ad9dc5b fix: only call on ios and remove...very professional logging 2023-12-22 21:18:55 -05:00
benonymity 2b1667e532 feat: now playing chapter track 2023-12-22 21:01:37 -05:00
JafeyyuandGitHub 228a198598 Update zh-cn.json 2023-12-21 16:09:21 +08:00
Hallo951andGitHub 2843308fb8 Update de.json 2023-12-19 09:03:06 +01:00
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
166 changed files with 10969 additions and 19922 deletions
+2 -2
View File
@@ -16,10 +16,10 @@ jobs:
- name: checkout sources
uses: actions/checkout@v3
- name: use Node.js 16.x
- name: use Node.js 20.x
uses: actions/setup-node@v3
with:
node-version: 16
node-version: 20
- name: Set up Java
uses: actions/setup-java@v2
+2 -2
View File
@@ -15,10 +15,10 @@ jobs:
- name: checkout sources
uses: actions/checkout@v3
- name: use Node.js 16.x
- name: use Node.js 20.x
uses: actions/setup-node@v3
with:
node-version: 16
node-version: 20
- name: Set up Java
uses: actions/setup-java@v2
+5 -3
View File
@@ -33,8 +33,8 @@ android {
applicationId "com.audiobookshelf.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 99
versionName "0.9.68-beta"
versionCode 104
versionName "0.9.73-beta"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
manifestPlaceholders = [
"appAuthRedirectScheme": "com.audiobookshelf.app"
@@ -64,6 +64,8 @@ repositories {
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
mavenCentral()
// TODO: Temporarily using SNAPSHOT version of Simple Storage that resolves https://github.com/anggrayudi/SimpleStorage/issues/133
maven { url "https://oss.sonatype.org/content/repositories/snapshots" }
}
configurations.configureEach {
@@ -118,7 +120,7 @@ dependencies {
implementation 'io.github.pilgr:paperdb:2.7.2'
// Simple Storage
implementation "com.anggrayudi:storage:1.5.4"
implementation "com.anggrayudi:storage:1.5.5-SNAPSHOT"
// OK HTTP
implementation 'com.squareup.okhttp3:okhttp:4.9.2'
+1
View File
@@ -9,6 +9,7 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':byteowls-capacitor-filesharer')
implementation project(':capacitor-app')
implementation project(':capacitor-browser')
implementation project(':capacitor-clipboard')
@@ -1,4 +1,8 @@
[
{
"pkg": "@byteowls/capacitor-filesharer",
"classpath": "com.byteowls.capacitor.filesharer.FileSharerPlugin"
},
{
"pkg": "@capacitor/app",
"classpath": "com.capacitorjs.plugins.app.AppPlugin"
@@ -103,6 +103,9 @@ class MainActivity : BridgeActivity() {
}
}
fun isPlayerNotificationServiceInitialized():Boolean {
return ::foregroundService.isInitialized
}
fun stopMyService() {
if (mBounded) {
@@ -116,6 +119,7 @@ class MainActivity : BridgeActivity() {
override fun onSaveInstanceState(outState: Bundle) {
storageHelper.onSaveInstanceState(outState)
super.onSaveInstanceState(outState)
outState.clear()
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
@@ -107,6 +107,7 @@ data class PlayItemRequestPayload(
data class DeviceSettings(
var disableAutoRewind:Boolean,
var enableAltView:Boolean,
var allowSeekingOnMediaControls:Boolean,
var jumpBackwardsTime:Int,
var jumpForwardTime:Int,
var enableMp3IndexSeeking:Boolean,
@@ -121,7 +122,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
@@ -129,6 +131,7 @@ data class DeviceSettings(
return DeviceSettings(
disableAutoRewind = false,
enableAltView = true,
allowSeekingOnMediaControls = false,
jumpBackwardsTime = 10,
jumpForwardTime = 10,
enableMp3IndexSeeking = false,
@@ -143,7 +146,8 @@ data class DeviceSettings(
autoSleepTimerAutoRewind = false,
autoSleepTimerAutoRewindTime = 300000L, // 5 minutes
disableSleepTimerFadeOut = false,
disableSleepTimerResetFeedback = false
disableSleepTimerResetFeedback = false,
languageCode = "en-us"
)
}
}
@@ -81,7 +81,7 @@ class LocalLibraryItem(
@JsonIgnore
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"
@@ -48,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 {
@@ -247,14 +247,18 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
}
private fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = runBlocking {
Log.d(tag, "checkSetValidServerConnectionConfig | $serverConfigIdUsed")
Log.d(tag, "checkSetValidServerConnectionConfig | serverConfigIdUsed=$serverConfigIdUsed | lastServerConnectionConfigId=${DeviceManager.deviceData.lastServerConnectionConfigId}")
coroutineScope {
if (!DeviceManager.checkConnectivity(ctx)) {
serverUserMediaProgress = mutableListOf()
Log.d(tag, "checkSetValidServerConnectionConfig: No connectivity")
cb(false)
} else if (DeviceManager.deviceData.lastServerConnectionConfigId.isNullOrBlank()) { // If in offline mode last server connection config is unset
serverUserMediaProgress = mutableListOf()
Log.d(tag, "checkSetValidServerConnectionConfig: No last server connection config")
cb(false)
} else {
var hasValidConn = false
var lookupMediaProgress = true
@@ -332,6 +336,7 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
checkSetValidServerConnectionConfig { isConnected ->
if (isConnected) {
serverConfigIdUsed = DeviceManager.serverConnectionConfigId
Log.d(tag, "loadAndroidAutoItems: Connected to server config id=$serverConfigIdUsed")
loadLibraries { libraries ->
if (libraries.isEmpty()) {
@@ -358,6 +363,7 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
}
}
} else { // Not connected to server
Log.d(tag, "loadAndroidAutoItems: Not connected to server")
cb()
}
}
@@ -238,6 +238,8 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
saveLocalProgress(it)
lastSyncTime = System.currentTimeMillis()
Log.d(tag, "Sync local device current serverConnectionConfigId=${DeviceManager.serverConnectionConfig?.id}")
// Local library item is linked to a server library item
// 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) {
@@ -329,15 +329,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
}
}
mediaSessionConnector.setEnabledPlaybackActions(
PlaybackStateCompat.ACTION_PLAY_PAUSE
or PlaybackStateCompat.ACTION_PLAY
or PlaybackStateCompat.ACTION_PAUSE
or PlaybackStateCompat.ACTION_SEEK_TO
or PlaybackStateCompat.ACTION_FAST_FORWARD
or PlaybackStateCompat.ACTION_REWIND
or PlaybackStateCompat.ACTION_STOP
)
setMediaSessionConnectorPlaybackActions()
mediaSessionConnector.setQueueNavigator(queueNavigator)
mediaSessionConnector.setPlaybackPreparer(MediaSessionPlaybackPreparer(this))
@@ -512,6 +504,20 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
mediaSessionConnector.setCustomActionProviders(*customActionProviders.toTypedArray())
}
fun setMediaSessionConnectorPlaybackActions() {
var playbackActions = PlaybackStateCompat.ACTION_PLAY_PAUSE or
PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PAUSE or
PlaybackStateCompat.ACTION_FAST_FORWARD or
PlaybackStateCompat.ACTION_REWIND or
PlaybackStateCompat.ACTION_STOP
if (deviceSettings.allowSeekingOnMediaControls) {
playbackActions = playbackActions or PlaybackStateCompat.ACTION_SEEK_TO
}
mediaSessionConnector.setEnabledPlaybackActions(playbackActions)
}
fun handlePlayerPlaybackError(errorMessage:String) {
// On error and was attempting to direct play - fallback to transcode
currentPlaybackSession?.let { playbackSession ->
@@ -1,5 +1,7 @@
package com.audiobookshelf.app.plugins
import android.os.Handler
import android.os.Looper
import android.util.Log
import com.audiobookshelf.app.MainActivity
import com.audiobookshelf.app.data.*
@@ -493,9 +495,16 @@ class AbsDatabase : Plugin() {
fun updateDeviceSettings(call:PluginCall) { // Returns device data
Log.d(tag, "updateDeviceSettings ${call.data}")
val newDeviceSettings = jacksonMapper.readValue<DeviceSettings>(call.data.toString())
GlobalScope.launch(Dispatchers.IO) {
Handler(Looper.getMainLooper()).post {
DeviceManager.deviceData.deviceSettings = newDeviceSettings
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
// Updates playback actions for media notification (handles media control seek locking setting)
if (mainActivity.isPlayerNotificationServiceInitialized()) {
mainActivity.foregroundService.setMediaSessionConnectorPlaybackActions()
}
call.resolve(JSObject(jacksonMapper.writeValueAsString(DeviceManager.deviceData)))
}
}
@@ -1,6 +1,9 @@
package com.audiobookshelf.app.server
import android.annotation.SuppressLint
import android.content.Context
import android.os.Build
import android.provider.Settings
import android.util.Log
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.device.DeviceManager
@@ -31,7 +34,7 @@ class ApiHandler(var ctx:Context) {
private var pingClient = OkHttpClient.Builder().callTimeout(3, TimeUnit.SECONDS).build()
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
data class LocalSessionsSyncRequestPayload(val sessions:List<PlaybackSession>)
data class LocalSessionsSyncRequestPayload(val sessions:List<PlaybackSession>, val deviceInfo:DeviceInfo)
@JsonIgnoreProperties(ignoreUnknown = true)
data class LocalSessionSyncResult(val id:String, val success:Boolean, val progressSynced:Boolean?, val error:String?)
data class LocalSessionsSyncResponsePayload(val results:List<LocalSessionSyncResult>)
@@ -322,7 +325,11 @@ class ApiHandler(var ctx:Context) {
}
fun sendSyncLocalSessions(playbackSessions:List<PlaybackSession>, cb: (Boolean, String?) -> Unit) {
val payload = JSObject(jacksonMapper.writeValueAsString(LocalSessionsSyncRequestPayload(playbackSessions)))
@SuppressLint("HardwareIds")
val deviceId = Settings.Secure.getString(ctx.contentResolver, Settings.Secure.ANDROID_ID)
val deviceInfo = DeviceInfo(deviceId, Build.MANUFACTURER, Build.MODEL, Build.VERSION.SDK_INT, Build.VERSION.RELEASE)
val payload = JSObject(jacksonMapper.writeValueAsString(LocalSessionsSyncRequestPayload(playbackSessions, deviceInfo)))
Log.d(tag, "Sending ${playbackSessions.size} saved local playback sessions to server")
postRequest("/api/session/local-all", payload, null) {
if (!it.getString("error").isNullOrEmpty()) {
+3
View File
@@ -2,6 +2,9 @@
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
include ':byteowls-capacitor-filesharer'
project(':byteowls-capacitor-filesharer').projectDir = new File('../node_modules/@byteowls/capacitor-filesharer/android')
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
+117
View File
@@ -0,0 +1,117 @@
class WrappingMarquee {
#scrollDelay = 2000
#scrollSpeed = 30
/**
* @param {HTMLElement} el
*/
constructor(el) {
this.el = el
/** @type {HTMLElement} */
this.pEl = el?.firstElementChild
this.innerText = ''
this.isScrolling = false
/** @type {NodeJS.Timeout} */
this.timer = null
/** @type {number} */
this.animationId = null
}
/**
* Transparent gradient mask shown when text is scrolling left and overflowing right
*
* @param {boolean} showLeft
*/
setMask(showLeft) {
if (!this.el) return
this.el.style.maskImage = showLeft ? 'linear-gradient(90deg, transparent 0%, #fff 10%, #000 90%, transparent)' : 'linear-gradient(90deg, #000 90%, transparent)'
}
startScroll() {
if (this.isScrolling) {
console.warn('Already scrolling')
return
}
this.isScrolling = true
this.setMask(true)
let textScrollAmount = this.el.scrollWidth
this.pEl.innerHTML = this.innerText + '&nbsp;'.repeat(15)
let totalScrollAmount = this.el.scrollWidth
let scrollDuration = totalScrollAmount * this.#scrollSpeed
this.pEl.innerHTML = this.pEl.innerHTML + this.innerText
let done = false
let start, previousTimeStamp
const step = (timeStamp) => {
if (start === undefined) {
start = timeStamp
}
const elapsed = timeStamp - start
if (this.isScrolling && previousTimeStamp !== timeStamp) {
const amountToMove = Math.min(elapsed / scrollDuration * totalScrollAmount, totalScrollAmount)
this.pEl.style.transform = `translateX(-${amountToMove}px)`
if (amountToMove === totalScrollAmount) done = true
if (amountToMove > textScrollAmount) this.setMask(false)
}
if (!this.isScrolling || done) { // canceled or done
this.isScrolling = false
this.pEl.style.transform = 'translateX(0px)'
this.pEl.innerText = this.innerText
this.setMask(false)
if (done) {
this.startTimer()
}
} else if (elapsed < scrollDuration) { // step
previousTimeStamp = timeStamp
this.animationId = window.requestAnimationFrame(step)
}
}
this.animationId = window.requestAnimationFrame(step)
}
startTimer() {
clearTimeout(this.timer)
this.timer = setTimeout(() => {
this.startScroll()
}, this.#scrollDelay)
}
reset() {
clearTimeout(this.timer)
this.timer = null
this.isScrolling = false
window.cancelAnimationFrame(this.animationId)
}
/**
* Initialize and start marquee if text overflows container
* resets the marquee if already active
*
* @param {string} innerText
*/
init(innerText) {
if (!this.el || !this.pEl) return
this.reset()
this.innerText = innerText
this.pEl.innerText = innerText
this.pEl.style.transform = 'translateX(0px)'
if (this.el.scrollWidth > this.el.clientWidth) {
this.setMask(false)
this.startTimer()
} else {
this.el.style.maskImage = ''
}
}
}
export default WrappingMarquee
+5 -7
View File
@@ -1,6 +1,8 @@
@import "./tailwind.css";
@import "./fonts.css";
@import './defaultStyles.css';
@import './absicons.css';
@import './transitions.css';
* {
-webkit-touch-callout: none;
@@ -15,10 +17,6 @@ textarea {
user-select: auto;
}
body {
background-color: #262626;
}
.layout-wrapper {
height: calc(100vh - env(safe-area-inset-top));
min-height: calc(100vh - env(safe-area-inset-top));
@@ -148,15 +146,15 @@ Bookshelf Label
.tracksTable {
border-collapse: collapse;
width: 100%;
border: 1px solid #474747;
border: 1px solid rgb(var(--color-secondary));
}
.tracksTable tr:nth-child(even) {
background-color: #2e2e2e;
background-color: rgb(var(--color-secondary));
}
.tracksTable tr {
background-color: #373838;
background-color: rgb(var(--color-bg));
}
.tracksTable td {
-158
View File
@@ -1,158 +0,0 @@
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
typeof define === 'function' && define.amd ? define(['exports'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global['fast-sort'] = {}));
}(this, (function (exports) {
'use strict';
// >>> INTERFACES <<<
// >>> HELPERS <<<
var castComparer = function (comparer) { return function (a, b, order) { return comparer(a, b, order) * order; }; };
var throwInvalidConfigErrorIfTrue = function (condition, context) {
if (condition)
throw Error("Invalid sort config: " + context);
};
var unpackObjectSorter = function (sortByObj) {
var _a = sortByObj || {}, asc = _a.asc, desc = _a.desc;
var order = asc ? 1 : -1;
var sortBy = (asc || desc);
// Validate object config
throwInvalidConfigErrorIfTrue(!sortBy, 'Expected `asc` or `desc` property');
throwInvalidConfigErrorIfTrue(asc && desc, 'Ambiguous object with `asc` and `desc` config properties');
var comparer = sortByObj.comparer && castComparer(sortByObj.comparer);
return { order: order, sortBy: sortBy, comparer: comparer };
};
// >>> SORTERS <<<
var multiPropertySorterProvider = function (defaultComparer) {
return function multiPropertySorter(sortBy, sortByArr, depth, order, comparer, a, b) {
var valA;
var valB;
if (typeof sortBy === 'string') {
valA = a[sortBy];
valB = b[sortBy];
}
else if (typeof sortBy === 'function') {
valA = sortBy(a);
valB = sortBy(b);
}
else {
var objectSorterConfig = unpackObjectSorter(sortBy);
return multiPropertySorter(objectSorterConfig.sortBy, sortByArr, depth, objectSorterConfig.order, objectSorterConfig.comparer || defaultComparer, a, b);
}
var equality = comparer(valA, valB, order);
if ((equality === 0 || (valA == null && valB == null)) &&
sortByArr.length > depth) {
return multiPropertySorter(sortByArr[depth], sortByArr, depth + 1, order, comparer, a, b);
}
return equality;
};
};
function getSortStrategy(sortBy, comparer, order) {
// Flat array sorter
if (sortBy === undefined || sortBy === true) {
return function (a, b) { return comparer(a, b, order); };
}
// Sort list of objects by single object key
if (typeof sortBy === 'string') {
throwInvalidConfigErrorIfTrue(sortBy.includes('.'), 'String syntax not allowed for nested properties.');
return function (a, b) { return comparer(a[sortBy], b[sortBy], order); };
}
// Sort list of objects by single function sorter
if (typeof sortBy === 'function') {
return function (a, b) { return comparer(sortBy(a), sortBy(b), order); };
}
// Sort by multiple properties
if (Array.isArray(sortBy)) {
var multiPropSorter_1 = multiPropertySorterProvider(comparer);
return function (a, b) { return multiPropSorter_1(sortBy[0], sortBy, 1, order, comparer, a, b); };
}
// Unpack object config to get actual sorter strategy
var objectSorterConfig = unpackObjectSorter(sortBy);
return getSortStrategy(objectSorterConfig.sortBy, objectSorterConfig.comparer || comparer, objectSorterConfig.order);
}
var sortArray = function (order, ctx, sortBy, comparer) {
var _a;
if (!Array.isArray(ctx)) {
return ctx;
}
// Unwrap sortBy if array with only 1 value to get faster sort strategy
if (Array.isArray(sortBy) && sortBy.length < 2) {
_a = sortBy, sortBy = _a[0];
}
return ctx.sort(getSortStrategy(sortBy, comparer, order));
};
// >>> Public <<<
var createNewSortInstance = function (opts) {
var comparer = castComparer(opts.comparer);
return function (_ctx) {
var ctx = Array.isArray(_ctx) && !opts.inPlaceSorting
? _ctx.slice()
: _ctx;
return {
/**
* Sort array in ascending order.
* @example
* sort([3, 1, 4]).asc();
* sort(users).asc(u => u.firstName);
* sort(users).asc([
* U => u.firstName
* u => u.lastName,
* ]);
*/
asc: function (sortBy) {
return sortArray(1, ctx, sortBy, comparer);
},
/**
* Sort array in descending order.
* @example
* sort([3, 1, 4]).desc();
* sort(users).desc(u => u.firstName);
* sort(users).desc([
* U => u.firstName
* u => u.lastName,
* ]);
*/
desc: function (sortBy) {
return sortArray(-1, ctx, sortBy, comparer);
},
/**
* Sort array in ascending or descending order. It allows sorting on multiple props
* in different order for each of them.
* @example
* sort(users).by([
* { asc: u => u.score }
* { desc: u => u.age }
* ]);
*/
by: function (sortBy) {
return sortArray(1, ctx, sortBy, comparer);
},
};
};
};
var defaultComparer = function (a, b, order) {
if (a == null)
return order;
if (b == null)
return -order;
if (a < b)
return -1;
if (a === b)
return 0;
return 1;
};
var sort = createNewSortInstance({
comparer: defaultComparer,
});
var inPlaceSort = createNewSortInstance({
comparer: defaultComparer,
inPlaceSorting: true,
});
exports.createNewSortInstance = createNewSortInstance;
exports.inPlaceSort = inPlaceSort;
exports.sort = sort;
Object.defineProperty(exports, '__esModule', { value: true });
})));
+47
View File
@@ -0,0 +1,47 @@
@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(180deg, 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(180deg, rgba(255, 255, 255, 0) 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%);
}
body {
@apply bg-primary
}
}
+51
View File
@@ -0,0 +1,51 @@
.slide-enter-active {
-moz-transition-duration: 0.1s;
-webkit-transition-duration: 0.1s;
-o-transition-duration: 0.1s;
transition-duration: 0.1s;
-moz-transition-timing-function: ease-in;
-webkit-transition-timing-function: ease-in;
-o-transition-timing-function: ease-in;
transition-timing-function: ease-in;
}
.slide-leave-active {
-moz-transition-duration: 0.2s;
-webkit-transition-duration: 0.2s;
-o-transition-duration: 0.2s;
transition-duration: 0.2s;
-moz-transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
-webkit-transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
-o-transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
}
.slide-enter-to,
.slide-leave {
max-height: 600px;
overflow: hidden;
}
.slide-enter,
.slide-leave-to {
overflow: hidden;
max-height: 0;
}
.menu-enter,
.menu-leave-active {
transform: translateY(-15px);
}
.menu-enter-active {
transition: all 0.2s;
}
.menu-leave-active {
transition: all 0.1s;
}
.menu-enter,
.menu-leave-active {
opacity: 0;
}
+10 -7
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 v-if="user && currentLibrary && networkConnected">
<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>
@@ -54,14 +54,17 @@ export default {
this.$store.commit('setCastAvailable', val)
}
},
networkConnected() {
return this.$store.state.networkConnected
},
currentLibrary() {
return this.$store.getters['libraries/getCurrentLibrary']
},
currentLibraryName() {
return this.currentLibrary ? this.currentLibrary.name : ''
return this.currentLibrary?.name || ''
},
currentLibraryIcon() {
return this.currentLibrary ? this.currentLibrary.icon : 'database'
return this.currentLibrary?.icon || 'database'
},
showBack() {
if (!this.$route.name) return true
@@ -71,7 +74,7 @@ export default {
return this.$store.state.user.user
},
username() {
return this.user ? this.user.username : 'err'
return this.user?.username || 'err'
},
isCasting() {
return this.$store.state.isCasting
+207 -106
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 v-if="playerSettings.useChapterTrack && playerSettings.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>
@@ -41,64 +41,66 @@
</div>
<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>
<div ref="titlewrapper" class="overflow-hidden relative">
<p class="title-text whitespace-nowrap"></p>
</div>
<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 && serverLibraryItemId && 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>
<div class="flex items-center max-w-full" :class="playerSettings.lockUi ? 'justify-center' : 'justify-between'">
<span v-show="showFullscreen && !playerSettings.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="!playerSettings.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="!playerSettings.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 && !playerSettings.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-7 w-7 rounded-full absolute pointer-events-auto flex items-center justify-center" :style="{ top: '-11px' }" :class="{ 'opacity-0': lockUi || !showFullscreen }" @touchstart="touchstartCursor">
<div class="bg-gray-200 rounded-full w-3.5 h-3.5 pointer-events-none" />
<div 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': playerSettings.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" :playback-rate="currentPlaybackRate" @select="selectChapter" />
<modals-dialog v-model="showMoreMenuDialog" :items="menuItems" @action="clickMenuAction" />
<modals-dialog v-model="showMoreMenuDialog" :items="menuItems" width="80vw" @action="clickMenuAction" />
</div>
</template>
@@ -106,6 +108,7 @@
import { Capacitor } from '@capacitor/core'
import { AbsAudioPlayer } from '@/plugins/capacitor'
import { FastAverageColor } from 'fast-average-color'
import WrappingMarquee from '@/assets/WrappingMarquee.js'
export default {
props: {
@@ -145,10 +148,12 @@ export default {
onPlaybackSpeedChangedListener: null,
touchStartY: 0,
touchStartTime: 0,
touchEndY: 0,
useChapterTrack: false,
useTotalTrack: true,
lockUi: false,
playerSettings: {
useChapterTrack: false,
useTotalTrack: true,
scaleElapsedTimeBySpeed: true,
lockUi: false
},
isLoading: false,
isDraggingCursor: false,
draggingTouchStartX: 0,
@@ -157,16 +162,22 @@ export default {
syncStatus: 0,
showMoreMenuDialog: false,
coverRgb: 'rgb(55, 56, 56)',
coverBgIsLight: false
coverBgIsLight: false,
titleMarquee: null,
isRefreshingUI: false
}
},
watch: {
showFullscreen(val) {
this.updateScreenSize()
this.$store.commit('setPlayerFullscreen', !!val)
document.querySelector('body').style.backgroundColor = this.showFullscreen ? this.coverRgb : ''
},
bookCoverAspectRatio() {
this.updateScreenSize()
},
title(val) {
if (this.titleMarquee) this.titleMarquee.init(val)
}
},
computed: {
@@ -175,7 +186,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'
})
@@ -184,22 +195,27 @@ export default {
items.push(
...[
{
text: 'Total Track',
text: this.$strings.LabelTotalTrack,
value: 'total_track',
icon: this.useTotalTrack ? 'check_box' : 'check_box_outline_blank'
icon: this.playerSettings.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'
icon: this.playerSettings.useChapterTrack ? 'check_box' : 'check_box_outline_blank'
},
{
text: this.lockUi ? 'Unlock Player' : 'Lock Player',
text: this.$strings.LabelScaleElapsedTimeBySpeed,
value: 'scale_elapsed_time',
icon: this.playerSettings.scaleElapsedTimeBySpeed ? 'check_box' : 'check_box_outline_blank'
},
{
text: this.playerSettings.lockUi ? this.$strings.LabelUnlockPlayer : this.$strings.LabelLockPlayer,
value: 'lock',
icon: this.lockUi ? 'lock' : 'lock_open'
icon: this.playerSettings.lockUi ? 'lock' : 'lock_open'
},
{
text: 'Close Player',
text: this.$strings.LabelClosePlayer,
value: 'close',
icon: 'close'
}
@@ -256,22 +272,22 @@ export default {
return this.mediaPlayer === 'cast-player'
},
mediaPlayer() {
return this.playbackSession ? this.playbackSession.mediaPlayer : null
return this.playbackSession?.mediaPlayer || null
},
mediaType() {
return this.playbackSession ? this.playbackSession.mediaType : null
return this.playbackSession?.mediaType || null
},
isPodcast() {
return this.mediaType === 'podcast'
},
mediaMetadata() {
return this.playbackSession ? this.playbackSession.mediaMetadata : null
return this.playbackSession?.mediaMetadata || null
},
libraryItem() {
return this.playbackSession ? this.playbackSession.libraryItem || null : null
return this.playbackSession?.libraryItem || null
},
localLibraryItem() {
return this.playbackSession ? this.playbackSession.localLibraryItem || null : null
return this.playbackSession?.localLibraryItem || null
},
localLibraryItemCoverSrc() {
var localItemCover = this.localLibraryItem?.coverContentUrl || null
@@ -279,7 +295,7 @@ export default {
return null
},
playMethod() {
return this.playbackSession ? this.playbackSession.playMethod : null
return this.playbackSession?.playMethod || 0
},
isLocalPlayMethod() {
return this.playMethod == this.$constants.PlayMethod.LOCAL
@@ -288,19 +304,19 @@ export default {
return this.playMethod == this.$constants.PlayMethod.DIRECTPLAY
},
title() {
if (this.currentChapterTitle && this.showFullscreen) return this.currentChapterTitle
if (this.playbackSession) return this.playbackSession.displayTitle
return this.mediaMetadata ? this.mediaMetadata.title : 'Title'
const mediaItemTitle = this.playbackSession?.displayTitle || this.mediaMetadata?.title || 'Title'
if (this.currentChapterTitle) {
if (this.showFullscreen) return this.currentChapterTitle
return `${mediaItemTitle} | ${this.currentChapterTitle}`
}
return mediaItemTitle
},
authorName() {
if (this.playbackSession) return this.playbackSession.displayAuthor
return this.mediaMetadata ? this.mediaMetadata.authorName : 'Author'
return this.mediaMetadata?.authorName || 'Author'
},
chapters() {
if (this.playbackSession && this.playbackSession.chapters) {
return this.playbackSession.chapters
}
return []
return this.playbackSession?.chapters || []
},
currentChapter() {
if (!this.chapters.length) return null
@@ -311,7 +327,7 @@ export default {
return this.chapters.find((c) => Number(Number(c.start).toFixed(2)) > this.currentTime)
},
currentChapterTitle() {
return this.currentChapter ? this.currentChapter.title : ''
return this.currentChapter?.title || ''
},
currentChapterDuration() {
return this.currentChapter ? this.currentChapter.end - this.currentChapter.start : this.totalDuration
@@ -321,11 +337,14 @@ export default {
},
currentTimePretty() {
let currentTimeToUse = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
return this.$secondsToTimestamp(currentTimeToUse / this.currentPlaybackRate)
if (this.playerSettings.scaleElapsedTimeBySpeed) {
currentTimeToUse = currentTimeToUse / this.currentPlaybackRate
}
return this.$secondsToTimestamp(currentTimeToUse)
},
timeRemaining() {
let currentTimeToUse = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
if (this.useChapterTrack && this.currentChapter) {
if (this.playerSettings.useChapterTrack && this.currentChapter) {
var currChapTime = currentTimeToUse - this.currentChapter.start
return (this.currentChapterDuration - currChapTime) / this.currentPlaybackRate
}
@@ -411,7 +430,11 @@ export default {
AbsAudioPlayer.requestSession()
},
clickContainer() {
this.expandToFullscreen()
},
expandToFullscreen() {
this.showFullscreen = true
if (this.titleMarquee) this.titleMarquee.reset()
// Update track for total time bar if useChapterTrack is set
this.$nextTick(() => {
@@ -420,6 +443,8 @@ export default {
},
collapseFullscreen() {
this.showFullscreen = false
if (this.titleMarquee) this.titleMarquee.reset()
this.forceCloseDropdownMenu()
},
async jumpNextChapter() {
@@ -493,7 +518,7 @@ export default {
this.updateReadyTrack()
},
updateReadyTrack() {
if (this.useChapterTrack) {
if (this.playerSettings.useChapterTrack) {
if (this.$refs.totalReadyTrack) {
this.$refs.totalReadyTrack.style.width = this.readyTrackWidth + 'px'
}
@@ -510,11 +535,14 @@ export default {
}
let currentTime = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
if (this.useChapterTrack && this.currentChapter) {
if (this.playerSettings.useChapterTrack && this.currentChapter) {
currentTime = Math.max(0, currentTime - this.currentChapter.start)
}
if (this.playerSettings.scaleElapsedTimeBySpeed) {
currentTime = currentTime / this.currentPlaybackRate
}
ts.innerText = this.$secondsToTimestamp(currentTime / this.currentPlaybackRate)
ts.innerText = this.$secondsToTimestamp(currentTime)
},
timeupdate() {
if (!this.$refs.playedTrack) {
@@ -542,7 +570,7 @@ export default {
let bufferedPercent = this.bufferedTime / this.totalDuration
const totalBufferedPercent = bufferedPercent
if (this.useChapterTrack && this.currentChapter) {
if (this.playerSettings.useChapterTrack && this.currentChapter) {
const currChapTime = currentTimeToUse - this.currentChapter.start
percentDone = currChapTime / this.currentChapterDuration
bufferedPercent = Math.max(0, Math.min(1, (this.bufferedTime - this.currentChapter.start) / this.currentChapterDuration))
@@ -556,7 +584,7 @@ export default {
this.$refs.trackCursor.style.left = ptWidth - 14 + 'px'
}
if (this.useChapterTrack) {
if (this.playerSettings.useChapterTrack) {
if (this.$refs.totalPlayedTrack) this.$refs.totalPlayedTrack.style.width = Math.round(totalPercentDone * this.trackWidth) + 'px'
if (this.$refs.totalBufferedTrack) this.$refs.totalBufferedTrack.style.width = Math.round(totalBufferedPercent * this.trackWidth) + 'px'
}
@@ -583,7 +611,7 @@ export default {
}
},
async touchstartCursor(e) {
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen || this.lockUi) return
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen || this.playerSettings.lockUi) return
await this.$hapticsImpact()
this.isDraggingCursor = true
@@ -624,38 +652,49 @@ export default {
resetStream(startTime) {
this.closePlayback()
},
handleGesture() {
var touchDistance = this.touchEndY - this.touchStartY
if (touchDistance > 100) {
this.collapseFullscreen()
}
},
touchstart(e) {
if (!this.showFullscreen || !e.changedTouches) return
if (!e.changedTouches || this.$store.state.globals.isModalOpen) return
const touchPosY = e.changedTouches[0].pageY
// when minimized only listen to touchstart on the player
if (!this.showFullscreen && touchPosY < window.innerHeight - 120) return
this.touchStartY = e.changedTouches[0].screenY
if (this.touchStartY > window.innerHeight / 3) {
// console.log('touch too low')
return
// for ios
if (!this.showFullscreen && e.pageX < 20) {
e.preventDefault()
e.stopImmediatePropagation()
}
this.touchStartY = touchPosY
this.touchStartTime = Date.now()
},
touchend(e) {
if (!e.changedTouches) return
const touchDuration = Date.now() - this.touchStartTime
const touchEndY = e.changedTouches[0].pageY
const touchDistanceY = touchEndY - this.touchStartY
// reset touch start data
this.touchStartTime = 0
this.touchStartY = 0
if (this.isDraggingCursor) {
if (this.draggingCurrentTime !== this.currentTime) {
this.seek(this.draggingCurrentTime)
}
this.isDraggingCursor = false
} else if (this.showFullscreen) {
this.touchEndY = e.changedTouches[0].screenY
var touchDuration = Date.now() - this.touchStartTime
} else {
if (touchDuration > 1200) {
// console.log('touch too long', touchDuration)
return
}
this.handleGesture()
if (this.showFullscreen) {
// Touch start higher than touchend
if (touchDistanceY > 100) {
this.collapseFullscreen()
}
} else if (touchDistanceY < -100) {
this.expandToFullscreen()
}
}
},
touchmove(e) {
@@ -665,7 +704,7 @@ export default {
let duration = this.totalDuration
let minTime = 0
let maxTime = duration
if (this.useChapterTrack && this.currentChapter) {
if (this.playerSettings.useChapterTrack && this.currentChapter) {
duration = this.currentChapterDuration
minTime = this.currentChapter.start
maxTime = minTime + duration
@@ -685,32 +724,42 @@ export default {
if (action === 'history') {
this.$router.push(`/media/${this.mediaId}/history?title=${this.title}`)
this.showFullscreen = false
} else if (action === 'scale_elapsed_time') {
this.playerSettings.scaleElapsedTimeBySpeed = !this.playerSettings.scaleElapsedTimeBySpeed
this.updateTimestamp()
this.savePlayerSettings()
} else if (action === 'lock') {
this.lockUi = !this.lockUi
this.$localStore.setPlayerLock(this.lockUi)
this.playerSettings.lockUi = !this.playerSettings.lockUi
this.savePlayerSettings()
} else if (action === 'chapter_track') {
this.useChapterTrack = !this.useChapterTrack
this.useTotalTrack = !this.useChapterTrack || this.useTotalTrack
this.playerSettings.useChapterTrack = !this.playerSettings.useChapterTrack
this.playerSettings.useTotalTrack = !this.playerSettings.useChapterTrack || this.playerSettings.useTotalTrack
this.updateTimestamp()
this.updateTrack()
this.updateReadyTrack()
this.$localStore.setUseTotalTrack(this.useTotalTrack)
this.$localStore.setUseChapterTrack(this.useChapterTrack)
this.updateUseChapterTrack()
this.savePlayerSettings()
} else if (action === 'total_track') {
this.useTotalTrack = !this.useTotalTrack
this.useChapterTrack = !this.useTotalTrack || this.useChapterTrack
this.playerSettings.useTotalTrack = !this.playerSettings.useTotalTrack
this.playerSettings.useChapterTrack = !this.playerSettings.useTotalTrack || this.playerSettings.useChapterTrack
this.updateTimestamp()
this.updateTrack()
this.updateReadyTrack()
this.$localStore.setUseTotalTrack(this.useTotalTrack)
this.$localStore.setUseChapterTrack(this.useChapterTrack)
this.updateUseChapterTrack()
this.savePlayerSettings()
} else if (action === 'close') {
this.closePlayback()
}
})
},
updateUseChapterTrack() {
// Chapter track in NowPlaying only supported on iOS for now
if (this.$platform === 'ios') {
AbsAudioPlayer.setChapterTrack({ enabled: this.playerSettings.useChapterTrack })
}
},
forceCloseDropdownMenu() {
if (this.$refs.dropdownMenu && this.$refs.dropdownMenu.closeMenu) {
this.$refs.dropdownMenu.closeMenu()
@@ -727,6 +776,30 @@ export default {
this.isLoading = false
this.playbackSession = null
},
async loadPlayerSettings() {
const savedPlayerSettings = await this.$localStore.getPlayerSettings()
if (!savedPlayerSettings) {
// In 0.9.72-beta 'useChapterTrack', 'useTotalTrack' and 'playerLock' was replaced with 'playerSettings' JSON object
// Check if this old key was set and if so migrate them over to 'playerSettings'
const chapterTrackPref = await this.$localStore.getPreferenceByKey('useChapterTrack')
if (chapterTrackPref) {
this.playerSettings.useChapterTrack = chapterTrackPref === '1'
const totalTrackPref = await this.$localStore.getPreferenceByKey('useTotalTrack')
this.playerSettings.useTotalTrack = totalTrackPref === '1'
const playerLockPref = await this.$localStore.getPreferenceByKey('playerLock')
this.playerSettings.lockUi = playerLockPref === '1'
}
this.savePlayerSettings()
} else {
this.playerSettings.useChapterTrack = !!savedPlayerSettings.useChapterTrack
this.playerSettings.useTotalTrack = !!savedPlayerSettings.useTotalTrack
this.playerSettings.lockUi = !!savedPlayerSettings.lockUi
this.playerSettings.scaleElapsedTimeBySpeed = !!savedPlayerSettings.scaleElapsedTimeBySpeed
}
},
savePlayerSettings() {
return this.$localStore.setPlayerSettings({ ...this.playerSettings })
},
//
// Listeners from audio AbsAudioPlayer
//
@@ -771,6 +844,10 @@ export default {
// Set track width
this.$nextTick(() => {
if (this.titleMarquee) this.titleMarquee.reset()
this.titleMarquee = new WrappingMarquee(this.$refs.titlewrapper)
this.titleMarquee.init(this.title)
if (this.$refs.track) {
this.trackWidth = this.$refs.track.clientWidth
} else {
@@ -793,9 +870,7 @@ export default {
this.updateTimestamp()
},
async init() {
this.useChapterTrack = await this.$localStore.getUseChapterTrack()
this.useTotalTrack = await this.$localStore.getUseTotalTrack()
this.lockUi = await this.$localStore.getPlayerLock()
await this.loadPlayerSettings()
this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession)
this.onPlaybackClosedListener = AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed)
@@ -806,26 +881,51 @@ export default {
this.onProgressSyncSuccess = AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess)
this.onPlaybackSpeedChangedListener = AbsAudioPlayer.addListener('onPlaybackSpeedChanged', this.onPlaybackSpeedChanged)
},
screenOrientationChange() {
setTimeout(() => {
this.updateScreenSize()
if (this.$refs.track) {
this.trackWidth = this.$refs.track.clientWidth
this.updateTrack()
this.updateReadyTrack()
async screenOrientationChange() {
if (this.isRefreshingUI) return
this.isRefreshingUI = true
const windowWidth = window.innerWidth
this.refreshUI()
// Window width does not always change right away. Wait up to 250ms for a change.
// iPhone 10 on iOS 16 took between 100 - 200ms to update when going from portrait to landscape
// but landscape to portrait was immediate
for (let i = 0; i < 5; i++) {
await new Promise((resolve) => setTimeout(resolve, 50))
if (window.innerWidth !== windowWidth) {
this.refreshUI()
break
}
}, 50)
}
this.isRefreshingUI = false
},
refreshUI() {
this.updateScreenSize()
if (this.$refs.track) {
this.trackWidth = this.$refs.track.clientWidth
this.updateTrack()
this.updateReadyTrack()
}
},
updateScreenSize() {
setTimeout(() => {
if (this.titleMarquee) this.titleMarquee.init(this.title)
}, 500)
this.windowHeight = window.innerHeight
this.windowWidth = window.innerWidth
const coverHeight = this.fullscreenBookCoverWidth * this.bookCoverAspectRatio
const coverImageWidthCollapsed = 46 / this.bookCoverAspectRatio
const titleAuthorLeftOffsetCollapsed = 30 + coverImageWidthCollapsed
const titleAuthorWidthCollapsed = this.windowWidth - 128 - titleAuthorLeftOffsetCollapsed - 10
document.documentElement.style.setProperty('--cover-image-width', this.fullscreenBookCoverWidth + 'px')
document.documentElement.style.setProperty('--cover-image-height', coverHeight + 'px')
document.documentElement.style.setProperty('--cover-image-width-collapsed', coverImageWidthCollapsed + 'px')
document.documentElement.style.setProperty('--cover-image-height-collapsed', 46 + 'px')
document.documentElement.style.setProperty('--title-author-left-offset-collapsed', 30 + coverImageWidthCollapsed + 'px')
document.documentElement.style.setProperty('--title-author-left-offset-collapsed', titleAuthorLeftOffsetCollapsed + 'px')
document.documentElement.style.setProperty('--title-author-width-collapsed', titleAuthorWidthCollapsed + 'px')
},
minimizePlayerEvt() {
this.collapseFullscreen()
@@ -848,7 +948,7 @@ export default {
window.addEventListener('resize', this.screenOrientationChange)
this.$eventBus.$on('minimize-player', this.minimizePlayerEvt)
document.body.addEventListener('touchstart', this.touchstart)
document.body.addEventListener('touchstart', this.touchstart, { passive: false })
document.body.addEventListener('touchend', this.touchend)
document.body.addEventListener('touchmove', this.touchmove)
this.$nextTick(this.init)
@@ -893,6 +993,7 @@ export default {
--cover-image-width-collapsed: 46px;
--cover-image-height-collapsed: 46px;
--title-author-left-offset-collapsed: 80px;
--title-author-width-collapsed: 40%;
}
.playerContainer {
@@ -940,7 +1041,7 @@ export default {
transition-property: left, bottom, width, height;
transform-origin: left bottom;
width: 40%;
width: var(--title-author-width-collapsed);
bottom: 76px;
left: var(--title-author-left-offset-collapsed);
text-align: left;
+5
View File
@@ -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,6 +256,9 @@ 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.isPlaying) {
+14 -14
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,25 +11,25 @@
<div class="w-full overflow-y-auto">
<template v-for="item in navItems">
<button v-if="item.action" :key="item.text" class="w-full hover:bg-bg hover:bg-opacity-60 flex items-center py-3 px-6 text-gray-300" @click="clickAction(item.action)">
<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 hover:bg-opacity-60 flex items-center py-3 px-6 text-gray-300" :class="currentRoutePath.startsWith(item.to) ? 'bg-bg bg-opacity-60' : ''">
<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="disconnect">
<p class="text-xs pr-2">Disconnect</p>
<p class="text-xs pr-2">{{ $strings.ButtonDisconnect }}</p>
<i class="material-icons text-sm -mb-0.5">cloud_off</i>
</div>
</div>
@@ -85,7 +85,7 @@ export default {
var items = [
{
icon: 'home',
text: 'Home',
text: this.$strings.ButtonHome,
to: '/bookshelf'
}
]
@@ -93,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'
})
}
@@ -114,27 +114,27 @@ 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: 'Switch Server/User',
text: this.$strings.ButtonSwitchServerUser,
action: 'logout'
})
}
+2 -2
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>
@@ -79,7 +79,7 @@ export default {
return this.page
},
hasFilter() {
if (this.page === 'series' || this.page === 'collections') return false
if (this.page === 'series' || this.page === 'collections' || this.page === 'playlists') return false
return this.filterBy !== 'all'
},
orderBy() {
+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' }">
+2 -2
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 -->
+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>
+33 -12
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 }" />
@@ -46,15 +46,20 @@
</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` }">
<div v-if="showHasLocalDownload" class="absolute right-0 top-0 z-20" :style="{ top: (isPodcast || (seriesSequence && showSequence) ? 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>
@@ -75,19 +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 && !numEpisodesIncomplete && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodes }}</p>
<p 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 :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodesIncomplete }}</p>
<p class="text-white" :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodesIncomplete }}</p>
</div>
</div>
</template>
@@ -322,6 +327,14 @@ export default {
streamIsPlaying() {
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
},
@@ -447,6 +460,8 @@ export default {
},
async play() {},
async playEpisode() {
if (this.playerIsStartingPlayback) return
await this.$hapticsImpact()
const eventBus = this.$eventBus || this.$nuxt.$eventBus
if (this.streamIsPlaying) {
@@ -454,6 +469,7 @@ export default {
return
}
this.store.commit('setPlayerIsStartingPlayback', this.recentEpisode.id)
if (this.localEpisode) {
// Play episode locally
eventBus.$emit('play-item', {
@@ -477,7 +493,12 @@ export default {
if (router) {
if (this.recentEpisode) router.push(`/item/${this.libraryItemId}/${this.recentEpisode.id}`)
else if (this.collapsedSeries) router.push(`/bookshelf/series/${this.collapsedSeries.id}`)
else router.push(`/item/${this.libraryItemId}`)
else if (this.localLibraryItem) {
// Pass local library item id to server page to allow falling back to offline page
router.push(`/item/${this.libraryItemId}?localLibraryItemId=${this.localLibraryItemId}`)
} else {
router.push(`/item/${this.libraryItemId}`)
}
}
}
},
+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>
+8 -8
View File
@@ -15,13 +15,13 @@
<div v-if="!isPodcast && !collapsedSeries" 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: coverWidth * userProgressPercent + 'px' }"></div>
</div>
<div class="flex-grow px-2">
<p class="whitespace-normal" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">
<p class="whitespace-normal line-clamp-2" :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' }">{{ 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` }">
@@ -176,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
@@ -209,10 +209,10 @@ 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.numMissingParts || this.isMissing || this.isInvalid
+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>
+1 -1
View File
@@ -1,7 +1,7 @@
<template>
<div class="flex h-full px-1 overflow-hidden">
<div class="w-10 h-10 flex items-center justify-center">
<span class="material-icons text-2xl text-gray-200">local_offer</span>
<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>
+55 -26
View File
@@ -3,16 +3,16 @@
<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 -->
@@ -20,37 +20,37 @@
<!-- 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">
<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>
<!-- 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" />
<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="username" class="w-full mb-2 text-lg" />
<ui-text-input v-model="password" type="password" :disabled="processing" placeholder="password" class="w-full mb-2 text-lg" />
<ui-text-input v-model="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-white bg-opacity-10 my-4" />
<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>
@@ -62,7 +62,7 @@
</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" />
@@ -73,7 +73,7 @@
</div>
</div>
<p v-if="!serverConnectionConfigs.length" class="mt-2 text-center text-error"><strong>Important!</strong> This app is designed to work with an Audiobookshelf server that you or someone you know is hosting. This app does not provide any content.</p>
<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>
@@ -95,6 +95,7 @@ export default {
processing: false,
serverConfig: {
address: null,
version: null,
username: null,
customHeaders: null
},
@@ -107,7 +108,8 @@ export default {
state: null,
verifier: null,
challenge: null,
buttonText: 'Login with OpenID'
buttonText: 'Login with OpenID',
enforceHTTPs: true // RFC 6749, Section 10.9 requires https
}
}
},
@@ -115,6 +117,9 @@ export default {
deviceData() {
return this.$store.state.deviceData || {}
},
deviceSettings() {
return this.deviceData.deviceSettings || {}
},
networkConnected() {
return this.$store.state.networkConnected
},
@@ -155,7 +160,7 @@ export default {
*/
async clickLoginWithOpenId() {
// oauth standard requires https explicitly
if (!this.serverConfig.address.startsWith('https')) {
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
@@ -179,14 +184,19 @@ export default {
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) {
console.warn(`[SSO] Invalid OpenID URL - client_id scope or state missing: ${redirectUrl}`)
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:') {
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
@@ -195,8 +205,8 @@ export default {
// We need to verify if the state is the same later
this.oauth.state = state
const host = `https://${redirectUrl.host}`
const buildUrl = `${host}${redirectUrl.pathname}?response_type=code` + `&client_id=${encodeURIComponent(client_id)}&scope=${encodeURIComponent(scope)}&state=${encodeURIComponent(state)}` + `&redirect_uri=${encodeURIComponent('audiobookshelf://oauth')}` + `&code_challenge=${encodeURIComponent(this.oauth.challenge)}&code_challenge_method=S256`
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..."
@@ -243,8 +253,11 @@ export default {
this.oauth.verifier = verifier
this.oauth.challenge = challenge
// set parameter isRest to true, so the backend wont attempt a redirect after we call backend:/callback in exchangeCodeForToken
const backendEndpoint = `${url}/auth/openid?code_challenge=${challenge}&code_challenge_method=S256&isRest=true`
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({
@@ -608,6 +621,7 @@ export default {
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()
@@ -789,6 +803,7 @@ export default {
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()
@@ -801,9 +816,23 @@ 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)
+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>
+11 -11
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>
@@ -144,11 +144,11 @@ export default {
.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: 'Add'
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'
}
@@ -1,29 +1,30 @@
<template>
<modals-modal v-model="show" :width="200" height="100%">
<template #outer>
<div class="absolute top-8 left-4 z-40">
<p class="text-white text-2xl truncate">Auto Rewind Time</p>
<div class="absolute top-11 left-4 z-40">
<p class="text-white text-2xl truncate capitalize">{{ $strings.LabelAutoRewindTime }}</p>
</div>
</template>
<div
class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center"
@click="
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="
show = false
manualTimerModal = false
"
>
">
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
<div v-if="manualTimerModal" class="p-4">
<div class="flex mb-4" @click="manualTimerModal = false">
<span class="material-icons text-3xl">arrow_back</span>
</div>
<div class="flex my-2 justify-between">
<ui-btn @click="decreaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">remove</span></ui-btn>
<ui-btn @click="decreaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px">
<span class="material-icons">remove</span>
</ui-btn>
<p class="text-2xl font-mono text-center">{{ manualTimeoutMin }} min</p>
<ui-btn @click="increaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">add</span></ui-btn>
<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 +36,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>
+12 -16
View File
@@ -1,12 +1,12 @@
<template>
<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>
<div class="absolute top-11 left-4 z-40">
<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">
@@ -14,9 +14,7 @@
</div>
<p class="text-xl pl-2">{{ selectedBookmark ? 'Edit Bookmark' : 'New Bookmark' }}</p>
<div class="flex-grow" />
<p class="text-xl font-mono">
{{ this.$secondsToTimestamp(currentTime) }}
</p>
<p class="text-xl font-mono">{{ this.$secondsToTimestamp(currentTime) }}</p>
</div>
<ui-text-input-with-label v-model="newBookmarkTitle" label="Note" />
@@ -29,14 +27,12 @@
<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-sm font-mono">
{{ this.$secondsToTimestamp(currentTime) }}
</p>
<p class="text-base pl-2">{{ $strings.ButtonCreateBookmark }}</p>
<p class="text-sm font-mono">{{ this.$secondsToTimestamp(currentTime) }}</p>
</div>
</div>
</div>
@@ -98,7 +94,7 @@ 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
@@ -108,7 +104,7 @@ export default {
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)
})
},
@@ -124,7 +120,7 @@ export default {
this.showBookmarkTitleInput = false
})
.catch((error) => {
this.$toast.error(`Failed to update bookmark`)
this.$toast.error(this.$strings.ToastBookmarkUpdateFailed)
console.error(error)
})
},
@@ -142,7 +138,7 @@ export default {
this.$toast.success('Bookmark added')
})
.catch((error) => {
this.$toast.error(`Failed to create bookmark`)
this.$toast.error(this.$strings.ToastBookmarkCreateFailed)
console.error(error)
})
+6 -6
View File
@@ -2,19 +2,19 @@
<modals-modal v-model="show" :width="400" height="100%">
<template #outer>
<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 }} Chapters</p>
<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-secondary 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" :class="currentChapterId === chapter.id ? 'bg-primary 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 / _playbackRate) }}</span>
<span class="font-mono text-fg-muted leading-3 text-sm" style="letter-spacing: -0.5px">{{ $secondsToTimestamp(chapter.start / _playbackRate) }}</span>
</div>
</div>
+5 -5
View File
@@ -7,14 +7,14 @@
</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/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 itemsToShow">
<slot :name="item.value" :item="item" :selected="item.value === selected">
<li :key="item.value" :ref="`item-${item.value}`" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" :style="{ paddingTop: itemPaddingY, paddingBottom: itemPaddingY }" role="option" @click="clickedOption(item.value)">
<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>
@@ -40,7 +40,7 @@ export default {
default: '16px'
},
width: {
type: Number,
type: [String, Number],
default: 300
}
},
+40 -23
View File
@@ -1,15 +1,15 @@
<template>
<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>
<div v-show="selected !== 'all'" class="absolute top-12 left-4 z-40">
<ui-btn class="text-lg border-yellow-400 border-opacity-40 h-10" :padding-y="0" @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 mt-8" 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
}
]
},
+2 -2
View File
@@ -1,6 +1,6 @@
<template>
<div ref="wrapper" class="modal modal-bg w-screen fixed bottom-0 left-0 flex items-center justify-center z-50" :class="halfScreen ? 'h-[50vh]' : 'h-screen'" @click.stop @touchstart.stop @touchend.stop>
<div ref="content" class="relative text-white h-full w-full bg-bg">
<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] min-h-[400px]' : 'h-screen'" @click.stop @touchstart.stop @touchend.stop>
<div ref="content" class="relative text-fg h-full w-full bg-bg">
<slot />
</div>
</div>
+5 -5
View File
@@ -1,18 +1,18 @@
<template>
<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>
<div class="absolute top-11 left-4 z-40">
<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>
+55 -27
View File
@@ -2,7 +2,7 @@
<div>
<modals-dialog v-model="show" :items="moreMenuItems" @action="moreMenuAction" />
<modals-item-details-modal v-model="showDetailsModal" :library-item="libraryItem" />
<modals-dialog v-model="showSendEbookDevicesModal" title="Select a device" :items="ereaderDeviceItems" @action="sendEbookToDeviceAction" />
<modals-dialog v-model="showSendEbookDevicesModal" :title="$strings.LabelSelectADevice" :items="ereaderDeviceItems" @action="sendEbookToDeviceAction" />
</div>
</template>
@@ -26,6 +26,10 @@ export default {
type: Object,
default: () => null
},
playlist: {
type: Object,
default: () => null
},
hideRssFeedOption: Boolean
},
data() {
@@ -52,7 +56,7 @@ export default {
// TODO: Implement on iOS
if (this.$platform !== 'ios' && !this.isPodcast) {
items.push({
text: 'History',
text: this.$strings.ButtonHistory,
value: 'history',
icon: 'history'
})
@@ -61,7 +65,7 @@ export default {
if (!this.isPodcast || this.episode) {
if (!this.userIsFinished) {
items.push({
text: 'Mark as Finished',
text: this.$strings.MessageMarkAsFinished,
value: 'markFinished',
icon: 'beenhere'
})
@@ -69,7 +73,7 @@ export default {
if (this.progressPercent > 0) {
items.push({
text: 'Discard Progress',
text: this.$strings.MessageDiscardProgress,
value: 'discardProgress',
icon: 'backspace'
})
@@ -78,23 +82,32 @@ export default {
if ((!this.isPodcast && this.serverLibraryItemId) || (this.episode && this.serverEpisodeId)) {
items.push({
text: 'Add to Playlist',
text: this.$strings.LabelAddToPlaylist,
value: 'playlist',
icon: 'playlist_add'
})
if (this.ereaderDeviceItems.length) {
items.push({
text: 'Send ebook to device',
text: this.$strings.ButtonSendEbookToDevice,
value: 'sendEbook',
icon: 'send'
})
}
}
// If on playlist page show remove from playlist option
if (this.playlist) {
items.push({
text: this.$strings.LabelRemoveFromPlaylist,
value: 'removeFromPlaylist',
icon: 'playlist_remove'
})
}
if (this.showRSSFeedOption) {
items.push({
text: this.rssFeed ? 'RSS Feed' : 'Open RSS Feed',
text: this.rssFeed ? this.$strings.HeaderRSSFeed : this.$strings.HeaderOpenRSSFeed,
value: 'rssFeed',
icon: 'rss_feed'
})
@@ -102,20 +115,20 @@ export default {
if (this.localLibraryItemId) {
items.push({
text: 'Manage Local Files',
text: this.$strings.ButtonManageLocalFiles,
value: 'manageLocal',
icon: 'folder'
})
if (!this.isPodcast) {
items.push({
text: 'Delete Local Item',
text: this.$strings.ButtonDeleteLocalItem,
value: 'deleteLocal',
icon: 'delete'
})
} else if (this.localEpisodeId) {
items.push({
text: 'Delete Local Episode',
text: this.$strings.ButtonDeleteLocalEpisode,
value: 'deleteLocalEpisode',
icon: 'delete'
})
@@ -124,7 +137,7 @@ export default {
if (!this.episode) {
items.push({
text: 'More Info',
text: this.$strings.LabelMoreInfo,
value: 'details',
icon: 'info'
})
@@ -255,6 +268,8 @@ export default {
} else if (action === 'playlist') {
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: this.episode }])
this.$store.commit('globals/setShowPlaylistsAddCreateModal', true)
} else if (action === 'removeFromPlaylist') {
this.removeFromPlaylistClick()
} else if (action === 'markFinished') {
if (this.episode) this.toggleEpisodeFinished()
else this.toggleFinished()
@@ -279,7 +294,7 @@ export default {
if (this.userItemProgress && this.userItemProgress.progress > 0 && !this.userIsFinished) {
const { value } = await Dialog.confirm({
title: 'Confirm',
message: 'Are you sure you want to mark this item as Finished?'
message: this.$strings.MessageConfirmMarkAsFinished
})
if (!value) return
}
@@ -304,7 +319,7 @@ export default {
}
await this.$nativeHttp.patch(`/api/me/progress/${this.serverLibraryItemId}`, updatePayload).catch((error) => {
console.error('Failed', error)
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
this.$toast.error(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedFailed : this.$strings.ToastItemMarkedAsNotFinishedFailed)
})
}
this.$emit('update:processing', false)
@@ -335,7 +350,7 @@ export default {
}
await this.$nativeHttp.patch(`/api/me/progress/${this.serverLibraryItemId}/${this.serverEpisodeId}`, updatePayload).catch((error) => {
console.error('Failed', error)
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
this.$toast.error(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedFailed : this.$strings.ToastItemMarkedAsNotFinishedFailed)
})
}
this.$emit('update:processing', false)
@@ -345,7 +360,7 @@ export default {
const { value } = await Dialog.confirm({
title: 'Confirm',
message: 'Are you sure you want to reset your progress?'
message: this.$strings.MessageConfirmDiscardProgress
})
if (value) {
this.$emit('update:processing', true)
@@ -381,18 +396,13 @@ export default {
return
}
let confirmMessage = `Remove local episode "${localFile.basePath}" from your device?`
if (this.serverLibraryItemId) {
confirmMessage += ' The file on the server will be unaffected.'
}
const { value } = await Dialog.confirm({
title: 'Confirm',
message: confirmMessage
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) {
this.$toast.success('Deleted episode successfully')
if (this.isLocal) {
// If this is local episode then redirect to server episode when available
if (this.serverEpisodeId) {
@@ -411,18 +421,13 @@ export default {
async deleteLocalItem() {
await this.$hapticsImpact()
let confirmMessage = 'Remove local files of this item from your device?'
if (this.serverLibraryItemId) {
confirmMessage += ' The files on the server and your progress will be unaffected.'
}
const { value } = await Dialog.confirm({
title: 'Confirm',
message: confirmMessage
message: this.$strings.MessageConfirmDeleteLocalFiles
})
if (value) {
const res = await AbsFileSystem.deleteItem(this.localLibraryItem)
if (res?.success) {
this.$toast.success('Deleted successfully')
if (this.isLocal) {
// If local then redirect to server version when available
if (this.serverLibraryItemId) {
@@ -466,6 +471,29 @@ export default {
.finally(() => {
this.$emit('update:processing', false)
})
},
removeFromPlaylistClick() {
if (!this.playlist) {
this.$toast.error('Invalid: No Playlist')
return
}
this.$emit('update:processing', true)
let url = `/api/playlists/${this.playlist.id}/item/${this.serverLibraryItemId}`
if (this.serverEpisodeId) url += `/${this.serverEpisodeId}`
this.$axios
.$delete(url)
.then(() => {
this.$toast.success('Item removed from playlist')
})
.catch((error) => {
const errorMsg = error.response?.data || 'Unknown error'
console.error('Failed to remove item from playlist', error)
this.$toast.error('Failed to remove from playlist: ' + errorMsg)
})
.finally(() => {
this.$emit('update:processing', false)
})
}
},
mounted() {}
+4 -4
View File
@@ -1,16 +1,16 @@
<template>
<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>
<div class="absolute top-11 left-4 z-40" style="max-width: 80%">
<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-secondary 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" :class="currentLibraryId === library.id ? 'bg-primary 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-10 w-10 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="show = false">
<div class="absolute z-40 top-11 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>
+2 -2
View File
@@ -2,7 +2,7 @@
<modals-modal v-model="show" :width="300" height="100%">
<template #outer>
<div class="absolute top-10 left-4 z-40" style="max-width: 80%">
<p class="text-white text-lg truncate">Select Download Location</p>
<p class="text-white text-lg truncate">{{ $strings.HeaderSelectDownloadLocation }}</p>
</div>
</template>
@@ -71,7 +71,7 @@ export default {
if (!localFolders.some((lf) => lf.id === `internal-${this.mediaType}`)) {
localFolders.push({
id: `internal-${this.mediaType}`,
name: 'Internal App Storage',
name: this.$strings.LabelInternalAppStorage,
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>
+5 -5
View File
@@ -1,17 +1,17 @@
<template>
<div :key="bookmark.id" :id="`bookmark-row-${bookmark.id}`" class="flex items-center px-1 py-4 justify-start cursor-pointer hover:bg-bg relative" :class="highlight ? 'bg-bg bg-opacity-60' : ' bg-opacity-20'" @click="click">
<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-white/60'">{{ highlight ? 'bookmark' : 'bookmark_border' }}</i>
<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-gray-300 flex items-center"><span class="material-icons text-base text-white/60 pl-px pr-1">schedule</span>{{ $secondsToTimestamp(bookmark.time) }}</p>
<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 justify-end transform w-16 pr-2" @click.stop>
<span class="material-icons text-2xl mr-2 text-gray-200 hover:text-yellow-400" @click.stop="editClick">edit</span>
<span class="material-icons text-2xl text-gray-200 hover:text-error cursor-pointer" @click.stop="deleteClick">delete</span>
<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>
+13 -14
View File
@@ -1,46 +1,46 @@
<template>
<modals-fullscreen-modal v-model="show" :processing="processing">
<div class="flex items-end justify-between h-16 px-4 pb-2">
<h1 class="text-lg">Add to Playlist</h1>
<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>
<!-- create new playlist form -->
<div v-if="showPlaylistNameInput" class="w-full h-full max-h-[calc(100vh-128px)] flex items-center">
<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 hover:bg-white hover:bg-opacity-10 cursor-pointer" @click.stop="showPlaylistNameInput = false">
<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">New Playlist</p>
<p class="text-xl pl-2 leading-none">{{ $strings.HeaderNewPlaylist }}</p>
<div class="flex-grow" />
</div>
<ui-text-input-with-label v-model="newPlaylistName" label="Name" />
<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">Create</ui-btn>
<ui-btn color="success" :loading="processing" class="w-full" @click.stop="submitCreatePlaylist">{{ $strings.ButtonCreate }}</ui-btn>
</div>
</div>
</div>
<!-- playlists list -->
<div v-if="!showPlaylistNameInput" class="w-full overflow-y-auto overflow-x-hidden h-full max-h-[calc(100vh-128px)]">
<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 ? 'Loading..' : 'No Playlists' }}</p>
<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-16 pt-2 absolute bottom-0 left-0 w-full">
<ui-btn :loading="processing" color="success" class="w-full h-full flex items-center justify-center" @click.stop="createPlaylist">
<p class="text-base">Create New Playlist</p>
<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>
@@ -193,8 +193,7 @@ export default {
})
.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
+2 -2
View File
@@ -8,8 +8,8 @@
<p class="px-2 truncate text-sm">{{ playlist.name }}</p>
</div>
<div class="w-24 min-w-[96px] px-1">
<ui-btn v-if="inPlaylist" small class="w-full" @click.stop="click">Remove</ui-btn>
<ui-btn v-else small class="w-full" @click.stop="click">Add</ui-btn>
<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>
@@ -1,22 +1,22 @@
<template>
<div class="w-full py-2 text-sm">
<div class="flex -mb-px">
<div class="w-1/2 h-6 rounded-tl-md relative border border-white/10 flex items-center justify-center cursor-pointer" :class="!showAdvancedView ? 'text-white bg-bg border-b-bg' : 'text-gray-400 bg-primary bg-opacity-70'" @click="showAdvancedView = false">
<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-white/10 flex items-center justify-center -ml-px cursor-pointer" :class="showAdvancedView ? 'text-white bg-bg border-b-bg' : 'text-gray-400 bg-primary bg-opacity-70'" @click="showAdvancedView = true">
<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-white/10 rounded-b-md mr-px" style="min-height: 220px">
<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="gray-600" label-class="pl-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="gray-600" label-class="pl-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" />
+12 -13
View File
@@ -1,38 +1,38 @@
<template>
<modals-fullscreen-modal v-model="show" :processing="processing">
<div class="flex items-end justify-end h-16 pr-4 pb-2 mb-2">
<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%-138px)] overflow-y-auto">
<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">RSS feed is open</h1>
<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-gray-300'" @click="copyToClipboard(currentFeed.feedUrl)">{{ linkCopied ? 'done' : 'content_copy' }}</span>
<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-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedPreventIndexing }}</span>
<span class="text-fg-muted uppercase text-sm">{{ $strings.LabelRSSFeedPreventIndexing }}</span>
</div>
<div>{{ currentFeed.meta.preventIndexing ? 'Yes' : 'No' }}</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-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedCustomOwnerName }}</span>
<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-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedCustomOwnerEmail }}</span>
<span class="text-fg-muted uppercase text-sm">{{ $strings.LabelRSSFeedCustomOwnerEmail }}</span>
</div>
<div>{{ currentFeed.meta.ownerEmail }}</div>
</div>
@@ -41,7 +41,7 @@
<div v-else class="w-full">
<div class="w-full relative mb-2">
<ui-text-input-with-label v-model="newFeedSlug" :label="$strings.LabelRSSFeedSlug" />
<p class="text-xs text-gray-400 py-0.5 px-1">{{ $getString('MessageFeedURLWillBe', [demoFeedUrl]) }}</p>
<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" />
@@ -50,10 +50,9 @@
</div>
</div>
<div v-show="userIsAdminOrUp" class="flex items-center px-2 h-16">
<div class="flex-grow" />
<ui-btn v-if="currentFeed" color="error" class="w-full" @click="closeFeed">{{ $strings.ButtonCloseFeed }}</ui-btn>
<ui-btn v-else color="success" class="w-full" @click="openFeed">{{ $strings.ButtonOpenFeed }}</ui-btn>
<div 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>
+2 -2
View File
@@ -2,7 +2,7 @@
<div id="comic-reader" class="w-full h-full relative">
<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-secondary 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-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>
@@ -23,7 +23,7 @@
</div>
</div>
<div class="fixed left-0 h-8 w-full bg-primary px-4 flex items-center text-white/80" :style="{ bottom: isPlayerOpen ? '120px' : '0px' }">
<div class="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>
+1 -1
View File
@@ -2,7 +2,7 @@
<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 px-4 flex items-center" :class="isLightTheme ? 'bg-white text-black' : 'bg-primary text-white/80'" :style="{ bottom: isPlayerOpen ? '120px' : '0px' }">
<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-xs">{{ progress }}%</p>
+1 -1
View File
@@ -18,7 +18,7 @@
</div>
</div>
<div class="fixed left-0 h-8 w-full bg-primary px-4 flex items-center text-white/80" :style="{ bottom: isPlayerOpen ? '120px' : '0px' }">
<div class="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>
+61 -35
View File
@@ -1,20 +1,20 @@
<template>
<div v-if="show" :data-theme="ereaderTheme" class="group fixed top-0 left-0 right-0 layout-wrapper w-full z-40 pt-8 data-[theme=dark]:bg-primary data-[theme=dark]:text-white data-[theme=light]:bg-white data-[theme=light]:text-black" :class="{ 'reader-player-open': isPlayerOpen }">
<div 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-white" :class="showingToolbar ? 'translate-y-0' : '-translate-y-32'" @touchstart.stop @mousedown.stop @touchend.stop @mouseup.stop>
<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-white">chevron_left</span>
<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-white">format_list_bulleted</span>
<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-white">settings</span>
<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-white">more</span>
<span class="material-icons text-2xl text-fg">more</span>
</button>
</div>
@@ -27,7 +27,7 @@
<!-- table of contents modal -->
<modals-fullscreen-modal v-model="showTOCModal" :theme="ereaderTheme">
<div class="flex items-end justify-between h-20 px-4 pb-2">
<h1 class="text-lg">Table of Contents</h1>
<h1 class="text-lg">{{ $strings.HeaderTableOfContents }}</h1>
<button class="flex" @click.stop="showTOCModal = false">
<span class="material-icons">close</span>
</button>
@@ -47,7 +47,7 @@
</li>
</ul>
<div v-if="!chapters.length" class="flex h-full items-center justify-center">
<p class="text-xl">{{ 'No Chapters' }}</p>
<p class="text-xl">{{ $strings.MessageNoChapters }}</p>
</div>
</div>
</div>
@@ -55,31 +55,39 @@
<!-- ereader settings modal -->
<modals-fullscreen-modal v-model="showSettingsModal" :theme="ereaderTheme" half-screen>
<div class="flex items-end justify-between h-20 px-4 pb-2 mb-8">
<h1 class="text-lg">Ereader Settings</h1>
<button class="flex" @click="showSettingsModal = false">
<span class="material-icons">close</span>
</button>
</div>
<div class="w-full overflow-y-auto overflow-x-hidden h-full max-h-[calc(100vh-85px)]">
<div class="w-full h-full px-4">
<div class="flex items-center mb-8">
<div class="w-32">
<p class="text-base">Theme:</p>
<div style="box-shadow: 0px -8px 8px #11111155">
<div class="flex items-end justify-between h-20 px-4 pb-2 mb-6">
<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-6">
<div class="w-32">
<p class="text-base">{{ $strings.LabelTheme }}:</p>
</div>
<ui-toggle-btns v-model="ereaderSettings.theme" :items="themeItems" @input="settingsUpdated" />
</div>
<ui-toggle-btns v-model="ereaderSettings.theme" :items="themeItems" @input="settingsUpdated" />
</div>
<div class="flex items-center mb-8">
<div class="w-32">
<p class="text-base">Font scale:</p>
<div class="flex items-center mb-6">
<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>
<ui-range-input v-model="ereaderSettings.fontScale" :min="5" :max="300" :step="5" input-width="180px" @input="settingsUpdated" />
</div>
<div class="flex items-center mb-8">
<div class="w-32">
<p class="text-base">Line spacing:</p>
<div class="flex items-center mb-6">
<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 class="flex items-center">
<div class="w-32">
<p class="text-base">{{ $strings.LabelLayout }}:</p>
</div>
<ui-toggle-btns v-model="ereaderSettings.spread" :items="spreadItems" @input="settingsUpdated" />
</div>
<ui-range-input v-model="ereaderSettings.lineSpacing" :min="100" :max="300" :step="5" input-width="180px" @input="settingsUpdated" />
</div>
</div>
</div>
@@ -107,7 +115,8 @@ export default {
ereaderSettings: {
theme: 'dark',
fontScale: 100,
lineSpacing: 115
lineSpacing: 115,
spread: 'auto'
}
}
},
@@ -150,16 +159,28 @@ export default {
},
ereaderTheme() {
if (this.isEpub) return this.ereaderSettings.theme
return 'dark'
return document.documentElement.dataset.theme || 'dark'
},
spreadItems() {
return [
{
text: this.$strings.LabelLayoutSinglePage,
value: 'none'
},
{
text: this.$strings.LabelLayoutAuto,
value: 'auto'
}
]
},
themeItems() {
return [
{
text: 'Dark',
text: this.$strings.LabelThemeDark,
value: 'dark'
},
{
text: 'Light',
text: this.$strings.LabelThemeLight,
value: 'light'
}
]
@@ -348,7 +369,12 @@ export default {
try {
const settings = localStorage.getItem('ereaderSettings')
if (settings) {
this.ereaderSettings = JSON.parse(settings)
const ereaderSettings = JSON.parse(settings)
if (!ereaderSettings.spread) {
// Added in 0.9.71
ereaderSettings.spread = 'auto'
}
this.ereaderSettings = ereaderSettings
this.settingsUpdated()
}
} catch (error) {
+9 -9
View File
@@ -1,6 +1,6 @@
<template>
<div class="w-96 my-6 mx-auto">
<h1 class="text-2xl mb-4">Minutes Listening <span class="text-white text-opacity-60 text-lg">(Last 7 days)</span></h1>
<h1 class="text-2xl mb-4">{{ $strings.HeaderStatsMinutesListeningChart }}</h1>
<div class="relative w-96 h-72">
<div class="absolute top-0 left-0">
<template v-for="lbl in yAxisLabels">
@@ -32,24 +32,24 @@
</div>
<div class="flex justify-between pt-12">
<div>
<p class="text-sm text-center">Week Listening</p>
<p class="text-sm text-center">{{ $strings.LabelStatsWeekListening }}</p>
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ totalMinutesListeningThisWeek }}</p>
<p class="text-sm text-center">minutes</p>
<p class="text-sm text-center">{{ $strings.LabelStatsMinutes }}</p>
</div>
<div>
<p class="text-sm text-center">Daily Average</p>
<p class="text-sm text-center">{{ $strings.LabelStatsDailyAverage }}</p>
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ averageMinutesPerDay }}</p>
<p class="text-sm text-center">minutes</p>
<p class="text-sm text-center">{{ $strings.LabelStatsMinutes }}</p>
</div>
<div>
<p class="text-sm text-center">Best Day</p>
<p class="text-sm text-center">{{ $strings.LabelStatsBestDay }}</p>
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ mostListenedDay }}</p>
<p class="text-sm text-center">minutes</p>
<p class="text-sm text-center">{{ $strings.LabelStatsMinutes }}</p>
</div>
<div>
<p class="text-sm text-center">Days</p>
<p class="text-sm text-center">{{ $strings.LabelStatsDays }}</p>
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ daysInARow }}</p>
<p class="text-sm text-center">in a row</p>
<p class="text-sm text-center">{{ $strings.LabelStatsInARow }}</p>
</div>
</div>
</div>
+283
View File
@@ -0,0 +1,283 @@
<template>
<div>
<div v-if="processing" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center">
<widgets-loading-spinner />
</div>
<img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" />
</div>
</template>
<script>
import { FileSharer } from '@byteowls/capacitor-filesharer'
export default {
props: {
variant: {
type: Number,
default: 0
},
year: Number,
processing: Boolean
},
data() {
return {
canvas: null,
dataUrl: null,
yearStats: null
}
},
watch: {
variant() {
this.init()
}
},
methods: {
async initCanvas() {
if (!this.yearStats) return
const canvas = document.createElement('canvas')
canvas.width = 800
canvas.height = 800
const ctx = canvas.getContext('2d')
const createRoundedRect = (x, y, w, h) => {
const grd1 = ctx.createLinearGradient(x, y, x + w, y + h)
grd1.addColorStop(0, '#44444455')
grd1.addColorStop(1, '#ffffff11')
ctx.fillStyle = grd1
ctx.strokeStyle = '#C0C0C088'
ctx.beginPath()
ctx.roundRect(x, y, w, h, [20])
ctx.fill()
ctx.stroke()
}
const addText = (text, fontSize, fontWeight, color, letterSpacing, x, y, maxWidth = 0) => {
ctx.fillStyle = color
ctx.font = `${fontWeight} ${fontSize} Source Sans Pro`
ctx.letterSpacing = letterSpacing
// If maxWidth is specified then continue to remove chars until under maxWidth and add ellipsis
if (maxWidth) {
let txtWidth = ctx.measureText(text).width
while (txtWidth > maxWidth) {
console.warn(`Text "${text}" is greater than max width ${maxWidth} (width:${txtWidth})`)
if (text.endsWith('...')) text = text.slice(0, -4) // Repeated checks remove 1 char at a time
else text = text.slice(0, -3) // First check remove last 3 chars
text += '...'
txtWidth = ctx.measureText(text).width
console.log(`Checking text "${text}" (width:${txtWidth})`)
}
}
ctx.fillText(text, x, y)
}
const addIcon = (icon, color, fontSize, x, y) => {
ctx.fillStyle = color
ctx.font = `${fontSize} Material Icons Outlined`
ctx.fillText(icon, x, y)
}
// Bg color
ctx.fillStyle = '#232323'
ctx.fillRect(0, 0, canvas.width, canvas.height)
// Cover image tiles
const bookCovers = this.yearStats.finishedBooksWithCovers
bookCovers.push(...this.yearStats.booksWithCovers)
let finishedBookCoverImgs = {}
if (bookCovers.length) {
let index = 0
ctx.globalAlpha = 0.25
ctx.save()
ctx.translate(canvas.width / 2, canvas.height / 2)
ctx.rotate((-Math.PI / 180) * 25)
ctx.translate(-canvas.width / 2, -canvas.height / 2)
ctx.translate(-130, -120)
for (let x = 0; x < 5; x++) {
for (let y = 0; y < 5; y++) {
const coverIndex = index % bookCovers.length
let libraryItemId = bookCovers[coverIndex]
index++
await new Promise((resolve) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.addEventListener('load', () => {
let sw = img.width
if (img.width > img.height) {
sw = img.height
}
let sx = -(sw - img.width) / 2
let sy = -(sw - img.height) / 2
ctx.drawImage(img, sx, sy, sw, sw, 215 * x, 215 * y, 215, 215)
resolve()
if (this.yearStats.finishedBooksWithCovers.includes(libraryItemId) && !finishedBookCoverImgs[libraryItemId]) {
finishedBookCoverImgs[libraryItemId] = {
img,
sx,
sy,
sw
}
}
})
img.addEventListener('error', () => {
resolve()
})
img.src = this.$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId)
})
}
}
ctx.restore()
}
ctx.globalAlpha = 1
ctx.textBaseline = 'middle'
// Create gradient
const grd1 = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
grd1.addColorStop(0, '#000000aa')
grd1.addColorStop(1, '#cd9d49aa')
ctx.fillStyle = grd1
ctx.fillRect(0, 0, canvas.width, canvas.height)
// Top Abs icon
let tanColor = '#ffdb70'
ctx.fillStyle = tanColor
ctx.font = '42px absicons'
ctx.fillText('\ue900', 15, 36)
// Top text
addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28)
addText(`${this.year} YEAR IN REVIEW`, '18px', 'bold', 'white', '1px', 65, 51)
// Top left box
createRoundedRect(50, 100, 340, 160)
addText(this.yearStats.numBooksFinished, '64px', 'bold', 'white', '0px', 160, 165)
addText('books finished', '28px', 'normal', tanColor, '0px', 160, 210)
const readIconPath = new Path2D()
readIconPath.addPath(new Path2D('M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z'), { a: 2, d: 2, e: 100, f: 160 })
ctx.fillStyle = '#ffffff'
ctx.fill(readIconPath)
// Box top right
createRoundedRect(410, 100, 340, 160)
addText(this.$elapsedPrettyExtended(this.yearStats.totalListeningTime, true, false), '40px', 'bold', 'white', '0px', 500, 165)
addText('spent listening', '28px', 'normal', tanColor, '0px', 500, 205)
addIcon('watch_later', 'white', '52px', 440, 180)
// Box bottom left
createRoundedRect(50, 280, 340, 160)
addText(this.yearStats.totalListeningSessions, '64px', 'bold', 'white', '0px', 160, 345)
addText('sessions', '28px', 'normal', tanColor, '1px', 160, 390)
addIcon('headphones', 'white', '52px', 95, 360)
// Box bottom right
createRoundedRect(410, 280, 340, 160)
addText(this.yearStats.numBooksListened, '64px', 'bold', 'white', '0px', 500, 345)
addText('books listened to', '28px', 'normal', tanColor, '0px', 500, 390)
addIcon('local_library', 'white', '52px', 440, 360)
if (!this.variant) {
// Text stats
const topNarrator = this.yearStats.mostListenedNarrator
if (topNarrator) {
addText('TOP NARRATOR', '24px', 'normal', tanColor, '1px', 70, 520)
addText(topNarrator.name, '36px', 'bolder', 'white', '0px', 70, 564, 330)
addText(this.$elapsedPrettyExtended(topNarrator.time, true, false), '24px', 'lighter', 'white', '1px', 70, 599)
}
const topGenre = this.yearStats.topGenres[0]
if (topGenre) {
addText('TOP GENRE', '24px', 'normal', tanColor, '1px', 430, 520)
addText(topGenre.genre, '36px', 'bolder', 'white', '0px', 430, 564, 330)
addText(this.$elapsedPrettyExtended(topGenre.time, true, false), '24px', 'lighter', 'white', '1px', 430, 599)
}
const topAuthor = this.yearStats.topAuthors[0]
if (topAuthor) {
addText('TOP AUTHOR', '24px', 'normal', tanColor, '1px', 70, 670)
addText(topAuthor.name, '36px', 'bolder', 'white', '0px', 70, 714, 330)
addText(this.$elapsedPrettyExtended(topAuthor.time, true, false), '24px', 'lighter', 'white', '1px', 70, 749)
}
if (this.yearStats.mostListenedMonth?.time) {
const jsdate = new Date(this.year, this.yearStats.mostListenedMonth.month, 1)
const monthName = this.$formatJsDate(jsdate, 'LLLL')
addText('TOP MONTH', '24px', 'normal', tanColor, '1px', 430, 670)
addText(monthName, '36px', 'bolder', 'white', '0px', 430, 714, 330)
addText(this.$elapsedPrettyExtended(this.yearStats.mostListenedMonth.time, true, false), '24px', 'lighter', 'white', '1px', 430, 749)
}
} else if (this.variant === 1) {
// Bottom images
finishedBookCoverImgs = Object.values(finishedBookCoverImgs)
if (finishedBookCoverImgs.length > 0) {
ctx.textAlign = 'center'
addText('Some books finished this year...', '28px', 'normal', tanColor, '0px', canvas.width / 2, 530)
for (let i = 0; i < Math.min(5, finishedBookCoverImgs.length); i++) {
let imgToAdd = finishedBookCoverImgs[i]
ctx.drawImage(imgToAdd.img, imgToAdd.sx, imgToAdd.sy, imgToAdd.sw, imgToAdd.sw, 40 + 145 * i, 570, 140, 140)
}
}
} else if (this.variant === 2) {
// Text stats
if (this.yearStats.topAuthors.length) {
addText('TOP AUTHORS', '24px', 'normal', tanColor, '1px', 70, 524)
for (let i = 0; i < this.yearStats.topAuthors.length; i++) {
addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 584 + i * 60, 330)
}
}
if (this.yearStats.topGenres.length) {
addText('TOP GENRES', '24px', 'normal', tanColor, '1px', 430, 524)
for (let i = 0; i < this.yearStats.topGenres.length; i++) {
addText(this.yearStats.topGenres[i].genre, '36px', 'bolder', 'white', '0px', 430, 584 + i * 60, 330)
}
}
}
this.canvas = canvas
this.dataUrl = canvas.toDataURL('png')
},
refresh() {
this.init()
},
share() {
const base64Data = this.dataUrl.split(';base64,').pop()
FileSharer.share({
filename: `audiobookshelf_my_${this.year}.png`,
contentType: 'image/png',
base64Data
}).catch((error) => {
if (error.message !== 'USER_CANCELLED') {
console.error('Failed to share', error.message)
this.$toast.error('Failed to share: ' + error.message)
}
})
},
init() {
this.$emit('update:processing', true)
this.$nativeHttp
.get(`/api/me/stats/year/${this.year}`)
.then((data) => {
this.yearStats = data || []
return this.initCanvas()
})
.catch((error) => {
console.error('Failed', error)
this.$toast.error('Failed to load year stats')
})
.finally(() => {
this.$emit('update:processing', false)
})
}
},
mounted() {
this.init()
}
}
</script>
+134
View File
@@ -0,0 +1,134 @@
<template>
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-2 sm:p-4 mb-4">
<!-- hack to get icon fonts loaded on init -->
<div class="h-0 w-0 overflow-hidden opacity-0">
<span class="material-icons-outlined">close</span>
<span class="abs-icons icon-audiobookshelf" />
</div>
<div class="flex items-center">
<p class="hidden md:block text-xl font-semibold">{{ yearInReviewYear }} Year in Review</p>
<div class="hidden md:block flex-grow" />
<ui-btn class="w-full md:w-auto" @click.stop="clickShowYearInReview">{{ showYearInReview ? 'Hide Year in Review' : `See ${yearInReviewYear} Year in Review` }}</ui-btn>
</div>
<!-- your year in review -->
<div v-if="showYearInReview">
<div class="w-full h-px bg-slate-200/10 my-4" />
<div class="flex items-center justify-center mb-2 max-w-[800px] mx-auto">
<!-- previous button -->
<ui-btn small :disabled="!yearInReviewVariant || processingYearInReview" class="inline-flex items-center font-semibold" @click="yearInReviewVariant--">
<span class="material-icons text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
<span class="hidden sm:inline-block pr-2">Previous</span>
</ui-btn>
<!-- share button -->
<ui-btn small :disabled="processingYearInReview" class="inline-flex sm:hidden items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReview"> Share </ui-btn>
<div class="flex-grow" />
<p class="hidden sm:block text-lg font-semibold">Your Year in Review ({{ yearInReviewVariant + 1 }})</p>
<p class="block sm:hidden text-lg font-semibold">{{ yearInReviewVariant + 1 }}</p>
<div class="flex-grow" />
<!-- refresh button -->
<ui-btn small :disabled="processingYearInReview" class="inline-flex items-center font-semibold mr-1 sm:mr-2" @click="refreshYearInReview">
<span class="hidden sm:inline-block">Refresh</span>
<span class="material-icons sm:!hidden text-lg py-px">refresh</span>
</ui-btn>
<!-- next button -->
<ui-btn small :disabled="yearInReviewVariant >= 2 || processingYearInReview" class="inline-flex items-center font-semibold" @click="yearInReviewVariant++">
<span class="hidden sm:inline-block pl-2">Next</span>
<span class="material-icons-outlined text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
</ui-btn>
</div>
<stats-year-in-review ref="yearInReview" :variant="yearInReviewVariant" :year="yearInReviewYear" :processing.sync="processingYearInReview" />
<!-- your year in review short -->
<div class="w-full max-w-[800px] mx-auto my-4">
<!-- share button -->
<ui-btn small :disabled="processingYearInReviewShort" class="inline-flex sm:hidden items-center font-semibold mb-1" @click="shareYearInReviewShort"> Share </ui-btn>
<stats-year-in-review-short ref="yearInReviewShort" :year="yearInReviewYear" :processing.sync="processingYearInReviewShort" />
</div>
<!-- your server in review -->
<div v-if="isAdminOrUp" class="w-full max-w-[800px] mx-auto mb-2 mt-4 border-t pt-4 border-white/10">
<div class="flex items-center justify-center mb-2">
<!-- previous button -->
<ui-btn small :disabled="!yearInReviewServerVariant || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant--">
<span class="material-icons text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
<span class="hidden sm:inline-block pr-2">Previous</span>
</ui-btn>
<!-- share button -->
<ui-btn small :disabled="processingYearInReviewServer" class="inline-flex sm:hidden items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReviewServer"> Share </ui-btn>
<div class="flex-grow" />
<p class="hidden sm:block text-lg font-semibold">Server Year in Review ({{ yearInReviewServerVariant + 1 }})</p>
<p class="block sm:hidden text-lg font-semibold">{{ yearInReviewServerVariant + 1 }}</p>
<div class="flex-grow" />
<!-- refresh button -->
<ui-btn small :disabled="processingYearInReviewServer" class="inline-flex items-center font-semibold mr-1 sm:mr-2" @click="refreshYearInReviewServer">
<span class="hidden sm:inline-block">Refresh</span>
<span class="material-icons sm:!hidden text-lg py-px">refresh</span>
</ui-btn>
<!-- next button -->
<ui-btn small :disabled="yearInReviewServerVariant >= 2 || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant++">
<span class="hidden sm:inline-block pl-2">Next</span>
<span class="material-icons-outlined text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
</ui-btn>
</div>
</div>
<stats-year-in-review-server v-if="isAdminOrUp" ref="yearInReviewServer" :year="yearInReviewYear" :variant="yearInReviewServerVariant" :processing.sync="processingYearInReviewServer" />
</div>
</div>
</template>
<script>
export default {
data() {
return {
showYearInReview: false,
yearInReviewYear: 0,
yearInReviewVariant: 0,
yearInReviewServerVariant: 0,
processingYearInReview: false,
processingYearInReviewShort: false,
processingYearInReviewServer: false
}
},
computed: {
isAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
}
},
methods: {
shareYearInReviewServer() {
this.$refs.yearInReviewServer.share()
},
shareYearInReview() {
this.$refs.yearInReview.share()
},
shareYearInReviewShort() {
this.$refs.yearInReviewShort.share()
},
refreshYearInReviewServer() {
this.$refs.yearInReviewServer.refresh()
},
refreshYearInReview() {
this.$refs.yearInReview.refresh()
this.$refs.yearInReviewShort.refresh()
},
clickShowYearInReview() {
this.showYearInReview = !this.showYearInReview
}
},
beforeMount() {
this.yearInReviewYear = new Date().getFullYear()
// When not December show previous year
if (new Date().getMonth() < 11) {
this.yearInReviewYear--
}
},
mounted() {}
}
</script>
+262
View File
@@ -0,0 +1,262 @@
<template>
<div>
<div v-if="processing" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center">
<widgets-loading-spinner />
</div>
<img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" />
</div>
</template>
<script>
import { FileSharer } from '@byteowls/capacitor-filesharer'
export default {
props: {
variant: {
type: Number,
default: 0
},
processing: Boolean,
year: Number
},
data() {
return {
canvas: null,
dataUrl: null,
yearStats: null
}
},
watch: {
variant() {
this.init()
}
},
methods: {
async initCanvas() {
if (!this.yearStats) return
const canvas = document.createElement('canvas')
canvas.width = 800
canvas.height = 800
const ctx = canvas.getContext('2d')
const createRoundedRect = (x, y, w, h) => {
const grd1 = ctx.createLinearGradient(x, y, x + w, y + h)
grd1.addColorStop(0, '#44444455')
grd1.addColorStop(1, '#ffffff11')
ctx.fillStyle = grd1
ctx.strokeStyle = '#C0C0C088'
ctx.beginPath()
ctx.roundRect(x, y, w, h, [20])
ctx.fill()
ctx.stroke()
}
const addText = (text, fontSize, fontWeight, color, letterSpacing, x, y, maxWidth = 0) => {
ctx.fillStyle = color
ctx.font = `${fontWeight} ${fontSize} Source Sans Pro`
ctx.letterSpacing = letterSpacing
// If maxWidth is specified then continue to remove chars until under maxWidth and add ellipsis
if (maxWidth) {
let txtWidth = ctx.measureText(text).width
while (txtWidth > maxWidth) {
console.warn(`Text "${text}" is greater than max width ${maxWidth} (width:${txtWidth})`)
if (text.endsWith('...')) text = text.slice(0, -4) // Repeated checks remove 1 char at a time
else text = text.slice(0, -3) // First check remove last 3 chars
text += '...'
txtWidth = ctx.measureText(text).width
console.log(`Checking text "${text}" (width:${txtWidth})`)
}
}
ctx.fillText(text, x, y)
}
// Bg color
ctx.fillStyle = '#232323'
ctx.fillRect(0, 0, canvas.width, canvas.height)
// Cover image tiles
let imgsToAdd = {}
if (this.yearStats.booksAddedWithCovers.length) {
let index = 0
ctx.globalAlpha = 0.25
ctx.save()
ctx.translate(canvas.width / 2, canvas.height / 2)
ctx.rotate((-Math.PI / 180) * 25)
ctx.translate(-canvas.width / 2, -canvas.height / 2)
ctx.translate(-130, -120)
for (let x = 0; x < 5; x++) {
for (let y = 0; y < 5; y++) {
const coverIndex = index % this.yearStats.booksAddedWithCovers.length
let libraryItemId = this.yearStats.booksAddedWithCovers[coverIndex]
index++
await new Promise((resolve) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.addEventListener('load', () => {
let sw = img.width
if (img.width > img.height) {
sw = img.height
}
let sx = -(sw - img.width) / 2
let sy = -(sw - img.height) / 2
ctx.drawImage(img, sx, sy, sw, sw, 215 * x, 215 * y, 215, 215)
if (!imgsToAdd[libraryItemId]) {
imgsToAdd[libraryItemId] = {
img,
sx,
sy,
sw
}
}
resolve()
})
img.addEventListener('error', () => {
resolve()
})
img.src = this.$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId)
})
}
}
ctx.restore()
}
ctx.globalAlpha = 1
ctx.textBaseline = 'middle'
// Create gradient
const grd1 = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
grd1.addColorStop(0, '#000000aa')
grd1.addColorStop(1, '#cd9d49aa')
ctx.fillStyle = grd1
ctx.fillRect(0, 0, canvas.width, canvas.height)
// Top Abs icon
let tanColor = '#ffdb70'
ctx.fillStyle = tanColor
ctx.font = '42px absicons'
ctx.fillText('\ue900', 15, 36)
// Top text
addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28)
addText(`${this.year} YEAR IN REVIEW`, '18px', 'bold', 'white', '1px', 65, 51)
// Top left box
createRoundedRect(40, 100, 230, 100)
ctx.textAlign = 'center'
addText(this.yearStats.numBooksAdded, '48px', 'bold', 'white', '0px', 155, 140)
addText('books added', '18px', 'normal', tanColor, '0px', 155, 170)
// Box top right
createRoundedRect(285, 100, 230, 100)
addText(this.yearStats.numAuthorsAdded, '48px', 'bold', 'white', '0px', 400, 140)
addText('authors added', '18px', 'normal', tanColor, '0px', 400, 170)
// Box bottom left
createRoundedRect(530, 100, 230, 100)
addText(this.yearStats.numListeningSessions, '48px', 'bold', 'white', '0px', 645, 140)
addText('sessions', '18px', 'normal', tanColor, '1px', 645, 170)
// Text stats
if (this.yearStats.totalBooksAddedSize) {
addText('Your book collection grew to...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 260)
addText(this.$bytesPretty(this.yearStats.totalBooksSize), '36px', 'bolder', 'white', '0px', canvas.width / 2, 300)
addText('+' + this.$bytesPretty(this.yearStats.totalBooksAddedSize), '20px', 'lighter', 'white', '0px', canvas.width / 2, 330)
}
if (this.yearStats.totalBooksAddedDuration) {
addText('With a total duration of...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 400)
addText(this.$elapsedPrettyExtended(this.yearStats.totalBooksDuration, true, false), '36px', 'bolder', 'white', '0px', canvas.width / 2, 440)
addText('+' + this.$elapsedPrettyExtended(this.yearStats.totalBooksAddedDuration, true, false), '20px', 'lighter', 'white', '0px', canvas.width / 2, 470)
}
if (!this.variant) {
// Bottom images
imgsToAdd = Object.values(imgsToAdd)
if (imgsToAdd.length > 0) {
addText('Some additions include...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 540)
for (let i = 0; i < Math.min(5, imgsToAdd.length); i++) {
let imgToAdd = imgsToAdd[i]
ctx.drawImage(imgToAdd.img, imgToAdd.sx, imgToAdd.sy, imgToAdd.sw, imgToAdd.sw, 40 + 145 * i, 580, 140, 140)
}
}
} else if (this.variant === 1) {
// Text stats
ctx.textAlign = 'left'
if (this.yearStats.topAuthors.length) {
addText('TOP AUTHORS', '24px', 'normal', tanColor, '1px', 70, 549)
for (let i = 0; i < this.yearStats.topAuthors.length; i++) {
addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 609 + i * 60, 330)
}
}
if (this.yearStats.topNarrators.length) {
addText('TOP NARRATORS', '24px', 'normal', tanColor, '1px', 430, 549)
for (let i = 0; i < this.yearStats.topNarrators.length; i++) {
addText(this.yearStats.topNarrators[i].name, '36px', 'bolder', 'white', '0px', 430, 609 + i * 60, 330)
}
}
} else if (this.variant === 2) {
// Text stats
ctx.textAlign = 'left'
if (this.yearStats.topAuthors.length) {
addText('TOP AUTHORS', '24px', 'normal', tanColor, '1px', 70, 549)
for (let i = 0; i < this.yearStats.topAuthors.length; i++) {
addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 609 + i * 60, 330)
}
}
if (this.yearStats.topGenres.length) {
addText('TOP GENRES', '24px', 'normal', tanColor, '1px', 430, 549)
for (let i = 0; i < this.yearStats.topGenres.length; i++) {
addText(this.yearStats.topGenres[i].genre, '36px', 'bolder', 'white', '0px', 430, 609 + i * 60, 330)
}
}
}
this.canvas = canvas
this.dataUrl = canvas.toDataURL('png')
},
share() {
const base64Data = this.dataUrl.split(';base64,').pop()
FileSharer.share({
filename: `audiobookshelf_server_${this.year}.png`,
contentType: 'image/png',
base64Data
}).catch((error) => {
if (error.message !== 'USER_CANCELLED') {
console.error('Failed to share', error.message)
this.$toast.error('Failed to share: ' + error.message)
}
})
},
refresh() {
this.init()
},
init() {
this.$emit('update:processing', true)
this.$nativeHttp
.get(`/api/stats/year/${this.year}`)
.then((data) => {
this.yearStats = data || []
return this.initCanvas()
})
.catch((error) => {
console.error('Failed', error)
this.$toast.error('Failed to load year stats')
})
.finally(() => {
this.$emit('update:processing', false)
})
}
},
mounted() {
this.init()
}
}
</script>
+192
View File
@@ -0,0 +1,192 @@
<template>
<div>
<div v-if="processing" class="max-w-[600px] h-32 sm:h-[200px] flex items-center justify-center">
<widgets-loading-spinner />
</div>
<img v-else-if="dataUrl" :src="dataUrl" />
</div>
</template>
<script>
import { FileSharer } from '@byteowls/capacitor-filesharer'
export default {
props: {
processing: Boolean,
year: Number
},
data() {
return {
canvas: null,
dataUrl: null,
yearStats: null
}
},
methods: {
async initCanvas() {
if (!this.yearStats) return
const canvas = document.createElement('canvas')
canvas.width = 600
canvas.height = 200
const ctx = canvas.getContext('2d')
const createRoundedRect = (x, y, w, h) => {
const grd1 = ctx.createLinearGradient(x, y, x + w, y + h)
grd1.addColorStop(0, '#44444455')
grd1.addColorStop(1, '#ffffff11')
ctx.fillStyle = grd1
ctx.strokeStyle = '#C0C0C088'
ctx.beginPath()
ctx.roundRect(x, y, w, h, [20])
ctx.fill()
ctx.stroke()
}
const addText = (text, fontSize, fontWeight, color, letterSpacing, x, y, maxWidth = 0) => {
ctx.fillStyle = color
ctx.font = `${fontWeight} ${fontSize} Source Sans Pro`
ctx.letterSpacing = letterSpacing
// If maxWidth is specified then continue to remove chars until under maxWidth and add ellipsis
if (maxWidth) {
let txtWidth = ctx.measureText(text).width
while (txtWidth > maxWidth) {
console.warn(`Text "${text}" is greater than max width ${maxWidth} (width:${txtWidth})`)
if (text.endsWith('...')) text = text.slice(0, -4) // Repeated checks remove 1 char at a time
else text = text.slice(0, -3) // First check remove last 3 chars
text += '...'
txtWidth = ctx.measureText(text).width
console.log(`Checking text "${text}" (width:${txtWidth})`)
}
}
ctx.fillText(text, x, y)
}
const addIcon = (icon, color, fontSize, x, y) => {
ctx.fillStyle = color
ctx.font = `${fontSize} Material Icons Outlined`
ctx.fillText(icon, x, y)
}
// Bg color
ctx.fillStyle = '#232323'
ctx.fillRect(0, 0, canvas.width, canvas.height)
// Cover image tiles
const bookCovers = this.yearStats.finishedBooksWithCovers
bookCovers.push(...this.yearStats.booksWithCovers)
if (bookCovers.length) {
let index = 0
ctx.globalAlpha = 0.25
ctx.save()
ctx.translate(canvas.width / 2, canvas.height / 2)
ctx.rotate((-Math.PI / 180) * 25)
ctx.translate(-canvas.width / 2, -canvas.height / 2)
ctx.translate(-10, -90)
for (let x = 0; x < 4; x++) {
for (let y = 0; y < 3; y++) {
const coverIndex = index % bookCovers.length
let libraryItemId = bookCovers[coverIndex]
index++
await new Promise((resolve) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.addEventListener('load', () => {
let sw = img.width
if (img.width > img.height) {
sw = img.height
}
let sx = -(sw - img.width) / 2
let sy = -(sw - img.height) / 2
ctx.drawImage(img, sx, sy, sw, sw, 155 * x, 155 * y, 155, 155)
resolve()
})
img.addEventListener('error', () => {
resolve()
})
img.src = this.$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId)
})
}
}
ctx.restore()
}
ctx.globalAlpha = 1
ctx.textBaseline = 'middle'
// Create gradient
const grd1 = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
grd1.addColorStop(0, '#000000aa')
grd1.addColorStop(1, '#cd9d49aa')
ctx.fillStyle = grd1
ctx.fillRect(0, 0, canvas.width, canvas.height)
// Top Abs icon
let tanColor = '#ffdb70'
ctx.fillStyle = tanColor
ctx.font = '42px absicons'
ctx.fillText('\ue900', 15, 36)
// Top text
addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28)
addText(`${this.year} YEAR IN REVIEW`, '18px', 'bold', 'white', '1px', 65, 51)
// Top left box
createRoundedRect(15, 75, 280, 110)
addText(this.yearStats.numBooksFinished, '48px', 'bold', 'white', '0px', 105, 120)
addText('books finished', '20px', 'normal', tanColor, '0px', 105, 155)
const readIconPath = new Path2D()
readIconPath.addPath(new Path2D('M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z'), { a: 1.5, d: 1.5, e: 55, f: 115 })
ctx.fillStyle = '#ffffff'
ctx.fill(readIconPath)
createRoundedRect(305, 75, 280, 110)
addText(this.yearStats.numBooksListened, '48px', 'bold', 'white', '0px', 400, 120)
addText('books listened to', '20px', 'normal', tanColor, '0px', 400, 155)
addIcon('local_library', 'white', '42px', 345, 130)
this.canvas = canvas
this.dataUrl = canvas.toDataURL('png')
},
share() {
const base64Data = this.dataUrl.split(';base64,').pop()
FileSharer.share({
filename: `audiobookshelf_my_${this.year}_short.png`,
contentType: 'image/png',
base64Data
}).catch((error) => {
if (error.message !== 'USER_CANCELLED') {
console.error('Failed to share', error.message)
this.$toast.error('Failed to share: ' + error.message)
}
})
},
refresh() {
this.init()
},
init() {
this.$emit('update:processing', true)
this.$nativeHttp
.get(`/api/me/stats/year/${this.year}`)
.then((data) => {
this.yearStats = data || []
return this.initCanvas()
})
.catch((error) => {
console.error('Failed', error)
this.$toast.error('Failed to load year stats')
})
.finally(() => {
this.$emit('update:processing', false)
})
}
},
mounted() {
this.init()
}
}
</script>
+4 -4
View File
@@ -1,8 +1,8 @@
<template>
<div class="w-full my-4">
<div class="w-full bg-primary px-4 py-2 flex items-center" :class="expanded ? 'rounded-t-md' : 'rounded-md'" @click.stop="clickBar">
<p class="pr-2">Chapters</p>
<div class="h-6 w-6 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
<p class="pr-2">{{ $strings.HeaderChapters }}</p>
<div class="h-6 w-6 rounded-full bg-fg/10 flex items-center justify-center">
<span class="text-xs font-mono">{{ chapters.length }}</span>
</div>
<div class="flex-grow" />
@@ -13,8 +13,8 @@
<transition name="slide">
<table class="text-xs tracksTable" v-show="expanded">
<tr>
<th class="text-left">Title</th>
<th class="text-center w-16">Start</th>
<th class="text-left">{{ $strings.LabelTitle }}</th>
<th class="text-center w-16">{{ $strings.LabelStart }}</th>
</tr>
<tr v-for="chapter in chapters" :key="chapter.id">
<td>
+4 -8
View File
@@ -1,8 +1,8 @@
<template>
<div class="w-full my-4">
<div class="w-full bg-primary px-4 py-2 flex items-center" :class="showTracks ? 'rounded-t-md' : 'rounded-md'" @click.stop="clickBar">
<p class="pr-2">{{ title }}</p>
<div class="h-6 w-6 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
<p class="pr-2">{{ $strings.HeaderAudioTracks }}</p>
<div class="h-6 w-6 rounded-full bg-fg/10 flex items-center justify-center">
<span class="text-xs font-mono">{{ tracks.length }}</span>
</div>
<div class="flex-grow" />
@@ -14,8 +14,8 @@
<div class="w-full" v-show="showTracks">
<table class="text-xs tracksTable">
<tr>
<th class="text-left">Filename</th>
<th class="text-center w-16">Duration</th>
<th class="text-left">{{ $strings.LabelFilename }}</th>
<th class="text-center w-16">{{ $strings.LabelDuration }}</th>
</tr>
<template v-for="track in tracks">
<tr :key="track.index">
@@ -34,10 +34,6 @@
<script>
export default {
props: {
title: {
type: String,
default: 'Audio Tracks'
},
tracks: {
type: Array,
default: () => []
@@ -7,8 +7,8 @@
<div class="book-table-content h-full px-2 flex items-center">
<div class="max-w-full">
<p class="truncate block text-sm">{{ bookTitle }}</p>
<p class="truncate block text-gray-400 text-xs">{{ bookAuthor }}</p>
<p v-if="media.duration" class="text-xxs text-gray-500">{{ bookDuration }}</p>
<p class="truncate block text-fg-muted text-xs">{{ bookAuthor }}</p>
<p v-if="media.duration" class="text-xxs text-fg-muted">{{ bookDuration }}</p>
</div>
</div>
<div class="w-8 min-w-8 flex justify-center">
@@ -1,14 +1,14 @@
<template>
<div class="w-full bg-primary bg-opacity-40">
<div class="w-full h-14 flex items-center px-4 bg-primary">
<p class="pr-4">Collection List</p>
<p class="pr-4">{{ $strings.HeaderCollectionItems }}</p>
<div class="w-6 h-6 md:w-7 md:h-7 bg-white bg-opacity-10 rounded-full flex items-center justify-center">
<div class="w-6 h-6 md:w-7 md:h-7 bg-fg/10 rounded-full flex items-center justify-center">
<span class="text-xs md:text-sm font-mono leading-none">{{ books.length }}</span>
</div>
<div class="flex-grow" />
<p v-if="totalDuration" class="text-sm text-gray-200">{{ totalDurationPretty }}</p>
<p v-if="totalDuration" class="text-sm text-fg">{{ totalDurationPretty }}</p>
</div>
<template v-for="book in booksCopy">
<tables-collection-book-table-row :key="book.id" :book="book" :collection-id="collectionId" class="item collection-book-item" @edit="editBook" />
+4 -4
View File
@@ -1,8 +1,8 @@
<template>
<div class="w-full my-2">
<div class="w-full bg-primary px-4 py-2 flex items-center" :class="showFiles ? 'rounded-t-md' : 'rounded-md'" @click.stop="clickBar">
<p class="pr-2">Ebook Files</p>
<div class="h-6 w-6 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
<p class="pr-2">{{ $strings.HeaderEbookFiles }}</p>
<div class="h-6 w-6 rounded-full bg-fg/10 flex items-center justify-center">
<span class="text-xs font-mono">{{ ebookFiles.length }}</span>
</div>
<div class="flex-grow" />
@@ -14,8 +14,8 @@
<div class="w-full" v-show="showFiles">
<table class="text-sm tracksTable">
<tr>
<th class="text-left px-4">Filename</th>
<th class="text-left px-4 w-16">Read</th>
<th class="text-left px-4">{{ $strings.LabelFilename }}</th>
<th class="text-left px-4 w-16">{{ $strings.LabelRead }}</th>
<th v-if="userCanUpdate && !libraryIsAudiobooksOnly" class="text-center w-16"></th>
</tr>
<template v-for="file in ebookFiles">
@@ -40,7 +40,7 @@ export default {
const items = []
if (this.userCanUpdate && !this.libraryIsAudiobooksOnly) {
items.push({
text: this.isPrimary ? 'Set as supplementary' : 'Set as primary',
text: this.isPrimary ? this.$strings.LabelSetEbookAsSupplementary : this.$strings.LabelSetEbookAsPrimary,
value: 'updateStatus'
})
}
+23 -4
View File
@@ -8,13 +8,16 @@
<div class="item-table-content h-full px-2 flex items-center">
<div class="max-w-full">
<p class="truncate block text-sm">{{ itemTitle }} <span v-if="localLibraryItem" class="material-icons text-success text-base align-text-bottom">download_done</span></p>
<p v-if="authorName" class="truncate block text-gray-300 text-xs">{{ authorName }}</p>
<p class="text-xxs text-gray-400">{{ itemDuration }}</p>
<p v-if="authorName" class="truncate block text-fg-muted text-xs">{{ authorName }}</p>
<p class="text-xxs text-fg-muted">{{ itemDuration }}</p>
</div>
</div>
<div class="w-8 min-w-8 flex justify-center">
<button v-if="showPlayBtn" class="w-8 h-8 rounded-full border border-white border-opacity-20 flex items-center justify-center" @click.stop.prevent="playClick">
<span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
<button v-if="showPlayBtn" class="w-8 h-8 rounded-full border border-white/20 flex items-center justify-center" @click.stop.prevent="playClick">
<span v-if="!playerIsStartingForThisMedia" class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
<svg v-else class="animate-spin" style="width: 18px; height: 18px" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>
</button>
</div>
<div class="w-8 min-w-8 flex justify-center">
@@ -117,6 +120,17 @@ export default {
streamIsPlaying() {
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
if (!mediaId) return false
let thisMediaId = this.episodeId || this.libraryItem.id
return mediaId === thisMediaId
},
userItemProgress() {
return this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, this.episodeId)
},
@@ -142,10 +156,14 @@ export default {
this.$emit('showMore', playlistItem)
},
async playClick() {
if (this.playerIsStartingPlayback) return
await this.$hapticsImpact()
let mediaId = this.episodeId || this.libraryItem.id
if (this.streamIsPlaying) {
this.$eventBus.$emit('pause-item')
} else if (this.localLibraryItem) {
this.$store.commit('setPlayerIsStartingPlayback', mediaId)
this.$eventBus.$emit('play-item', {
libraryItemId: this.localLibraryItem.id,
episodeId: this.localEpisode?.id,
@@ -153,6 +171,7 @@ export default {
serverEpisodeId: this.episodeId
})
} else {
this.$store.commit('setPlayerIsStartingPlayback', mediaId)
this.$eventBus.$emit('play-item', {
libraryItemId: this.libraryItem.id,
episodeId: this.episodeId
@@ -1,14 +1,14 @@
<template>
<div class="w-full bg-primary/50 rounded-lg">
<div class="w-full h-14 flex items-center px-3">
<p class="pr-2">Playlist Items</p>
<p class="pr-2">{{ $strings.HeaderPlaylistItems }}</p>
<div class="w-6 h-6 md:w-7 md:h-7 bg-white bg-opacity-10 rounded-full flex items-center justify-center">
<div class="w-6 h-6 md:w-7 md:h-7 bg-fg bg-opacity-10 rounded-full flex items-center justify-center">
<span class="text-xs md:text-sm font-mono leading-none">{{ items.length }}</span>
</div>
<div class="flex-grow" />
<p v-if="totalDuration" class="text-sm text-gray-200">{{ totalDurationPretty }}</p>
<p v-if="totalDuration" class="text-sm text-fg">{{ totalDurationPretty }}</p>
</div>
<template v-for="item in items">
<tables-playlist-item-table-row :key="item.id" :item="item" :playlist-id="playlistId" @showMore="showMore" />
+37 -25
View File
@@ -9,27 +9,26 @@
<p v-else>No Server Media Progress</p>
<p v-if="localMediaProgress">Local Media Progress {{ Math.round(localMediaProgress.progress * 100) }}</p>
<p v-else>No Local Media Progress</p>
</template> -->
</template>-->
<p v-if="publishedAt" class="text-xs text-gray-400 mb-1">Published {{ $formatDate(publishedAt, 'MMM do, yyyy') }}</p>
<p v-if="publishedAt" class="text-xs text-fg-muted mb-1">Published {{ $formatDate(publishedAt, 'MMM do, yyyy') }}</p>
<p class="text-sm font-semibold">
{{ title }}
</p>
<p class="text-sm font-semibold">{{ title }}</p>
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5" v-html="subtitle" />
<p class="text-sm text-fg episode-subtitle mt-1.5 mb-0.5" v-html="subtitle" />
<div v-if="episodeNumber || season || episodeType" class="flex py-2 items-center -mx-0.5">
<div v-if="episodeNumber" class="px-2 pt-px pb-0.5 mx-0.5 bg-primary bg-opacity-50 rounded-full text-xs font-light text-gray-200">Episode #{{ episodeNumber }}</div>
<div v-if="season" class="px-2 pt-px pb-0.5 mx-0.5 bg-primary bg-opacity-50 rounded-full text-xs font-light text-gray-200">Season #{{ season }}</div>
<div v-if="episodeType" class="px-2 pt-px pb-0.5 mx-0.5 bg-primary bg-opacity-50 rounded-full text-xs font-light text-gray-200 capitalize">
{{ episodeType }}
</div>
<div v-if="episodeNumber" class="px-2 pt-px pb-0.5 mx-0.5 bg-primary bg-opacity-50 rounded-full text-xs font-light text-fg">Episode #{{ episodeNumber }}</div>
<div v-if="season" class="px-2 pt-px pb-0.5 mx-0.5 bg-primary bg-opacity-50 rounded-full text-xs font-light text-fg">Season #{{ season }}</div>
<div v-if="episodeType" class="px-2 pt-px pb-0.5 mx-0.5 bg-primary bg-opacity-50 rounded-full text-xs font-light text-fg capitalize">{{ episodeType }}</div>
</div>
<div class="flex items-center pt-2">
<div class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick">
<span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
<div class="h-8 px-4 border border-border rounded-full flex items-center justify-center cursor-pointer" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick">
<span v-if="!playerIsStartingForThisMedia" class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
<svg v-else 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>
<p class="pl-2 pr-1 text-sm font-semibold">{{ timeRemaining }}</p>
</div>
@@ -41,7 +40,7 @@
<div v-if="userCanDownload">
<span v-if="isLocal" class="material-icons-outlined px-2 text-success text-lg">audio_file</span>
<span v-else-if="!localEpisode" class="material-icons mx-1.5 mt-2 text-xl" :class="downloadItem ? 'animate-bounce text-warning text-opacity-75' : ''" @click.stop="downloadClick">{{ downloadItem ? 'downloading' : 'download' }}</span>
<span v-else-if="!localEpisode" class="material-icons mx-1.5 mt-2 text-xl" :class="(downloadItem || startingDownload) ? 'animate-bounce text-warning text-opacity-75' : ''" @click.stop="downloadClick">{{ (downloadItem || startingDownload) ? 'downloading' : 'download' }}</span>
<span v-else class="material-icons px-2 text-success text-xl">download_done</span>
</div>
@@ -58,7 +57,6 @@
</template>
<script>
import { Dialog } from '@capacitor/dialog'
import { AbsFileSystem, AbsDownloader } from '@/plugins/capacitor'
export default {
@@ -78,7 +76,8 @@ export default {
data() {
return {
isProcessingReadUpdate: false,
processing: false
processing: false,
startingDownload: false
}
},
computed: {
@@ -119,6 +118,16 @@ export default {
streamIsPlaying() {
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
if (!mediaId) return false
return mediaId === this.episode?.id
},
itemProgress() {
if (this.isLocal) return this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId, this.episode.id)
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, this.episode.id)
@@ -169,7 +178,13 @@ export default {
return folderObj
},
async downloadClick() {
if (this.downloadItem) return
if (this.downloadItem || this.startingDownload) return
this.startingDownload = true
setTimeout(() => {
this.startingDownload = false
}, 1000)
await this.$hapticsImpact()
if (this.isIos) {
// no local folders on iOS
@@ -210,14 +225,7 @@ export default {
console.log('Local folder', JSON.stringify(localFolder))
var startDownloadMessage = `Start download for "${this.title}" to folder ${localFolder.name}?`
const { value } = await Dialog.confirm({
title: 'Confirm',
message: startDownloadMessage
})
if (value) {
this.startDownload(localFolder)
}
this.startDownload(localFolder)
},
async startDownload(localFolder) {
var payload = {
@@ -235,10 +243,14 @@ export default {
}
},
async playClick() {
if (this.playerIsStartingPlayback) return
await this.$hapticsImpact()
if (this.streamIsPlaying) {
this.$eventBus.$emit('pause-item')
} else {
this.$store.commit('setPlayerIsStartingPlayback', this.episode.id)
if (this.localEpisode && this.localLibraryItemId) {
console.log('Play local episode', this.localEpisode.id, this.localLibraryItemId)
+54 -50
View File
@@ -1,40 +1,40 @@
<template>
<div class="w-full">
<!-- Podcast episode downloads queue -->
<div v-if="episodeDownloadsQueued.length" class="px-4 py-2 my-2 bg-info bg-opacity-40 text-sm font-semibold rounded-md text-gray-100 relative w-full">
<div v-if="episodeDownloadsQueued.length" class="px-4 py-2 my-2 bg-info bg-opacity-40 text-sm font-semibold rounded-md text-fg relative w-full">
<div class="flex items-center">
<p class="text-sm py-1">{{ episodeDownloadsQueued.length }} Episode(s) queued for download</p>
<p class="text-sm py-1">{{ $getString('MessageEpisodesQueuedForDownload', [episodeDownloadsQueued.length]) }}</p>
<div class="flex-grow" />
<span v-if="isAdminOrUp" class="material-icons text-xl ml-3 cursor-pointer" @click="clearDownloadQueue">close</span>
</div>
</div>
<!-- Podcast episodes currently downloading -->
<div v-if="episodesDownloading.length" class="px-4 py-2 my-2 bg-success bg-opacity-20 text-sm font-semibold rounded-md text-gray-100 relative w-full">
<div v-if="episodesDownloading.length" class="px-4 py-2 my-2 bg-success bg-opacity-20 text-sm font-semibold rounded-md text-fg relative w-full">
<div v-for="episode in episodesDownloading" :key="episode.id" class="flex items-center">
<widgets-loading-spinner />
<p class="text-sm py-1 pl-4">Downloading episode "{{ episode.episodeDisplayTitle }}"</p>
<p class="text-sm py-1 pl-4">{{ $strings.MessageDownloadingEpisode }} "{{ episode.episodeDisplayTitle }}"</p>
</div>
</div>
<div class="flex items-center">
<p class="text-lg mb-1 font-semibold">Episodes ({{ episodesFiltered.length }})</p>
<p class="text-lg mb-1 font-semibold">{{ $strings.HeaderEpisodes }} ({{ episodesFiltered.length }})</p>
<div class="flex-grow" />
<button v-if="isAdminOrUp && !fetchingRSSFeed" class="outline:none mx-1 pt-0.5 relative" @click="searchEpisodes">
<span class="material-icons text-xl text-gray-200">search</span>
<span class="material-icons text-xl text-fg">search</span>
</button>
<widgets-loading-spinner v-else-if="fetchingRSSFeed" class="mx-1" />
<button class="outline:none mx-3 pt-0.5 relative" @click="showFilters">
<span class="material-icons text-xl text-gray-200">filter_alt</span>
<span class="material-icons text-xl text-fg">filter_alt</span>
<div v-show="filterKey !== 'all' && episodesAreFiltered" class="absolute top-0 right-0 w-1.5 h-1.5 rounded-full bg-success border border-green-300 shadow-sm z-10 pointer-events-none" />
</button>
<div class="flex items-center border border-white border-opacity-25 rounded px-2" @click="clickSort">
<p class="text-sm text-gray-200">{{ sortText }}</p>
<span class="material-icons ml-1 text-gray-200">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
<p class="text-sm text-fg">{{ sortText }}</p>
<span class="material-icons ml-1 text-fg">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
</div>
</div>
@@ -82,46 +82,6 @@ export default {
sortKey: 'publishedAt',
sortDesc: true,
filterKey: 'incomplete',
episodeSortItems: [
{
text: 'Pub Date',
value: 'publishedAt'
},
{
text: 'Title',
value: 'title'
},
{
text: 'Season',
value: 'season'
},
{
text: 'Episode',
value: 'episode'
}
],
filterItems: [
{
text: 'Show All',
value: 'all'
},
{
text: 'Incomplete',
value: 'incomplete'
},
{
text: 'In Progress',
value: 'inProgress'
},
{
text: 'Complete',
value: 'complete'
},
{
text: 'Downloaded',
value: 'downloaded'
}
],
fetchingRSSFeed: false,
podcastFeedEpisodes: [],
showPodcastEpisodeFeed: false,
@@ -156,6 +116,50 @@ export default {
episodesAreFiltered() {
return this.episodesFiltered.length !== this.episodesCopy.length
},
episodeSortItems() {
return [
{
text: this.$strings.LabelPubDate,
value: 'publishedAt'
},
{
text: this.$strings.LabelTitle,
value: 'title'
},
{
text: this.$strings.LabelSeason,
value: 'season'
},
{
text: this.$strings.LabelEpisode,
value: 'episode'
}
]
},
filterItems() {
return [
{
text: this.$strings.LabelShowAll,
value: 'all'
},
{
text: this.$strings.LabelIncomplete,
value: 'incomplete'
},
{
text: this.$strings.LabelInProgress,
value: 'inProgress'
},
{
text: this.$strings.LabelComplete,
value: 'complete'
},
{
text: this.$strings.LabelDownloaded,
value: 'downloaded'
}
]
},
episodesFiltered() {
return this.episodesCopy.filter((ep) => {
if (this.filterKey === 'downloaded') {
@@ -230,7 +234,7 @@ export default {
},
async searchEpisodes() {
if (!this.networkConnected) {
return this.$toast.error('No network connection')
return this.$toast.error(this.$strings.MessageNoNetworkConnection)
}
if (!this.mediaMetadata.feedUrl) {
+42 -30
View File
@@ -1,5 +1,5 @@
<template>
<div class="w-full py-4 overflow-hidden relative border-b border-white border-opacity-10" @click.stop="goToEpisodePage">
<div class="w-full py-4 overflow-hidden relative border-b border-border" @click.stop="goToEpisodePage">
<div v-if="episode" class="w-full px-1">
<div class="flex mb-2">
<div class="w-10 min-w-10">
@@ -7,29 +7,28 @@
</div>
<div class="flex-grow px-2">
<div class="-mt-0.5 mb-0.5" @click.stop>
<nuxt-link :to="`/item/${libraryItemId}`" class="text-sm text-gray-200 underline">{{ podcast.metadata.title }}</nuxt-link>
<nuxt-link :to="`/item/${libraryItemId}`" class="text-sm text-fg underline">{{ podcast.metadata.title }}</nuxt-link>
</div>
<p v-if="publishedAt" class="text-xs text-gray-300">{{ $dateDistanceFromNow(publishedAt) }}</p>
<p v-if="publishedAt" class="text-xs text-fg-muted">{{ $dateDistanceFromNow(publishedAt) }}</p>
</div>
</div>
<p class="text-sm font-semibold">
{{ title }}
</p>
<p class="text-sm font-semibold">{{ title }}</p>
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5" v-html="subtitle" />
<p class="text-sm text-fg episode-subtitle mt-1.5 mb-0.5" v-html="subtitle" />
<div v-if="episodeNumber || season || episodeType" class="flex pt-2 items-center -mx-0.5">
<div v-if="episodeNumber" class="px-2 pt-px pb-0.5 mx-0.5 bg-primary bg-opacity-50 rounded-full text-xs font-light text-gray-200">Episode #{{ episodeNumber }}</div>
<div v-if="season" class="px-2 pt-px pb-0.5 mx-0.5 bg-primary bg-opacity-50 rounded-full text-xs font-light text-gray-200">Season #{{ season }}</div>
<div v-if="episodeType" class="px-2 pt-px pb-0.5 mx-0.5 bg-primary bg-opacity-50 rounded-full text-xs font-light text-gray-200 capitalize">
{{ episodeType }}
</div>
<div v-if="episodeNumber" class="px-2 pt-px pb-0.5 mx-0.5 bg-primary bg-opacity-50 rounded-full text-xs font-light text-fg">{{ $strings.LabelEpisode }} #{{ episodeNumber }}</div>
<div v-if="season" class="px-2 pt-px pb-0.5 mx-0.5 bg-primary bg-opacity-50 rounded-full text-xs font-light text-fg">{{ $strings.LabelSeason }} #{{ season }}</div>
<div v-if="episodeType" class="px-2 pt-px pb-0.5 mx-0.5 bg-primary bg-opacity-50 rounded-full text-xs font-light text-fg capitalize">{{ episodeType }}</div>
</div>
<div class="flex items-center pt-2">
<div class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick">
<span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
<div class="h-8 px-4 border border-border hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer" :class="userIsFinished ? 'text-fg text-opacity-40' : ''" @click.stop="playClick">
<span v-if="!playerIsStartingForThisMedia" class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
<svg v-else 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>
<p class="pl-2 pr-1 text-sm font-semibold">{{ timeRemaining }}</p>
</div>
@@ -41,7 +40,7 @@
<div v-if="userCanDownload">
<span v-if="isLocal" class="material-icons-outlined px-2 text-success text-lg">audio_file</span>
<span v-else-if="!localEpisode" class="material-icons mx-1.5 mt-2 text-xl" :class="downloadItem ? 'animate-bounce text-warning text-opacity-75' : ''" @click.stop="downloadClick">{{ downloadItem ? 'downloading' : 'download' }}</span>
<span v-else-if="!localEpisode" class="material-icons mx-1.5 mt-2 text-xl" :class="downloadItem || pendingDownload ? 'animate-bounce text-warning text-opacity-75' : ''" @click.stop="downloadClick">{{ downloadItem || pendingDownload ? 'downloading' : 'download' }}</span>
<span v-else class="material-icons px-2 text-success text-xl">download_done</span>
</div>
@@ -58,7 +57,6 @@
</template>
<script>
import { Dialog } from '@capacitor/dialog'
import { AbsFileSystem, AbsDownloader } from '@/plugins/capacitor'
export default {
@@ -78,6 +76,7 @@ export default {
data() {
return {
isProcessingReadUpdate: false,
pendingDownload: false,
processing: false
}
},
@@ -122,6 +121,15 @@ export default {
streamIsPlaying() {
return this.$store.state.playerIsPlaying && this.isStreaming
},
playerIsStartingPlayback() {
// Play has been pressed and waiting for native play response
return this.$store.state.playerIsStartingPlayback
},
playerIsStartingForThisMedia() {
if (!this.episode?.id) return false
const mediaId = this.$store.state.playerStartingPlaybackMediaId
return mediaId === this.episode.id
},
itemProgress() {
if (this.isLocal) return this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId, this.episode.id)
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, this.episode.id)
@@ -135,10 +143,10 @@ export default {
}
},
itemProgressPercent() {
return this.itemProgress ? this.itemProgress.progress : 0
return this.itemProgress?.progress || 0
},
userIsFinished() {
return this.itemProgress ? !!this.itemProgress.isFinished : false
return !!this.itemProgress?.isFinished
},
timeRemaining() {
if (this.streamIsPlaying) return 'Playing'
@@ -154,7 +162,7 @@ export default {
return this.$store.getters['globals/getDownloadItem'](this.libraryItemId, this.episode.id)
},
localEpisodeId() {
return this.localEpisode ? this.localEpisode.id : null
return this.localEpisode?.id || null
},
podcast() {
return this.episode.podcast || {}
@@ -175,14 +183,19 @@ export default {
return folderObj
},
async downloadClick() {
if (this.downloadItem) return
if (this.downloadItem || this.pendingDownload) return
this.pendingDownload = true
await this.$hapticsImpact()
if (this.isIos) {
// no local folders on iOS
this.startDownload()
await this.startDownload()
} else {
this.download()
await this.download()
}
setTimeout(() => {
this.pendingDownload = false
}, 1000)
},
async download(selectedLocalFolder = null) {
let localFolder = selectedLocalFolder
@@ -216,14 +229,7 @@ export default {
console.log('Local folder', JSON.stringify(localFolder))
var startDownloadMessage = `Start download for "${this.title}" to folder ${localFolder.name}?`
const { value } = await Dialog.confirm({
title: 'Confirm',
message: startDownloadMessage
})
if (value) {
this.startDownload(localFolder)
}
return this.startDownload(localFolder)
},
async startDownload(localFolder) {
var payload = {
@@ -238,13 +244,19 @@ export default {
var errorMsg = downloadRes.error || 'Unknown error'
console.error('Download error', errorMsg)
this.$toast.error(errorMsg)
} else {
console.log('Download completed', JSON.stringify(downloadRes))
}
},
async playClick() {
if (this.playerIsStartingPlayback) return
await this.$hapticsImpact()
if (this.streamIsPlaying) {
this.$eventBus.$emit('pause-item')
} else {
this.$store.commit('setPlayerIsStartingPlayback', this.episode.id)
if (this.localEpisode && this.localLibraryItemId) {
console.log('Play local episode', this.localEpisode.id, this.localLibraryItemId)
+13 -7
View File
@@ -1,15 +1,15 @@
<template>
<nuxt-link v-if="to" :to="to" class="btn outline-none rounded-md shadow-md relative border border-gray-600 text-center" :disabled="disabled || loading" :class="classList">
<nuxt-link v-if="to" :to="to" class="btn outline-none rounded-md shadow-md relative border border-border text-center" :disabled="disabled || loading" :class="classList">
<slot />
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
<div v-if="loading" class="text-fg absolute top-0 left-0 w-full h-full flex items-center justify-center">
<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>
</nuxt-link>
<button v-else class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :type="type" :class="classList" @mousedown.prevent @click="click">
<button v-else class="btn outline-none rounded-md shadow-md relative border border-border" :disabled="disabled || loading" :type="type" :class="classList" @mousedown.prevent @click="click">
<slot />
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
<div v-if="loading" class="text-fg absolute top-0 left-0 w-full h-full flex items-center justify-center">
<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>
@@ -30,6 +30,7 @@ export default {
default: ''
},
paddingX: Number,
paddingY: Number,
small: Boolean,
loading: Boolean,
disabled: Boolean
@@ -41,19 +42,24 @@ export default {
classList() {
var list = []
if (this.loading) list.push('text-opacity-0')
list.push('text-white')
if (this.color === 'success') {
list.push('text-white')
}
list.push(`bg-${this.color}`)
if (this.small) {
list.push('text-sm')
if (this.paddingX === undefined) list.push('px-4')
list.push('py-1')
if (this.paddingY === undefined) list.push('py-1')
} else {
if (this.paddingX === undefined) list.push('px-8')
list.push('py-2')
if (this.paddingY === undefined) list.push('py-2')
}
if (this.paddingX !== undefined) {
list.push(`px-${this.paddingX}`)
}
if (this.paddingY !== undefined) {
list.push(`py-${this.paddingY}`)
}
if (this.disabled) {
list.push('cursor-not-allowed')
}
+1 -1
View File
@@ -4,7 +4,7 @@
<input v-model="selected" :disabled="disabled" type="checkbox" class="opacity-0 absolute" :class="!disabled ? 'cursor-pointer' : ''" />
<svg v-if="selected" class="fill-current pointer-events-none" :class="svgClass" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
</div>
<div v-if="label" class="select-none text-gray-100" :class="labelClassname">{{ label }}</div>
<div v-if="label" class="select-none text-fg" :class="labelClassname">{{ label }}</div>
</label>
</template>
+7 -7
View File
@@ -1,8 +1,8 @@
<template>
<div class="relative w-full" v-click-outside="clickedOutside">
<p class="text-sm font-semibold" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
<button type="button" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
<span class="flex items-center" :class="!selectedText ? 'text-gray-300' : 'text-white'">
<p class="text-sm font-semibold" :class="disabled ? 'text-fg-muted' : ''">{{ label }}</p>
<button type="button" :disabled="disabled" class="relative w-full border border-border rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
<span class="flex items-center" :class="!selectedText ? 'text-fg-muted' : 'text-fg'">
<span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText || placeholder || '' }}</span>
</span>
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
@@ -11,9 +11,9 @@
</button>
<transition name="menu">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-gray-600 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="listbox">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-border shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-bg ring-opacity-5 overflow-auto focus:outline-none text-sm" role="listbox">
<template v-for="item in items">
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption(item.value)">
<li :key="item.value" class="text-fg select-none relative py-2 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption(item.value)">
<div class="flex items-center">
<span class="font-normal ml-3 block truncate font-sans text-sm">{{ item.text }}</span>
</div>
@@ -65,8 +65,8 @@ export default {
if (this.small) classes.push('h-9')
else classes.push('h-10')
if (this.disabled) classes.push('cursor-not-allowed border-gray-600 bg-primary bg-opacity-70 border-opacity-70 text-gray-400')
else classes.push('cursor-pointer border-gray-600 bg-primary text-gray-100')
if (this.disabled) classes.push('cursor-not-allowed border-border bg-primary bg-opacity-70 border-opacity-70 text-fg-muted')
else classes.push('cursor-pointer border-border bg-primary text-fg')
return classes.join(' ')
}
+3 -3
View File
@@ -4,17 +4,17 @@
<slot />
</div>
<transition name="menu">
<ul ref="menu" v-show="showMenu" class="absolute z-50 -mt-px bg-primary border border-gray-600 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" style="width: 160px">
<ul ref="menu" v-show="showMenu" class="absolute z-50 -mt-px bg-primary border border-border shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-bg ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" style="width: 160px">
<template v-for="item in items">
<nuxt-link :key="item.value" v-if="item.to" :to="item.to">
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
<li :key="item.value" class="text-fg select-none relative py-2" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
<div class="flex items-center px-2">
<span v-if="item.icon" class="material-icons-outlined text-lg mr-2" :class="item.iconClass ? item.iconClass : ''">{{ item.icon }}</span>
<span class="font-normal block truncate font-sans text-center">{{ item.text }}</span>
</div>
</li>
</nuxt-link>
<li v-else :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
<li v-else :key="item.value" class="text-fg select-none relative py-2" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
<div class="flex items-center px-2">
<span v-if="item.icon" class="material-icons-outlined text-lg mr-2" :class="item.iconClass ? item.iconClass : ''">{{ item.icon }}</span>
<span class="font-normal block truncate font-sans text-center">{{ item.text }}</span>
+1 -1
View File
@@ -1,6 +1,6 @@
<template>
<button class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" @mousedown.prevent :disabled="disabled || loading" :class="className" @click="clickBtn">
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
<div v-if="loading" class="text-fg absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
<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>
+2 -2
View File
@@ -1,13 +1,13 @@
<template>
<div class="w-40">
<div class="bg-bg border border-gray-500 py-2 px-5 rounded-lg flex items-center flex-col box-shadow-md">
<div class="bg-bg border border-border py-2 px-5 rounded-lg flex items-center flex-col box-shadow-md">
<div class="loader-dots block relative w-20 h-5 mt-2">
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
</div>
<div class="text-gray-200 text-xs font-light mt-2 text-center">{{ text }}</div>
<div class="text-fg text-xs font-light mt-2 text-center">{{ text }}</div>
</div>
</div>
</template>
+5 -5
View File
@@ -1,25 +1,25 @@
<template>
<div class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full bg-fg border border-gray-500 rounded shadow-sm pl-3 pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="showMenu = !showMenu">
<button type="button" class="relative w-full bg-fg border border-border rounded shadow-sm pl-3 pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="showMenu = !showMenu">
<span class="flex items-center">
<span class="block truncate">{{ label }}</span>
</span>
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<span class="material-icons text-gray-100">person</span>
<span class="material-icons text-fg">person</span>
</span>
</button>
<transition name="menu">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-gray-600 shadow-lg max-h-56 rounded-b-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox" aria-activedescendant="listbox-option-3">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border bordesr-border shadow-lg max-h-56 rounded-b-md py-1 text-base ring-1 ring-bg ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox" aria-activedescendant="listbox-option-3">
<template v-for="item in items">
<nuxt-link :key="item.value" v-if="item.to" :to="item.to">
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
<li :key="item.value" class="text-fg select-none relative py-2" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
<div class="flex items-center">
<span class="font-normal ml-3 block truncate font-sans">{{ item.text }}</span>
</div>
</li>
</nuxt-link>
<li v-else :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
<li v-else :key="item.value" class="text-fg select-none relative py-2" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
<div class="flex items-center">
<span class="font-normal ml-3 block truncate font-sans">{{ item.text }}</span>
</div>
+8 -8
View File
@@ -49,7 +49,7 @@ input[type='range']:focus {
/* chromium */
input[type='range']::-webkit-slider-runnable-track {
background-color: rgb(0 0 0 / 0.25);
background-color: rgb(var(--color-track) / 0.5);
border-radius: 9999px;
height: 0.75rem;
}
@@ -58,19 +58,19 @@ input[type='range']::-webkit-slider-thumb {
appearance: none;
margin-top: -0.25rem;
border-radius: 9999px;
background-color: rgb(255 255 255 / 0.7);
background-color: rgb(var(--color-track-cursor));
height: 1.25rem;
width: 1.25rem;
}
input[type='range']:focus::-webkit-slider-thumb {
border: 1px solid #6b6b6b;
outline: 3px solid #6b6b6b;
border: 1px solid rgb(var(--color-track));
outline: 3px solid rgb(var(--color-track));
outline-offset: 0.125rem;
}
/* firefox */
input[type='range']::-moz-range-track {
background-color: rgb(0 0 0 / 0.25);
background-color: rgb(var(--color-track) / 0.5);
border-radius: 9999px;
height: 0.75rem;
}
@@ -78,13 +78,13 @@ input[type='range']::-moz-range-thumb {
border: none;
border-radius: 9999px;
margin-top: -0.25rem;
background-color: rgb(255 255 255 / 0.7);
background-color: rgb(var(--color-track-cursor));
height: 1.25rem;
width: 1.25rem;
}
input[type='range']:focus::-moz-range-thumb {
border: 1px solid #6b6b6b;
outline: 3px solid #6b6b6b;
border: 1px solid rgb(var(--color-track));
outline: 3px solid rgb(var(--color-track));
outline-offset: 0.125rem;
}
</style>
+7 -4
View File
@@ -1,6 +1,6 @@
<template>
<div class="relative">
<input v-model="input" ref="input" autofocus :type="type" :disabled="disabled" :readonly="readonly" autocorrect="off" autocapitalize="none" autocomplete="off" :placeholder="placeholder" class="py-2 w-full outline-none bg-primary" :class="inputClass" @keyup="keyup" />
<input v-model="input" ref="input" autofocus :type="type" :disabled="disabled" :readonly="readonly" autocorrect="off" autocapitalize="none" autocomplete="off" :placeholder="placeholder" class="py-2 w-full outline-none bg-primary disabled:text-fg-muted" :class="inputClass" @keyup="keyup" />
<div v-if="prependIcon" class="absolute top-0 left-0 h-full px-2 flex items-center justify-center">
<span class="material-icons text-lg">{{ prependIcon }}</span>
</div>
@@ -54,13 +54,13 @@ export default {
},
inputClass() {
var classes = [`bg-${this.bg}`, `rounded-${this.rounded}`]
if (this.disabled) classes.push('text-gray-300')
else classes.push('text-white')
if (this.disabled) classes.push('text-fg-muted')
else classes.push('text-fg')
if (this.prependIcon) classes.push('pl-10 pr-2')
else classes.push('px-2')
if (!this.borderless) classes.push('border border-gray-600')
if (!this.borderless) classes.push('border border-border')
return classes.join(' ')
}
},
@@ -88,4 +88,7 @@ export default {
input[type='time']::-webkit-calendar-picker-indicator {
filter: invert(100%);
}
html[data-theme='light'] input[type='time']::-webkit-calendar-picker-indicator {
filter: unset;
}
</style>
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<textarea ref="input" v-model="inputValue" :rows="rows" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="py-2 px-3 rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none" :class="transparent ? '' : 'border border-gray-600'" @change="change" />
<textarea ref="input" v-model="inputValue" :rows="rows" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="py-2 px-3 rounded bg-primary text-fg focus:outline-none" :class="transparent ? '' : 'border border-border'" @change="change" />
</template>
<script>
+1 -1
View File
@@ -1,6 +1,6 @@
<template>
<div class="w-full">
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-fg-muted' : ''">{{ label }}</p>
<ui-textarea-input ref="input" v-model="inputValue" :disabled="disabled" :rows="rows" class="w-full" />
</div>
</template>
+5 -30
View File
@@ -1,6 +1,6 @@
<template>
<div class="inline-flex toggle-btn-wrapper shadow-md">
<button v-for="item in items" :key="item.value" type="button" class="toggle-btn outline-none relative border border-gray-600 px-4 py-1" :class="{ selected: item.value === value }" @click.stop="clickBtn(item.value)">
<button v-for="item in items" :key="item.value" type="button" class="toggle-btn outline-none relative border border-border px-4 py-1" :class="{ selected: item.value === value }" @click.stop="clickBtn(item.value)">
{{ item.text }}
</button>
</div>
@@ -10,9 +10,6 @@
export default {
props: {
value: String,
/**
* [{ "text", "", "value": "" }]
*/
items: {
type: Array,
default: Object
@@ -52,34 +49,12 @@ export default {
.toggle-btn-wrapper .toggle-btn:not(:first-child) {
margin-left: -1px;
}
.toggle-btn::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0);
transition: all 0.1s ease-in-out;
}
.toggle-btn:hover:not(:disabled)::before {
background-color: rgba(255, 255, 255, 0.1);
}
.toggle-btn:hover:not(:disabled) {
color: white;
}
.toggle-btn {
color: rgba(255, 255, 255, 0.75);
background-color: rgb(var(--color-bg-toggle));
color: rgb(var(--color-fg) / 0.5);
}
.toggle-btn.selected {
color: white;
}
.toggle-btn.selected::before {
background-color: rgba(255, 255, 255, 0.1);
}
button.toggle-btn:disabled::before {
background-color: rgba(0, 0, 0, 0.2);
background-color: rgb(var(--color-bg-toggle-selected));
color: rgb(var(--color-fg));
}
</style>
-92
View File
@@ -1,92 +0,0 @@
<template>
<div ref="box" class="inline-block" @click.stop="click">
<slot />
</div>
</template>
<script>
export default {
props: {
text: {
type: [String, Number],
required: true
},
direction: {
type: String,
default: 'right'
},
disabled: Boolean
},
data() {
return {
tooltip: null,
tooltipTextEl: null,
tooltipId: null,
isShowing: false,
hideTimeout: null
}
},
watch: {
text() {
this.updateText()
},
disabled(newVal) {
if (newVal && this.isShowing) {
this.hideTooltip()
}
}
},
methods: {
updateText() {
if (this.tooltipTextEl) {
this.tooltipTextEl.innerHTML = this.text
}
},
createTooltip() {
if (!this.$refs.box) return
const tooltip = document.createElement('div')
this.tooltipId = String(Math.floor(Math.random() * 10000))
tooltip.id = this.tooltipId
tooltip.className = 'fixed inset-0 w-screen h-screen bg-black/25 text-xs flex items-center justify-center p-2'
tooltip.style.zIndex = 100
tooltip.style.backgroundColor = 'rgba(0,0,0,0.85)'
tooltip.addEventListener('click', this.hideTooltip)
const innerDiv = document.createElement('div')
innerDiv.className = 'w-full p-2 border border-white/20 pointer-events-none text-white bg-primary'
innerDiv.innerHTML = this.text
tooltip.appendChild(innerDiv)
this.tooltipTextEl = innerDiv
this.tooltip = tooltip
},
showTooltip() {
if (this.disabled) return
if (!this.tooltip) {
this.createTooltip()
if (!this.tooltip) return
}
if (!this.$refs.box) return // Ensure element is not destroyed
try {
document.body.appendChild(this.tooltip)
} catch (error) {
console.error(error)
}
this.isShowing = true
},
hideTooltip() {
if (!this.tooltip) return
this.tooltip.remove()
this.isShowing = false
},
click() {
if (!this.isShowing) this.showTooltip()
}
},
beforeDestroy() {
this.hideTooltip()
}
}
</script>
-4
View File
@@ -5,10 +5,6 @@
<circle cx="20" cy="20" r="17.5" class="progressbar__svg-circlebg"></circle>
</svg>
<p class="progressbar__text text-sm text-warning">{{ count }}</p>
<!-- <span class="material-icons progressbar__text text-xl">arrow_downward</span> -->
<!-- <div class="w-4 h-4 rounded-full bg-red-600 absolute bottom-1 right-1 flex items-center justify-center transform rotate-90">
<p class="text-xs text-white">4</p>
</div> -->
</div>
</template>
+6 -7
View File
@@ -59,20 +59,19 @@ export default {
methods: {
showAlertDialog() {
var msg = ''
var meteredString = this.isNetworkUnmetered ? 'unmetered' : 'metered'
if (this.attemptingConnection) {
msg = 'Attempting server connection'
msg = this.$strings.MessageAttemptingServerConnection
} else if (!this.networkConnected) {
msg = 'No internet'
msg = this.$strings.MessageNoNetworkConnection
} else if (!this.socketConnected) {
msg = 'Socket not connected'
msg = this.$strings.MessageSocketNotConnected
} else if (this.isCellular) {
msg = `Socket connected over ${meteredString} cellular`
msg = this.isNetworkUnmetered ? this.$strings.MessageSocketConnectedOverUnmeteredCellular : this.$strings.MessageSocketConnectedOverMeteredCellular
} else {
msg = `Socket connected over ${meteredString} wifi`
msg = this.isNetworkUnmetered ? this.$strings.MessageSocketConnectedOverUnmeteredWifi : this.$strings.MessageSocketConnectedOverMeteredWifi
}
Dialog.alert({
title: 'Connection Status',
title: this.$strings.HeaderConnectionStatus,
message: msg
})
}
@@ -54,9 +54,8 @@ export default {
}
if (!data.localLibraryItem) {
this.$toast.error('Item download complete but failed to create library item')
this.$toast.error(this.$strings.MessageItemDownloadCompleteFailedToCreate)
} else {
this.$toast.success(`Item "${data.localLibraryItem.media.metadata.title}" download finished`)
this.$eventBus.$emit('new-local-library-item', data.localLibraryItem)
}
+4 -4
View File
@@ -728,12 +728,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 25;
CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.9.68;
MARKETING_VERSION = 0.9.73;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -752,12 +752,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 25;
CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.9.68;
MARKETING_VERSION = 0.9.73;
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
+19 -6
View File
@@ -4,17 +4,17 @@ import RealmSwift
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
private let logger = AppLogger(category: "AppDelegate")
lazy var window: UIWindow? = UIWindow(frame: UIScreen.main.bounds)
var backgroundCompletionHandler: (() -> Void)?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
let configuration = Realm.Configuration(
schemaVersion: 14,
schemaVersion: 17,
migrationBlock: { [weak self] migration, oldSchemaVersion in
if (oldSchemaVersion < 1) {
self?.logger.log("Realm schema version was \(oldSchemaVersion)")
@@ -42,10 +42,23 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
newObject?["hapticFeedback"] = "LIGHT"
}
}
if (oldSchemaVersion < 15) {
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding languageCode setting")
migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in
newObject?["languageCode"] = "en-us"
}
}
if (oldSchemaVersion < 16) {
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding chapterTrack setting")
migration.enumerateObjects(ofType: PlayerSettings.className()) { oldObject, newObject in
newObject?["chapterTrack"] = false
}
}
}
)
Realm.Configuration.defaultConfiguration = configuration
return true
}
@@ -87,7 +100,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
// tracking app url opens, make sure to keep this call
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
}
func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) {
// Stores the completion handler for background downloads
// The identifier of this method can be ignored at this time as we only have one background url session
+1
View File
@@ -15,6 +15,7 @@ CAP_PLUGIN(AbsAudioPlayer, "AbsAudioPlayer",
CAP_PLUGIN_METHOD(closePlayback, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(setPlaybackSpeed, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(setChapterTrack, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(playPlayer, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(pausePlayer, CAPPluginReturnPromise);
+11
View File
@@ -119,6 +119,17 @@ public class AbsAudioPlayer: CAPPlugin {
PlayerHandler.setPlaybackSpeed(speed: settings.playbackRate)
call.resolve()
}
@objc func setChapterTrack(_ call: CAPPluginCall) {
let chapterTrack = call.getBool("enabled", true)
logger.log(String(chapterTrack))
let settings = PlayerSettings.main()
try? settings.update {
settings.chapterTrack = chapterTrack
}
PlayerHandler.setChapterTrack()
call.resolve()
}
@objc func playPlayer(_ call: CAPPluginCall) {
PlayerHandler.paused = false
+10 -3
View File
@@ -147,14 +147,15 @@ public class AbsDatabase: CAPPlugin {
}
@objc func syncLocalSessionsWithServer(_ call: CAPPluginCall) {
logger.log("syncLocalSessionsWithServer: Starting")
let isFirstSync = call.getBool("isFirstSync", false)
logger.log("syncLocalSessionsWithServer: Starting (First sync: \(isFirstSync))")
guard Store.serverConfig != nil else {
call.reject("syncLocalSessionsWithServer not connected to server")
return call.resolve()
}
Task {
await ApiClient.syncLocalSessionsWithServer()
await ApiClient.syncLocalSessionsWithServer(isFirstSync: isFirstSync)
call.resolve()
}
}
@@ -237,21 +238,27 @@ public class AbsDatabase: CAPPlugin {
@objc func updateDeviceSettings(_ call: CAPPluginCall) {
let disableAutoRewind = call.getBool("disableAutoRewind") ?? false
let enableAltView = call.getBool("enableAltView") ?? false
let allowSeekingOnMediaControls = call.getBool("allowSeekingOnMediaControls") ?? false
let jumpBackwardsTime = call.getInt("jumpBackwardsTime") ?? 10
let jumpForwardTime = call.getInt("jumpForwardTime") ?? 10
let lockOrientation = call.getString("lockOrientation") ?? "NONE"
let hapticFeedback = call.getString("hapticFeedback") ?? "LIGHT"
let languageCode = call.getString("languageCode") ?? "en-us"
let settings = DeviceSettings()
settings.disableAutoRewind = disableAutoRewind
settings.enableAltView = enableAltView
settings.allowSeekingOnMediaControls = allowSeekingOnMediaControls
settings.jumpBackwardsTime = jumpBackwardsTime
settings.jumpForwardTime = jumpForwardTime
settings.lockOrientation = lockOrientation
settings.hapticFeedback = hapticFeedback
settings.languageCode = languageCode
Database.shared.setDeviceSettings(deviceSettings: settings)
// call.resolve([ "value": [] ])
// Updates the media notification controls (for allowSeekingOnMediaControls setting)
PlayerHandler.updateRemoteTransportControls()
getDeviceData(call)
}

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