Compare commits

...
Author SHA1 Message Date
advplyr 4fd4cc5604 iOS version bump 0.9.60 2022-12-11 17:11:37 -06:00
advplyr c73b71345a Version bump 0.9.60 & android target sdk version to 31 2022-12-11 17:03:56 -06:00
advplyr 11f0af1ebd Update:Android Auto play next podcast episode after finishing one 2022-12-11 16:09:00 -06:00
advplyr a532f988b0 Update:Local covers for queue items 2022-12-11 14:00:27 -06:00
advplyr bec2796771 Update cast icon position in app bar 2022-12-11 12:18:10 -06:00
advplyr feec7f7399 Add:Sync local media progress button on local media page 2022-12-11 12:01:54 -06:00
advplyr 067c2f84dd Fix:Bookmarks for downloaded books linked to server #163 2022-12-11 11:09:50 -06:00
advplyr 69996a4346 Fix:Changing orientation update audio player progress bar 2022-12-11 10:49:05 -06:00
advplyr 80cda129d2 Fix:Progress bar width on item cover image 2022-12-11 10:29:43 -06:00
advplyr dd0ff04155 Fix:Android Auto downloaded item cover image #141, Update:Lockscreen display title/subtitle #431 2022-12-10 18:09:27 -06:00
advplyr c69558aa7f Fix:Chapters modal overflowing title/time #460 2022-12-10 14:55:33 -06:00
advplyr 8fefea0d94 Fix:Library icons min width 2022-12-10 14:34:21 -06:00
advplyr 0ddee6f44e Fix:Show Manage Local Files & Add to Playlist in more menu correctly 2022-12-10 14:26:58 -06:00
advplyr feec1ab55a Update:Local books/podcasts shelves on home page 2022-12-10 14:17:18 -06:00
advplyr 49500aecd6 Fix network flag 2022-12-10 11:35:16 -06:00
advplyr 894bf100d2 Update:Disable pod search when no network connection #225 2022-12-10 10:44:21 -06:00
advplyr 7ba810adb8 Update:Show error when searching for episodes with no network connection 2022-12-10 10:32:16 -06:00
advplyr d10f33362e Add:Option to delete episodes from server #225 2022-12-10 10:29:56 -06:00
advplyr 82e8fbba2b Download episodes to server & see downloads queued/in progress #225 2022-12-10 10:12:58 -06:00
advplyr 64325fe2a6 Add:Feed podcast episodes select modal #225 2022-12-09 18:03:17 -06:00
advplyrandGitHub 99217fee48 Merge pull request #459 from benonymity/hapticFeedback
Add haptic feedback
2022-12-09 17:08:17 -06:00
advplyrandGitHub f12e64990c Update components/home/BookshelfToolbar.vue 2022-12-09 17:08:00 -06:00
advplyrandGitHub ef7bb9a9d8 Update pages/settings.vue 2022-12-09 17:07:50 -06:00
advplyrandGitHub 5ac925e151 Update pages/settings.vue 2022-12-09 17:07:45 -06:00
benonymity acc5bde33a fix: use native haptic plugin and remove semicolons 2022-12-08 19:09:46 -05:00
advplyr b521f37ec1 Update:Only show podcast search page for admin or up #225 2022-12-08 17:56:58 -06:00
advplyr 379fa21571 Add:Podcast create form #225 2022-12-08 17:54:56 -06:00
benonymity 1aa6a441f3 feat: add haptic feedback to all action buttons 2022-12-08 00:28:28 -05:00
advplyr 705bbaccc1 Add:Podcast search page #225 2022-12-07 17:57:42 -06:00
advplyrandGitHub 8365e06762 Merge pull request #458 from benonymity/noTextSelect
Prevent selecting of text in the app
2022-12-07 16:37:47 -06:00
advplyrandGitHub 139a0b3412 Merge pull request #456 from benonymity/listeningStatsFix
Fix Listening Stats
2022-12-07 16:28:37 -06:00
advplyrandGitHub ea6b21169e Merge pull request #455 from benonymity/vertical-bounce
Don't allow webview to scroll out of bounds vertically
2022-12-07 16:13:17 -06:00
advplyr a262efe9da Fix lockOrientation setting and add Realm db migration; 2022-12-07 16:12:09 -06:00
benonymity ed5fb09007 fix: prevent selecting of text in the app 2022-12-07 15:41:32 -05:00
benonymity d3c6429fd6 fix: accurate time for streamed sessions 2022-12-07 14:40:50 -05:00
benonymity 371fcacc87 Fix: don't allow webview to scroll out of bounds vertically 2022-12-07 13:31:15 -05:00
advplyr 75666e523f Add:PDF EReader #420 2022-12-06 17:07:27 -06:00
advplyr 592ea7c533 Fix:EReader for ebooks in library folder root 2022-12-06 16:21:12 -06:00
advplyr b860dfc791 Fix:Library filter by progress #451 2022-12-05 17:30:15 -06:00
advplyr 96dde8cf31 Fix:Android seeking to invalid times 2022-12-04 16:57:03 -06:00
advplyr b4a37fed28 Update:Showing fullscreen cover uses raw cover 2022-12-04 16:30:18 -06:00
advplyr ad3fee8907 Fix:Save track order button hidden when player is open #380 2022-12-04 15:04:46 -06:00
advplyr 94db55598a Fix:Changing track sort order (Android) #380 2022-12-04 14:49:24 -06:00
advplyr 9687f47b6b Add:Device setting for locking screen orientation #449 2022-12-04 10:41:09 -06:00
advplyr af216d9b38 Update:Media session media button events for fast forward and rewind #423 2022-12-04 09:34:39 -06:00
advplyr c7879c2bc0 Update:Audio player UI updates #418 #408 2022-12-04 09:19:50 -06:00
advplyr 7db503a2cf Fix:EReader when player is open #442 2022-12-04 08:29:55 -06:00
advplyr d72295a0e6 Fix filter urls on items page 2022-12-03 17:58:22 -06:00
advplyr 638da4400f Update:Item page UI 2022-12-03 17:43:47 -06:00
advplyr af9a7aafae Add:Podcast latest page 2022-12-03 17:24:24 -06:00
advplyr ab9f7fed64 Add:Playlists management 2022-12-03 17:05:43 -06:00
advplyr b62ce27487 Add:Playlists 2022-12-03 15:20:27 -06:00
advplyr 6d9e902fe7 Update:Abs icon font and navbar to use icons 2022-12-03 12:06:24 -06:00
advplyr a0faf3f7d4 Update get libraries API call to support updated response payload 2022-12-02 17:39:45 -06:00
advplyr 32dfb3b40a Fix:Series books page sort order #443 2022-11-17 17:08:30 -06:00
advplyr 6bbcd300e4 iOS version bump 0.9.59 2022-11-05 16:41:45 -05:00
advplyr c54d9b3c5f Version bump 0.9.59 2022-11-05 16:29:30 -05:00
advplyr 9ab03fbcd4 fix & update comic reader for comics with subfolders and better sorting 2022-11-05 15:13:52 -05:00
advplyr a852114e9c Update:Use cover aspect ratio from library setting 2022-10-22 08:59:10 -05:00
advplyr 3c9b8c1c6f Fix read btn color 2022-10-21 16:29:46 -05:00
advplyr 4735b51dc0 Update:New library icons 2022-10-19 10:56:31 -05:00
advplyrandGitHub 23927a1d59 Merge pull request #413 from wnhrt/master
Colored status and navigationbar
2022-10-16 12:08:55 -05:00
Niklas Weinhart 40b592f7c1 Colored status and navigationbar 2022-10-16 05:37:35 +02:00
advplyrandGitHub 4da31f5850 Merge pull request #391 from ronaldheft/fix-ios-pause-during-seek
fix: Player sometimes pauses during seek on iOS
2022-09-23 15:37:50 -05:00
advplyrandGitHub f79e96cbfe Merge pull request #397 from ronaldheft/ios-player-seek-back-time-fixes
feat: Groundwork for testable iOS swift code
2022-09-23 15:36:59 -05:00
ronaldheft 2dbce1e3bb fix: Negative currentTime after seek back 2022-09-21 20:16:29 -04:00
ronaldheft cdfbbcbf8a refactor: Use refactored seek back time class 2022-09-20 18:34:43 -04:00
ronaldheft 890852bef5 refactor: Pull out seek back calculation into testable class 2022-09-20 18:32:28 -04:00
advplyrandGitHub 3b285db21a Merge pull request #393 from ronaldheft/fix-on-ready-multiple-calls
fix: onReady called multiple times
2022-09-19 16:46:00 -05:00
advplyrandGitHub d37be88977 Merge pull request #392 from ronaldheft/fix-ios-chapter-sleep-timer
fix: iOS chapter sleep timer issues
2022-09-19 16:45:09 -05:00
ronaldheft e6d27a1487 fix: onReady called multiple times 2022-09-18 14:38:32 -04:00
ronaldheft 5d23c17d30 fix: iOS chapter sleep timer issues 2022-09-18 14:21:41 -04:00
ronaldheft 9c9c1c3b94 fix: Player sometimes pauses during seek 2022-09-18 13:38:16 -04:00
advplyrandGitHub 4e94fd6ad0 Merge pull request #390 from ronaldheft/fix-ios-seek-back-time
fix: iOS seek back time not winding back
2022-09-18 11:58:50 -05:00
advplyrandGitHub 81a8203cca Merge pull request #389 from ronaldheft/ios-bug-artwork
Fix local cover images not used on iOS
2022-09-18 11:57:12 -05:00
ronaldheft b6b206c288 fix: Seek back time not winding back 2022-09-18 12:44:48 -04:00
advplyrandGitHub ed2310a2e7 Merge pull request #387 from ronaldheft/ios-realm-crash
fix: iOS 16 realm crash
2022-09-18 11:29:52 -05:00
ronaldheft bae5d97087 fix: Local artwork not loading 2022-09-18 12:24:19 -04:00
ronaldheft 3e12ff139b fix: Local artwork not used 2022-09-18 11:43:38 -04:00
ronaldheft 7ccd8db085 fix: iOS 16 realm crash 2022-09-17 17:47:18 -04:00
advplyrandGitHub fdc829d06c Merge pull request #386 from ronaldheft/ios-general-fixes
Fix various iOS app startup issues
2022-09-16 16:14:44 -05:00
ronaldheft e7f61e34e8 Fix player automatically starting after WebKit reload 2022-09-15 20:49:25 -04:00
ronaldheft 141877f111 Fix Pods with iOS target change 2022-09-14 20:24:27 -04:00
ronaldheft b71a976e61 Merge remote-tracking branch 'upstream/master' into ios-general-fixes 2022-09-14 19:55:42 -04:00
advplyrandGitHub 68c95ed6d5 Merge pull request #371 from ronaldheft/ios-logging
Improve iOS Logging
2022-09-14 17:54:31 -05:00
ronaldheft 7a68cc99af Convert remain NSLog statement to new logging framework 2022-09-13 21:16:55 -04:00
ronaldheft 7294bc7033 Audio player modifications should be on main queue 2022-09-13 20:47:07 -04:00
ronaldheft f8836be147 fix: Debug logging level does not persist to disk 2022-09-08 20:09:35 -04:00
ronaldheft 713999eb83 Fix deprecated and early window access warnings 2022-09-08 19:51:35 -04:00
ronaldheft cc4c9787c0 Rename ABS to Audiobookshelf 2022-09-06 21:56:10 -04:00
ronaldheft ecc5cf3e1b Rename app to provide better logging 2022-09-06 21:42:12 -04:00
ronaldheft ba7d2be03e Fix incorrect method call 2022-09-06 21:33:24 -04:00
ronaldheft 368f349c78 Use logging framework for AudioPlayer 2022-09-06 21:32:32 -04:00
ronaldheft 639d641c07 Fix usage of this 2022-09-06 21:32:10 -04:00
ronaldheft d9313a1654 Use os logging framework, so logs can be retrieved later 2022-09-06 21:26:48 -04:00
advplyrandGitHub 9dd532285c Merge pull request #365 from vangorra/vangorra/aa-button-cleanup
Cleanup Android Auto buttons
2022-09-04 11:51:11 -05:00
Robert Van GorkomandGitHub 92f10fdb5c Merge branch 'advplyr:master' into vangorra/aa-button-cleanup 2022-09-04 09:23:51 -07:00
advplyrandGitHub 9d0f43a664 Merge pull request #363 from vangorra/vangorra/debug-build
Add debug build to to allow prod and dev apps to work side by side.
2022-09-04 09:46:03 -05:00
Robbie Van Gorkom 453365fcd6 Fix buttons for Android Auto (and probably wear too). 2022-09-03 18:16:12 -07:00
Robbie Van Gorkom 6231c5fcc4 Add debug build to to allow prod and dev apps to work side by side. 2022-09-03 15:06:28 -07:00
advplyr 9821ddabcf iOS version bump 0.9.58-beta 2022-09-03 16:42:14 -05:00
advplyrandGitHub 57777f2f4b Merge pull request #362 from ronaldheft/fix-ios-mark-as-read-crash
Fix marking local book as read crashing iOS app
2022-09-03 16:35:50 -05:00
advplyrandGitHub 1f2c309d8c Merge pull request #361 from ronaldheft/ios-audio-events
Fix: Handle iOS audio events
2022-09-03 16:33:10 -05:00
Ron HeftandGitHub c11977870d Merge branch 'advplyr:master' into ios-audio-events 2022-09-03 17:21:52 -04:00
ronaldheft 41753cd853 Fix marking as read crashing app 2022-09-03 17:18:54 -04:00
advplyrandGitHub 003285f567 Merge pull request #359 from ronaldheft/bug-ios-sleep-timer
Rewrite iOS sleep timer to handle multi-track files
2022-09-03 16:17:28 -05:00
ronaldheft d1c1902cd3 Merge branch 'bug-ios-sleep-timer' into ios-audio-events 2022-09-03 16:53:03 -04:00
ronaldheft b697deac1c Fix cluegy way chapter sleep timer was being reset after skipping past 2022-09-03 16:44:01 -04:00
ronaldheft fb8e6408bb Fix memory leak 2022-09-03 16:34:31 -04:00
ronaldheft 836ffddd4f Fix race condition with chapter sleep timer 2022-09-02 18:36:57 -04:00
ronaldheft c8ff5a7817 Handle multi-track files for sleep timer 2022-09-02 18:31:16 -04:00
ronaldheft c14f6ec4c2 Rewrite sleep timer logic again 2022-09-02 18:22:42 -04:00
ronaldheft dc8852eb0d Guard against seeking during initialization 2022-09-02 16:52:08 -04:00
ronaldheft 586405f9de Rewrite sleep timer 2022-09-02 16:31:47 -04:00
advplyrandGitHub 34e68593bc Merge pull request #355 from ronaldheft/ios-bug-seek-multi-track
Fixes iOS player seek issues with multi-track books
2022-09-02 08:21:39 -05:00
ronaldheft c02adfa326 Fix typo in if statement 2022-09-01 18:41:21 -04:00
ronaldheft 021d63d973 Fix seek jumping 2022-09-01 18:19:45 -04:00
ronaldheft 50f6b15d23 Refactor to ensure we aren't missing any update time 2022-09-01 17:40:49 -04:00
ronaldheft 6257c6488b Fix race condition with the player queue 2022-09-01 17:34:54 -04:00
advplyrandGitHub defd895995 Merge pull request #347 from ronaldheft/ios-bg-downloads
feat: Handle iOS background downloads
2022-08-31 15:00:20 -05:00
ronaldheft 82159ee537 feat: Handle route change notifications 2022-08-30 22:59:59 -04:00
ronaldheft 11f22888d5 feat: Handle resuming iOS audio after pause 2022-08-30 22:47:55 -04:00
ronaldheft 20d932877e fix: Only mark audio session as active when playing 2022-08-30 22:33:55 -04:00
ronaldheft 35631fb211 feat: Handle iOS background downloads 2022-08-30 22:14:59 -04:00
advplyrandGitHub b02dd18e0b Merge pull request #340 from ronaldheft/ios-crashes
fix: iOS app crashes when playback session not loaded from Realm
2022-08-30 16:29:51 -05:00
ronaldheft b583a8e6e2 Handle iOS crashes when no session 2022-08-30 17:05:06 -04:00
advplyrandGitHub 5775a51731 Merge pull request #339 from ronaldheft/fix-ios-multitrack-currenttime
Fix: Session currentTime is inaccurate for books with multiple tracks
2022-08-30 04:34:25 -05:00
advplyrandGitHub 9b94a11ab9 Merge pull request #338 from ronaldheft/ios-fix-playback-timer
Fix: Extra timeListening when player initializes on iOS
2022-08-30 04:33:34 -05:00
ronaldheft 9ee1203130 Fix multi-track currentTime value 2022-08-29 21:16:51 -04:00
ronaldheft 711b079963 Fix: Extra timeListening when player initializes 2022-08-29 20:39:55 -04:00
advplyr 91dbd73d45 iOS version bump 0.9.57 2022-08-27 17:03:31 -05:00
advplyr a2ac0e29e4 Version bump 0.9.57-beta 2022-08-27 16:00:20 -05:00
advplyr 4271d832f1 Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2022-08-27 15:54:19 -05:00
advplyr 64147d065c Update:Book collection row item clickable #305 2022-08-27 15:54:13 -05:00
advplyrandGitHub fb03c8e67f Merge pull request #335 from ronaldheft/ios-respect-skip-settings
Fix: Skip preferences not respected for iOS remote control
2022-08-27 14:23:25 -05:00
advplyrandGitHub 75979960ab Merge pull request #330 from ronaldheft/bug-ios-background-currenttime
Improve iOS Player Sync Logic
2022-08-27 14:22:56 -05:00
ronaldheft 1411157bde Reindex server configs and fix bad data 2022-08-26 20:20:46 -04:00
ronaldheft eb7289c150 Fix the incorrect server config being persisted 2022-08-26 20:20:26 -04:00
ronaldheft c1f803bdd0 Fix configs sharing an index 2022-08-26 20:04:06 -04:00
ronaldheft ba1efedd79 Show currentTime in console 2022-08-26 19:48:54 -04:00
ronaldheft ac10997ed7 Fix: Skip preferences not respected for iOS remote control 2022-08-26 19:34:34 -04:00
ronaldheft 3c6f29bf3a Fix time observer crashing when already on main thread 2022-08-26 19:11:55 -04:00
advplyrandGitHub f623393f8d Merge pull request #334 from ronaldheft/ios-failed-playback-session
Fix failed set category on initAudioSession
2022-08-26 18:02:17 -05:00
ronaldheft 66ab402a50 Fix failed playback session on initAudioSession 2022-08-26 18:58:08 -04:00
ronaldheft 2076b93e19 Fix edge case when seeking past chapter 2022-08-26 18:35:47 -04:00
advplyr a5aae3fce8 Fix:Audio player more menu close playback 2022-08-25 18:04:14 -05:00
ronaldheft eb7a241e94 Fix sleep timer chapter locking 2022-08-25 19:03:05 -04:00
advplyr 14ff9097c7 Update:Changing library while on item page redirects to home 2022-08-25 17:58:29 -05:00
advplyr 79f09b334c Add:Book library sort by published year, Fix:Check and set sort/filter when changing library to different media type 2022-08-25 17:50:55 -05:00
ronaldheft f5d1e992ef Fix edge case where incorrect progress was tracked 2022-08-25 18:46:13 -04:00
ronaldheft aed2c31f5a Use persisted session to inform seek back 2022-08-25 18:43:57 -04:00
ronaldheft 3e31e72734 Configure time observer on the main queue 2022-08-25 18:31:09 -04:00
ronaldheft 7cf36d829a Fix progress updating issues 2022-08-25 18:28:17 -04:00
ronaldheft 268cf67576 Fix lost sleep time on play/pause 2022-08-25 17:39:06 -04:00
ronaldheft 5c76158729 Fix holding onto frozen Realm reference 2022-08-25 16:55:35 -04:00
ronaldheft 8c87b31e56 Improved error handling 2022-08-25 15:42:37 -04:00
ronaldheft 01678f2c91 Remove DispatchQueue as that did not fix Realm crashes 2022-08-24 19:57:39 -04:00
ronaldheft b5e33b1707 Fix thread-safety with transaction on local media progress 2022-08-24 19:33:10 -04:00
advplyr bba388aefa Update:Android auto show in progress media items across all libraries #285 2022-08-24 17:55:07 -05:00
ronaldheft 46623d70a3 Realm modifications should occur on concurrent queue 2022-08-23 22:37:28 -04:00
ronaldheft 452b25057e Use the same Realm instead for updates of LocalMediaProgress 2022-08-23 22:07:07 -04:00
ronaldheft 67a6aec132 Move AudioPlayer tasks off the main queue 2022-08-23 22:06:41 -04:00
ronaldheft 10ddc1c911 Fix server config not factored in determining active session 2022-08-23 18:56:08 -04:00
ronaldheft 2a2ebefeb9 Fix thread safety issues 2022-08-23 18:51:30 -04:00
ronaldheft d5f39e5cb1 Fix async call not waiting for results 2022-08-23 18:22:11 -04:00
ronaldheft 099be648bf Fix server not sending updates every 10 seconds 2022-08-23 17:32:43 -04:00
ronaldheft 6a885b7241 Fix session time using milliseconds instead of seconds 2022-08-23 17:13:43 -04:00
ronaldheft 15cdff5aa2 Fix typo in comment 2022-08-22 20:36:15 -04:00
advplyr 8bd0e863ae Fix:iOS audio player in landscape mode 2022-08-22 17:18:46 -05:00
ronaldheft 94e261d7bf Fix bad data being encoded 2022-08-22 18:12:07 -04:00
ronaldheft 06f87d24a7 Fix merge conflict 2022-08-22 18:12:00 -04:00
ronaldheft cdd2f40ef9 Merge branch 'master' into bug-ios-background-currenttime 2022-08-22 18:04:02 -04:00
ronaldheft 2448b461f0 Re-implement the paused timer 2022-08-22 18:00:37 -04:00
advplyr 4f85827585 Add:Lock/unlock player ui setting #313, Update:Player track ui thinner with cursor 2022-08-22 16:50:20 -05:00
ronaldheft fe042f3f83 Fix NaN causing bad data 2022-08-22 17:27:55 -04:00
ronaldheft d084958f2d Scale time reporting by the rate 2022-08-22 17:15:20 -04:00
ronaldheft ccecba7a19 If adjusting sleep time from chapter, convert to regular sleep timer 2022-08-22 17:11:34 -04:00
ronaldheft d57fe44bcc Sleep timer using native time observer 2022-08-22 17:04:48 -04:00
advplyr a32bd9d74c Update:Audio player menu to modal instead of dropdown 2022-08-22 16:02:35 -05:00
advplyr 39250b56c0 Fix:Play local episode from home page playing from server #324, Update:Podcast cover UI for displaying episodes and showing local download indicator 2022-08-21 18:03:27 -05:00
advplyrandGitHub 1c85e21e4f Merge pull request #329 from benonymity/download-page
Downloads page for iOS
2022-08-21 16:20:20 -05:00
advplyr d331e1f9ab iOS downloads page cleanup UI 2022-08-21 16:20:10 -05:00
advplyr d7c414f6c0 Merge 2022-08-21 15:59:44 -05:00
advplyr 523f861f77 Merge branch 'master' into download-page 2022-08-21 15:56:20 -05:00
advplyrandGitHub 8a0a6f383f Merge pull request #322 from jakob42/manualSleepTimer
added manual sleep timer dialog
2022-08-21 15:52:39 -05:00
advplyr b8b2d73eab Sleep timer UI update and min custom time to 1 min 2022-08-21 15:52:06 -05:00
advplyr 683b4e753a Merge branch 'master' into manualSleepTimer 2022-08-21 15:38:38 -05:00
advplyrandGitHub a3f2b02473 Merge pull request #328 from vangorra/vangorra/android_auto_buttons
Add additional controls to android auto
2022-08-21 15:20:44 -05:00
benonymity befe62f8a0 feat: downloads page for iOS 2022-08-21 15:02:22 -04:00
Robbie Van Gorkom 621a493c5b Add jump back and jump forward buttons to android auto. 2022-08-21 10:26:09 -07:00
ronaldheft 8952cbfd20 Start of refactor 2022-08-21 12:36:29 -04:00
advplyrandGitHub a576eff700 Merge pull request #327 from ronaldheft/bug-ios-playback-on-session-resume
Fix playback session starting sometimes on app launch
2022-08-21 11:15:23 -05:00
advplyrandGitHub 506e18a137 Merge pull request #326 from ronaldheft/bug-offline-plackback-no-progress
Fixes missing LocalMediaProgress for downloads without existing progress
2022-08-21 11:09:37 -05:00
ronaldheft 27d2ed2304 Convert PlayerHandler to shared instance 2022-08-21 12:06:37 -04:00
advplyrandGitHub a670ba9f95 Merge pull request #277 from ronaldheft/ios-downloads
iOS Downloads  / Improved Progress Syncing
2022-08-21 10:55:18 -05:00
ronaldheft 2321312427 Revert "Fixes missing LocalMediaProgress for downloads without existing progress"
This reverts commit 7745ccefab.
2022-08-21 11:34:30 -04:00
ronaldheft 85382d2bb4 Fix incorrect logic for checking if progress object exists 2022-08-21 11:34:23 -04:00
ronaldheft 7745ccefab Fixes missing LocalMediaProgress for downloads without existing progress 2022-08-21 11:25:50 -04:00
ronaldheft 872931f507 Guard against player actions before player is initialized 2022-08-21 11:05:16 -04:00
ronaldheft f335d8735f Fix progress sync not updating playback object 2022-08-21 10:59:43 -04:00
ronaldheft fca0fd7bbb Remove dead code 2022-08-20 10:28:39 -04:00
ronaldheft a461ed43de Finish cleaning up some refactors 2022-08-20 10:15:45 -04:00
ronaldheft 0f8672fb92 Remove Unrealm 2022-08-20 10:11:30 -04:00
ronaldheft 972fbd42ee Refactor and clean up sync logic 2022-08-19 23:00:40 -04:00
ronaldheft 062a217946 Fix more edge cases on player initialization 2022-08-19 22:15:06 -04:00
ronaldheft a59f3bb957 Fix player reporting currentTime=0 2022-08-19 21:20:53 -04:00
ronaldheft 93cb27d324 Fix player session restoration events 2022-08-19 16:36:56 -04:00
jakob42 b4e70da1f5 check for negative timer values 2022-08-19 08:46:15 +02:00
ronaldheft bf46c46fc0 Fix race conditions on first launch 2022-08-18 19:31:32 -04:00
ronaldheft af835f2c43 Persist playback settings 2022-08-18 19:01:10 -04:00
ronaldheft ffd0d4da7d Bump session restoration to 1 second 2022-08-18 18:18:22 -04:00
ronaldheft e9226671c9 Fix updatedAt not updating 2022-08-18 17:38:01 -04:00
ronaldheft 5fd3f3c080 Sync player session with server media progress 2022-08-18 17:08:41 -04:00
ronaldheft 519969eee0 Restore playback session on startup 2022-08-18 16:20:28 -04:00
ronaldheft 1746ed6469 Refactor starting a playback session 2022-08-18 15:57:03 -04:00
ronaldheft 2912d442b5 Clean up progress syncing in PlayerHandler 2022-08-18 15:56:25 -04:00
ronaldheft a82474cf45 Fix server media progress JSON decoding 2022-08-18 15:55:50 -04:00
jakob42 56a3d8bb72 added manual sleep timer dialog 2022-08-18 13:44:23 +02:00
ronaldheft add64249cd Times in milliseconds 2022-08-17 20:42:53 -04:00
ronaldheft d3172ece9b Fix missing metadata 2022-08-17 20:42:45 -04:00
ronaldheft f655f89c49 Fix missing fields in PlaybackSession 2022-08-17 19:43:11 -04:00
ronaldheft 911e46c4f0 Fix crash when offline 2022-08-16 21:57:34 -04:00
ronaldheft ec4647e2bc Handle server success 2022-08-16 21:36:09 -04:00
ronaldheft 10f2da9e90 Fix progress syncing 2022-08-16 21:14:33 -04:00
ronaldheft 6aa0f2253b Sync offline playback progress 2022-08-16 20:45:29 -04:00
ronaldheft 7c5ee940d3 Merge branch 'master' into ios-downloads 2022-08-16 16:56:47 -04:00
ronaldheft d83e04c47b Refactor storage model to work with native Realm 2022-08-16 16:53:47 -04:00
ronaldheft b0905d0270 Fix crashes related to Realm threading 2022-08-16 12:32:22 -04:00
ronaldheft 8ce0d9ce56 Fix warnings 2022-08-15 17:00:39 -04:00
ronaldheft ac2a1ce079 Provide DAO methods for Realm objects 2022-08-15 17:00:22 -04:00
ronaldheft 5d31499fd6 Fix the downloader for using Realm native 2022-08-14 22:20:24 -04:00
ronaldheft 4c8217edf6 Fix merge conflict errors 2022-08-14 18:22:50 -04:00
ronaldheft 934a07a5ad Merge branch 'ios-downloads' into ios-downloads-realm-native 2022-08-14 17:48:31 -04:00
ronaldheft 7aae1edf3c Fix incorrect downloadUrl 2022-08-14 16:11:22 -04:00
ronaldheft 7cc35e4b96 Switch to timer for download task 2022-08-14 16:10:55 -04:00
ronaldheft 1f8a8d1ce5 Make timer easier to start for the future 2022-08-13 12:56:02 -04:00
ronaldheft e0e2f02e0b Fix all kinds of syncing errors 2022-08-13 12:41:20 -04:00
ronaldheft c029e519e9 Sync local progress to front-end 2022-08-13 10:28:28 -04:00
ronaldheft ef661bba37 Sync local progress with server progress 2022-08-12 23:11:09 -04:00
ronaldheft 8d38f3358e API functions to sync local progress 2022-08-12 21:58:54 -04:00
ronaldheft f4e39ec7ca Functions to update local progress 2022-08-11 20:38:51 -04:00
ronaldheft c32c77b963 Fix player display on local media 2022-08-11 18:34:59 -04:00
ronaldheft 9477860bca Working local file playback 2022-08-11 18:29:55 -04:00
ronaldheft 65fcc45bc6 Remove todos for file system 2022-08-11 16:45:34 -04:00
ronaldheft 2e6946e1f7 Disable track reordering on iOS 2022-08-11 16:39:59 -04:00
ronaldheft 64707b0928 Support local media progress 2022-08-11 16:26:32 -04:00
ronaldheft a107a25e43 Fix downloading in progress books 2022-08-11 16:11:53 -04:00
ronaldheft 0a989e7811 Handle deleting podcast episodes 2022-08-11 15:36:25 -04:00
ronaldheft 4e63d43679 Fix progress done percent 2022-08-11 15:36:06 -04:00
ronaldheft 60cb5f398c Handle deleting library items 2022-08-11 14:12:45 -04:00
ronaldheft 8edeefc1cd Prevent iCloud backups of downloads 2022-08-11 13:46:30 -04:00
ronaldheft 02eabb82c1 Hide UI options that iOS does not need / support 2022-08-11 13:16:25 -04:00
ronaldheft 999d434fe9 Fix download item not clearing for podcasts 2022-08-11 12:58:47 -04:00
ronaldheft 2b07c81e54 Use book/podcast helper 2022-08-11 12:32:58 -04:00
ronaldheft 5f4ff38035 Support downloading new podcast episodes 2022-08-11 12:30:45 -04:00
ronaldheft e52a5fd585 Fix incorrect episode id 2022-08-11 11:20:29 -04:00
ronaldheft 599a8e23cb Fix podcast episode downloading 2022-08-11 11:20:17 -04:00
ronaldheft 81a4e4ff7b Enable podcast downloading on iOS 2022-08-10 23:20:56 -04:00
ronaldheft 7d730dcafb Fix optionals 2022-08-10 22:56:42 -04:00
ronaldheft 446e54cb91 Fix data model issues 2022-08-10 22:17:12 -04:00
ronaldheft a9d7fbc083 Convert objects to realm-native 2022-08-10 17:09:49 -04:00
ronaldheft a3e458fcc4 Make server config thread-safe 2022-08-10 17:08:19 -04:00
ronaldheft e275aa1699 More improvements to progress status 2022-08-08 20:05:09 -04:00
ronaldheft e9961f64a9 Handle a documents directory that can change
Thanks iOS
2022-08-08 19:25:59 -04:00
ronaldheft 8e2be4704e Clean up progress logic 2022-08-08 17:21:13 -04:00
ronaldheft 948cd3068a Fix more race conditions with progress reporting 2022-08-08 13:47:37 -04:00
ronaldheft 162eb9afad Fix race condition of reporting finished download 2022-08-08 12:39:55 -04:00
ronaldheft 30ae98c3de Throttle downloads 2022-08-07 21:17:13 -04:00
ronaldheft b1b10e846d Somewhat working progress under heavy load 2022-08-07 21:02:25 -04:00
ronaldheft ad802c16ea Remove blocking realmQueue 2022-08-07 17:46:13 -04:00
ronaldheft 849b6303e8 Display downloaded cover on items 2022-08-07 11:59:02 -04:00
ronaldheft 5b7fcca800 Support downloading covers and podcast episodes 2022-08-07 11:23:50 -04:00
ronaldheft b549528e23 Handle download complete lifecycle 2022-08-07 10:27:08 -04:00
ronaldheft 5495bcb945 Starting logic for handling completed downloads 2022-08-06 21:32:04 -04:00
ronaldheft 7fded5e105 Cleanup progress handling 2022-08-06 21:12:21 -04:00
ronaldheft dc8bc2fccd Clean up progress logic 2022-08-06 18:31:59 -04:00
ronaldheft af2c609405 Report download progress to the UI 2022-08-06 18:21:11 -04:00
ronaldheft d5d65e244b Rewrite downloader to use delegate and download item 2022-08-06 10:11:46 -04:00
ronaldheft 33041608f8 Switch to async logic 2022-08-04 18:25:27 -04:00
ronaldheft 528854b285 Bump min ios version to support async 2022-08-04 16:24:41 -04:00
ronaldheft e02edb9f9e Fix field collision 2022-08-02 21:05:28 -04:00
ronaldheft 37fa931672 Don't save in database until download is complete 2022-08-02 17:32:40 -04:00
ronaldheft a7424cc428 Fix persisting active config 2022-08-02 17:10:45 -04:00
ronaldheft f6c43e479d Rewrite of object model to use Unrealm
This addresses issues with JSON serialization
2022-08-01 09:40:28 -04:00
ronaldheft 0b46a9c9b1 Begin handling playback of local media 2022-07-31 13:33:36 -04:00
ronaldheft 76de92fe1f Fix id typos 2022-07-30 18:40:30 -04:00
ronaldheft 32550a75ec Return local items on DB lookup 2022-07-30 18:25:20 -04:00
ronaldheft 9eca03cfd7 Configure more local file data 2022-07-30 17:21:50 -04:00
ronaldheft fec1ec554b feat: Populate LocalFile with real info 2022-07-30 16:22:41 -04:00
ronaldheft db7a8cef77 Merge branch 'master' into ios-downloads 2022-07-30 15:16:58 -04:00
ronaldheft 2ab9dbc72d Set encoder and init methods 2022-07-17 21:50:19 -04:00
ronaldheft 52d0890032 Refactor to use extensions for objects 2022-07-16 21:46:49 -04:00
ronaldheft b7725c455b Prototype response to app 2022-07-16 20:25:04 -04:00
ronaldheft 175e642081 First pass at attempting to save library items 2022-07-09 18:37:32 -04:00
ronaldheft e620f53705 Get something saving in the database 2022-07-09 17:35:59 -04:00
ronaldheft f56561b898 Refactor into smaller functions 2022-07-09 15:57:45 -04:00
ronaldheft a29a7669e1 Add unrealm to use Swift object in db 2022-07-09 15:19:20 -04:00
ronaldheft 29f08a5e5d Fix use of optionals 2022-07-09 15:18:59 -04:00
ronaldheft f40da562a6 Fix missing braces 2022-07-09 15:18:45 -04:00
ronaldheft affcdea09b Fix class name collisions 2022-07-09 15:18:34 -04:00
ronaldheft 16ca7460f4 Convert back to Swift objects 2022-07-09 15:18:15 -04:00
benonymity 2ca9ce797d feat: iOS download groundwork 2022-07-06 10:09:17 -04:00
192 changed files with 8537 additions and 1732 deletions
+8 -2
View File
@@ -33,8 +33,8 @@ android {
applicationId "com.audiobookshelf.app" applicationId "com.audiobookshelf.app"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 87 versionCode 91
versionName "0.9.56-beta" versionName "0.9.60-beta"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -43,6 +43,12 @@ android {
} }
} }
buildTypes { buildTypes {
debug {
applicationIdSuffix ".debug"
versionNameSuffix "-debug"
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
release { release {
minifyEnabled false minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 879 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 685 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+1 -1
View File
@@ -75,7 +75,7 @@
</intent-filter> </intent-filter>
</activity> </activity>
<receiver android:name="androidx.media.session.MediaButtonReceiver" > <receiver android:name="androidx.media.session.MediaButtonReceiver" android:exported="false">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" /> <action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter> </intent-filter>
@@ -0,0 +1,34 @@
package com.audiobookshelf.app.data
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true)
data class AudioTrack(
var index:Int,
var startOffset:Double,
var duration:Double,
var title:String,
var contentUrl:String,
var mimeType:String,
var metadata:FileMetadata?,
var isLocal:Boolean,
var localFileId:String?,
var audioProbeResult:AudioProbeResult?,
var serverIndex:Int? // Need to know if server track index is different
) {
@get:JsonIgnore
val startOffsetMs get() = (startOffset * 1000L).toLong()
@get:JsonIgnore
val durationMs get() = (duration * 1000L).toLong()
@get:JsonIgnore
val endOffsetMs get() = startOffsetMs + durationMs
@get:JsonIgnore
val relPath get() = metadata?.relPath ?: ""
@JsonIgnore
fun getBookChapter():BookChapter {
return BookChapter(index + 1,startOffset, startOffset + duration, title)
}
}
@@ -1,69 +1,13 @@
package com.audiobookshelf.app.data package com.audiobookshelf.app.data
import android.net.Uri import android.content.Context
import android.os.Bundle
import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.MediaMetadataCompat
import com.audiobookshelf.app.R import androidx.media.utils.MediaConstants
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.media.MediaManager
import com.fasterxml.jackson.annotation.* import com.fasterxml.jackson.annotation.*
@JsonIgnoreProperties(ignoreUnknown = true)
data class LibraryItem(
var id:String,
var ino:String,
var libraryId:String,
var folderId:String,
var path:String,
var relPath:String,
var mtimeMs:Long,
var ctimeMs:Long,
var birthtimeMs:Long,
var addedAt:Long,
var updatedAt:Long,
var lastScan:Long?,
var scanVersion:String?,
var isMissing:Boolean,
var isInvalid:Boolean,
var mediaType:String,
var media:MediaType,
var libraryFiles:MutableList<LibraryFile>?,
var userMediaProgress:MediaProgress? // Only included when requesting library item with progress (for downloads)
) : LibraryItemWrapper() {
@get:JsonIgnore
val title get() = media.metadata.title
@get:JsonIgnore
val authorName get() = media.metadata.getAuthorDisplayName()
@JsonIgnore
fun getCoverUri():Uri {
if (media.coverPath == null) {
return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
}
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}")
}
@JsonIgnore
fun checkHasTracks():Boolean {
return if (mediaType == "podcast") {
((media as Podcast).numEpisodes ?: 0) > 0
} else {
((media as Book).numTracks ?: 0) > 0
}
}
@JsonIgnore
fun getMediaMetadata(): MediaMetadataCompat {
return MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, authorName)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, getCoverUri().toString())
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, authorName)
}.build()
}
}
// This auto-detects whether it is a Book or Podcast // This auto-detects whether it is a Book or Podcast
@JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION) @JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION)
@JsonSubTypes( @JsonSubTypes(
@@ -108,7 +52,7 @@ class Podcast(
// Add new episodes // Add new episodes
audioTracks.forEach { at -> audioTracks.forEach { at ->
if (episodes?.find{ it.audioTrack?.localFileId == at.localFileId } == null) { if (episodes?.find{ it.audioTrack?.localFileId == at.localFileId } == null) {
val newEpisode = PodcastEpisode("local_ep_" + at.localFileId,episodes?.size ?: 0 + 1,null,null,at.title,null,null,null,at,at.duration,0, null) val newEpisode = PodcastEpisode("local_ep_" + at.localFileId,(episodes?.size ?: 0) + 1,null,null,at.title,null,null,null, null, null, at,at.duration,0, null)
episodes?.add(newEpisode) episodes?.add(newEpisode)
} }
} }
@@ -121,7 +65,7 @@ class Podcast(
} }
@JsonIgnore @JsonIgnore
override fun addAudioTrack(audioTrack:AudioTrack) { override fun addAudioTrack(audioTrack:AudioTrack) {
val newEpisode = PodcastEpisode("local_" + audioTrack.localFileId,episodes?.size ?: 0 + 1,null,null,audioTrack.title,null,null,null,audioTrack,audioTrack.duration,0, null) val newEpisode = PodcastEpisode("local_ep_" + audioTrack.localFileId,(episodes?.size ?: 0) + 1,null,null,audioTrack.title,null,null,null, null, null,audioTrack,audioTrack.duration,0, null)
episodes?.add(newEpisode) episodes?.add(newEpisode)
var index = 1 var index = 1
@@ -140,9 +84,16 @@ class Podcast(
index++ index++
} }
} }
// Used for FolderScanner local podcast item to get copy of Podcast excluding episodes
@JsonIgnore
override fun getLocalCopy(): Podcast {
return Podcast(metadata as PodcastMetadata,coverPath,tags, mutableListOf(),autoDownloadEpisodes, 0)
}
@JsonIgnore @JsonIgnore
fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode { fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode {
val newEpisode = PodcastEpisode("local_" + episode.id,episodes?.size ?: 0 + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,audioTrack,audioTrack.duration,0, episode.id) val newEpisode = PodcastEpisode("local_ep_" + episode.id,(episodes?.size ?: 0) + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,null,null,audioTrack,audioTrack.duration,0, episode.id)
episodes?.add(newEpisode) episodes?.add(newEpisode)
var index = 1 var index = 1
@@ -153,10 +104,14 @@ class Podcast(
return newEpisode return newEpisode
} }
// Used for FolderScanner local podcast item to get copy of Podcast excluding episodes
@JsonIgnore @JsonIgnore
override fun getLocalCopy(): Podcast { fun getNextUnfinishedEpisode(libraryItemId:String, mediaManager: MediaManager):PodcastEpisode? {
return Podcast(metadata as PodcastMetadata,coverPath,tags, mutableListOf(),autoDownloadEpisodes, 0) val sortedEpisodes = episodes?.sortedByDescending { it.publishedAt }
val podcastEpisode = sortedEpisodes?.find { episode ->
val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItemId && it.episodeId == episode.id }
progress == null || !progress.isFinished
}
return podcastEpisode
} }
} }
@@ -180,10 +135,13 @@ class Book(
override fun setAudioTracks(audioTracks:MutableList<AudioTrack>) { override fun setAudioTracks(audioTracks:MutableList<AudioTrack>) {
tracks = audioTracks tracks = audioTracks
tracks?.sortBy { it.index } tracks?.sortBy { it.index }
// TODO: Is it necessary to calculate this each time? check if can remove safely
var totalDuration = 0.0 var totalDuration = 0.0
var currentStartOffset = 0.0
tracks?.forEach { tracks?.forEach {
totalDuration += it.duration totalDuration += it.duration
it.startOffset = currentStartOffset
currentStartOffset += it.duration
} }
duration = totalDuration duration = totalDuration
} }
@@ -216,7 +174,6 @@ class Book(
} }
duration = totalDuration duration = totalDuration
} }
@JsonIgnore @JsonIgnore
override fun getLocalCopy(): Book { override fun getLocalCopy(): Book {
return Book(metadata as BookMetadata,coverPath,tags, mutableListOf(),chapters,mutableListOf(),null,null, 0) return Book(metadata as BookMetadata,coverPath,tags, mutableListOf(),chapters,mutableListOf(),null,null, 0)
@@ -286,6 +243,8 @@ data class PodcastEpisode(
var title:String?, var title:String?,
var subtitle:String?, var subtitle:String?,
var description:String?, var description:String?,
var pubDate:String?,
var publishedAt:Long?,
var audioFile:AudioFile?, var audioFile:AudioFile?,
var audioTrack:AudioTrack?, var audioTrack:AudioTrack?,
var duration:Double?, var duration:Double?,
@@ -293,24 +252,49 @@ data class PodcastEpisode(
var serverEpisodeId:String? // For local podcasts to match with server podcasts var serverEpisodeId:String? // For local podcasts to match with server podcasts
) { ) {
@JsonIgnore @JsonIgnore
fun getMediaMetadata(libraryItem:LibraryItemWrapper): MediaMetadataCompat { fun getMediaDescription(libraryItem:LibraryItemWrapper, progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat {
var coverUri:Uri = Uri.EMPTY val coverUri = if (libraryItem is LocalLibraryItem) {
val podcast = if(libraryItem is LocalLibraryItem) { libraryItem.getCoverUri()
coverUri = libraryItem.getCoverUri()
libraryItem.media as Podcast
} else { } else {
coverUri = (libraryItem as LibraryItem).getCoverUri() (libraryItem as LibraryItem).getCoverUri()
(libraryItem as LibraryItem).media as Podcast
} }
return MediaMetadataCompat.Builder().apply { val extras = Bundle()
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id) if (progress != null) {
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title) if (progress.isFinished) {
putString(MediaMetadataCompat.METADATA_KEY_TITLE, title) extras.putInt(
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, podcast.metadata.getAuthorDisplayName()) MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString()) MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED
)
} else {
extras.putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED
)
extras.putDouble(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress
)
}
} else {
extras.putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
)
}
}.build() val libraryItemDescription = libraryItem.getMediaDescription(null, ctx)
val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
.setMediaId(id)
.setTitle(title)
.setIconUri(coverUri)
.setSubtitle(libraryItemDescription.title)
.setExtras(extras)
libraryItemDescription.iconBitmap?.let {
mediaDescriptionBuilder.setIconBitmap(it)
}
return mediaDescriptionBuilder.build()
} }
} }
@@ -360,36 +344,6 @@ data class Folder(
var fullPath:String var fullPath:String
) )
@JsonIgnoreProperties(ignoreUnknown = true)
data class AudioTrack(
var index:Int,
var startOffset:Double,
var duration:Double,
var title:String,
var contentUrl:String,
var mimeType:String,
var metadata:FileMetadata?,
var isLocal:Boolean,
var localFileId:String?,
var audioProbeResult:AudioProbeResult?,
var serverIndex:Int? // Need to know if server track index is different
) {
@get:JsonIgnore
val startOffsetMs get() = (startOffset * 1000L).toLong()
@get:JsonIgnore
val durationMs get() = (duration * 1000L).toLong()
@get:JsonIgnore
val endOffsetMs get() = startOffsetMs + durationMs
@get:JsonIgnore
val relPath get() = metadata?.relPath ?: ""
@JsonIgnore
fun getBookChapter():BookChapter {
return BookChapter(index + 1,startOffset, startOffset + duration, title)
}
}
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class BookChapter( data class BookChapter(
var id:Int, var id:Int,
@@ -1,10 +1,16 @@
package com.audiobookshelf.app.data package com.audiobookshelf.app.data
import android.content.Context
import android.support.v4.media.MediaDescriptionCompat
import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonSubTypes import com.fasterxml.jackson.annotation.JsonSubTypes
import com.fasterxml.jackson.annotation.JsonTypeInfo import com.fasterxml.jackson.annotation.JsonTypeInfo
enum class LockOrientationSetting {
NONE, PORTRAIT, LANDSCAPE
}
data class ServerConnectionConfig( data class ServerConnectionConfig(
var id:String, var id:String,
var index:Int, var index:Int,
@@ -21,7 +27,8 @@ data class DeviceSettings(
var enableAltView:Boolean, var enableAltView:Boolean,
var jumpBackwardsTime:Int, var jumpBackwardsTime:Int,
var jumpForwardTime:Int, var jumpForwardTime:Int,
var disableShakeToResetSleepTimer:Boolean var disableShakeToResetSleepTimer:Boolean,
var lockOrientation:LockOrientationSetting
) { ) {
companion object { companion object {
// Static method to get default device settings // Static method to get default device settings
@@ -31,15 +38,16 @@ data class DeviceSettings(
enableAltView = false, enableAltView = false,
jumpBackwardsTime = 10, jumpBackwardsTime = 10,
jumpForwardTime = 10, jumpForwardTime = 10,
disableShakeToResetSleepTimer = false disableShakeToResetSleepTimer = false,
lockOrientation = LockOrientationSetting.NONE
) )
} }
} }
@get:JsonIgnore @get:JsonIgnore
val jumpBackwardsTimeMs get() = jumpBackwardsTime * 1000L val jumpBackwardsTimeMs get() = (jumpBackwardsTime ?: default().jumpBackwardsTime) * 1000L
@get:JsonIgnore @get:JsonIgnore
val jumpForwardTimeMs get() = jumpForwardTime * 1000L val jumpForwardTimeMs get() = (jumpForwardTime ?: default().jumpForwardTime) * 1000L
} }
data class DeviceData( data class DeviceData(
@@ -92,7 +100,10 @@ data class LocalFolder(
JsonSubTypes.Type(LibraryItem::class), JsonSubTypes.Type(LibraryItem::class),
JsonSubTypes.Type(LocalLibraryItem::class) JsonSubTypes.Type(LocalLibraryItem::class)
) )
open class LibraryItemWrapper() open class LibraryItemWrapper(var id:String) {
@JsonIgnore
open fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat { return MediaDescriptionCompat.Builder().build() }
}
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class DeviceInfo( data class DeviceInfo(
@@ -0,0 +1,34 @@
/*
Used in Android Auto to represent a podcast episode or an audiobook in progress
*/
package com.audiobookshelf.app.data
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import org.json.JSONObject
@JsonIgnoreProperties(ignoreUnknown = true)
data class ItemInProgress(
val libraryItemWrapper: LibraryItemWrapper,
val episode: PodcastEpisode?,
val progressLastUpdate: Long,
val isLocal: Boolean
) {
companion object {
fun makeFromServerObject(serverItem: JSONObject):ItemInProgress {
val jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
val libraryItem = jacksonMapper.readValue<LibraryItem>(serverItem.toString())
var episode:PodcastEpisode? = null
if (serverItem.has("recentEpisode")) {
episode = jacksonMapper.readValue<PodcastEpisode>(serverItem.get("recentEpisode").toString())
}
val progressLastUpdate:Long = serverItem.getLong("progressLastUpdate")
return ItemInProgress(libraryItem, episode, progressLastUpdate, false)
}
}
}
@@ -0,0 +1,92 @@
package com.audiobookshelf.app.data
import android.content.Context
import android.net.Uri
import android.os.Bundle
import android.support.v4.media.MediaDescriptionCompat
import androidx.media.utils.MediaConstants
import com.audiobookshelf.app.R
import com.audiobookshelf.app.device.DeviceManager
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true)
class LibraryItem(
id:String,
var ino:String,
var libraryId:String,
var folderId:String,
var path:String,
var relPath:String,
var mtimeMs:Long,
var ctimeMs:Long,
var birthtimeMs:Long,
var addedAt:Long,
var updatedAt:Long,
var lastScan:Long?,
var scanVersion:String?,
var isMissing:Boolean,
var isInvalid:Boolean,
var mediaType:String,
var media:MediaType,
var libraryFiles:MutableList<LibraryFile>?,
var userMediaProgress:MediaProgress? // Only included when requesting library item with progress (for downloads)
) : LibraryItemWrapper(id) {
@get:JsonIgnore
val title get() = media.metadata.title
@get:JsonIgnore
val authorName get() = media.metadata.getAuthorDisplayName()
@JsonIgnore
fun getCoverUri(): Uri {
if (media.coverPath == null) {
return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
}
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}")
}
@JsonIgnore
fun checkHasTracks():Boolean {
return if (mediaType == "podcast") {
((media as Podcast).numEpisodes ?: 0) > 0
} else {
((media as Book).numTracks ?: 0) > 0
}
}
@JsonIgnore
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat {
val extras = Bundle()
if (progress != null) {
if (progress.isFinished) {
extras.putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED
)
} else {
extras.putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED
)
extras.putDouble(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress
)
}
} else if (mediaType != "podcast") {
extras.putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
)
}
return MediaDescriptionCompat.Builder()
.setMediaId(id)
.setTitle(title)
.setIconUri(getCoverUri())
.setSubtitle(authorName)
.setExtras(extras)
.build()
}
}
@@ -1,10 +1,13 @@
package com.audiobookshelf.app.data package com.audiobookshelf.app.data
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.graphics.ImageDecoder
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.MediaStore
import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat
import android.util.Log import android.util.Log
import androidx.media.utils.MediaConstants import androidx.media.utils.MediaConstants
import com.audiobookshelf.app.R import com.audiobookshelf.app.R
@@ -14,8 +17,8 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import java.util.* import java.util.*
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class LocalLibraryItem( class LocalLibraryItem(
var id:String, id:String,
var folderId:String, var folderId:String,
var basePath:String, var basePath:String,
var absolutePath:String, var absolutePath:String,
@@ -32,7 +35,7 @@ data class LocalLibraryItem(
var serverAddress:String?, var serverAddress:String?,
var serverUserId:String?, var serverUserId:String?,
var libraryItemId:String? var libraryItemId:String?
) : LibraryItemWrapper() { ) : LibraryItemWrapper(id) {
@get:JsonIgnore @get:JsonIgnore
val title get() = media.metadata.title val title get() = media.metadata.title
@get:JsonIgnore @get:JsonIgnore
@@ -98,16 +101,55 @@ data class LocalLibraryItem(
} }
@JsonIgnore @JsonIgnore
fun getMediaMetadata(): MediaMetadataCompat { override fun getMediaDescription(progress:MediaProgressWrapper?, ctx:Context?): MediaDescriptionCompat {
val coverUri = getCoverUri() val coverUri = getCoverUri()
return MediaMetadataCompat.Builder().apply { var bitmap:Bitmap? = null
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id) if (coverContentUrl != null) {
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title) ctx?.let {
putString(MediaMetadataCompat.METADATA_KEY_TITLE, title) bitmap = if (Build.VERSION.SDK_INT < 28) {
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, authorName) MediaStore.Images.Media.getBitmap(it.contentResolver, coverUri)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString()) } else {
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, authorName) val source: ImageDecoder.Source = ImageDecoder.createSource(it.contentResolver, coverUri)
}.build() ImageDecoder.decodeBitmap(source)
}
}
}
val extras = Bundle()
if (progress != null) {
if (progress.isFinished) {
extras.putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED
)
} else {
extras.putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED
)
extras.putDouble(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress
)
}
} else if (mediaType != "podcast") {
extras.putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
)
}
val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
.setMediaId(id)
.setTitle(title)
.setIconUri(coverUri)
.setSubtitle(authorName)
.setExtras(extras)
bitmap?.let {
mediaDescriptionBuilder.setIconBitmap(bitmap)
}
return mediaDescriptionBuilder.build()
} }
} }
@@ -1,8 +1,11 @@
package com.audiobookshelf.app.data package com.audiobookshelf.app.data
import android.content.Context
import android.graphics.ImageDecoder
import android.net.Uri import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.MediaMetadataCompat
import com.audiobookshelf.app.R
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.player.MediaProgressSyncData import com.audiobookshelf.app.player.MediaProgressSyncData
import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonIgnore
@@ -13,6 +16,7 @@ import com.google.android.gms.cast.MediaInfo
import com.google.android.gms.cast.MediaQueueItem import com.google.android.gms.cast.MediaQueueItem
import com.google.android.gms.common.images.WebImage import com.google.android.gms.common.images.WebImage
// TODO: enum or something in kotlin? // TODO: enum or something in kotlin?
val PLAYMETHOD_DIRECTPLAY = 0 val PLAYMETHOD_DIRECTPLAY = 0
val PLAYMETHOD_DIRECTSTREAM = 1 val PLAYMETHOD_DIRECTSTREAM = 1
@@ -53,6 +57,8 @@ class PlaybackSession(
@get:JsonIgnore @get:JsonIgnore
val isLocal get() = playMethod == PLAYMETHOD_LOCAL val isLocal get() = playMethod == PLAYMETHOD_LOCAL
@get:JsonIgnore @get:JsonIgnore
val isPodcastEpisode get() = mediaType == "podcast"
@get:JsonIgnore
val currentTimeMs get() = (currentTime * 1000L).toLong() val currentTimeMs get() = (currentTime * 1000L).toLong()
@get:JsonIgnore @get:JsonIgnore
val totalDurationMs get() = (getTotalDuration() * 1000L).toLong() val totalDurationMs get() = (getTotalDuration() * 1000L).toLong()
@@ -105,9 +111,9 @@ class PlaybackSession(
@JsonIgnore @JsonIgnore
fun getCoverUri(): Uri { fun getCoverUri(): Uri {
if (localLibraryItem?.coverContentUrl != null) return Uri.parse(localLibraryItem?.coverContentUrl) ?: Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon) if (localLibraryItem?.coverContentUrl != null) return Uri.parse(localLibraryItem?.coverContentUrl) ?: Uri.parse("android.resource://com.audiobookshelf.app/" + com.audiobookshelf.app.R.drawable.icon)
if (coverPath == null) return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon) if (coverPath == null) return Uri.parse("android.resource://com.audiobookshelf.app/" + com.audiobookshelf.app.R.drawable.icon)
return Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}") return Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")
} }
@@ -118,13 +124,33 @@ class PlaybackSession(
} }
@JsonIgnore @JsonIgnore
fun getMediaMetadataCompat(): MediaMetadataCompat { fun getMediaMetadataCompat(ctx: Context): MediaMetadataCompat {
val metadataBuilder = MediaMetadataCompat.Builder() val metadataBuilder = MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, displayTitle) .putString(MediaMetadataCompat.METADATA_KEY_TITLE, displayTitle)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle) .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, displayAuthor) .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, displayAuthor)
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, displayAuthor) .putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, displayAuthor)
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, displayAuthor)
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, displayAuthor)
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, displayAuthor)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, displayAuthor)
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id) .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getCoverUri().toString())
.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, getCoverUri().toString())
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, getCoverUri().toString())
// Local covers get bitmap
if (localLibraryItem?.coverContentUrl != null) {
val bitmap = if (Build.VERSION.SDK_INT < 28) {
MediaStore.Images.Media.getBitmap(ctx.contentResolver, getCoverUri())
} else {
val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, getCoverUri())
ImageDecoder.decodeBitmap(source)
}
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap)
}
return metadataBuilder.build() return metadataBuilder.build()
} }
@@ -136,6 +162,9 @@ class PlaybackSession(
.setArtist(displayAuthor) .setArtist(displayAuthor)
.setAlbumArtist(displayAuthor) .setAlbumArtist(displayAuthor)
.setSubtitle(displayAuthor) .setSubtitle(displayAuthor)
.setAlbumTitle(displayAuthor)
.setDescription(displayAuthor)
.setArtworkUri(getCoverUri())
val contentUri = this.getContentUri(audioTrack) val contentUri = this.getContentUri(audioTrack)
metadataBuilder.setMediaUri(contentUri) metadataBuilder.setMediaUri(contentUri)
@@ -169,7 +198,9 @@ class PlaybackSession(
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_TITLE, displayTitle ?: "") castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_TITLE, displayTitle ?: "")
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_ARTIST, displayAuthor ?: "") castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_ARTIST, displayAuthor ?: "")
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_ALBUM_TITLE, displayAuthor ?: "")
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_CHAPTER_TITLE, audioTrack.title) castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_CHAPTER_TITLE, audioTrack.title)
castMetadata.putInt(com.google.android.gms.cast.MediaMetadata.KEY_TRACK_NUMBER, audioTrack.index) castMetadata.putInt(com.google.android.gms.cast.MediaMetadata.KEY_TRACK_NUMBER, audioTrack.index)
return castMetadata return castMetadata
} }
@@ -2,12 +2,8 @@ package com.audiobookshelf.app.media
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.os.Bundle
import android.support.v4.media.MediaBrowserCompat import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat
import android.util.Log import android.util.Log
import androidx.media.utils.MediaConstants
import com.audiobookshelf.app.data.* import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.server.ApiHandler import com.audiobookshelf.app.server.ApiHandler
@@ -26,11 +22,11 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
var selectedLibraryItems = mutableListOf<LibraryItem>() var selectedLibraryItems = mutableListOf<LibraryItem>()
var selectedLibraryId = "" var selectedLibraryId = ""
var selectedLibraryItemWrapper:LibraryItemWrapper? = null
var selectedPodcast:Podcast? = null var selectedPodcast:Podcast? = null
var selectedLibraryItemId:String? = null var selectedLibraryItemId:String? = null
var serverPodcastEpisodes = listOf<PodcastEpisode>() var podcastEpisodeLibraryItemMap = mutableMapOf<String, LibraryItemWithEpisode>()
var serverLibraryCategories = listOf<LibraryCategory>() var serverLibraryCategories = listOf<LibraryCategory>()
var serverItemsInProgress = listOf<ItemInProgress>()
var serverLibraries = listOf<Library>() var serverLibraries = listOf<Library>()
var serverConfigIdUsed:String? = null var serverConfigIdUsed:String? = null
var serverConfigLastPing:Long = 0L var serverConfigLastPing:Long = 0L
@@ -71,7 +67,7 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
val serverConnConfig = if (DeviceManager.isConnectedToServer) DeviceManager.serverConnectionConfig else DeviceManager.deviceData.getLastServerConnectionConfig() val serverConnConfig = if (DeviceManager.isConnectedToServer) DeviceManager.serverConnectionConfig else DeviceManager.deviceData.getLastServerConnectionConfig()
if (!DeviceManager.isConnectedToServer || !apiHandler.isOnline() || serverConnConfig == null || serverConnConfig.id !== serverConfigIdUsed) { if (!DeviceManager.isConnectedToServer || !apiHandler.isOnline() || serverConnConfig == null || serverConnConfig.id !== serverConfigIdUsed) {
serverPodcastEpisodes = listOf() podcastEpisodeLibraryItemMap = mutableMapOf()
serverLibraryCategories = listOf() serverLibraryCategories = listOf()
serverLibraries = listOf() serverLibraries = listOf()
serverLibraryItems = mutableListOf() serverLibraryItems = mutableListOf()
@@ -80,13 +76,13 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
} }
} }
fun loadLibraryCategories(libraryId:String, cb: (List<LibraryCategory>) -> Unit) { fun loadItemsInProgressForAllLibraries(cb: (List<ItemInProgress>) -> Unit) {
if (serverLibraryCategories.isNotEmpty()) { if (serverItemsInProgress.isNotEmpty()) {
cb(serverLibraryCategories) cb(serverItemsInProgress)
} else { } else {
apiHandler.getLibraryCategories(libraryId) { apiHandler.getAllItemsInProgress { itemsInProgress ->
serverLibraryCategories = it serverItemsInProgress = itemsInProgress
cb(it) cb(serverItemsInProgress)
} }
} }
} }
@@ -125,48 +121,43 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
} }
} }
fun loadPodcastEpisodeMediaBrowserItems(libraryItemId:String, cb: (MutableList<MediaBrowserCompat.MediaItem>) -> Unit) { fun loadPodcastEpisodeMediaBrowserItems(libraryItemId:String, ctx:Context, cb: (MutableList<MediaBrowserCompat.MediaItem>) -> Unit) {
loadLibraryItem(libraryItemId) { libraryItemWrapper -> loadLibraryItem(libraryItemId) { libraryItemWrapper ->
Log.d(tag, "Loaded Podcast library item $libraryItemWrapper") Log.d(tag, "Loaded Podcast library item $libraryItemWrapper")
selectedLibraryItemWrapper = libraryItemWrapper
libraryItemWrapper?.let { libraryItemWrapper?.let {
if (libraryItemWrapper is LocalLibraryItem) { // Local podcast episodes if (libraryItemWrapper is LocalLibraryItem) { // Local podcast episodes
if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) { if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) {
serverPodcastEpisodes = listOf()
cb(mutableListOf()) cb(mutableListOf())
} else { } else {
val podcast = libraryItemWrapper.media as Podcast val podcast = libraryItemWrapper.media as Podcast
serverPodcastEpisodes = podcast.episodes ?: listOf()
selectedLibraryItemId = libraryItemWrapper.id selectedLibraryItemId = libraryItemWrapper.id
selectedPodcast = podcast selectedPodcast = podcast
val children = podcast.episodes?.map { podcastEpisode -> val children = podcast.episodes?.map { podcastEpisode ->
Log.d(tag, "Local Podcast Episode ${podcastEpisode.title} | ${podcastEpisode.id}") Log.d(tag, "Local Podcast Episode ${podcastEpisode.title} | ${podcastEpisode.id}")
val mediaMetadata = podcastEpisode.getMediaMetadata(libraryItemWrapper)
val progress = DeviceManager.dbManager.getLocalMediaProgress("${libraryItemWrapper.id}-${podcastEpisode.id}") val progress = DeviceManager.dbManager.getLocalMediaProgress("${libraryItemWrapper.id}-${podcastEpisode.id}")
val description = getMediaDescriptionFromMediaMetadata(mediaMetadata, progress) val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress, ctx)
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
} }
children?.let { cb(children as MutableList) } ?: cb(mutableListOf()) children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
} }
} else if (libraryItemWrapper is LibraryItem) { // Server podcast episodes } else if (libraryItemWrapper is LibraryItem) { // Server podcast episodes
if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) { if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) {
serverPodcastEpisodes = listOf()
cb(mutableListOf()) cb(mutableListOf())
} else { } else {
val podcast = libraryItemWrapper.media as Podcast val podcast = libraryItemWrapper.media as Podcast
serverPodcastEpisodes = podcast.episodes ?: listOf() podcast.episodes?.forEach { podcastEpisode ->
podcastEpisodeLibraryItemMap[podcastEpisode.id] = LibraryItemWithEpisode(libraryItemWrapper, podcastEpisode)
}
selectedLibraryItemId = libraryItemWrapper.id selectedLibraryItemId = libraryItemWrapper.id
selectedPodcast = podcast selectedPodcast = podcast
val children = podcast.episodes?.map { podcastEpisode -> val children = podcast.episodes?.map { podcastEpisode ->
val mediaMetadata = podcastEpisode.getMediaMetadata(libraryItemWrapper)
val progress = serverUserMediaProgress.find { it.libraryItemId == libraryItemWrapper.id && it.episodeId == podcastEpisode.id } val progress = serverUserMediaProgress.find { it.libraryItemId == libraryItemWrapper.id && it.episodeId == podcastEpisode.id }
val description = getMediaDescriptionFromMediaMetadata(mediaMetadata, progress) val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress, ctx)
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
} }
children?.let { cb(children as MutableList) } ?: cb(mutableListOf()) children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
@@ -275,26 +266,25 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
} }
// TODO: Load currently listening category for local items fun loadServerUserMediaProgress(cb: () -> Unit) {
fun loadLocalCategory():List<LibraryCategory> { Log.d(tag, "Loading server media progress")
val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book") if (DeviceManager.serverConnectionConfig == null) {
val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast") return cb()
val cats = mutableListOf<LibraryCategory>()
if (localBooks.isNotEmpty()) {
cats.add(LibraryCategory("local-books", "Local Books", "book", localBooks, true))
} }
if (localPodcasts.isNotEmpty()) {
cats.add(LibraryCategory("local-podcasts", "Local Podcasts", "podcast", localPodcasts, true)) DeviceManager.serverConnectionConfig?.let { config ->
apiHandler.authorize(config) {
Log.d(tag, "loadServerUserMediaProgress: Authorized server config ${config.address} result = $it")
if (!it.isNullOrEmpty()) {
serverUserMediaProgress = it
}
cb()
}
} }
return cats
} }
fun loadAndroidAutoItems(cb: (List<LibraryCategory>) -> Unit) { fun loadAndroidAutoItems(cb: () -> Unit) {
Log.d(tag, "Load android auto items") Log.d(tag, "Load android auto items")
val cats = mutableListOf<LibraryCategory>()
val localCategories = loadLocalCategory()
cats.addAll(localCategories)
// Check if any valid server connection if not use locally downloaded books // Check if any valid server connection if not use locally downloaded books
checkSetValidServerConnectionConfig { isConnected -> checkSetValidServerConnectionConfig { isConnected ->
@@ -304,40 +294,29 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
loadLibraries { libraries -> loadLibraries { libraries ->
if (libraries.isEmpty()) { if (libraries.isEmpty()) {
Log.w(tag, "No libraries returned from server request") Log.w(tag, "No libraries returned from server request")
cb(cats) // Return download category only cb()
} else { } else {
val library = libraries[0] val library = libraries[0]
Log.d(tag, "Loading categories for library ${library.name} - ${library.id} - ${library.mediaType}") Log.d(tag, "Loading categories for library ${library.name} - ${library.id} - ${library.mediaType}")
loadLibraryCategories(library.id) { libraryCategories -> loadItemsInProgressForAllLibraries { itemsInProgress ->
itemsInProgress.forEach {
// Only using book or podcast library categories for now val libraryItem = it.libraryItemWrapper as LibraryItem
libraryCategories.forEach { if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
serverLibraryItems.add(libraryItem)
// Add items in continue listening to serverLibraryItems
if (it.id == "continue-listening") {
it.entities.forEach { libraryItemWrapper ->
val libraryItem = libraryItemWrapper as LibraryItem
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
serverLibraryItems.add(libraryItem)
}
}
} }
if (it.episode != null) {
// Log.d(tag, "Found library category ${it.label} with type ${it.type}") podcastEpisodeLibraryItemMap[it.episode.id] = LibraryItemWithEpisode(it.libraryItemWrapper, it.episode)
if (it.type == library.mediaType) {
// Log.d(tag, "Using library category ${it.id}")
cats.add(it)
} }
} }
cb(cats) cb() // Fully loaded
} }
} }
} }
} else { // Not connected/no internet sent downloaded cats only } else { // Not connected to server
cb(cats) cb()
} }
} }
} }
@@ -355,12 +334,7 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
if (id.startsWith("local")) { if (id.startsWith("local")) {
return DeviceManager.dbManager.getLocalLibraryItemWithEpisode(id) return DeviceManager.dbManager.getLocalLibraryItemWithEpisode(id)
} else { } else {
val podcastEpisode = serverPodcastEpisodes.find { it.id == id } return podcastEpisodeLibraryItemMap[id]
return if (podcastEpisode != null && selectedLibraryItemWrapper != null) {
LibraryItemWithEpisode(selectedLibraryItemWrapper!!, podcastEpisode)
} else {
null
}
} }
} }
@@ -394,41 +368,6 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
} }
} }
fun getMediaDescriptionFromMediaMetadata(item: MediaMetadataCompat, progress:MediaProgressWrapper?): MediaDescriptionCompat {
val extras = Bundle()
if (progress != null) {
Log.d(tag, "Has media progress for ${item.description.title} | ${progress}")
if (progress.isFinished) {
extras.putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED
)
} else {
extras.putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED
)
extras.putDouble(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress
)
}
} else {
Log.d(tag, "No media progress for ${item.description.title} | ${item.description.mediaId}")
extras.putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
)
}
return MediaDescriptionCompat.Builder()
.setMediaId(item.description.mediaId)
.setTitle(item.description.title)
.setIconUri(item.description.iconUri)
.setSubtitle(item.description.subtitle)
.setExtras(extras).build()
}
private fun levenshtein(lhs : CharSequence, rhs : CharSequence) : Int { private fun levenshtein(lhs : CharSequence, rhs : CharSequence) : Int {
val lhsLength = lhs.length + 1 val lhsLength = lhs.length + 1
val rhsLength = rhs.length + 1 val rhsLength = rhs.length + 1
@@ -2,7 +2,10 @@ package com.audiobookshelf.app.player
import android.app.PendingIntent import android.app.PendingIntent
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.ImageDecoder
import android.net.Uri import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.support.v4.media.session.MediaControllerCompat import android.support.v4.media.session.MediaControllerCompat
import android.util.Log import android.util.Log
import com.audiobookshelf.app.R import com.audiobookshelf.app.R
@@ -42,13 +45,25 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
// `getCurrentLargeIcon` don't cause the bitmap to be recreated. // `getCurrentLargeIcon` don't cause the bitmap to be recreated.
currentIconUri = albumArtUri currentIconUri = albumArtUri
Log.d(tag, "ART $currentIconUri") Log.d(tag, "ART $currentIconUri")
serviceScope.launch {
currentBitmap = albumArtUri?.let { if (currentIconUri.toString().startsWith("content://")) {
resolveUriAsBitmap(it) currentBitmap = if (Build.VERSION.SDK_INT < 28) {
MediaStore.Images.Media.getBitmap(playerNotificationService.contentResolver, currentIconUri)
} else {
val source: ImageDecoder.Source = ImageDecoder.createSource(playerNotificationService.contentResolver, currentIconUri!!)
ImageDecoder.decodeBitmap(source)
} }
currentBitmap?.let { callback.onBitmap(it) } currentBitmap
} else {
serviceScope.launch {
currentBitmap = albumArtUri?.let {
resolveUriAsBitmap(it)
}
currentBitmap?.let { callback.onBitmap(it) }
}
null
} }
null
} else { } else {
currentBitmap currentBitmap
} }
@@ -10,7 +10,7 @@ import com.audiobookshelf.app.data.*
class BrowseTree( class BrowseTree(
val context: Context, val context: Context,
libraryCategories: List<LibraryCategory>, itemsInProgress: List<ItemInProgress>,
libraries: List<Library> libraries: List<Library>
) { ) {
private val mediaIdToChildren = mutableMapOf<String, MutableList<MediaMetadataCompat>>() private val mediaIdToChildren = mutableMapOf<String, MutableList<MediaMetadataCompat>>()
@@ -21,8 +21,7 @@ class BrowseTree(
* @param drawableId - drawable res id * @param drawableId - drawable res id
* @return - uri * @return - uri
*/ */
fun getUriToDrawable(context: Context, fun getUriToDrawable(@AnyRes drawableId: Int): Uri {
@AnyRes drawableId: Int): Uri {
return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE
+ "://" + context.resources.getResourcePackageName(drawableId) + "://" + context.resources.getResourcePackageName(drawableId)
+ '/' + context.resources.getResourceTypeName(drawableId) + '/' + context.resources.getResourceTypeName(drawableId)
@@ -32,38 +31,26 @@ class BrowseTree(
init { init {
val rootList = mediaIdToChildren[AUTO_BROWSE_ROOT] ?: mutableListOf() val rootList = mediaIdToChildren[AUTO_BROWSE_ROOT] ?: mutableListOf()
val continueReadingMetadata = MediaMetadataCompat.Builder().apply { val continueListeningMetadata = MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, CONTINUE_ROOT) putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, CONTINUE_ROOT)
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Listening") putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Listening")
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_localaudio).toString()) putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.exo_icon_localaudio).toString())
}.build() }.build()
val downloadsMetadata = MediaMetadataCompat.Builder().apply { val downloadsMetadata = MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, DOWNLOADS_ROOT) putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, DOWNLOADS_ROOT)
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Downloads") putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Downloads")
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_downloaddone).toString()) putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.exo_icon_downloaddone).toString())
}.build() }.build()
val librariesMetadata = MediaMetadataCompat.Builder().apply { val librariesMetadata = MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, LIBRARIES_ROOT) putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, LIBRARIES_ROOT)
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Libraries") putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Libraries")
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.icon_library_folder).toString()) putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.icon_library_folder).toString())
}.build() }.build()
// Server continue Listening cat if (!itemsInProgress.isEmpty()) {
libraryCategories.find { it.id == "continue-listening" }?.let { continueListeningCategory -> rootList += continueListeningMetadata
val continueListeningMediaMetadata = continueListeningCategory.entities.map { liw ->
val libraryItem = liw as LibraryItem
libraryItem.getMediaMetadata()
}
if (continueListeningMediaMetadata.isNotEmpty()) {
rootList += continueReadingMetadata
}
continueListeningMediaMetadata.forEach {
val children = mediaIdToChildren[CONTINUE_ROOT] ?: mutableListOf()
children += it
mediaIdToChildren[CONTINUE_ROOT] = children
}
} }
if (libraries.isNotEmpty()) { if (libraries.isNotEmpty()) {
@@ -78,23 +65,6 @@ class BrowseTree(
} }
rootList += downloadsMetadata rootList += downloadsMetadata
libraryCategories.find { it.id == "local-books" }?.let { localBooksCat ->
localBooksCat.entities.forEach { libc ->
val libraryItem = libc as LocalLibraryItem
val children = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf()
children += libraryItem.getMediaMetadata()
mediaIdToChildren[DOWNLOADS_ROOT] = children
}
}
libraryCategories.find { it.id == "local-podcasts" }?.let { localPodcastsCat ->
localPodcastsCat.entities.forEach { libc ->
val libraryItem = libc as LocalLibraryItem
val children = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf()
children += libraryItem.getMediaMetadata()
mediaIdToChildren[DOWNLOADS_ROOT] = children
}
}
mediaIdToChildren[AUTO_BROWSE_ROOT] = rootList mediaIdToChildren[AUTO_BROWSE_ROOT] = rootList
} }
@@ -115,7 +115,19 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
failedSyncs = 0 failedSyncs = 0
cb() cb()
} }
}
fun finished(cb: () -> Unit) {
if (!listeningTimerRunning) return
listeningTimerTask?.cancel()
listeningTimerTask = null
listeningTimerRunning = false
Log.d(tag, "stop: Stopping listening for $currentDisplayTitle")
sync(true, currentPlaybackSession?.duration ?: 0.0) {
reset()
cb()
}
} }
fun syncFromServerProgress(mediaProgress: MediaProgress) { fun syncFromServerProgress(mediaProgress: MediaProgress) {
@@ -7,10 +7,8 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import android.os.Message import android.os.Message
import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import android.util.Log import android.util.Log
import android.view.KeyEvent import android.view.KeyEvent
import com.audiobookshelf.app.R
import com.audiobookshelf.app.data.LibraryItemWrapper import com.audiobookshelf.app.data.LibraryItemWrapper
import com.audiobookshelf.app.data.PodcastEpisode import com.audiobookshelf.app.data.PodcastEpisode
import java.util.* import java.util.*
@@ -21,7 +19,6 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
private var mediaButtonClickCount: Int = 0 private var mediaButtonClickCount: Int = 0
var mediaButtonClickTimeout: Long = 1000 //ms var mediaButtonClickTimeout: Long = 1000 //ms
var seekAmount: Long = 20000 //ms
override fun onPrepare() { override fun onPrepare() {
Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT") Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT")
@@ -75,19 +72,19 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
} }
override fun onSkipToPrevious() { override fun onSkipToPrevious() {
playerNotificationService.seekBackward(seekAmount) playerNotificationService.skipToPrevious()
} }
override fun onSkipToNext() { override fun onSkipToNext() {
playerNotificationService.seekForward(seekAmount) playerNotificationService.skipToNext()
} }
override fun onFastForward() { override fun onFastForward() {
playerNotificationService.seekForward(seekAmount) playerNotificationService.jumpForward()
} }
override fun onRewind() { override fun onRewind() {
playerNotificationService.seekForward(seekAmount) playerNotificationService.jumpBackward()
} }
override fun onSeekTo(pos: Long) { override fun onSeekTo(pos: Long) {
@@ -179,10 +176,16 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
handleMediaButtonClickCount() handleMediaButtonClickCount()
} }
KeyEvent.KEYCODE_MEDIA_NEXT -> { KeyEvent.KEYCODE_MEDIA_NEXT -> {
playerNotificationService.seekForward(seekAmount) playerNotificationService.jumpForward()
} }
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> { KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
playerNotificationService.seekBackward(seekAmount) playerNotificationService.jumpBackward()
}
KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
playerNotificationService.jumpForward()
}
KeyEvent.KEYCODE_MEDIA_REWIND -> {
playerNotificationService.jumpBackward()
} }
KeyEvent.KEYCODE_MEDIA_STOP -> { KeyEvent.KEYCODE_MEDIA_STOP -> {
playerNotificationService.closePlayback() playerNotificationService.closePlayback()
@@ -226,22 +229,24 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
override fun handleMessage(msg: Message) { override fun handleMessage(msg: Message) {
super.handleMessage(msg) super.handleMessage(msg)
if (2 == msg.what) { if (2 == msg.what) {
playerNotificationService.seekBackward(seekAmount) playerNotificationService.jumpBackward()
playerNotificationService.play() playerNotificationService.play()
} }
else if (msg.what >= 3) { else if (msg.what >= 3) {
playerNotificationService.seekForward(seekAmount) playerNotificationService.jumpForward()
playerNotificationService.play() playerNotificationService.play()
} }
} }
} }
// Example Using a custom action in android auto override fun onCustomAction(action: String?, extras: Bundle?) {
// override fun onCustomAction(action: String?, extras: Bundle?) { super.onCustomAction(action, extras)
// super.onCustomAction(action, extras)
// when (action) {
// if ("com.audiobookshelf.app.PLAYBACK_RATE" == action) { CUSTOM_ACTION_JUMP_FORWARD -> onFastForward()
// CUSTOM_ACTION_JUMP_BACKWARD -> onRewind()
// } CUSTOM_ACTION_SKIP_FORWARD -> onSkipToNext()
// } CUSTOM_ACTION_SKIP_BACKWARD -> onSkipToPrevious()
}
}
} }
@@ -0,0 +1,6 @@
package com.audiobookshelf.app.player
const val CUSTOM_ACTION_JUMP_FORWARD = "com.audiobookshelf.customAction.jump_forward";
const val CUSTOM_ACTION_JUMP_BACKWARD = "com.audiobookshelf.customAction.jump_backward";
const val CUSTOM_ACTION_SKIP_FORWARD = "com.audiobookshelf.customAction.skip_forward";
const val CUSTOM_ACTION_SKIP_BACKWARD = "com.audiobookshelf.customAction.skip_backward";
@@ -52,6 +52,8 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
if (playerNotificationService.currentPlayer.playbackState == Player.STATE_ENDED) { if (playerNotificationService.currentPlayer.playbackState == Player.STATE_ENDED) {
Log.d(tag, "STATE_ENDED") Log.d(tag, "STATE_ENDED")
playerNotificationService.sendClientMetadata(PlayerState.ENDED) playerNotificationService.sendClientMetadata(PlayerState.ENDED)
playerNotificationService.handlePlaybackEnded()
} }
if (playerNotificationService.currentPlayer.playbackState == Player.STATE_IDLE) { if (playerNotificationService.currentPlayer.playbackState == Player.STATE_IDLE) {
Log.d(tag, "STATE_IDLE") Log.d(tag, "STATE_IDLE")
@@ -3,7 +3,9 @@ package com.audiobookshelf.app.player
import android.app.* import android.app.*
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Color import android.graphics.Color
import android.graphics.ImageDecoder
import android.hardware.Sensor import android.hardware.Sensor
import android.hardware.SensorManager import android.hardware.SensorManager
import android.net.ConnectivityManager import android.net.ConnectivityManager
@@ -11,8 +13,10 @@ import android.net.Network
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.net.NetworkRequest import android.net.NetworkRequest
import android.os.* import android.os.*
import android.provider.MediaStore
import android.support.v4.media.MediaBrowserCompat import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaControllerCompat import android.support.v4.media.session.MediaControllerCompat
import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat import android.support.v4.media.session.PlaybackStateCompat
@@ -20,7 +24,6 @@ import android.util.Log
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat
import androidx.media.MediaBrowserServiceCompat import androidx.media.MediaBrowserServiceCompat
import androidx.media.utils.MediaConstants import androidx.media.utils.MediaConstants
import com.audiobookshelf.app.BuildConfig import com.audiobookshelf.app.BuildConfig
@@ -30,9 +33,11 @@ import com.audiobookshelf.app.data.DeviceInfo
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.media.MediaManager import com.audiobookshelf.app.media.MediaManager
import com.audiobookshelf.app.server.ApiHandler import com.audiobookshelf.app.server.ApiHandler
import com.fasterxml.jackson.annotation.JsonIgnore
import com.google.android.exoplayer2.* import com.google.android.exoplayer2.*
import com.google.android.exoplayer2.audio.AudioAttributes import com.google.android.exoplayer2.audio.AudioAttributes
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.CustomActionProvider
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator
import com.google.android.exoplayer2.source.MediaSource import com.google.android.exoplayer2.source.MediaSource
import com.google.android.exoplayer2.source.ProgressiveMediaSource import com.google.android.exoplayer2.source.ProgressiveMediaSource
@@ -42,7 +47,10 @@ import com.google.android.exoplayer2.upstream.*
import java.util.* import java.util.*
import kotlin.concurrent.schedule import kotlin.concurrent.schedule
const val SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m const val SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m
const val PLAYER_CAST = "cast-player";
const val PLAYER_EXO = "exo-player";
class PlayerNotificationService : MediaBrowserServiceCompat() { class PlayerNotificationService : MediaBrowserServiceCompat() {
@@ -246,6 +254,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX) playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX)
playerNotificationManager.setUseFastForwardActionInCompactView(true) playerNotificationManager.setUseFastForwardActionInCompactView(true)
playerNotificationManager.setUseRewindActionInCompactView(true) playerNotificationManager.setUseRewindActionInCompactView(true)
playerNotificationManager.setSmallIcon(R.drawable.exo_icon_localaudio)
// Unknown action // Unknown action
playerNotificationManager.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE) playerNotificationManager.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE)
@@ -254,6 +263,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
mediaSessionConnector = MediaSessionConnector(mediaSession) mediaSessionConnector = MediaSessionConnector(mediaSession)
val queueNavigator: TimelineQueueNavigator = object : TimelineQueueNavigator(mediaSession) { val queueNavigator: TimelineQueueNavigator = object : TimelineQueueNavigator(mediaSession) {
override fun getSupportedQueueNavigatorActions(player: Player): Long {
return PlaybackStateCompat.ACTION_PLAY_PAUSE or PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PAUSE
}
override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat { override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat {
if (currentPlaybackSession == null) { if (currentPlaybackSession == null) {
Log.e(tag,"Playback session is not set - returning blank MediaDescriptionCompat") Log.e(tag,"Playback session is not set - returning blank MediaDescriptionCompat")
@@ -262,6 +275,17 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
val coverUri = currentPlaybackSession!!.getCoverUri() val coverUri = currentPlaybackSession!!.getCoverUri()
var bitmap:Bitmap? = null
// Local covers get bitmap
if (currentPlaybackSession!!.localLibraryItem?.coverContentUrl != null) {
bitmap = if (Build.VERSION.SDK_INT < 28) {
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
} else {
val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, coverUri)
ImageDecoder.decodeBitmap(source)
}
}
// Fix for local images crashing on Android 11 for specific devices // Fix for local images crashing on Android 11 for specific devices
// https://stackoverflow.com/questions/64186578/android-11-mediastyle-notification-crash/64232958#64232958 // https://stackoverflow.com/questions/64186578/android-11-mediastyle-notification-crash/64232958#64232958
try { try {
@@ -274,11 +298,19 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
Log.e(tag, "Grant uri permission error $error") Log.e(tag, "Grant uri permission error $error")
} }
return MediaDescriptionCompat.Builder() val extra = Bundle()
.setMediaId(currentPlaybackSession!!.id) extra.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, currentPlaybackSession!!.displayAuthor)
val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
.setExtras(extra)
.setTitle(currentPlaybackSession!!.displayTitle) .setTitle(currentPlaybackSession!!.displayTitle)
.setSubtitle(currentPlaybackSession!!.displayAuthor) .setIconUri(coverUri)
.setIconUri(coverUri).build()
bitmap?.let {
mediaDescriptionBuilder.setIconBitmap(it)
}
return mediaDescriptionBuilder.build()
} }
} }
@@ -294,18 +326,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
mediaSessionConnector.setQueueNavigator(queueNavigator) mediaSessionConnector.setQueueNavigator(queueNavigator)
mediaSessionConnector.setPlaybackPreparer(MediaSessionPlaybackPreparer(this)) mediaSessionConnector.setPlaybackPreparer(MediaSessionPlaybackPreparer(this))
// Example adding custom action with icon in android auto
// mediaSessionConnector.setCustomActionProviders(object : MediaSessionConnector.CustomActionProvider {
// override fun onCustomAction(player: Player, action: String, extras: Bundle?) {
// }
// override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? {
// var icon = R.drawable.exo_icon_rewind
// return PlaybackStateCompat.CustomAction.Builder(
// "com.audiobookshelf.app.PLAYBACK_RATE", "Playback Rate", icon)
// .build()
// }
// })
mediaSession.setCallback(MediaSessionCallback(this)) mediaSession.setCallback(MediaSessionCallback(this))
initializeMPlayer() initializeMPlayer()
@@ -320,13 +340,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
1000 * 20 // 20s playback rebuffer 1000 * 20 // 20s playback rebuffer
).build() ).build()
val seekBackTime = DeviceManager.deviceData.deviceSettings?.jumpBackwardsTimeMs ?: 10000
val seekForwardTime = DeviceManager.deviceData.deviceSettings?.jumpForwardTimeMs ?: 10000
mPlayer = ExoPlayer.Builder(this) mPlayer = ExoPlayer.Builder(this)
.setLoadControl(customLoadControl) .setLoadControl(customLoadControl)
.setSeekBackIncrementMs(seekBackTime) .setSeekBackIncrementMs(deviceSettings.jumpBackwardsTimeMs)
.setSeekForwardIncrementMs(seekForwardTime) .setSeekForwardIncrementMs(deviceSettings.jumpForwardTimeMs)
.build() .build()
mPlayer.setHandleAudioBecomingNoisy(true) mPlayer.setHandleAudioBecomingNoisy(true)
mPlayer.addListener(PlayerListener(this)) mPlayer.addListener(PlayerListener(this))
@@ -350,20 +367,43 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
} }
// TODO: When an item isFinished the currentTime should be reset to 0
// will reset the time if currentTime is within 5s of duration (for android auto)
Log.d(tag, "Prepare Player Session Current Time=${playbackSession.currentTime}, Duration=${playbackSession.duration}")
if (playbackSession.duration - playbackSession.currentTime < 5) {
Log.d(tag, "Prepare Player Session is finished, so restart it")
playbackSession.currentTime = 0.0
}
isClosed = false isClosed = false
val customActionProviders = mutableListOf(
JumpBackwardCustomActionProvider(),
JumpForwardCustomActionProvider(),
)
val metadata = playbackSession.getMediaMetadataCompat(ctx)
mediaSession.setMetadata(metadata)
val mediaItems = playbackSession.getMediaItems()
val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f
initialPlaybackRate = playbackRate initialPlaybackRate = playbackRate
if (playbackSession.mediaPlayer != PLAYER_CAST && mediaItems.size > 1) {
customActionProviders.addAll(listOf(
SkipBackwardCustomActionProvider(),
SkipForwardCustomActionProvider(),
))
}
mediaSessionConnector.setCustomActionProviders(*customActionProviders.toTypedArray());
playbackSession.mediaPlayer = getMediaPlayer() playbackSession.mediaPlayer = getMediaPlayer()
if (playbackSession.mediaPlayer == "cast-player" && playbackSession.isLocal) { if (playbackSession.mediaPlayer == PLAYER_CAST && playbackSession.isLocal) {
Log.w(tag, "Cannot cast local media item - switching player") Log.w(tag, "Cannot cast local media item - switching player")
currentPlaybackSession = null currentPlaybackSession = null
switchToPlayer(false) switchToPlayer(false)
playbackSession.mediaPlayer = getMediaPlayer() playbackSession.mediaPlayer = getMediaPlayer()
} }
if (playbackSession.mediaPlayer == "cast-player") { if (playbackSession.mediaPlayer == PLAYER_CAST) {
// If cast-player is the first player to be used // If cast-player is the first player to be used
mediaSessionConnector.setPlayer(castPlayer) mediaSessionConnector.setPlayer(castPlayer)
playerNotificationManager.setPlayer(castPlayer) playerNotificationManager.setPlayer(castPlayer)
@@ -374,10 +414,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
clientEventEmitter?.onPlaybackSession(playbackSession) clientEventEmitter?.onPlaybackSession(playbackSession)
val metadata = playbackSession.getMediaMetadataCompat()
mediaSession.setMetadata(metadata)
val mediaItems = playbackSession.getMediaItems()
if (mediaItems.isEmpty()) { if (mediaItems.isEmpty()) {
Log.e(tag, "Invalid playback session no media items to play") Log.e(tag, "Invalid playback session no media items to play")
currentPlaybackSession = null currentPlaybackSession = null
@@ -460,6 +496,36 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
} }
fun handlePlaybackEnded() {
Log.d(tag, "handlePlaybackEnded")
if (isAndroidAuto && currentPlaybackSession?.isPodcastEpisode == true) {
Log.d(tag, "Podcast playback ended on android auto")
val libraryItem = currentPlaybackSession?.libraryItem ?: return
// Need to sync with server to set as finished
mediaProgressSyncer.finished {
// Need to reload media progress
mediaManager.loadServerUserMediaProgress {
val podcast = libraryItem.media as Podcast
val nextEpisode = podcast.getNextUnfinishedEpisode(libraryItem.id, mediaManager)
Log.d(tag, "handlePlaybackEnded nextEpisode=$nextEpisode")
nextEpisode?.let { podcastEpisode ->
mediaManager.play(libraryItem, podcastEpisode, getPlayItemRequestPayload(false)) {
if (it == null) {
Log.e(tag, "Failed to play library item")
} else {
val playbackRate = mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post {
preparePlayer(it,true, playbackRate)
}
}
}
}
}
}
}
}
fun startNewPlaybackSession() { fun startNewPlaybackSession() {
currentPlaybackSession?.let { playbackSession -> currentPlaybackSession?.let { playbackSession ->
val forceTranscode = playbackSession.isHLS // If already HLS then force val forceTranscode = playbackSession.isHLS // If already HLS then force
@@ -635,7 +701,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
// Streaming from server so check if playback session still exists on server // Streaming from server so check if playback session still exists on server
Log.d( Log.d(
tag, tag,
"checkCurrentSessionProgress: Checking if playback session for server stream is still available" "checkCurrentSessionProgress: Checking if playback session ${playbackSession.id} for server stream is still available"
) )
apiHandler.getPlaybackSession(playbackSession.id) { apiHandler.getPlaybackSession(playbackSession.id) {
if (it == null) { if (it == null) {
@@ -690,17 +756,42 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
fun seekPlayer(time: Long) { fun seekPlayer(time: Long) {
Log.d(tag, "seekPlayer mediaCount = ${currentPlayer.mediaItemCount} | $time") var timeToSeek = time
Log.d(tag, "seekPlayer mediaCount = ${currentPlayer.mediaItemCount} | $timeToSeek")
if (timeToSeek < 0) {
Log.w(tag, "seekPlayer invalid time ${timeToSeek} - setting to 0")
timeToSeek = 0L
} else if (timeToSeek > getDuration()) {
Log.w(tag, "seekPlayer invalid time ${timeToSeek} - setting to MAX - 2000")
timeToSeek = getDuration() - 2000L
}
if (currentPlayer.mediaItemCount > 1) { if (currentPlayer.mediaItemCount > 1) {
currentPlaybackSession?.currentTime = time / 1000.0 currentPlaybackSession?.currentTime = timeToSeek / 1000.0
val newWindowIndex = currentPlaybackSession?.getCurrentTrackIndex() ?: 0 val newWindowIndex = currentPlaybackSession?.getCurrentTrackIndex() ?: 0
val newTimeOffset = currentPlaybackSession?.getCurrentTrackTimeMs() ?: 0 val newTimeOffset = currentPlaybackSession?.getCurrentTrackTimeMs() ?: 0
currentPlayer.seekTo(newWindowIndex, newTimeOffset) currentPlayer.seekTo(newWindowIndex, newTimeOffset)
} else { } else {
currentPlayer.seekTo(time) currentPlayer.seekTo(timeToSeek)
} }
} }
fun skipToPrevious() {
currentPlayer.seekToPrevious()
}
fun skipToNext() {
currentPlayer.seekToNext()
}
fun jumpForward() {
seekForward(deviceSettings.jumpForwardTimeMs)
}
fun jumpBackward() {
seekBackward(deviceSettings.jumpBackwardsTimeMs)
}
fun seekForward(amount: Long) { fun seekForward(amount: Long) {
seekPlayer(getCurrentTime() + amount) seekPlayer(getCurrentTime() + amount)
} }
@@ -743,7 +834,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
fun getMediaPlayer():String { fun getMediaPlayer():String {
return if(currentPlayer == castPlayer) "cast-player" else "exo-player" return if(currentPlayer == castPlayer) PLAYER_CAST else PLAYER_EXO
} }
fun getDeviceInfo(): DeviceInfo { fun getDeviceInfo(): DeviceInfo {
@@ -757,6 +848,9 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
return DeviceInfo(Build.MANUFACTURER, Build.MODEL, Build.BRAND, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME) return DeviceInfo(Build.MANUFACTURER, Build.MODEL, Build.BRAND, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
} }
@get:JsonIgnore
val deviceSettings get() = DeviceManager.deviceData.deviceSettings ?: DeviceSettings.default()
fun getPlayItemRequestPayload(forceTranscode:Boolean):PlayItemRequestPayload { fun getPlayItemRequestPayload(forceTranscode:Boolean):PlayItemRequestPayload {
return PlayItemRequestPayload(getMediaPlayer(), !forceTranscode, forceTranscode, getDeviceInfo()) return PlayItemRequestPayload(getMediaPlayer(), !forceTranscode, forceTranscode, getDeviceInfo())
} }
@@ -784,8 +878,9 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
private val VALID_MEDIA_BROWSERS = mutableListOf("com.audiobookshelf.app", ANDROID_AUTO_PKG_NAME, ANDROID_AUTO_SIMULATOR_PKG_NAME, ANDROID_WEARABLE_PKG_NAME, ANDROID_GSEARCH_PKG_NAME, ANDROID_AUTOMOTIVE_PKG_NAME) private val VALID_MEDIA_BROWSERS = mutableListOf("com.audiobookshelf.app", ANDROID_AUTO_PKG_NAME, ANDROID_AUTO_SIMULATOR_PKG_NAME, ANDROID_WEARABLE_PKG_NAME, ANDROID_GSEARCH_PKG_NAME, ANDROID_AUTOMOTIVE_PKG_NAME)
private val AUTO_MEDIA_ROOT = "/" private val AUTO_MEDIA_ROOT = "/"
private val ALL_ROOT = "__ALL__"
private val LIBRARIES_ROOT = "__LIBRARIES__" private val LIBRARIES_ROOT = "__LIBRARIES__"
private val DOWNLOADS_ROOT = "__DOWNLOADS__"
private val CONTINUE_ROOT = "__CONTINUE__"
private lateinit var browseTree:BrowseTree private lateinit var browseTree:BrowseTree
@@ -828,66 +923,78 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
override fun onLoadChildren(parentMediaId: String, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) { override fun onLoadChildren(parentMediaId: String, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
Log.d(tag, "ON LOAD CHILDREN $parentMediaId") Log.d(tag, "ON LOAD CHILDREN $parentMediaId")
val flag = if (parentMediaId == AUTO_MEDIA_ROOT || parentMediaId == LIBRARIES_ROOT) MediaBrowserCompat.MediaItem.FLAG_BROWSABLE else MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
result.detach() result.detach()
if (parentMediaId.startsWith("li_") || parentMediaId.startsWith("local_")) { // Show podcast episodes if (parentMediaId.startsWith("li_") || parentMediaId.startsWith("local_")) { // Show podcast episodes
Log.d(tag, "Loading podcast episodes") Log.d(tag, "Loading podcast episodes")
mediaManager.loadPodcastEpisodeMediaBrowserItems(parentMediaId) { mediaManager.loadPodcastEpisodeMediaBrowserItems(parentMediaId, ctx) {
result.sendResult(it) result.sendResult(it)
} }
} else if (::browseTree.isInitialized && browseTree[parentMediaId] == null && mediaManager.getIsLibrary(parentMediaId)) { // Load library items for library } else if (::browseTree.isInitialized && browseTree[parentMediaId] == null && mediaManager.getIsLibrary(parentMediaId)) { // Load library items for library
mediaManager.loadLibraryItemsWithAudio(parentMediaId) { libraryItems -> mediaManager.loadLibraryItemsWithAudio(parentMediaId) { libraryItems ->
val children = libraryItems.map { libraryItem -> val children = libraryItems.map { libraryItem ->
val libraryItemMediaMetadata = libraryItem.getMediaMetadata()
if (libraryItem.mediaType == "podcast") { // Podcasts are browseable if (libraryItem.mediaType == "podcast") { // Podcasts are browseable
MediaBrowserCompat.MediaItem(libraryItemMediaMetadata.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) val mediaDescription = libraryItem.getMediaDescription(null, ctx)
MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
} else { } else {
val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItemMediaMetadata.description.mediaId } val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.id }
val description = mediaManager.getMediaDescriptionFromMediaMetadata(libraryItemMediaMetadata, progress) val description = libraryItem.getMediaDescription(progress, ctx)
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
} }
} }
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?) result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
} }
} else if (parentMediaId == "__DOWNLOADS__") { // Load downloads } else if (parentMediaId == DOWNLOADS_ROOT) { // Load downloads
val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book") val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast") val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast")
val localBrowseItems:MutableList<MediaBrowserCompat.MediaItem> = mutableListOf() val localBrowseItems:MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
localBooks.forEach { localLibraryItem -> localBooks.forEach { localLibraryItem ->
val mediaMetadata = localLibraryItem.getMediaMetadata() val progress = DeviceManager.dbManager.getLocalMediaProgress(localLibraryItem.id)
val progress = DeviceManager.dbManager.getLocalMediaProgress(mediaMetadata.description.mediaId ?: "") val description = localLibraryItem.getMediaDescription(progress, ctx)
val description = mediaManager.getMediaDescriptionFromMediaMetadata(mediaMetadata, progress)
localBrowseItems += MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) localBrowseItems += MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
} }
localPodcasts.forEach { localLibraryItem -> localPodcasts.forEach { localLibraryItem ->
val mediaMetadata = localLibraryItem.getMediaMetadata() val mediaDescription = localLibraryItem.getMediaDescription(null, ctx)
localBrowseItems += MediaBrowserCompat.MediaItem(mediaMetadata.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) localBrowseItems += MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
} }
result.sendResult(localBrowseItems) result.sendResult(localBrowseItems)
} else { // Load categories } else if (parentMediaId == CONTINUE_ROOT) {
mediaManager.loadAndroidAutoItems { libraryCategories -> val localBrowseItems:MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
browseTree = BrowseTree(this, libraryCategories, mediaManager.serverLibraries) mediaManager.serverItemsInProgress.forEach { itemInProgress ->
val progress: MediaProgressWrapper?
val mediaDescription:MediaDescriptionCompat
if (itemInProgress.episode != null) {
if (itemInProgress.isLocal) {
progress = DeviceManager.dbManager.getLocalMediaProgress("${itemInProgress.libraryItemWrapper.id}-${itemInProgress.episode.id}")
} else {
progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == itemInProgress.libraryItemWrapper.id && it.episodeId == itemInProgress.episode.id }
}
mediaDescription = itemInProgress.episode.getMediaDescription(itemInProgress.libraryItemWrapper, progress, ctx)
} else {
if (itemInProgress.isLocal) {
progress = DeviceManager.dbManager.getLocalMediaProgress(itemInProgress.libraryItemWrapper.id)
} else {
progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == itemInProgress.libraryItemWrapper.id }
}
mediaDescription = itemInProgress.libraryItemWrapper.getMediaDescription(progress, ctx)
}
localBrowseItems += MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
}
result.sendResult(localBrowseItems)
} else {
mediaManager.loadAndroidAutoItems {
browseTree = BrowseTree(this, mediaManager.serverItemsInProgress, mediaManager.serverLibraries)
val children = browseTree[parentMediaId]?.map { item -> val children = browseTree[parentMediaId]?.map { item ->
Log.d(tag, "Loading Browser Media Item ${item.description.title} $flag") Log.d(tag, "Loading Browser Media Item ${item.description.title}")
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
if (flag == MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) {
val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == item.description.mediaId }
val description = mediaManager.getMediaDescriptionFromMediaMetadata(item, progress)
MediaBrowserCompat.MediaItem(description, flag)
} else {
MediaBrowserCompat.MediaItem(item.description, flag)
}
} }
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?) result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
} }
@@ -896,10 +1003,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
override fun onSearch(query: String, extras: Bundle?, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) { override fun onSearch(query: String, extras: Bundle?, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
result.detach() result.detach()
mediaManager.loadAndroidAutoItems() { libraryCategories -> mediaManager.loadAndroidAutoItems {
browseTree = BrowseTree(this, libraryCategories, mediaManager.serverLibraries) browseTree = BrowseTree(this, mediaManager.serverItemsInProgress, mediaManager.serverLibraries)
val children = browseTree[ALL_ROOT]?.map { item -> val children = browseTree[LIBRARIES_ROOT]?.map { item ->
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
} }
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?) result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
} }
@@ -966,5 +1073,73 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
clientEventEmitter?.onNetworkMeteredChanged(unmetered) clientEventEmitter?.onNetworkMeteredChanged(unmetered)
} }
} }
inner class JumpBackwardCustomActionProvider : CustomActionProvider {
override fun onCustomAction(player: Player, action: String, extras: Bundle?) {
/*
This does not appear to ever get called. Instead, MediaSessionCallback.onCustomAction() is
responsible to reacting to a custom action.
*/
}
override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? {
return PlaybackStateCompat.CustomAction.Builder(
CUSTOM_ACTION_JUMP_BACKWARD,
getContext().getString(R.string.action_jump_backward),
R.drawable.exo_icon_rewind
).build()
}
}
inner class JumpForwardCustomActionProvider : CustomActionProvider {
override fun onCustomAction(player: Player, action: String, extras: Bundle?) {
/*
This does not appear to ever get called. Instead, MediaSessionCallback.onCustomAction() is
responsible to reacting to a custom action.
*/
}
override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? {
return PlaybackStateCompat.CustomAction.Builder(
CUSTOM_ACTION_JUMP_FORWARD,
getContext().getString(R.string.action_jump_forward),
R.drawable.exo_icon_fastforward
).build()
}
}
inner class SkipForwardCustomActionProvider : CustomActionProvider {
override fun onCustomAction(player: Player, action: String, extras: Bundle?) {
/*
This does not appear to ever get called. Instead, MediaSessionCallback.onCustomAction() is
responsible to reacting to a custom action.
*/
}
override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? {
return PlaybackStateCompat.CustomAction.Builder(
CUSTOM_ACTION_SKIP_FORWARD,
getContext().getString(R.string.action_skip_forward),
R.drawable.skip_next_24
).build()
}
}
inner class SkipBackwardCustomActionProvider : CustomActionProvider {
override fun onCustomAction(player: Player, action: String, extras: Bundle?) {
/*
This does not appear to ever get called. Instead, MediaSessionCallback.onCustomAction() is
responsible to reacting to a custom action.
*/
}
override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? {
return PlaybackStateCompat.CustomAction.Builder(
CUSTOM_ACTION_SKIP_BACKWARD,
getContext().getString(R.string.action_skip_backward),
R.drawable.skip_previous_24
).build()
}
}
} }
@@ -12,7 +12,6 @@ import com.getcapacitor.annotation.CapacitorPlugin
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.json.JSONObject
@CapacitorPlugin(name = "AbsDatabase") @CapacitorPlugin(name = "AbsDatabase")
class AbsDatabase : Plugin() { class AbsDatabase : Plugin() {
@@ -138,12 +138,16 @@ class ApiHandler(var ctx:Context) {
val mapper = jacksonMapper val mapper = jacksonMapper
getRequest("/api/libraries", null,null) { getRequest("/api/libraries", null,null) {
val libraries = mutableListOf<Library>() val libraries = mutableListOf<Library>()
if (it.has("value")) {
val array = it.getJSONArray("value") var array = JSONArray()
for (i in 0 until array.length()) { if (it.has("libraries")) { // TODO: Server 2.2.9 changed to this
val library = mapper.readValue<Library>(array.get(i).toString()) array = it.getJSONArray("libraries")
libraries.add(library) } else if (it.has("value")) {
} array = it.getJSONArray("value")
}
for (i in 0 until array.length()) {
libraries.add(mapper.readValue(array.get(i).toString()))
} }
cb(libraries) cb(libraries)
} }
@@ -189,21 +193,16 @@ class ApiHandler(var ctx:Context) {
} }
} }
fun getLibraryCategories(libraryId:String, cb: (List<LibraryCategory>) -> Unit) { fun getAllItemsInProgress(cb: (List<ItemInProgress>) -> Unit) {
getRequest("/api/libraries/$libraryId/personalized", null, null) { getRequest("/api/me/items-in-progress", null, null) {
val items = mutableListOf<LibraryCategory>() val items = mutableListOf<ItemInProgress>()
if (it.has("value")) { if (it.has("libraryItems")) {
val array = it.getJSONArray("value") val array = it.getJSONArray("libraryItems")
for (i in 0 until array.length()) { for (i in 0 until array.length()) {
val jsobj = array.get(i) as JSONObject val jsobj = array.get(i) as JSONObject
val type = jsobj.get("type").toString() val itemInProgress = ItemInProgress.makeFromServerObject(jsobj)
// Only support for podcast and book in android auto items.add(itemInProgress)
if (type == "podcast" || type == "book") {
jsobj.put("isLocal", false)
val item = jacksonMapper.readValue<LibraryCategory>(jsobj.toString())
items.add(item)
}
} }
} }
cb(items) cb(items)
@@ -322,6 +321,7 @@ class ApiHandler(var ctx:Context) {
} }
fun getPlaybackSession(playbackSessionId:String, cb: (PlaybackSession?) -> Unit) { fun getPlaybackSession(playbackSessionId:String, cb: (PlaybackSession?) -> Unit) {
Log.d(tag, "getPlaybackSession for $playbackSessionId for server ${DeviceManager.serverAddress}")
val endpoint = "/api/session/$playbackSessionId" val endpoint = "/api/session/$playbackSessionId"
getRequest(endpoint, null, null) { getRequest(endpoint, null, null) {
val err = it.getString("error") val err = it.getString("error")
@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<group android:scaleX="1.127451"
android:scaleY="1.127451"
android:translateX="-1.5294118"
android:translateY="-1.5294118">
<path
android:fillColor="@android:color/white"
android:pathData="M6,18l8.5,-6L6,6v12zM16,6v12h2V6h-2z"/>
</group>
</vector>
@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<group android:scaleX="1.127451"
android:scaleY="1.127451"
android:translateX="-1.5294118"
android:translateY="-1.5294118">
<path
android:fillColor="@android:color/white"
android:pathData="M6,6h2v12L6,18zM9.5,12l8.5,6L18,6z"/>
</group>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path
android:fillColor="@android:color/white"
android:pathData="M6,18l8.5,-6L6,6v12zM16,6v12h2V6h-2z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path
android:fillColor="@android:color/white"
android:pathData="M6,6h2v12L6,18zM9.5,12l8.5,6L18,6z"/>
</vector>
@@ -11,6 +11,8 @@
<item name="windowActionBar">false</item> <item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item> <item name="windowNoTitle">true</item>
<item name="android:background">@null</item> <item name="android:background">@null</item>
<item name="android:statusBarColor">@color/background_dark</item>
<item name="android:navigationBarColor">@color/background_dark</item>
</style> </style>
@@ -3,4 +3,5 @@
<color name="light_blue_200">#FF81D4FA</color> <color name="light_blue_200">#FF81D4FA</color>
<color name="light_blue_600">#FF039BE5</color> <color name="light_blue_600">#FF039BE5</color>
<color name="light_blue_900">#FF01579B</color> <color name="light_blue_900">#FF01579B</color>
<color name="background_dark">#262626</color>
</resources> </resources>
@@ -6,4 +6,8 @@
<string name="custom_url_scheme">com.audiobookshelf.app</string> <string name="custom_url_scheme">com.audiobookshelf.app</string>
<string name="add_widget">Add widget</string> <string name="add_widget">Add widget</string>
<string name="app_widget_description">Simple widget for audiobookshelf playback</string> <string name="app_widget_description">Simple widget for audiobookshelf playback</string>
<string name="action_jump_forward">Jump Forward</string>
<string name="action_jump_backward">Jump Backward</string>
<string name="action_skip_forward">Skip Forward</string>
<string name="action_skip_backward">Skip Backward</string>
</resources> </resources>
@@ -13,6 +13,8 @@
<item name="windowActionBar">false</item> <item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item> <item name="windowNoTitle">true</item>
<item name="android:background">@null</item> <item name="android:background">@null</item>
<item name="android:statusBarColor">@color/background_dark</item>
<item name="android:navigationBarColor">@color/background_dark</item>
</style> </style>
+4
View File
@@ -2,5 +2,9 @@
<widget version="1.0.0" xmlns="http://www.w3.org/ns/widgets" xmlns:cdv="http://cordova.apache.org/ns/1.0"> <widget version="1.0.0" xmlns="http://www.w3.org/ns/widgets" xmlns:cdv="http://cordova.apache.org/ns/1.0">
<access origin="*" /> <access origin="*" />
<feature name="CDVOrientation">
<param name="android-package" value="cordova.plugins.screenorientation.CDVOrientation"/>
</feature>
</widget> </widget>
+2 -2
View File
@@ -8,8 +8,8 @@ buildscript {
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath 'com.google.gms:google-services:4.3.5' classpath 'com.google.gms:google-services:4.3.10'
classpath 'com.android.tools.build:gradle:7.2.0-beta04' classpath 'com.android.tools.build:gradle:7.2.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// NOTE: Do not place your application dependencies here; they belong // NOTE: Do not place your application dependencies here; they belong
+1 -1
View File
@@ -1,7 +1,7 @@
ext { ext {
minSdkVersion = 24 minSdkVersion = 24
compileSdkVersion = 31 compileSdkVersion = 31
targetSdkVersion = 30 targetSdkVersion = 31
androidxActivityVersion = '1.2.0' androidxActivityVersion = '1.2.0'
androidxAppCompatVersion = '1.4.1' androidxAppCompatVersion = '1.4.1'
androidxCoordinatorLayoutVersion = '1.2.0' androidxCoordinatorLayoutVersion = '1.2.0'
+121
View File
@@ -0,0 +1,121 @@
@font-face {
font-family: 'absicons';
src: url('~static/fonts/absicons/absicons.eot?2jfq33');
src: url('~static/fonts/absicons/absicons.eot?2jfq33#iefix') format('embedded-opentype'),
url('~static/fonts/absicons/absicons.ttf?2jfq33') format('truetype'),
url('~static/fonts/absicons/absicons.woff?2jfq33') format('woff'),
url('~static/fonts/absicons/absicons.svg?2jfq33#absicons') format('svg');
font-weight: normal;
font-style: normal;
font-display: block;
}
.abs-icons {
/* use !important to prevent issues with browser extensions that change fonts */
font-family: 'absicons' !important;
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
/* Better Font Rendering =========== */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-list:before {
content: "\e907";
}
.icon-home:before {
content: "\e909";
}
.icon-authors:before {
content: "\e90a";
}
.icon-books-1:before {
content: "\e905";
}
.icon-microphone-1:before {
content: "\e902";
}
.icon-radio:before {
content: "\e903";
}
.icon-podcast:before {
content: "\e904";
}
.icon-audiobookshelf:before {
content: "\e900";
}
.icon-database:before {
content: "\e906";
}
.icon-microphone-2:before {
content: "\e901";
}
.icon-columns:before {
content: "\e90b";
}
.icon-headphones:before {
content: "\e910";
}
.icon-music:before {
content: "\e911";
}
.icon-video:before {
content: "\e914";
}
.icon-microphone-3:before {
content: "\e91e";
}
.icon-book-1:before {
content: "\e91f";
}
.icon-books-2:before {
content: "\e920";
}
.icon-file-picture:before {
content: "\e927";
}
.icon-database-1:before {
content: "\e964";
}
.icon-rocket:before {
content: "\e9a5";
}
.icon-power:before {
content: "\e9b5";
}
.icon-star:before {
content: "\e9d9";
}
.icon-heart:before {
content: "\e9da";
}
.icon-rss:before {
content: "\ea9b";
}
+1
View File
@@ -1,5 +1,6 @@
@import "./fonts.css"; @import "./fonts.css";
@import './defaultStyles.css'; @import './defaultStyles.css';
@import './absicons.css';
body { body {
background-color: #262626; background-color: #262626;
+5 -5
View File
@@ -9,7 +9,7 @@
</a> </a>
<div v-if="user && currentLibrary"> <div v-if="user && currentLibrary">
<div class="pl-1.5 pr-2.5 py-2 bg-bg bg-opacity-30 rounded-md flex items-center" @click="clickShowLibraryModal"> <div class="pl-1.5 pr-2.5 py-2 bg-bg bg-opacity-30 rounded-md flex items-center" @click="clickShowLibraryModal">
<widgets-library-icon :icon="currentLibraryIcon" :size="4" /> <ui-library-icon :icon="currentLibraryIcon" :size="4" font-size="base" />
<p class="text-sm font-book leading-4 ml-2 mt-0.5 max-w-24 truncate">{{ currentLibraryName }}</p> <p class="text-sm font-book leading-4 ml-2 mt-0.5 max-w-24 truncate">{{ currentLibraryName }}</p>
</div> </div>
</div> </div>
@@ -21,12 +21,12 @@
<widgets-download-progress-indicator /> <widgets-download-progress-indicator />
<!-- Must be connected to a server to cast, only supports media items on server --> <!-- Must be connected to a server to cast, only supports media items on server -->
<div v-show="isCastAvailable && user" class="mx-2 cursor-pointer mt-1.5"> <div v-show="isCastAvailable && user" class="mx-2 cursor-pointer flex items-center pt-0.5" @click="castClick">
<span class="material-icons text-2xl" :class="isCasting ? 'text-success' : ''" @click="castClick">cast</span> <span class="material-icons" :class="isCasting ? 'text-success' : ''">cast</span>
</div> </div>
<nuxt-link v-if="user" class="h-7 mx-1.5" to="/search"> <nuxt-link v-if="user" class="h-7 mx-1.5" style="padding-top: 3px" to="/search">
<span class="material-icons" style="font-size: 1.75rem">search</span> <span class="material-icons">search</span>
</nuxt-link> </nuxt-link>
<div class="h-7 mx-1.5"> <div class="h-7 mx-1.5">
+137 -74
View File
@@ -1,18 +1,16 @@
<template> <template>
<div v-if="playbackSession" id="streamContainer" class="playerContainer fixed top-0 left-0 layout-wrapper right-0 z-50 pointer-events-none" :class="{ fullscreen: showFullscreen, 'ios-player': $platform === 'ios', 'web-player': $platform === 'web' }"> <div v-if="playbackSession" id="streamContainer" class="playerContainer fixed top-0 left-0 layout-wrapper right-0 z-50 pointer-events-none" :class="{ fullscreen: showFullscreen, 'ios-player': $platform === 'ios', 'web-player': $platform === 'web' }">
<div v-if="showFullscreen" class="w-full h-full z-10 bg-bg absolute top-0 left-0 pointer-events-auto"> <div v-if="showFullscreen" class="w-full h-full z-10 bg-bg absolute top-0 left-0 pointer-events-auto">
<div class="top-2 left-4 absolute cursor-pointer"> <div class="top-4 left-4 absolute cursor-pointer">
<span class="material-icons text-5xl" @click="collapseFullscreen">expand_more</span> <span class="material-icons text-5xl" @click="collapseFullscreen">expand_more</span>
</div> </div>
<div v-show="showCastBtn" class="top-4 right-16 absolute cursor-pointer"> <div v-show="showCastBtn" class="top-6 right-16 absolute cursor-pointer">
<span class="material-icons text-3xl" :class="isCasting ? 'text-success' : ''" @click="castClick">cast</span> <span class="material-icons text-3xl" :class="isCasting ? 'text-success' : ''" @click="castClick">cast</span>
</div> </div>
<div class="top-4 right-4 absolute cursor-pointer"> <div class="top-6 right-4 absolute cursor-pointer">
<ui-dropdown-menu ref="dropdownMenu" :items="menuItems" @action="clickMenuAction"> <span class="material-icons text-3xl" @click="showMoreMenuDialog = true">more_vert</span>
<span class="material-icons text-3xl">more_vert</span>
</ui-dropdown-menu>
</div> </div>
<p class="top-2 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" style="font-size: 10px" :class="{ 'text-success': isLocalPlayMethod, 'text-accent': !isLocalPlayMethod }">{{ isDirectPlayMethod ? 'Direct' : isLocalPlayMethod ? 'Local' : 'Transcode' }}</p> <p class="top-4 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" style="font-size: 10px" :class="{ 'text-success': isLocalPlayMethod, 'text-accent': !isLocalPlayMethod }">{{ isDirectPlayMethod ? 'Direct' : isLocalPlayMethod ? 'Local' : 'Transcode' }}</p>
</div> </div>
<div v-if="useChapterTrack && showFullscreen" class="absolute total-track w-full px-3 z-30"> <div v-if="useChapterTrack && showFullscreen" class="absolute total-track w-full px-3 z-30">
@@ -48,7 +46,7 @@
<div id="playerContent" class="playerContainer w-full z-20 bg-primary absolute bottom-0 left-0 right-0 p-2 pointer-events-auto transition-all" @click="clickContainer"> <div id="playerContent" class="playerContainer w-full z-20 bg-primary absolute bottom-0 left-0 right-0 p-2 pointer-events-auto transition-all" @click="clickContainer">
<div v-if="showFullscreen" class="absolute top-0 left-0 right-0 w-full py-3 mx-auto px-3" style="max-width: 380px"> <div v-if="showFullscreen" class="absolute top-0 left-0 right-0 w-full py-3 mx-auto px-3" style="max-width: 380px">
<div class="flex items-center justify-between pointer-events-auto"> <div class="flex items-center justify-between pointer-events-auto">
<span v-if="!isPodcast && !isLocalPlayMethod" class="material-icons text-3xl text-white text-opacity-75 cursor-pointer" @click="$emit('showBookmarks')">{{ bookmarks.length ? 'bookmark' : 'bookmark_border' }}</span> <span v-if="!isPodcast && isServerItem && networkConnected" class="material-icons text-3xl text-white text-opacity-75 cursor-pointer" @click="$emit('showBookmarks')">{{ bookmarks.length ? 'bookmark' : 'bookmark_border' }}</span>
<!-- hidden for podcasts but still using this as a placeholder --> <!-- hidden for podcasts but still using this as a placeholder -->
<span v-else class="material-icons text-3xl text-white text-opacity-0">bookmark</span> <span v-else class="material-icons text-3xl text-white text-opacity-0">bookmark</span>
@@ -66,23 +64,24 @@
<div id="playerControls" class="absolute right-0 bottom-0 py-2"> <div id="playerControls" class="absolute right-0 bottom-0 py-2">
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<span v-show="showFullscreen" class="material-icons next-icon text-white text-opacity-75 cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span> <span v-show="showFullscreen && !lockUi" class="material-icons next-icon text-white text-opacity-75 cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
<span class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span> <span v-show="!lockUi" class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span>
<div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4" :class="{ 'animate-spin': seekLoading, 'bg-accent': !isLocalPlayMethod, 'bg-success': isLocalPlayMethod }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick"> <div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4" :class="{ 'animate-spin': seekLoading, 'bg-accent': !isLocalPlayMethod, 'bg-success': isLocalPlayMethod }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
<span v-if="!isLoading" class="material-icons">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span> <span v-if="!isLoading" class="material-icons">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
<widgets-spinner-icon v-else class="h-8 w-8" /> <widgets-spinner-icon v-else class="h-8 w-8" />
</div> </div>
<span 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="!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" 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="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>
</div> </div>
</div> </div>
<div id="playerTrack" class="absolute bottom-0 left-0 w-full px-3"> <div id="playerTrack" class="absolute left-0 w-full px-3">
<div ref="track" class="h-2 w-full bg-gray-500 bg-opacity-50 relative" :class="isLoading ? 'animate-pulse' : ''" @touchstart="touchstartTrack" @click="clickTrack"> <div ref="track" class="h-1.5 w-full bg-gray-500 bg-opacity-50 relative" :class="{ 'animate-pulse': isLoading }" @touchstart="touchstartTrack" @click="clickTrack">
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" /> <div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
<div ref="bufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 pointer-events-none" /> <div ref="bufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 pointer-events-none" />
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" /> <div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" />
<div ref="draggingTrack" class="h-full bg-warning bg-opacity-25 absolute top-0 left-0 pointer-events-none" /> <div ref="draggingTrack" class="h-full bg-warning bg-opacity-25 absolute top-0 left-0 pointer-events-none" />
<div ref="trackCursor" class="h-3.5 w-3.5 rounded-full bg-gray-200 absolute -top-1 pointer-events-none" :class="{ 'opacity-0': lockUi || !showFullscreen }" />
</div> </div>
<div id="timestamp-row" class="flex pt-0.5"> <div id="timestamp-row" class="flex pt-0.5">
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem" ref="currentTimestamp">0:00</p> <p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem" ref="currentTimestamp">0:00</p>
@@ -95,6 +94,24 @@
</div> </div>
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" /> <modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
<modals-dialog v-model="showMoreMenuDialog" :items="menuItems" @action="clickMenuAction">
<template v-slot:chapter_track="{ item }">
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickMenuAction(item.value)">
<div class="flex items-center px-3">
<span v-if="item.icon" class="material-icons-outlined text-xl mr-2 text-white text-opacity-80">{{ item.icon }}</span>
<span class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</span>
</div>
</li>
</template>
<template v-slot:lock="{ item }">
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickMenuAction(item.value)">
<div class="flex items-center px-3">
<span v-if="item.icon" class="material-icons-outlined text-xl mr-2 text-opacity-80" :class="{ 'text-red-500': lockUi, 'text-white': !lockUi }">{{ item.icon }}</span>
<span class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</span>
</div>
</li>
</template>
</modals-dialog>
</div> </div>
</template> </template>
@@ -109,7 +126,8 @@ export default {
default: () => [] default: () => []
}, },
sleepTimerRunning: Boolean, sleepTimerRunning: Boolean,
sleepTimeRemaining: Number sleepTimeRemaining: Number,
isServerItem: Boolean
}, },
data() { data() {
return { return {
@@ -139,10 +157,12 @@ export default {
touchStartTime: 0, touchStartTime: 0,
touchEndY: 0, touchEndY: 0,
useChapterTrack: false, useChapterTrack: false,
lockUi: false,
isLoading: false, isLoading: false,
touchTrackStart: false, touchTrackStart: false,
dragPercent: 0, dragPercent: 0,
syncStatus: 0 syncStatus: 0,
showMoreMenuDialog: false
} }
}, },
watch: { watch: {
@@ -153,17 +173,24 @@ export default {
}, },
computed: { computed: {
menuItems() { menuItems() {
var items = [] var items = [
items.push({ {
text: 'Chapter Track', text: 'Chapter Track',
value: 'chapter_track', value: 'chapter_track',
icon: this.useChapterTrack ? 'check_box' : 'check_box_outline_blank' icon: this.useChapterTrack ? 'check_box' : 'check_box_outline_blank'
}) },
items.push({ {
text: 'Close Player', text: this.lockUi ? 'Unlock Player' : 'Lock Player',
value: 'close', value: 'lock',
icon: 'close' icon: this.lockUi ? 'lock' : 'lock_open'
}) },
{
text: 'Close Player',
value: 'close',
icon: 'close'
}
]
return items return items
}, },
jumpForwardIcon() { jumpForwardIcon() {
@@ -179,11 +206,11 @@ export default {
return this.$store.getters['getJumpBackwardsTime'] return this.$store.getters['getJumpBackwardsTime']
}, },
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio'] return this.$store.getters['libraries/getBookCoverAspectRatio']
}, },
bookCoverWidth() { bookCoverWidth() {
if (this.showFullscreen) return this.fullscreenBookCoverWidth if (this.showFullscreen) return this.fullscreenBookCoverWidth
return 60 return 46 / this.bookCoverAspectRatio
}, },
fullscreenBookCoverWidth() { fullscreenBookCoverWidth() {
var heightScale = (this.windowHeight - 200) / 651 var heightScale = (this.windowHeight - 200) / 651
@@ -298,6 +325,9 @@ export default {
} else { } else {
return secondsRemaining + 's' return secondsRemaining + 's'
} }
},
networkConnected() {
return this.$store.state.networkConnected
} }
}, },
methods: { methods: {
@@ -309,15 +339,18 @@ export default {
this.showFullscreen = false this.showFullscreen = false
} }
}, },
touchstartTrack(e) { async touchstartTrack(e) {
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen) return await this.$hapticsImpactMedium()
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen || this.lockUi) return
this.touchTrackStart = true this.touchTrackStart = true
}, },
selectChapter(chapter) { async selectChapter(chapter) {
await this.$hapticsImpactMedium()
this.seek(chapter.start) this.seek(chapter.start)
this.showChapterModal = false this.showChapterModal = false
}, },
castClick() { async castClick() {
await this.$hapticsImpactMedium()
if (this.isLocalPlayMethod) { if (this.isLocalPlayMethod) {
this.$eventBus.$emit('cast-local-item') this.$eventBus.$emit('cast-local-item')
return return
@@ -336,12 +369,14 @@ export default {
this.showFullscreen = false this.showFullscreen = false
this.forceCloseDropdownMenu() this.forceCloseDropdownMenu()
}, },
jumpNextChapter() { async jumpNextChapter() {
await this.$hapticsImpactMedium()
if (this.isLoading) return if (this.isLoading) return
if (!this.nextChapter) return if (!this.nextChapter) return
this.seek(this.nextChapter.start) this.seek(this.nextChapter.start)
}, },
jumpChapterStart() { async jumpChapterStart() {
await this.$hapticsImpactMedium()
if (this.isLoading) return if (this.isLoading) return
if (!this.currentChapter) { if (!this.currentChapter) {
return this.restart() return this.restart()
@@ -349,9 +384,9 @@ export default {
// If 4 seconds or less into current chapter, then go to previous // If 4 seconds or less into current chapter, then go to previous
if (this.currentTime - this.currentChapter.start <= 4) { if (this.currentTime - this.currentChapter.start <= 4) {
var currChapterIndex = this.chapters.findIndex((ch) => Number(ch.start) <= this.currentTime && Number(ch.end) >= this.currentTime) const currChapterIndex = this.chapters.findIndex((ch) => Number(ch.start) <= this.currentTime && Number(ch.end) >= this.currentTime)
if (currChapterIndex > 0) { if (currChapterIndex > 0) {
var prevChapter = this.chapters[currChapterIndex - 1] const prevChapter = this.chapters[currChapterIndex - 1]
this.seek(prevChapter.start) this.seek(prevChapter.start)
} }
} else { } else {
@@ -361,7 +396,8 @@ export default {
showSleepTimerModal() { showSleepTimerModal() {
this.$emit('showSleepTimer') this.$emit('showSleepTimer')
}, },
setPlaybackSpeed(speed) { async setPlaybackSpeed(speed) {
await this.$hapticsImpactMedium()
console.log(`[AudioPlayer] Set Playback Rate: ${speed}`) console.log(`[AudioPlayer] Set Playback Rate: ${speed}`)
this.currentPlaybackRate = speed this.currentPlaybackRate = speed
AbsAudioPlayer.setPlaybackSpeed({ value: speed }) AbsAudioPlayer.setPlaybackSpeed({ value: speed })
@@ -369,11 +405,13 @@ export default {
restart() { restart() {
this.seek(0) this.seek(0)
}, },
jumpBackwards() { async jumpBackwards() {
await this.$hapticsImpactMedium()
if (this.isLoading) return if (this.isLoading) return
AbsAudioPlayer.seekBackward({ value: this.jumpBackwardsTime }) AbsAudioPlayer.seekBackward({ value: this.jumpBackwardsTime })
}, },
jumpForward() { async jumpForward() {
await this.$hapticsImpactMedium()
if (this.isLoading) return if (this.isLoading) return
AbsAudioPlayer.seekForward({ value: this.jumpForwardTime }) AbsAudioPlayer.seekForward({ value: this.jumpForwardTime })
}, },
@@ -382,19 +420,19 @@ export default {
this.updateReadyTrack() this.updateReadyTrack()
}, },
setChunksReady(chunks, numSegments) { setChunksReady(chunks, numSegments) {
var largestSeg = 0 let largestSeg = 0
for (let i = 0; i < chunks.length; i++) { for (let i = 0; i < chunks.length; i++) {
var chunk = chunks[i] const chunk = chunks[i]
if (typeof chunk === 'string') { if (typeof chunk === 'string') {
var chunkRange = chunk.split('-').map((c) => Number(c)) const chunkRange = chunk.split('-').map((c) => Number(c))
if (chunkRange.length < 2) continue if (chunkRange.length < 2) continue
if (chunkRange[1] > largestSeg) largestSeg = chunkRange[1] if (chunkRange[1] > largestSeg) largestSeg = chunkRange[1]
} else if (chunk > largestSeg) { } else if (chunk > largestSeg) {
largestSeg = chunk largestSeg = chunk
} }
} }
var percentageReady = largestSeg / numSegments const percentageReady = largestSeg / numSegments
var widthReady = Math.round(this.trackWidth * percentageReady) const widthReady = Math.round(this.trackWidth * percentageReady)
if (this.readyTrackWidth === widthReady) { if (this.readyTrackWidth === widthReady) {
return return
} }
@@ -446,20 +484,24 @@ export default {
}, },
updateTrack() { updateTrack() {
// Update progress track UI // Update progress track UI
var percentDone = this.currentTime / this.totalDuration let percentDone = this.currentTime / this.totalDuration
var totalPercentDone = percentDone const totalPercentDone = percentDone
var bufferedPercent = this.bufferedTime / this.totalDuration let bufferedPercent = this.bufferedTime / this.totalDuration
var totalBufferedPercent = bufferedPercent const totalBufferedPercent = bufferedPercent
if (this.useChapterTrack && this.currentChapter) { if (this.useChapterTrack && this.currentChapter) {
var currChapTime = this.currentTime - this.currentChapter.start const currChapTime = this.currentTime - this.currentChapter.start
percentDone = currChapTime / this.currentChapterDuration percentDone = currChapTime / this.currentChapterDuration
bufferedPercent = Math.max(0, Math.min(1, (this.bufferedTime - this.currentChapter.start) / this.currentChapterDuration)) bufferedPercent = Math.max(0, Math.min(1, (this.bufferedTime - this.currentChapter.start) / this.currentChapterDuration))
} }
var ptWidth = Math.round(percentDone * this.trackWidth) const ptWidth = Math.round(percentDone * this.trackWidth)
this.$refs.playedTrack.style.width = ptWidth + 'px' this.$refs.playedTrack.style.width = ptWidth + 'px'
this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px' this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px'
if (this.$refs.trackCursor) {
this.$refs.trackCursor.style.left = ptWidth - 8 + 'px'
}
if (this.useChapterTrack) { if (this.useChapterTrack) {
if (this.$refs.totalPlayedTrack) this.$refs.totalPlayedTrack.style.width = Math.round(totalPercentDone * this.trackWidth) + 'px' 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' if (this.$refs.totalBufferedTrack) this.$refs.totalBufferedTrack.style.width = Math.round(totalBufferedPercent * this.trackWidth) + 'px'
@@ -487,7 +529,7 @@ export default {
} }
}, },
clickTrack(e) { clickTrack(e) {
if (this.isLoading) return if (this.isLoading || this.lockUi) return
if (!this.showFullscreen) { if (!this.showFullscreen) {
// Track not clickable on mini-player // Track not clickable on mini-player
return return
@@ -509,6 +551,7 @@ export default {
this.seek(time) this.seek(time)
}, },
async playPauseClick() { async playPauseClick() {
await this.$hapticsImpactMedium()
if (this.isLoading) return if (this.isLoading) return
this.isPlaying = !!((await AbsAudioPlayer.playPause()) || {}).playing this.isPlaying = !!((await AbsAudioPlayer.playPause()) || {}).playing
@@ -611,19 +654,24 @@ export default {
ts.innerText = currTimeStr ts.innerText = currTimeStr
} }
}, },
clickMenuAction(action) { async clickMenuAction(action) {
if (action === 'chapter_track') { await this.$hapticsImpactMedium()
this.useChapterTrack = !this.useChapterTrack this.showMoreMenuDialog = false
this.$nextTick(() => {
if (action === 'lock') {
this.lockUi = !this.lockUi
this.$localStore.setPlayerLock(this.lockUi)
} else if (action === 'chapter_track') {
this.useChapterTrack = !this.useChapterTrack
this.$nextTick(() => {
this.updateTimestamp() this.updateTimestamp()
this.updateTrack() this.updateTrack()
this.updateReadyTrack() this.updateReadyTrack()
}) this.$localStore.setUseChapterTrack(this.useChapterTrack)
this.$localStore.setUseChapterTrack(this.useChapterTrack) } else if (action === 'close') {
} else if (action === 'close') { this.closePlayback()
this.closePlayback() }
} })
}, },
forceCloseDropdownMenu() { forceCloseDropdownMenu() {
if (this.$refs.dropdownMenu && this.$refs.dropdownMenu.closeMenu) { if (this.$refs.dropdownMenu && this.$refs.dropdownMenu.closeMenu) {
@@ -705,6 +753,7 @@ export default {
}, },
async init() { async init() {
this.useChapterTrack = await this.$localStore.getUseChapterTrack() this.useChapterTrack = await this.$localStore.getUseChapterTrack()
this.lockUi = await this.$localStore.getPlayerLock()
this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession) this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession)
this.onPlaybackClosedListener = AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed) this.onPlaybackClosedListener = AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed)
@@ -715,16 +764,22 @@ export default {
this.onProgressSyncSuccess = AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess) this.onProgressSyncSuccess = AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess)
}, },
screenOrientationChange() { screenOrientationChange() {
setTimeout(this.updateScreenSize, 50) setTimeout(() => {
this.updateScreenSize()
if (this.$refs.track) {
this.trackWidth = this.$refs.track.clientWidth
this.updateTrack()
this.updateReadyTrack()
}
}, 50)
}, },
updateScreenSize() { updateScreenSize() {
this.windowHeight = window.innerHeight this.windowHeight = window.innerHeight
var coverHeight = this.fullscreenBookCoverWidth * this.bookCoverAspectRatio const coverHeight = this.fullscreenBookCoverWidth * this.bookCoverAspectRatio
document.documentElement.style.setProperty('--cover-image-width', this.fullscreenBookCoverWidth + 'px') document.documentElement.style.setProperty('--cover-image-width', this.fullscreenBookCoverWidth + 'px')
document.documentElement.style.setProperty('--cover-image-height', coverHeight + 'px') document.documentElement.style.setProperty('--cover-image-height', coverHeight + 'px')
}, },
minimizePlayerEvt() { minimizePlayerEvt() {
console.log('Minimize Player Evt')
this.showFullscreen = false this.showFullscreen = false
}, },
showProgressSyncIsFailing() { showProgressSyncIsFailing() {
@@ -737,8 +792,12 @@ export default {
mounted() { mounted() {
this.updateScreenSize() this.updateScreenSize()
if (screen.orientation) { if (screen.orientation) {
// Not available on ios
screen.orientation.addEventListener('change', this.screenOrientationChange) screen.orientation.addEventListener('change', this.screenOrientationChange)
} else {
document.addEventListener('orientationchange', this.screenOrientationChange)
} }
window.addEventListener('resize', this.screenOrientationChange)
this.$eventBus.$on('minimize-player', this.minimizePlayerEvt) this.$eventBus.$on('minimize-player', this.minimizePlayerEvt)
document.body.addEventListener('touchstart', this.touchstart) document.body.addEventListener('touchstart', this.touchstart)
@@ -748,8 +807,12 @@ export default {
}, },
beforeDestroy() { beforeDestroy() {
if (screen.orientation) { if (screen.orientation) {
// Not available on ios
screen.orientation.removeEventListener('change', this.screenOrientationChange) screen.orientation.removeEventListener('change', this.screenOrientationChange)
} else {
document.removeEventListener('orientationchange', this.screenOrientationChange)
} }
window.removeEventListener('resize', this.screenOrientationChange)
if (this.playbackSession) { if (this.playbackSession) {
console.log('[AudioPlayer] Before destroy closing playback') console.log('[AudioPlayer] Before destroy closing playback')
@@ -778,8 +841,8 @@ export default {
:root { :root {
--cover-image-width: 0px; --cover-image-width: 0px;
--cover-image-height: 0px; --cover-image-height: 0px;
--cover-image-width-collapsed: 60px; --cover-image-width-collapsed: 46px;
--cover-image-height-collapsed: 60px; --cover-image-height-collapsed: 46px;
} }
.bookCoverWrapper { .bookCoverWrapper {
box-shadow: 3px -2px 5px #00000066; box-shadow: 3px -2px 5px #00000066;
@@ -797,10 +860,10 @@ export default {
#playerTrack { #playerTrack {
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1); transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
transition-property: margin; transition-property: margin;
margin-bottom: 4px; bottom: 10px;
} }
.fullscreen #playerTrack { .fullscreen #playerTrack {
margin-bottom: 18px; bottom: 32px;
} }
.ios-player #timestamp-row { .ios-player #timestamp-row {
@@ -809,7 +872,7 @@ export default {
} }
.cover-wrapper { .cover-wrapper {
bottom: 44px; bottom: 48px;
left: 12px; left: 12px;
height: var(--cover-image-height-collapsed); height: var(--cover-image-height-collapsed);
width: var(--cover-image-width-collapsed); width: var(--cover-image-width-collapsed);
@@ -830,8 +893,8 @@ export default {
transform-origin: left bottom; transform-origin: left bottom;
width: 40%; width: 40%;
bottom: 50px; bottom: 56px;
left: 80px; left: 70px;
text-align: left; text-align: left;
} }
.title-author-texts .title-text { .title-author-texts .title-text {
@@ -869,7 +932,7 @@ export default {
width: 140px; width: 140px;
padding-left: 12px; padding-left: 12px;
padding-right: 12px; padding-right: 12px;
bottom: 45px; bottom: 50px;
} }
#playerControls .jump-icon { #playerControls .jump-icon {
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1); transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
@@ -906,7 +969,7 @@ export default {
.fullscreen #playerControls { .fullscreen #playerControls {
width: 100%; width: 100%;
bottom: 100px; bottom: 105px;
} }
.fullscreen #playerControls .jump-icon { .fullscreen #playerControls .jump-icon {
margin: 0px 18px; margin: 0px 18px;
@@ -927,4 +990,4 @@ export default {
.fullscreen #playerControls .play-btn .material-icons { .fullscreen #playerControls .play-btn .material-icons {
font-size: 2.1rem; font-size: 2.1rem;
} }
</style> </style>
+29 -39
View File
@@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<app-audio-player ref="audioPlayer" :bookmarks="bookmarks" :sleep-timer-running="isSleepTimerRunning" :sleep-time-remaining="sleepTimeRemaining" @selectPlaybackSpeed="showPlaybackSpeedModal = true" @updateTime="(t) => (currentTime = t)" @showSleepTimer="showSleepTimer" @showBookmarks="showBookmarks" /> <app-audio-player ref="audioPlayer" :bookmarks="bookmarks" :sleep-timer-running="isSleepTimerRunning" :sleep-time-remaining="sleepTimeRemaining" :is-server-item="!!serverLibraryItemId" @selectPlaybackSpeed="showPlaybackSpeedModal = true" @updateTime="(t) => (currentTime = t)" @showSleepTimer="showSleepTimer" @showBookmarks="showBookmarks" />
<modals-playback-speed-modal v-model="showPlaybackSpeedModal" :playback-rate.sync="playbackSpeed" @update:playbackRate="updatePlaybackSpeed" @change="changePlaybackSpeed" /> <modals-playback-speed-modal v-model="showPlaybackSpeedModal" :playback-rate.sync="playbackSpeed" @update:playbackRate="updatePlaybackSpeed" @change="changePlaybackSpeed" />
<modals-sleep-timer-modal v-model="showSleepTimerModal" :current-time="sleepTimeRemaining" :sleep-timer-running="isSleepTimerRunning" :current-end-of-chapter-time="currentEndOfChapterTime" @change="selectSleepTimeout" @cancel="cancelSleepTimer" @increase="increaseSleepTimer" @decrease="decreaseSleepTimer" /> <modals-sleep-timer-modal v-model="showSleepTimerModal" :current-time="sleepTimeRemaining" :sleep-timer-running="isSleepTimerRunning" :current-end-of-chapter-time="currentEndOfChapterTime" @change="selectSleepTimeout" @cancel="cancelSleepTimer" @increase="increaseSleepTimer" @decrease="decreaseSleepTimer" />
@@ -15,6 +15,8 @@ import { Dialog } from '@capacitor/dialog'
export default { export default {
data() { data() {
return { return {
isReady: false,
settingsLoaded: false,
audioPlayerReady: false, audioPlayerReady: false,
stream: null, stream: null,
download: null, download: null,
@@ -36,21 +38,10 @@ export default {
serverEpisodeId: null serverEpisodeId: null
} }
}, },
watch: {
socketConnected(newVal) {
if (newVal) {
console.log('Socket Connected set listeners')
this.setListeners()
}
}
},
computed: { computed: {
bookmarks() { bookmarks() {
if (!this.serverLibraryItemId) return [] if (!this.serverLibraryItemId) return []
return this.$store.getters['user/getUserBookmarksForItem'](this.serverLibraryItemId) return this.$store.getters['user/getUserBookmarksForItem'](this.serverLibraryItemId)
},
socketConnected() {
return this.$store.state.socketConnected
} }
}, },
methods: { methods: {
@@ -60,7 +51,7 @@ export default {
selectBookmark(bookmark) { selectBookmark(bookmark) {
this.showBookmarksModal = false this.showBookmarksModal = false
if (!bookmark || isNaN(bookmark.time)) return if (!bookmark || isNaN(bookmark.time)) return
var bookmarkTime = Number(bookmark.time) const bookmarkTime = Number(bookmark.time)
if (this.$refs.audioPlayer) { if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.seek(bookmarkTime) this.$refs.audioPlayer.seek(bookmarkTime)
} }
@@ -69,8 +60,6 @@ export default {
this.isSleepTimerRunning = false this.isSleepTimerRunning = false
if (currentPosition) { if (currentPosition) {
console.log('Sleep Timer Ended Current Position: ' + currentPosition) console.log('Sleep Timer Ended Current Position: ' + currentPosition)
var currentTime = Math.floor(currentPosition / 1000)
// TODO: Was syncing to the server here before
} }
}, },
onSleepTimerSet({ value: sleepTimeRemaining }) { onSleepTimerSet({ value: sleepTimeRemaining }) {
@@ -151,17 +140,12 @@ export default {
console.log(`[AudioPlayerContainer] PlaybackRate Updated: ${this.playbackSpeed}`) console.log(`[AudioPlayerContainer] PlaybackRate Updated: ${this.playbackSpeed}`)
this.$refs.audioPlayer.setPlaybackSpeed(this.playbackSpeed) this.$refs.audioPlayer.setPlaybackSpeed(this.playbackSpeed)
} }
},
setListeners() { // Settings have been loaded (at least once, so it's safe to kickoff onReady)
// if (!this.$server.socket) { if (!this.settingsLoaded) {
// console.error('Invalid server socket not set') this.settingsLoaded = true
// return this.notifyOnReady()
// } }
// this.$server.socket.on('stream_open', this.streamOpen)
// this.$server.socket.on('stream_closed', this.streamClosed)
// this.$server.socket.on('stream_progress', this.streamProgress)
// this.$server.socket.on('stream_ready', this.streamReady)
// this.$server.socket.on('stream_reset', this.streamReset)
}, },
closeStreamOnly() { closeStreamOnly() {
// If user logs out or disconnects from server and not playing local // If user logs out or disconnects from server and not playing local
@@ -198,13 +182,13 @@ export default {
}) })
}, },
async playLibraryItem(payload) { async playLibraryItem(payload) {
var libraryItemId = payload.libraryItemId const libraryItemId = payload.libraryItemId
var episodeId = payload.episodeId const episodeId = payload.episodeId
// When playing local library item and can also play this item from the server // When playing local library item and can also play this item from the server
// then store the server library item id so it can be used if a cast is made // then store the server library item id so it can be used if a cast is made
var serverLibraryItemId = payload.serverLibraryItemId || null const serverLibraryItemId = payload.serverLibraryItemId || null
var serverEpisodeId = payload.serverEpisodeId || null const serverEpisodeId = payload.serverEpisodeId || null
if (libraryItemId.startsWith('local') && this.$store.state.isCasting) { if (libraryItemId.startsWith('local') && this.$store.state.isCasting) {
const { value } = await Dialog.confirm({ const { value } = await Dialog.confirm({
@@ -219,7 +203,7 @@ export default {
this.serverLibraryItemId = null this.serverLibraryItemId = null
this.serverEpisodeId = null this.serverEpisodeId = null
var playbackRate = 1 let playbackRate = 1
if (this.$refs.audioPlayer) { if (this.$refs.audioPlayer) {
playbackRate = this.$refs.audioPlayer.currentPlaybackRate || 1 playbackRate = this.$refs.audioPlayer.currentPlaybackRate || 1
} }
@@ -261,6 +245,18 @@ export default {
onMediaPlayerChanged(data) { onMediaPlayerChanged(data) {
var mediaPlayer = data.value var mediaPlayer = data.value
this.$store.commit('setMediaPlayer', mediaPlayer) this.$store.commit('setMediaPlayer', mediaPlayer)
},
onReady() {
// The UI is reporting elsewhere we are ready
this.isReady = true
this.notifyOnReady()
},
notifyOnReady() {
// If settings aren't loaded yet, native player will receive incorrect settings
console.log('Notify on ready... settingsLoaded:', this.settingsLoaded, 'isReady:', this.isReady)
if (this.settingsLoaded && this.isReady) {
AbsAudioPlayer.onReady()
}
} }
}, },
mounted() { mounted() {
@@ -272,7 +268,7 @@ export default {
this.playbackSpeed = this.$store.getters['user/getUserSetting']('playbackRate') this.playbackSpeed = this.$store.getters['user/getUserSetting']('playbackRate')
console.log(`[AudioPlayerContainer] Init Playback Speed: ${this.playbackSpeed}`) console.log(`[AudioPlayerContainer] Init Playback Speed: ${this.playbackSpeed}`)
this.setListeners() this.$eventBus.$on('abs-ui-ready', this.onReady)
this.$eventBus.$on('play-item', this.playLibraryItem) this.$eventBus.$on('play-item', this.playLibraryItem)
this.$eventBus.$on('pause-item', this.pauseItem) this.$eventBus.$on('pause-item', this.pauseItem)
this.$eventBus.$on('close-stream', this.closeStreamOnly) this.$eventBus.$on('close-stream', this.closeStreamOnly)
@@ -285,13 +281,7 @@ export default {
if (this.onSleepTimerSetListener) this.onSleepTimerSetListener.remove() if (this.onSleepTimerSetListener) this.onSleepTimerSetListener.remove()
if (this.onMediaPlayerChangedListener) this.onMediaPlayerChangedListener.remove() if (this.onMediaPlayerChangedListener) this.onMediaPlayerChangedListener.remove()
// if (this.$server.socket) { this.$eventBus.$off('abs-ui-ready', this.onReady)
// this.$server.socket.off('stream_open', this.streamOpen)
// this.$server.socket.off('stream_closed', this.streamClosed)
// this.$server.socket.off('stream_progress', this.streamProgress)
// this.$server.socket.off('stream_ready', this.streamReady)
// this.$server.socket.off('stream_reset', this.streamReset)
// }
this.$eventBus.$off('play-item', this.playLibraryItem) this.$eventBus.$off('play-item', this.playLibraryItem)
this.$eventBus.$off('pause-item', this.pauseItem) this.$eventBus.$off('pause-item', this.pauseItem)
this.$eventBus.$off('close-stream', this.closeStreamOnly) this.$eventBus.$off('close-stream', this.closeStreamOnly)
+8
View File
@@ -113,6 +113,13 @@ export default {
text: 'Local Media', text: 'Local Media',
to: '/localMedia/folders' to: '/localMedia/folders'
}) })
} else {
items.push({
icon: 'download',
iconOutlined: false,
text: 'Downloads',
to: '/downloads'
})
} }
items.push({ items.push({
icon: 'settings', icon: 'settings',
@@ -130,6 +137,7 @@ export default {
this.show = false this.show = false
}, },
async logout() { async logout() {
await this.$hapticsImpactMedium()
if (this.user) { if (this.user) {
await this.$axios.$post('/logout').catch((error) => { await this.$axios.$post('/logout').catch((error) => {
console.error(error) console.error(error)
+12 -14
View File
@@ -89,22 +89,22 @@ export default {
return this.bookCoverAspectRatio === 1 return this.bookCoverAspectRatio === 1
}, },
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio'] return this.$store.getters['libraries/getBookCoverAspectRatio']
}, },
bookWidth() { bookWidth() {
var coverSize = 100 var coverSize = 100
if (window.innerWidth <= 375) coverSize = 90 if (window.innerWidth <= 375) coverSize = 90
if (this.isCoverSquareAspectRatio) return coverSize * 1.6 if (this.isCoverSquareAspectRatio || this.entityName === 'playlists') return coverSize * 1.6
return coverSize return coverSize
}, },
bookHeight() { bookHeight() {
if (this.isCoverSquareAspectRatio) return this.bookWidth if (this.isCoverSquareAspectRatio || this.entityName === 'playlists') return this.bookWidth
return this.bookWidth * 1.6 return this.bookWidth * 1.6
}, },
entityWidth() { entityWidth() {
if (this.showBookshelfListView) return this.bookshelfWidth - 16 if (this.showBookshelfListView) return this.bookshelfWidth - 16
if (this.isBookEntity) return this.bookWidth if (this.isBookEntity || this.entityName === 'playlists') return this.bookWidth
return this.bookWidth * 2 return this.bookWidth * 2
}, },
entityHeight() { entityHeight() {
@@ -134,7 +134,7 @@ export default {
return this.$store.getters['getAltViewEnabled'] return this.$store.getters['getAltViewEnabled']
}, },
sizeMultiplier() { sizeMultiplier() {
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120 const baseSize = this.isCoverSquareAspectRatio ? 192 : 120
return this.entityWidth / baseSize return this.entityWidth / baseSize
} }
}, },
@@ -145,7 +145,7 @@ export default {
}) })
}, },
async fetchEntities(page) { async fetchEntities(page) {
var startIndex = page * this.booksPerFetch const startIndex = page * this.booksPerFetch
this.isFetchingEntities = true this.isFetchingEntities = true
@@ -153,11 +153,11 @@ export default {
this.currentSFQueryString = this.buildSearchParams() this.currentSFQueryString = this.buildSearchParams()
} }
var entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `items` : this.entityName const entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `items` : this.entityName
var sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : '' const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
var fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1` const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1`
var payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => { const payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
console.error('failed to fetch books', error) console.error('failed to fetch books', error)
return null return null
}) })
@@ -179,13 +179,13 @@ export default {
} }
for (let i = 0; i < payload.results.length; i++) { for (let i = 0; i < payload.results.length; i++) {
var index = i + startIndex const index = i + startIndex
this.entities[index] = payload.results[i] this.entities[index] = payload.results[i]
if (this.entityComponentRefs[index]) { if (this.entityComponentRefs[index]) {
this.entityComponentRefs[index].setEntity(this.entities[index]) this.entityComponentRefs[index].setEntity(this.entities[index])
if (this.isBookEntity) { if (this.isBookEntity) {
var localLibraryItem = this.localLibraryItems.find((lli) => lli.libraryItemId == this.entities[index].id) const localLibraryItem = this.localLibraryItems.find((lli) => lli.libraryItemId == this.entities[index].id)
if (localLibraryItem) { if (localLibraryItem) {
this.entityComponentRefs[index].setLocalLibraryItem(localLibraryItem) this.entityComponentRefs[index].setLocalLibraryItem(localLibraryItem)
} }
@@ -356,8 +356,6 @@ export default {
let searchParams = new URLSearchParams() let searchParams = new URLSearchParams()
if (this.page === 'series-books') { if (this.page === 'series-books') {
searchParams.set('filter', `series.${this.$encode(this.seriesId)}`) searchParams.set('filter', `series.${this.$encode(this.seriesId)}`)
searchParams.set('sort', 'book.volumeNumber')
searchParams.set('desc', 0)
} else { } else {
if (this.filterBy && this.filterBy !== 'all') { if (this.filterBy && this.filterBy !== 'all') {
searchParams.set('filter', this.filterBy) searchParams.set('filter', this.filterBy)
+1 -1
View File
@@ -69,7 +69,7 @@ export default {
return this.bookCoverAspectRatio === 1 return this.bookCoverAspectRatio === 1
}, },
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio'] return this.$store.getters['libraries/getBookCoverAspectRatio']
}, },
altViewEnabled() { altViewEnabled() {
return this.$store.getters['getAltViewEnabled'] return this.$store.getters['getAltViewEnabled']
+1 -1
View File
@@ -31,7 +31,7 @@ export default {
}, },
computed: { computed: {
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio'] return this.$store.getters['libraries/getBookCoverAspectRatio']
}, },
coverWidth() { coverWidth() {
if (this.bookCoverAspectRatio === 1) return 50 * 1.2 if (this.bookCoverAspectRatio === 1) return 50 * 1.2
+35 -7
View File
@@ -42,7 +42,7 @@
<!-- No progress shown for collapsed series in library --> <!-- No progress shown for collapsed series in library -->
<div v-if="!collapsedSeries && (!isPodcast || recentEpisode)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div> <div v-if="!collapsedSeries && (!isPodcast || recentEpisode)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
<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` }"> <div v-if="showHasLocalDownload" class="absolute right-0 top-0 z-20" :style="{ top: (isPodcast ? 1.75 : 0.375) * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<span class="material-icons text-2xl text-success">{{ isLocalOnly ? 'task' : 'download_done' }}</span> <span class="material-icons text-2xl text-success">{{ isLocalOnly ? 'task' : 'download_done' }}</span>
</div> </div>
@@ -164,6 +164,7 @@ export default {
return this.media.numTracks return this.media.numTracks
}, },
numEpisodes() { numEpisodes() {
if (this.isLocal && this.isPodcast && this.media.episodes) return this.media.episodes.length
return this.media.numEpisodes return this.media.numEpisodes
}, },
processingBatch() { processingBatch() {
@@ -318,9 +319,6 @@ export default {
userIsRoot() { userIsRoot() {
return this.store.getters['user/getIsRoot'] return this.store.getters['user/getIsRoot']
}, },
_socket() {
return this.$root.socket || this.$nuxt.$root.socket
},
titleFontSize() { titleFontSize() {
return 0.75 * this.sizeMultiplier return 0.75 * this.sizeMultiplier
}, },
@@ -351,6 +349,17 @@ export default {
if (!this.isAltViewEnabled) return 0 if (!this.isAltViewEnabled) return 0
else if (!this.displaySortLine) return 3 * this.sizeMultiplier else if (!this.displaySortLine) return 3 * this.sizeMultiplier
return 4.25 * this.sizeMultiplier return 4.25 * this.sizeMultiplier
},
showHasLocalDownload() {
if (this.localLibraryItem || this.isLocal) {
if (this.recentEpisode && !this.isLocal) {
const localEpisode = this.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId === this.recentEpisode.id)
return !!localEpisode
} else {
return true
}
}
return false
} }
}, },
methods: { methods: {
@@ -392,15 +401,34 @@ export default {
// Server books may have a local library item // Server books may have a local library item
this.localLibraryItem = localLibraryItem this.localLibraryItem = localLibraryItem
}, },
clickCard(e) { async clickCard(e) {
if (this.isSelectionMode) { if (this.isSelectionMode) {
e.stopPropagation() e.stopPropagation()
e.preventDefault() e.preventDefault()
this.selectBtnClick() this.selectBtnClick()
} else if (this.recentEpisode) { } else if (this.recentEpisode) {
await this.$hapticsImpactMedium()
var eventBus = this.$eventBus || this.$nuxt.$eventBus var eventBus = this.$eventBus || this.$nuxt.$eventBus
if (this.streamIsPlaying) eventBus.$emit('pause-item') if (this.streamIsPlaying) {
else eventBus.$emit('play-item', { libraryItemId: this.libraryItemId, episodeId: this.recentEpisode.id }) eventBus.$emit('pause-item')
return
}
if (this.localLibraryItem) {
const localEpisode = this.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId === this.recentEpisode.id)
if (localEpisode) {
// Play episode locally
eventBus.$emit('play-item', {
libraryItemId: this.localLibraryItemId,
episodeId: localEpisode.id,
serverLibraryItemId: this.libraryItemId,
serverEpisodeId: this.recentEpisode.id
})
return
}
}
eventBus.$emit('play-item', { libraryItemId: this.libraryItemId, episodeId: this.recentEpisode.id })
} else { } else {
var router = this.$router || this.$nuxt.$router var router = this.$router || this.$nuxt.$router
if (router) { if (router) {
-3
View File
@@ -63,9 +63,6 @@ export default {
var router = this.$router || this.$nuxt.$router var router = this.$router || this.$nuxt.$router
router.push(`/collection/${this.collection.id}`) router.push(`/collection/${this.collection.id}`)
}, },
clickEdit() {
this.$emit('edit', this.collection)
},
destroy() { destroy() {
// destroy the vue listeners, etc // destroy the vue listeners, etc
this.$destroy() this.$destroy()
-3
View File
@@ -278,9 +278,6 @@ export default {
userIsRoot() { userIsRoot() {
return this.store.getters['user/getIsRoot'] return this.store.getters['user/getIsRoot']
}, },
_socket() {
return this.$root.socket || this.$nuxt.$root.socket
},
titleFontSize() { titleFontSize() {
return 0.75 * this.sizeMultiplier return 0.75 * this.sizeMultiplier
}, },
+86
View File
@@ -0,0 +1,86 @@
<template>
<div ref="card" :id="`playlist-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @click="clickCard">
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
<covers-playlist-cover ref="cover" :items="items" :width="width" :height="height" />
</div>
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
<div class="w-full h-full flex items-center justify-center rounded-sm border" :class="isAltViewEnabled ? 'altBookshelfLabel' : 'shinyBlack'" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
index: Number,
width: Number,
height: Number,
bookCoverAspectRatio: Number,
playlistMount: {
type: Object,
default: () => null
},
isAltViewEnabled: Boolean
},
data() {
return {
playlist: null,
isSelectionMode: false
}
},
computed: {
labelFontSize() {
if (this.width < 160) return 0.75
return 0.875
},
sizeMultiplier() {
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
return this.width / 240
},
title() {
return this.playlist ? this.playlist.name : ''
},
items() {
return this.playlist ? this.playlist.items || [] : []
},
store() {
return this.$store || this.$nuxt.$store
},
currentLibraryId() {
return this.store.state.libraries.currentLibraryId
}
},
methods: {
setEntity(playlist) {
this.playlist = playlist
},
setSelectionMode(val) {
this.isSelectionMode = val
},
clickCard() {
if (!this.playlist) return
var router = this.$router || this.$nuxt.$router
router.push(`/playlist/${this.playlist.id}`)
},
destroy() {
// destroy the vue listeners, etc
this.$destroy()
// remove the element from the DOM
if (this.$el && this.$el.parentNode) {
this.$el.parentNode.removeChild(this.$el)
} else if (this.$el && this.$el.remove) {
this.$el.remove()
}
}
},
mounted() {
if (this.playlistMount) {
this.setEntity(this.playlistMount)
}
}
}
</script>
+1 -1
View File
@@ -24,7 +24,7 @@ export default {
}, },
computed: { computed: {
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio'] return this.$store.getters['libraries/getBookCoverAspectRatio']
}, },
name() { name() {
return this.series.name return this.series.name
+5 -2
View File
@@ -132,6 +132,7 @@ export default {
} }
}, },
async connectToServer(config) { async connectToServer(config) {
await this.$hapticsImpactMedium()
console.log('[ServerConnectForm] connectToServer', config.address) console.log('[ServerConnectForm] connectToServer', config.address)
this.processing = true this.processing = true
this.serverConfig = { this.serverConfig = {
@@ -159,6 +160,7 @@ export default {
}, },
async removeServerConfigClick() { async removeServerConfigClick() {
if (!this.serverConfig.id) return if (!this.serverConfig.id) return
await this.$hapticsImpactMedium()
const { value } = await Dialog.confirm({ const { value } = await Dialog.confirm({
title: 'Confirm', title: 'Confirm',
@@ -189,7 +191,8 @@ export default {
this.showForm = true this.showForm = true
this.showAuth = true this.showAuth = true
}, },
newServerConfigClick() { async newServerConfigClick() {
await this.$hapticsImpactMedium()
this.serverConfig = { this.serverConfig = {
address: '', address: '',
userId: '', userId: '',
@@ -348,4 +351,4 @@ export default {
this.init() this.init()
} }
} }
</script> </script>
+3 -2
View File
@@ -47,7 +47,8 @@ export default {
default: 120 default: 120
}, },
bookCoverAspectRatio: Number, bookCoverAspectRatio: Number,
downloadCover: String downloadCover: String,
raw: Boolean
}, },
data() { data() {
return { return {
@@ -115,7 +116,7 @@ export default {
if (this.downloadCover) return this.downloadCover if (this.downloadCover) return this.downloadCover
if (!this.libraryItem) return null if (!this.libraryItem) return null
var store = this.$store || this.$nuxt.$store var store = this.$store || this.$nuxt.$store
return store.getters['globals/getLibraryItemCoverSrc'](this.libraryItem, this.placeholderUrl) return store.getters['globals/getLibraryItemCoverSrc'](this.libraryItem, this.placeholderUrl, this.raw)
}, },
cover() { cover() {
return this.media.coverPath || this.placeholderUrl return this.media.coverPath || this.placeholderUrl
+51
View File
@@ -0,0 +1,51 @@
<template>
<div class="relative rounded-sm overflow-hidden" :style="{ width: width + 'px', height: height + 'px' }">
<div v-if="items.length" class="flex flex-wrap justify-center h-full relative bg-primary bg-opacity-95 rounded-sm">
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
<covers-book-cover v-for="(li, index) in libraryItemCovers" :key="index" :library-item="li" :width="itemCoverWidth" :book-cover-aspect-ratio="1" />
</div>
<div v-else class="relative w-full h-full flex items-center justify-center p-2 bg-primary rounded-sm">
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
</div>
</div>
</template>
<script>
export default {
props: {
items: {
type: Array,
default: () => []
},
width: Number,
height: Number
},
data() {
return {}
},
computed: {
sizeMultiplier() {
return this.width / (120 * 1.6 * 2)
},
itemCoverWidth() {
if (this.libraryItemCovers.length === 1) return this.width
return this.width / 2
},
libraryItemCovers() {
if (!this.items.length) return []
if (this.items.length === 1) return [this.items[0].libraryItem]
const covers = []
for (let i = 0; i < 4; i++) {
let index = i % this.items.length
if (this.items.length === 2 && i >= 2) index = (i + 1) % 2 // for playlists with 2 items show covers in checker pattern
covers.push(this.items[index].libraryItem)
}
return covers
}
},
methods: {},
mounted() {}
}
</script>
+182
View File
@@ -0,0 +1,182 @@
<template>
<div class="w-full px-2">
<img v-if="podcast.imageUrl" :src="podcast.imageUrl" class="h-36 w-36 object-contain mx-auto mb-2" />
<ui-text-input-with-label v-model="podcast.title" :label="'Title'" class="mb-2 text-sm" @input="titleUpdated" />
<ui-text-input-with-label v-model="podcast.author" :label="'Author'" class="mb-2 text-sm" />
<ui-text-input-with-label v-model="podcast.feedUrl" :label="'Feed URL'" readonly class="mb-2 text-sm" />
<ui-multi-select v-model="podcast.genres" :items="podcast.genres" :label="'Genres'" class="mb-2 text-sm" />
<ui-textarea-with-label v-model="podcast.description" :label="'Description'" :rows="3" class="mb-2 text-sm" />
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="processing" :label="'Folder'" class="mb-2 text-sm" @input="folderUpdated" />
<ui-text-input-with-label v-model="fullPath" :label="'Podcast Path'" input-class="h-10" readonly class="mb-2 text-sm" />
<div class="flex items-center py-4 px-2">
<ui-checkbox v-model="podcast.autoDownloadEpisodes" :label="'Auto Download Episodes'" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-sm font-semibold" />
<div class="flex-grow" />
<ui-btn color="success" @click="submit">Submit</ui-btn>
</div>
</div>
</template>
<script>
import Path from 'path'
export default {
props: {
processing: Boolean,
podcastData: {
type: Object,
default: () => null
},
podcastFeedData: {
type: Object,
default: () => null
}
},
data() {
return {
selectedFolderId: null,
fullPath: null,
podcast: {
title: '',
author: '',
description: '',
releaseDate: '',
genres: [],
feedUrl: '',
feedImageUrl: '',
itunesPageUrl: '',
itunesId: '',
itunesArtistId: '',
autoDownloadEpisodes: false
}
}
},
computed: {
_processing: {
get() {
return this.processing
},
set(val) {
this.$emit('update:processing', val)
}
},
title() {
return this._podcastData.title
},
currentLibrary() {
return this.$store.getters['libraries/getCurrentLibrary']
},
folders() {
if (!this.currentLibrary) return []
return this.currentLibrary.folders || []
},
folderItems() {
return this.folders.map((fold) => {
return {
value: fold.id,
text: fold.fullPath
}
})
},
_podcastData() {
return this.podcastData || {}
},
feedMetadata() {
if (!this.podcastFeedData) return {}
return this.podcastFeedData.metadata || {}
},
episodes() {
if (!this.podcastFeedData) return []
return this.podcastFeedData.episodes || []
},
selectedFolder() {
return this.folders.find((f) => f.id === this.selectedFolderId)
},
selectedFolderPath() {
if (!this.selectedFolder) return ''
return this.selectedFolder.fullPath
}
},
methods: {
titleUpdated() {
this.folderUpdated()
},
folderUpdated() {
if (!this.selectedFolderPath || !this.podcast.title) {
this.fullPath = ''
return
}
this.fullPath = Path.join(this.selectedFolderPath, this.$sanitizeFilename(this.podcast.title))
},
submit() {
const podcastPayload = {
path: this.fullPath,
folderId: this.selectedFolderId,
libraryId: this.currentLibrary.id,
media: {
metadata: {
title: this.podcast.title,
author: this.podcast.author,
description: this.podcast.description,
releaseDate: this.podcast.releaseDate,
genres: [...this.podcast.genres],
feedUrl: this.podcast.feedUrl,
imageUrl: this.podcast.imageUrl,
itunesPageUrl: this.podcast.itunesPageUrl,
itunesId: this.podcast.itunesId,
itunesArtistId: this.podcast.itunesArtistId,
language: this.podcast.language
},
autoDownloadEpisodes: this.podcast.autoDownloadEpisodes
}
}
console.log('Podcast payload', podcastPayload)
this._processing = true
this.$axios
.$post('/api/podcasts', podcastPayload)
.then((libraryItem) => {
this._processing = false
this.$toast.success('Podcast added')
this.$router.push(`/item/${libraryItem.id}`)
})
.catch((error) => {
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to add podcast'
console.error('Failed to create podcast', error)
this._processing = false
this.$toast.error(errorMsg)
})
},
init() {
// Prefer using itunes podcast data but not always passed in if manually entering rss feed
this.podcast.title = this._podcastData.title || this.feedMetadata.title || ''
this.podcast.author = this._podcastData.artistName || this.feedMetadata.author || ''
this.podcast.description = this._podcastData.description || this.feedMetadata.descriptionPlain || ''
this.podcast.releaseDate = this._podcastData.releaseDate || ''
this.podcast.genres = this._podcastData.genres || this.feedMetadata.categories || []
this.podcast.feedUrl = this._podcastData.feedUrl || this.feedMetadata.feedUrl || ''
this.podcast.imageUrl = this._podcastData.cover || this.feedMetadata.image || ''
this.podcast.itunesPageUrl = this._podcastData.pageUrl || ''
this.podcast.itunesId = this._podcastData.id || ''
this.podcast.itunesArtistId = this._podcastData.artistId || ''
this.podcast.language = this._podcastData.language || ''
this.podcast.autoDownloadEpisodes = false
if (this.folderItems[0]) {
this.selectedFolderId = this.folderItems[0].value
this.folderUpdated()
}
}
},
mounted() {
this.init()
}
}
</script>
+101 -26
View File
@@ -1,8 +1,10 @@
<template> <template>
<div class="w-full h-9 bg-bg relative"> <div class="w-full h-9 bg-bg relative">
<div id="bookshelf-navbar" class="absolute z-10 top-0 left-0 w-full h-full flex bg-secondary text-gray-200"> <div id="bookshelf-navbar" class="absolute z-10 top-0 left-0 w-full h-full flex bg-secondary text-gray-200">
<nuxt-link v-for="item in items" :key="item.to" :to="item.to" class="h-full flex items-center justify-center" :style="{ width: isPodcast ? '50%' : '25%' }" :class="routeName === item.routeName ? 'bg-primary' : 'text-gray-400'"> <nuxt-link v-for="item in items" :key="item.to" :to="item.to" class="h-full flex-grow flex items-center justify-center" :class="routeName === item.routeName ? 'bg-primary' : 'text-gray-400'">
<p>{{ item.text }}</p> <p v-if="routeName === item.routeName" class="text-sm font-semibold">{{ item.text }}</p>
<span v-else-if="item.iconPack === 'abs-icons'" class="abs-icons" :class="`icon-${item.icon} ${item.iconClass || ''}`"></span>
<span v-else :class="`${item.iconPack} ${item.iconClass || ''}`">{{ item.icon }}</span>
</nuxt-link> </nuxt-link>
</div> </div>
</div> </div>
@@ -14,43 +16,114 @@ export default {
return {} return {}
}, },
computed: { computed: {
currentLibrary() {
return this.$store.getters['libraries/getCurrentLibrary']
},
currentLibraryIcon() {
return this.currentLibrary ? this.currentLibrary.icon : 'database'
},
userHasPlaylists() {
return this.$store.state.libraries.numUserPlaylists
},
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
items() { items() {
let items = []
if (this.isPodcast) { if (this.isPodcast) {
return [ items = [
{ {
to: '/bookshelf', to: '/bookshelf',
routeName: 'bookshelf', routeName: 'bookshelf',
iconPack: 'abs-icons',
icon: 'home',
iconClass: 'text-xl',
text: 'Home'
},
{
to: '/bookshelf/latest',
routeName: 'bookshelf-latest',
iconPack: 'abs-icons',
icon: 'list',
iconClass: 'text-xl',
text: 'Latest'
},
{
to: '/bookshelf/library',
routeName: 'bookshelf-library',
iconPack: 'abs-icons',
icon: this.currentLibraryIcon,
iconClass: 'text-lg',
text: 'Library'
}
]
if (this.userIsAdminOrUp) {
items.push({
to: '/bookshelf/search',
routeName: 'bookshelf-search',
iconPack: 'material-icons',
icon: 'podcasts',
iconClass: 'text-xl',
text: 'Search'
})
}
} else {
items = [
{
to: '/bookshelf',
routeName: 'bookshelf',
iconPack: 'abs-icons',
icon: 'home',
iconClass: 'text-xl',
text: 'Home' text: 'Home'
}, },
{ {
to: '/bookshelf/library', to: '/bookshelf/library',
routeName: 'bookshelf-library', routeName: 'bookshelf-library',
iconPack: 'abs-icons',
icon: this.currentLibraryIcon,
iconClass: 'text-lg',
text: 'Library' text: 'Library'
},
{
to: '/bookshelf/series',
routeName: 'bookshelf-series',
iconPack: 'abs-icons',
icon: 'columns',
iconClass: 'text-lg pt-px',
text: 'Series'
},
{
to: '/bookshelf/collections',
routeName: 'bookshelf-collections',
iconPack: 'material-icons-outlined',
icon: 'collections_bookmark',
iconClass: 'text-xl',
text: 'Collections'
} }
// {
// to: '/bookshelf/authors',
// routeName: 'bookshelf-authors',
// iconPack: 'abs-icons',
// icon: 'authors',
// iconClass: 'text-2xl pb-px',
// text: 'Authors'
// }
] ]
} }
return [
{ if (this.userHasPlaylists) {
to: '/bookshelf', items.push({
routeName: 'bookshelf', to: '/bookshelf/playlists',
text: 'Home' routeName: 'bookshelf-playlists',
}, iconPack: 'material-icons',
{ icon: 'queue_music',
to: '/bookshelf/library', text: 'Playlists'
routeName: 'bookshelf-library', })
text: 'Library' }
},
{ return items
to: '/bookshelf/series',
routeName: 'bookshelf-series',
text: 'Series'
},
{
to: '/bookshelf/collections',
routeName: 'bookshelf-collections',
text: 'Collections'
}
]
}, },
routeName() { routeName() {
return this.$route.name return this.$route.name
@@ -62,7 +135,9 @@ export default {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] return this.$store.getters['libraries/getCurrentLibraryMediaType']
} }
}, },
methods: {}, methods: {
isSelected(item) {}
},
mounted() {} mounted() {}
} }
</script> </script>
+8 -2
View File
@@ -8,7 +8,7 @@
<p v-show="!selectedSeriesName" class="font-book pt-1">{{ totalEntities }} {{ entityTitle }}</p> <p v-show="!selectedSeriesName" class="font-book pt-1">{{ totalEntities }} {{ entityTitle }}</p>
<p v-show="selectedSeriesName" class="ml-2 font-book pt-1">{{ selectedSeriesName }} ({{ totalEntities }})</p> <p v-show="selectedSeriesName" class="ml-2 font-book pt-1">{{ selectedSeriesName }} ({{ totalEntities }})</p>
<div class="flex-grow" /> <div class="flex-grow" />
<span v-if="page == 'library' || seriesBookPage" class="material-icons px-2" @click="bookshelfListView = !bookshelfListView">{{ !bookshelfListView ? 'view_list' : 'grid_view' }}</span> <span v-if="page == 'library' || seriesBookPage" class="material-icons px-2" @click="changeView">{{ !bookshelfListView ? 'view_list' : 'grid_view' }}</span>
<template v-if="page === 'library'"> <template v-if="page === 'library'">
<div class="relative flex items-center px-2"> <div class="relative flex items-center px-2">
<span class="material-icons" @click="showFilterModal = true">filter_alt</span> <span class="material-icons" @click="showFilterModal = true">filter_alt</span>
@@ -64,6 +64,8 @@ export default {
return 'Series' return 'Series'
} else if (this.page === 'collections') { } else if (this.page === 'collections') {
return 'Collections' return 'Collections'
} else if (this.page === 'playlists') {
return 'Playlists'
} }
return '' return ''
}, },
@@ -100,6 +102,10 @@ export default {
}, },
setTotalEntities(total) { setTotalEntities(total) {
this.totalEntities = total this.totalEntities = total
},
async changeView() {
this.bookshelfListView = !this.bookshelfListView
await this.$hapticsImpactMedium()
} }
}, },
mounted() { mounted() {
@@ -118,4 +124,4 @@ export default {
#bookshelf-toolbar { #bookshelf-toolbar {
box-shadow: 0px 5px 5px #11111155; box-shadow: 0px 5px 5px #11111155;
} }
</style> </style>
-23
View File
@@ -1,23 +0,0 @@
<template>
<svg fill="currentColor" class="h-full w-full p-px" viewBox="0 0 1978.03 2349.44">
<path
d="M2519.5,1438.39c-12.13-10.1-31-25-56.57-42.62V1197.31c0-505.94-410.15-916.09-916.1-916.09h0c-505.94,0-916.09,410.15-916.09,916.09v198.46c-25.57,17.66-44.44,32.52-56.57,42.62a45.45,45.45,0,0,0-16.35,34.95v237.74a45.45,45.45,0,0,0,16.35,35c28.28,23.54,93.18,72.92,194.22,123.55v23.11c0,62.32,40.21,112.85,89.8,112.85h0c49.59,0,89.8-50.53,89.8-112.85V1322.51c0-62.33-40.21-112.86-89.8-112.86h0c-47.51,0-86.4,46.38-89.58,105.07l-.22.11V1197.31c0-429.92,348.52-778.43,778.44-778.43h0c429.92,0,778.44,348.51,778.44,778.43v117.52l-.22-.11c-3.18-58.69-42.06-105.07-89.58-105.07h0c-49.59,0-89.79,50.53-89.79,112.86v570.18c0,62.32,40.2,112.85,89.79,112.85h0c49.6,0,89.8-50.53,89.8-112.85v-23.11c101.05-50.63,165.95-100,194.23-123.55a45.48,45.48,0,0,0,16.35-35V1473.34A45.48,45.48,0,0,0,2519.5,1438.39Z"
transform="translate(-557.82 -281.22)"
/>
<path d="M1227.4,2429.63a108.47,108.47,0,0,0,108.47-108.47V1106.56A108.47,108.47,0,0,0,1227.4,998.08H1115.33a108.48,108.48,0,0,0-108.48,108.48v1214.6a108.47,108.47,0,0,0,108.48,108.47ZM1047.75,1289.38H1295v25.83H1047.75Z" transform="translate(-557.82 -281.22)" />
<path d="M1602.87,2429.63a108.47,108.47,0,0,0,108.47-108.47V1106.56a108.47,108.47,0,0,0-108.47-108.48H1490.8a108.48,108.48,0,0,0-108.48,108.48v1214.6a108.47,108.47,0,0,0,108.48,108.47ZM1423.22,1289.38h247.22v25.83H1423.22Z" transform="translate(-557.82 -281.22)" />
<path d="M1978.34,2429.63a108.47,108.47,0,0,0,108.47-108.47V1106.56a108.47,108.47,0,0,0-108.47-108.48H1866.27a108.48,108.48,0,0,0-108.48,108.48v1214.6a108.47,108.47,0,0,0,108.48,108.47ZM1798.69,1289.38h247.22v25.83H1798.69Z" transform="translate(-557.82 -281.22)" />
<rect x="180.05" y="2185.95" width="1617.93" height="163.49" rx="81.74" />
</svg>
</template>
<script>
export default {
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
-16
View File
@@ -1,16 +0,0 @@
<template>
<svg fill="currentColor" viewBox="0 0 24 24">
<path fill="currentColor" d="M9 3V18H12V3H9M12 5L16 18L19 17L15 4L12 5M5 5V18H8V5H5M3 19V21H21V19H3Z" />
</svg>
</template>
<script>
export default {
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
-16
View File
@@ -1,16 +0,0 @@
<template>
<svg viewBox="0 0 24 24">
<path fill="currentColor" d="M6,19L9,15.14L11.14,17.72L14.14,13.86L18,19H6M6,4H11V12L8.5,10.5L6,12M18,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V4A2,2 0 0,0 18,2Z" />
</svg>
</template>
<script>
export default {
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
-16
View File
@@ -1,16 +0,0 @@
<template>
<svg class="p-px" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
</template>
<script>
export default {
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
-19
View File
@@ -1,19 +0,0 @@
<template>
<svg class="p-px" viewBox="0 0 122.877 120.596">
<path
fill="currentColor"
d="M68.925,69.906v50.689H53.953V69.906c-4.918-2.662-8.259-7.867-8.259-13.854 c0-8.694,7.05-15.744,15.745-15.744c8.694,0,15.745,7.05,15.745,15.744C77.184,62.039,73.843,67.244,68.925,69.906L68.925,69.906z M39.32,11.165c2.916-1.438,4.111-4.969,2.673-7.882c-1.438-2.914-4.966-4.111-7.88-2.674C22.213,6.479,12.958,16.19,7.11,27.625 c-4.32,8.445-6.783,17.842-7.08,27.325c-0.299,9.563,1.587,19.223,5.973,28.114c5.401,10.953,14.558,20.695,28.039,27.592 c2.889,1.477,6.429,0.33,7.905-2.559c1.477-2.889,0.331-6.428-2.558-7.904c-11.037-5.645-18.486-13.525-22.833-22.334 c-3.506-7.111-5.014-14.857-4.774-22.539c0.243-7.757,2.256-15.442,5.79-22.348C22.304,23.721,29.76,15.879,39.32,11.165 L39.32,11.165z M88.765,0.608c-2.914-1.438-6.443-0.24-7.881,2.674c-1.438,2.914-0.242,6.445,2.674,7.882 c9.561,4.715,17.017,12.556,21.747,21.808c3.533,6.905,5.547,14.59,5.789,22.348c0.24,7.682-1.268,15.428-4.773,22.539 c-4.347,8.809-11.796,16.689-22.833,22.334c-2.889,1.477-4.034,5.016-2.558,7.904c1.476,2.889,5.016,4.035,7.905,2.559 c13.48-6.896,22.638-16.639,28.039-27.592c4.386-8.891,6.272-18.551,5.973-28.114c-0.297-9.483-2.76-18.88-7.079-27.325 C109.919,16.19,100.665,6.479,88.765,0.608L88.765,0.608z M82.791,26.505c-2.195-1.581-5.256-1.082-6.837,1.113 c-1.58,2.195-1.082,5.256,1.113,6.837c0.885,0.637,1.753,1.352,2.604,2.134c4.971,4.583,7.919,10.694,8.538,17.16 c0.626,6.524-1.111,13.437-5.518,19.552c-0.748,1.039-1.61,2.092-2.585,3.15c-1.835,1.992-1.708,5.098,0.287,6.932 c1.994,1.834,5.099,1.705,6.933-0.287c1.18-1.279,2.286-2.641,3.315-4.072c5.862-8.139,8.166-17.4,7.322-26.197 c-0.848-8.853-4.871-17.208-11.648-23.457C85.249,28.387,84.074,27.431,82.791,26.505L82.791,26.505z M45.81,34.458 c2.195-1.581,2.694-4.642,1.113-6.837c-1.581-2.195-4.642-2.694-6.837-1.114c-1.284,0.926-2.458,1.882-3.524,2.864 c-6.778,6.25-10.801,14.604-11.649,23.457c-0.844,8.796,1.46,18.06,7.323,26.199c1.031,1.43,2.136,2.791,3.315,4.07 c1.834,1.992,4.939,2.121,6.932,0.287c1.996-1.834,2.123-4.939,0.288-6.932c-0.975-1.059-1.837-2.111-2.585-3.15 c-4.406-6.115-6.144-13.027-5.518-19.551c0.619-6.465,3.567-12.577,8.538-17.16C44.058,35.81,44.926,35.095,45.81,34.458 L45.81,34.458z"
/>
</svg>
</template>
<script>
export default {
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
+6 -2
View File
@@ -46,6 +46,7 @@
<script> <script>
import { Dialog } from '@capacitor/dialog' import { Dialog } from '@capacitor/dialog'
export default { export default {
props: { props: {
value: Boolean, value: Boolean,
@@ -94,6 +95,7 @@ export default {
this.showBookmarkTitleInput = true this.showBookmarkTitleInput = true
}, },
async deleteBookmark(bm) { async deleteBookmark(bm) {
await this.$hapticsImpactMedium()
const { value } = await Dialog.confirm({ const { value } = await Dialog.confirm({
title: 'Remove Bookmark', title: 'Remove Bookmark',
message: `Are you sure you want to remove bookmark?` message: `Are you sure you want to remove bookmark?`
@@ -111,7 +113,8 @@ export default {
}) })
this.show = false this.show = false
}, },
clickBookmark(bm) { async clickBookmark(bm) {
await this.$hapticsImpactMedium()
this.$emit('select', bm) this.$emit('select', bm)
}, },
submitUpdateBookmark(updatedBookmark) { submitUpdateBookmark(updatedBookmark) {
@@ -155,7 +158,8 @@ export default {
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm') this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
this.showBookmarkTitleInput = true this.showBookmarkTitleInput = true
}, },
submitBookmark() { async submitBookmark() {
await this.$hapticsImpactMedium()
if (this.selectedBookmark) { if (this.selectedBookmark) {
var updatePayload = { var updatePayload = {
...this.selectedBookmark, ...this.selectedBookmark,
+3 -3
View File
@@ -1,5 +1,5 @@
<template> <template>
<modals-modal v-model="show" :width="300" height="100%"> <modals-modal v-model="show" :width="350" height="100%">
<template #outer> <template #outer>
<div v-if="currentChapter" class="absolute top-7 left-4 z-40" style="max-width: 80%"> <div v-if="currentChapter" class="absolute top-7 left-4 z-40" style="max-width: 80%">
<p class="text-white text-lg truncate">Current: {{ currentChapterTitle }}</p> <p class="text-white text-lg truncate">Current: {{ currentChapterTitle }}</p>
@@ -11,10 +11,10 @@
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label"> <ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="chapter in chapters"> <template v-for="chapter in chapters">
<li :key="chapter.id" :id="`chapter-row-${chapter.id}`" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="currentChapterId === chapter.id ? 'bg-bg bg-opacity-80' : ''" role="option" @click="clickedOption(chapter)"> <li :key="chapter.id" :id="`chapter-row-${chapter.id}`" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="currentChapterId === chapter.id ? 'bg-bg bg-opacity-80' : ''" role="option" @click="clickedOption(chapter)">
<div class="relative flex items-center pl-3" style="padding-right: 4.5rem"> <div class="relative flex items-center pl-3 pr-20">
<p class="font-normal block truncate text-sm text-white text-opacity-80">{{ chapter.title }}</p> <p class="font-normal block truncate text-sm text-white text-opacity-80">{{ chapter.title }}</p>
<div class="absolute top-0 right-3 -mt-0.5"> <div class="absolute top-0 right-3 -mt-0.5">
<span class="font-mono text-white text-opacity-90 leading-3" style="letter-spacing: -0.5px">{{ $secondsToTimestamp(chapter.start) }}</span> <span class="font-mono text-white text-opacity-90 leading-3 text-sm" style="letter-spacing: -0.5px">{{ $secondsToTimestamp(chapter.start) }}</span>
</div> </div>
</div> </div>
+7 -5
View File
@@ -10,11 +10,13 @@
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop> <div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label"> <ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items"> <template v-for="item in items">
<li :key="item.value" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" role="option" @click="clickedOption(item.value)"> <slot :name="item.value" :item="item" :selected="item.value === selected">
<div class="relative flex items-center px-3"> <li :key="item.value" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" role="option" @click="clickedOption(item.value)">
<p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p> <div class="relative flex items-center px-3">
</div> <p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p>
</li> </div>
</li>
</slot>
</template> </template>
</ul> </ul>
</div> </div>
+23 -5
View File
@@ -3,7 +3,6 @@
<template #outer> <template #outer>
<div v-show="selected !== 'all'" class="absolute top-4 left-4 z-40"> <div v-show="selected !== 'all'" class="absolute top-4 left-4 z-40">
<ui-btn class="text-xl border-yellow-400 border-opacity-40" @click="clearSelected">Clear</ui-btn> <ui-btn class="text-xl border-yellow-400 border-opacity-40" @click="clearSelected">Clear</ui-btn>
<!-- <span class="font-semibold uppercase text-white text-2xl">Clear</span> -->
</div> </div>
</template> </template>
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false"> <div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
@@ -174,7 +173,24 @@ export default {
return this.filterData.languages || [] return this.filterData.languages || []
}, },
progress() { progress() {
return ['Finished', 'In Progress', 'Not Started', 'Not Finished'] return [
{
id: 'finished',
name: 'Finished'
},
{
id: 'in-progress',
name: 'In Progress'
},
{
id: 'not-started',
name: 'Not Started'
},
{
id: 'not-finished',
name: 'Not Finished'
}
]
}, },
sublistItems() { sublistItems() {
return (this[this.sublist] || []).map((item) => { return (this[this.sublist] || []).map((item) => {
@@ -196,7 +212,8 @@ export default {
} }
}, },
methods: { methods: {
clearSelected() { async clearSelected() {
await this.$hapticsImpactMedium()
this.selected = 'all' this.selected = 'all'
this.show = false this.show = false
this.$nextTick(() => this.$emit('change', 'all')) this.$nextTick(() => this.$emit('change', 'all'))
@@ -204,7 +221,7 @@ export default {
clickedSublistOption(item) { clickedSublistOption(item) {
this.clickedOption({ value: `${this.sublist}.${item}` }) this.clickedOption({ value: `${this.sublist}.${item}` })
}, },
clickedOption(option) { async clickedOption(option) {
if (option.sublist) { if (option.sublist) {
this.sublist = option.value this.sublist = option.value
return return
@@ -215,6 +232,7 @@ export default {
this.show = false this.show = false
return return
} }
await this.$hapticsImpactMedium()
this.selected = val this.selected = val
this.show = false this.show = false
this.$nextTick(() => this.$emit('change', val)) this.$nextTick(() => this.$emit('change', val))
@@ -228,4 +246,4 @@ export default {
.filter-modal-wrapper { .filter-modal-wrapper {
max-height: calc(100% - 320px); max-height: calc(100% - 320px);
} }
</style> </style>
+2 -2
View File
@@ -1,7 +1,7 @@
<template> <template>
<modals-modal v-model="show" width="100%" height="100%" max-width="100%"> <modals-modal v-model="show" width="100%" height="100%" max-width="100%">
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false"> <div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
<covers-book-cover :library-item="libraryItem" :width="width" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <covers-book-cover :library-item="libraryItem" :width="width" raw :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div> </div>
</modals-modal> </modals-modal>
</template> </template>
@@ -40,7 +40,7 @@ export default {
} }
}, },
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio'] return this.$store.getters['libraries/getBookCoverAspectRatio']
} }
}, },
methods: { methods: {
+4 -2
View File
@@ -13,7 +13,7 @@
<li :key="library.id" class="text-gray-50 select-none relative py-3 cursor-pointer hover:bg-black-400" :class="currentLibraryId === library.id ? 'bg-bg bg-opacity-80' : ''" role="option" @click="clickedOption(library)"> <li :key="library.id" class="text-gray-50 select-none relative py-3 cursor-pointer hover:bg-black-400" :class="currentLibraryId === library.id ? 'bg-bg bg-opacity-80' : ''" role="option" @click="clickedOption(library)">
<div v-show="currentLibraryId === library.id" class="absolute top-0 left-0 w-0.5 bg-warning h-full" /> <div v-show="currentLibraryId === library.id" class="absolute top-0 left-0 w-0.5 bg-warning h-full" />
<div class="flex items-center px-3"> <div class="flex items-center px-3">
<widgets-library-icon :icon="library.icon" /> <ui-library-icon :icon="library.icon" />
<span class="font-normal block truncate text-lg ml-4">{{ library.name }}</span> <span class="font-normal block truncate text-lg ml-4">{{ library.name }}</span>
</div> </div>
</li> </li>
@@ -49,9 +49,11 @@ export default {
}, },
methods: { methods: {
async clickedOption(lib) { async clickedOption(lib) {
await this.$hapticsImpactMedium()
this.show = false this.show = false
if (lib.id === this.currentLibraryId) return
await this.$store.dispatch('libraries/fetch', lib.id) await this.$store.dispatch('libraries/fetch', lib.id)
this.$eventBus.$emit('library-changed') this.$eventBus.$emit('library-changed', lib.id)
this.$localStore.setLastLibraryId(lib.id) this.$localStore.setLastLibraryId(lib.id)
} }
}, },
+7 -2
View File
@@ -39,6 +39,10 @@ export default {
text: 'Author (Last, First)', text: 'Author (Last, First)',
value: 'media.metadata.authorNameLF' value: 'media.metadata.authorNameLF'
}, },
{
text: 'Published Year',
value: 'media.metadata.publishedYear'
},
{ {
text: 'Added At', text: 'Added At',
value: 'addedAt' value: 'addedAt'
@@ -122,7 +126,8 @@ export default {
} }
}, },
methods: { methods: {
clickedOption(val) { async clickedOption(val) {
await this.$hapticsImpactMedium()
if (this.selected === val) { if (this.selected === val) {
this.selectedDesc = !this.selectedDesc this.selectedDesc = !this.selectedDesc
} else { } else {
@@ -135,4 +140,4 @@ export default {
}, },
mounted() {} mounted() {}
} }
</script> </script>
@@ -0,0 +1,137 @@
<template>
<modals-modal v-model="show" width="100%" height="100%" max-width="100%">
<template #outer>
<div class="absolute top-6 left-4 z-40">
<p class="text-white text-2xl truncate">Feed Episodes</p>
</div>
</template>
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
<div class="feed-content w-full overflow-x-hidden overflow-y-auto bg-bg rounded-lg border border-white border-opacity-20" @click.stop.prevent>
<template v-for="(episode, index) in episodes">
<div :key="index" class="relative" :class="itemEpisodeMap[episode.enclosure.url] ? 'bg-primary bg-opacity-40' : selectedEpisodes[String(index)] ? 'bg-success bg-opacity-10' : index % 2 == 0 ? 'bg-primary bg-opacity-25' : 'bg-primary bg-opacity-5'" @click="selectEpisode(episode, index)">
<div class="absolute top-0 left-0 h-full flex items-center p-2">
<span v-if="itemEpisodeMap[episode.enclosure.url]" class="material-icons text-success text-xl">download_done</span>
<ui-checkbox v-else v-model="selectedEpisodes[String(index)]" small checkbox-bg="primary" border-color="gray-600" />
</div>
<div class="pl-9 pr-2 py-2 border-b border-white border-opacity-10">
<p v-if="episode.episode" class="font-semibold text-gray-200 text-xs">#{{ episode.episode }}</p>
<p class="break-words mb-1 text-sm">{{ episode.title }}</p>
<p v-if="episode.subtitle" class="break-words mb-1 text-xs text-gray-300 episode-subtitle">{{ episode.subtitle }}</p>
<p class="text-xxs text-gray-300">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
</div>
</div>
</template>
</div>
<div class="absolute bottom-0 left-0 w-full flex items-center" style="height: 50px">
<ui-btn class="w-full" :disabled="!episodesSelected.length" color="success" @click.stop="downloadEpisodes">{{ episodesSelected.length ? `Add ${episodesSelected.length} Episode(s) to Server` : 'No Episodes Selected' }}</ui-btn>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
libraryItem: {
type: Object,
default: () => {}
},
episodes: {
type: Array,
default: () => []
}
},
data() {
return {
processing: false,
selectedEpisodes: {}
}
},
watch: {
show: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
allDownloaded() {
return !this.episodes.some((episode) => !this.itemEpisodeMap[episode.enclosure.url])
},
episodesSelected() {
return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key])
},
itemEpisodes() {
if (!this.libraryItem) return []
return this.libraryItem.media.episodes || []
},
itemEpisodeMap() {
var map = {}
this.itemEpisodes.forEach((item) => {
if (item.enclosure) map[item.enclosure.url] = true
})
return map
}
},
methods: {
downloadEpisodes() {
const episodesToDownload = this.episodesSelected.map((episodeIndex) => this.episodes[Number(episodeIndex)])
const payloadSize = JSON.stringify(episodesToDownload).length
const sizeInMb = payloadSize / 1024 / 1024
const sizeInMbPretty = sizeInMb.toFixed(2) + 'MB'
console.log('Request size', sizeInMb)
if (sizeInMb > 4.99) {
return this.$toast.error(`Request is too large (${sizeInMbPretty}) should be < 5Mb`)
}
this.processing = true
this.$axios
.$post(`/api/podcasts/${this.libraryItem.id}/download-episodes`, episodesToDownload)
.then(() => {
this.processing = false
this.$toast.success('Started downloading episodes on server')
this.show = false
})
.catch((error) => {
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to download episodes'
console.error('Failed to download episodes', error)
this.processing = false
this.$toast.error(errorMsg)
this.selectedEpisodes = {}
})
},
selectEpisode(episode, index) {
if (this.itemEpisodeMap[episode.enclosure.url]) return
this.selectedEpisodes[String(index)] = !this.selectedEpisodes[String(index)]
},
init() {
this.episodes.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1))
for (let i = 0; i < this.episodes.length; i++) {
// this.selectedEpisodes[String(i)] = false
this.$set(this.selectedEpisodes, String(i), false)
}
}
},
mounted() {}
}
</script>
<style>
.feed-content {
height: calc(100vh - 125px);
max-height: calc(100vh - 125px);
margin-top: 20px;
}
</style>
+49 -10
View File
@@ -6,9 +6,26 @@
</div> </div>
</template> </template>
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false"> <div
class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center"
@click="
show = false
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-white border-opacity-20" style="max-height: 75%" @click.stop>
<ul v-if="!sleepTimerRunning" class="h-full w-full" role="listbox" aria-labelledby="listbox-label"> <div v-if="manualTimerModal" class="p-4">
<div class="flex mb-4" @click="manualTimerModal = false">
<span class="material-icons text-3xl">arrow_back</span>
</div>
<div class="flex my-2 justify-between">
<ui-btn @click="decreaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">remove</span></ui-btn>
<p class="text-2xl font-mono text-center">{{ manualTimeoutMin }} min</p>
<ui-btn @click="increaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">add</span></ui-btn>
</div>
<ui-btn @click="clickedOption(manualTimeoutMin)" class="w-full">Set Timer</ui-btn>
</div>
<ul v-else-if="!sleepTimerRunning" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="timeout in timeouts"> <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-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption(timeout)">
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
@@ -21,8 +38,13 @@
<span class="font-normal block truncate text-lg text-center">End of Chapter</span> <span class="font-normal block truncate text-lg text-center">End of Chapter</span>
</div> </div>
</li> </li>
<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">Manual sleep timer</span>
</div>
</li>
</ul> </ul>
<div v-else class="px-2 py-4"> <div v-else class="p-4">
<div class="flex my-2 justify-between"> <div class="flex my-2 justify-between">
<ui-btn @click="decreaseSleepTime" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">remove</span></ui-btn> <ui-btn @click="decreaseSleepTime" 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">{{ timeRemainingPretty }}</p> <p class="text-2xl font-mono text-center">{{ timeRemainingPretty }}</p>
@@ -45,7 +67,10 @@ export default {
currentEndOfChapterTime: Number currentEndOfChapterTime: Number
}, },
data() { data() {
return {} return {
manualTimerModal: null,
manualTimeoutMin: 1
}
}, },
computed: { computed: {
show: { show: {
@@ -57,31 +82,45 @@ export default {
} }
}, },
timeouts() { timeouts() {
return [1, 5, 10, 15, 30, 45, 60, 90] return [5, 10, 15, 30, 45, 60, 90]
}, },
timeRemainingPretty() { timeRemainingPretty() {
return this.$secondsToTimestamp(this.currentTime) return this.$secondsToTimestamp(this.currentTime)
} }
}, },
methods: { methods: {
clickedChapterOption() { async clickedChapterOption() {
await this.$hapticsImpactMedium()
this.show = false this.show = false
this.$nextTick(() => this.$emit('change', { time: this.currentEndOfChapterTime * 1000, isChapterTime: true })) this.$nextTick(() => this.$emit('change', { time: this.currentEndOfChapterTime * 1000, isChapterTime: true }))
}, },
clickedOption(timeoutMin) { async clickedOption(timeoutMin) {
await this.$hapticsImpactMedium()
var timeout = timeoutMin * 1000 * 60 var timeout = timeoutMin * 1000 * 60
this.show = false this.show = false
this.manualTimerModal = false
this.$nextTick(() => this.$emit('change', { time: timeout, isChapterTime: false })) this.$nextTick(() => this.$emit('change', { time: timeout, isChapterTime: false }))
}, },
cancelSleepTimer() { async cancelSleepTimer() {
await this.$hapticsImpactMedium()
this.$emit('cancel') this.$emit('cancel')
this.show = false this.show = false
}, },
increaseSleepTime() { async increaseSleepTime() {
await this.$hapticsImpactMedium()
this.$emit('increase') this.$emit('increase')
}, },
decreaseSleepTime() { async decreaseSleepTime() {
await this.$hapticsImpactMedium()
this.$emit('decrease') this.$emit('decrease')
},
async increaseManualTimeout() {
await this.$hapticsImpactMedium()
this.manualTimeoutMin++
},
async decreaseManualTimeout() {
await this.$hapticsImpactMedium()
if (this.manualTimeoutMin > 1) this.manualTimeoutMin--
} }
}, },
mounted() {} mounted() {}
@@ -0,0 +1,227 @@
<template>
<modals-modal v-model="show" :width="360" height="100%" :processing="processing">
<template #outer>
<div class="absolute top-5 left-4 z-40">
<p class="text-white text-2xl truncate">Add to Playlist</p>
</div>
</template>
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
<div ref="container" class="w-full rounded-lg bg-primary border border-white border-opacity-20 overflow-y-auto overflow-x-hidden" style="max-height: 80vh" @click.stop.prevent>
<div class="w-full h-full p-4" v-show="showPlaylistNameInput">
<div class="flex mb-4 items-center">
<div class="w-9 h-9 flex items-center justify-center rounded-full hover:bg-white hover:bg-opacity-10 cursor-pointer" @click.stop="showPlaylistNameInput = false">
<span class="material-icons text-3xl">arrow_back</span>
</div>
<p class="text-xl pl-2">New Playlist</p>
<div class="flex-grow" />
</div>
<ui-text-input-with-label v-model="newPlaylistName" label="Name" />
<div class="flex justify-end mt-6">
<ui-btn color="success" :loading="processing" class="w-full" @click.stop="submitCreatePlaylist">Create</ui-btn>
</div>
</div>
<div class="w-full h-full" v-show="!showPlaylistNameInput">
<template v-for="playlist in sortedPlaylists">
<modals-playlists-playlist-row :key="playlist.id" :in-playlist="playlist.isItemIncluded" :playlist="playlist" @click="clickPlaylist" />
</template>
<div v-if="!playlists.length" class="flex h-32 items-center justify-center">
<p class="text-xl">{{ loading ? 'Loading..' : 'No Playlists' }}</p>
</div>
<ui-btn :loading="processing" color="success" class="w-full flex items-center justify-center" @click.stop="createPlaylist">
<span class="material-icons text-xl">add</span>
<p class="text-base pl-2">New Playlist</p>
</ui-btn>
</div>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
libraryItemId: String
},
data() {
return {
showPlaylistNameInput: false,
newPlaylistName: '',
playlists: [],
processing: false,
loading: false
}
},
watch: {
show(newVal) {
if (newVal) {
this.setListeners()
this.showPlaylistNameInput = false
this.newPlaylistName = ''
this.loadPlaylists()
} else {
this.unsetListeners()
}
}
},
computed: {
show: {
get() {
return this.$store.state.globals.showPlaylistsAddCreateModal
},
set(val) {
this.$store.commit('globals/setShowPlaylistsAddCreateModal', val)
}
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
selectedPlaylistItems() {
return this.$store.state.globals.selectedPlaylistItems || []
},
sortedPlaylists() {
return this.playlists
.map((playlist) => {
const includesItem = !this.selectedPlaylistItems.some((item) => !this.checkIsItemInPlaylist(playlist, item))
return {
isItemIncluded: includesItem,
...playlist
}
})
.sort((a, b) => (a.isItemIncluded ? -1 : 1))
}
},
methods: {
checkIsItemInPlaylist(playlist, item) {
if (item.episode) {
return playlist.items.some((i) => i.libraryItemId === item.libraryItem.id && i.episodeId === item.episode.id)
}
return playlist.items.some((i) => i.libraryItemId === item.libraryItem.id)
},
loadPlaylists() {
this.loading = true
this.$axios
.$get(`/api/libraries/${this.currentLibraryId}/playlists`)
.then((data) => {
this.playlists = data.results || []
})
.catch((error) => {
console.error('Failed', error)
this.$toast.error('Failed to load playlists')
})
.finally(() => {
this.loading = false
})
},
async clickPlaylist(playlist) {
await this.$hapticsImpactMedium()
if (playlist.isItemIncluded) {
this.removeFromPlaylist(playlist)
} else {
this.addToPlaylist(playlist)
}
},
removeFromPlaylist(playlist) {
if (!this.selectedPlaylistItems.length) return
this.processing = true
const itemObjects = this.selectedPlaylistItems.map((pi) => ({ libraryItemId: pi.libraryItem.id, episodeId: pi.episode ? pi.episode.id : null }))
this.$axios
.$post(`/api/playlists/${playlist.id}/batch/remove`, { items: itemObjects })
.then((updatedPlaylist) => {
console.log(`Items removed from playlist`, updatedPlaylist)
this.$toast.success('Playlist item(s) removed')
})
.catch((error) => {
console.error('Failed to remove items from playlist', error)
this.$toast.error('Failed to remove playlist item(s)')
})
.finally(() => {
this.processing = false
})
},
addToPlaylist(playlist) {
if (!this.selectedPlaylistItems.length) return
this.processing = true
const itemObjects = this.selectedPlaylistItems.map((pi) => ({ libraryItemId: pi.libraryItem.id, episodeId: pi.episode ? pi.episode.id : null }))
this.$axios
.$post(`/api/playlists/${playlist.id}/batch/add`, { items: itemObjects })
.then((updatedPlaylist) => {
console.log(`Items added to playlist`, updatedPlaylist)
this.$toast.success('Items added to playlist')
})
.catch((error) => {
console.error('Failed to add items to playlist', error)
this.$toast.error('Failed to add items to playlist')
})
.finally(() => {
this.processing = false
})
},
createPlaylist() {
this.newPlaylistName = ''
this.showPlaylistNameInput = true
},
async submitCreatePlaylist() {
await this.$hapticsImpactMedium()
if (!this.newPlaylistName || !this.selectedPlaylistItems.length) {
return
}
this.processing = true
const itemObjects = this.selectedPlaylistItems.map((pi) => ({ libraryItemId: pi.libraryItem.id, episodeId: pi.episode ? pi.episode.id : null }))
const newPlaylist = {
items: itemObjects,
libraryId: this.currentLibraryId,
name: this.newPlaylistName
}
this.$axios
.$post('/api/playlists', newPlaylist)
.then((data) => {
console.log('New playlist created', data)
this.$toast.success(`Playlist "${data.name}" created`)
this.newPlaylistName = ''
this.showPlaylistNameInput = false
})
.catch((error) => {
console.error('Failed to create playlist', error)
var errMsg = error.response ? error.response.data || '' : ''
this.$toast.error(`Failed to create playlist: ${errMsg}`)
})
.finally(() => {
this.processing = false
})
},
playlistAdded(playlist) {
if (!this.playlists.some((p) => p.id === playlist.id)) {
this.playlists.push(playlist)
}
},
playlistUpdated(playlist) {
const index = this.playlists.findIndex((p) => p.id === playlist.id)
if (index >= 0) {
this.playlists.splice(index, 1, playlist)
} else {
this.playlists.push(playlist)
}
},
playlistRemoved(playlist) {
this.playlists = this.playlists.filter((p) => p.id !== playlist.id)
},
setListeners() {
this.$socket.$on('playlist_added', this.playlistAdded)
this.$socket.$on('playlist_updated', this.playlistUpdated)
this.$socket.$on('playlist_removed', this.playlistRemoved)
},
unsetListeners() {
this.$socket.$off('playlist_added', this.playlistAdded)
this.$socket.$off('playlist_updated', this.playlistUpdated)
this.$socket.$off('playlist_removed', this.playlistRemoved)
}
},
mounted() {}
}
</script>
@@ -0,0 +1,42 @@
<template>
<div :key="playlist.id" :id="`playlist-row-${playlist.id}`" class="flex items-center px-3 py-2 justify-start cursor-pointer relative" :class="inPlaylist ? 'bg-bg bg-opacity-60' : 'bg-opacity-20'">
<div v-if="inPlaylist" class="absolute top-0 left-0 h-full w-1 bg-success z-10" />
<div class="w-16 max-w-16 text-center">
<covers-playlist-cover :items="items" :width="52" :height="52" />
</div>
<div class="flex-grow overflow-hidden">
<p class="pl-1 pr-2 truncate text-sm">{{ playlist.name }}</p>
</div>
<div class="absolute top-0 right-0 h-full flex items-center px-3" @click.stop="click">
<span v-if="inPlaylist" class="material-icons text-error">remove</span>
<span v-else class="material-icons text-success">add</span>
</div>
</div>
</template>
<script>
export default {
props: {
playlist: {
type: Object,
default: () => {}
},
inPlaylist: Boolean
},
data() {
return {}
},
computed: {
items() {
return this.playlist.items || []
}
},
methods: {
click() {
this.$emit('click', this.playlist)
}
},
mounted() {}
}
</script>
+27 -2
View File
@@ -39,6 +39,7 @@
<script> <script>
import Path from 'path' import Path from 'path'
import { Archive } from 'libarchive.js/main.js' import { Archive } from 'libarchive.js/main.js'
import { CompressedFile } from 'libarchive.js/src/compressed-file'
Archive.init({ Archive.init({
workerUrl: '/libarchive/worker-bundle.js' workerUrl: '/libarchive/worker-bundle.js'
@@ -136,7 +137,11 @@ export default {
responseType: 'blob' responseType: 'blob'
}) })
const archive = await Archive.open(buff) const archive = await Archive.open(buff)
this.filesObject = await archive.getFilesObject() const originalFilesObject = await archive.getFilesObject()
// to support images in subfolders we need to flatten the object
// ref: https://github.com/advplyr/audiobookshelf/issues/811
this.filesObject = this.flattenFilesObject(originalFilesObject)
console.log('Extracted files object', this.filesObject)
var filenames = Object.keys(this.filesObject) var filenames = Object.keys(this.filesObject)
this.parseFilenames(filenames) this.parseFilenames(filenames)
@@ -154,6 +159,26 @@ export default {
this.loading = false this.loading = false
} }
}, },
flattenFilesObject(filesObject) {
const flattenObject = (obj, prefix = '') => {
var _obj = {}
for (const key in obj) {
const newKey = prefix ? prefix + '/' + key : key
if (obj[key] instanceof CompressedFile) {
_obj[newKey] = obj[key]
} else if (!key.startsWith('_') && typeof obj[key] === 'object' && !Array.isArray(obj[key])) {
_obj = {
..._obj,
...flattenObject(obj[key], newKey)
}
} else {
_obj[newKey] = obj[key]
}
}
return _obj
}
return flattenObject(filesObject)
},
async extractXmlFile(filename) { async extractXmlFile(filename) {
console.log('extracting xml filename', filename) console.log('extracting xml filename', filename)
try { try {
@@ -173,7 +198,7 @@ export default {
}, },
parseImageFilename(filename) { parseImageFilename(filename) {
var basename = Path.basename(filename, Path.extname(filename)) var basename = Path.basename(filename, Path.extname(filename))
var numbersinpath = basename.match(/\d{1,4}/g) var numbersinpath = basename.match(/\d{1,5}/g)
if (!numbersinpath || !numbersinpath.length) { if (!numbersinpath || !numbersinpath.length) {
return { return {
index: -1, index: -1,
+26 -4
View File
@@ -1,7 +1,7 @@
<template> <template>
<div id="epub-frame" class="w-full"> <div id="epub-frame" class="w-full">
<div id="viewer" class="border border-gray-100 bg-white shadow-md h-full w-full"></div> <div id="viewer" class="border border-gray-100 bg-white shadow-md h-full w-full"></div>
<div class="fixed bottom-0 left-0 h-8 w-full bg-bg px-2 flex items-center"> <div class="fixed left-0 h-8 w-full bg-bg px-2 flex items-center" :style="{ bottom: playerLibraryItemId ? '100px' : '0px' }">
<p class="text-xs">epub</p> <p class="text-xs">epub</p>
<div class="flex-grow" /> <div class="flex-grow" />
@@ -29,8 +29,25 @@ export default {
hasPrev: false hasPrev: false
} }
}, },
computed: {}, watch: {
playerLibraryItemId() {
this.updateHeight()
}
},
computed: {
playerLibraryItemId() {
return this.$store.state.playerLibraryItemId
},
readerHeightOffset() {
return this.playerLibraryItemId ? 164 : 64
}
},
methods: { methods: {
updateHeight() {
if (this.rendition && this.rendition.resize) {
this.rendition.resize(window.innerWidth, window.innerHeight - this.readerHeightOffset)
}
},
prev() { prev() {
if (this.rendition) { if (this.rendition) {
this.rendition.prev() this.rendition.prev()
@@ -54,12 +71,12 @@ export default {
this.rendition = book.renderTo('viewer', { this.rendition = book.renderTo('viewer', {
width: window.innerWidth, width: window.innerWidth,
height: window.innerHeight - 64, height: window.innerHeight - this.readerHeightOffset,
snap: true, snap: true,
manager: 'continuous', manager: 'continuous',
flow: 'paginated' flow: 'paginated'
}) })
var displayed = this.rendition.display() const displayed = this.rendition.display()
book.ready book.ready
.then(() => { .then(() => {
@@ -127,4 +144,9 @@ export default {
max-height: calc(100% - 32px); max-height: calc(100% - 32px);
overflow: hidden; overflow: hidden;
} }
.reader-player-open #epub-frame {
height: calc(100% - 132px);
max-height: calc(100% - 132px);
overflow: hidden;
}
</style> </style>
+20 -6
View File
@@ -1,9 +1,9 @@
<template> <template>
<div class="ebook-viewer w-full h-full"> <div class="mobi-ebook-viewer w-full">
<div class="absolute overflow-y-scroll left-0 right-0 w-full max-w-screen m-auto z-10 border border-black border-opacity-20 shadow-md bg-white"> <div class="absolute overflow-hidden left-0 right-0 w-full max-w-full m-auto z-10 border border-black border-opacity-20 shadow-md bg-white">
<iframe title="html-viewer" class="w-screen"> Loading </iframe> <iframe title="html-viewer" class="w-full overflow-hidden"> Loading </iframe>
</div> </div>
<div class="fixed bottom-0 left-0 h-8 w-full bg-bg px-2 flex items-center z-20"> <div class="fixed bottom-0 left-0 h-8 w-full bg-bg px-2 flex items-center z-20" :style="{ bottom: playerLibraryItemId ? '100px' : '0px' }">
<p class="text-xs">mobi</p> <p class="text-xs">mobi</p>
<div class="flex-grow" /> <div class="flex-grow" />
</div> </div>
@@ -22,7 +22,14 @@ export default {
data() { data() {
return {} return {}
}, },
computed: {}, computed: {
playerLibraryItemId() {
return this.$store.state.playerLibraryItemId
},
readerHeightOffset() {
return this.playerLibraryItemId ? 164 : 64
}
},
methods: { methods: {
addHtmlCss() { addHtmlCss() {
let iframe = document.getElementsByTagName('iframe')[0] let iframe = document.getElementsByTagName('iframe')[0]
@@ -114,7 +121,14 @@ export default {
</script> </script>
<style> <style>
.ebook-viewer { .mobi-ebook-viewer {
height: calc(100% - 32px); height: calc(100% - 32px);
max-height: calc(100% - 32px);
overflow: hidden;
}
.reader-player-open .mobi-ebook-viewer {
height: calc(100% - 132px);
max-height: calc(100% - 132px);
overflow: hidden;
} }
</style> </style>
+79
View File
@@ -0,0 +1,79 @@
<template>
<div class="w-full h-full pt-20 relative">
<div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent>
<div class="flex items-center justify-center h-full w-1/2">
<span class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span>
</div>
</div>
<div v-show="canGoNext" class="absolute top-0 right-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="next" @mousedown.prevent>
<div class="flex items-center justify-center h-full w-1/2 ml-auto">
<span class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_forward_ios</span>
</div>
</div>
<div class="absolute top-0 right-1 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-6 flex items-center text-center">
<p class="font-mono text-xs">{{ page }} / {{ numPages }}</p>
</div>
<div :style="{ height: pdfHeight + 'px' }" class="overflow-hidden m-auto">
<div class="flex items-center justify-center">
<div :style="{ width: pdfWidth + 'px', height: pdfHeight + 'px' }" class="w-full h-full overflow-auto">
<div v-if="loadedRatio > 0 && loadedRatio < 1" style="background-color: green; color: white; text-align: center" :style="{ width: loadedRatio * 100 + '%' }">{{ Math.floor(loadedRatio * 100) }}%</div>
<pdf ref="pdf" class="m-auto z-10 border border-black border-opacity-20 shadow-md bg-white" :src="url" :page="page" :rotate="rotate" @progress="loadedRatio = $event" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event"></pdf>
</div>
</div>
</div>
</div>
</template>
<script>
import pdf from 'vue-pdf'
export default {
components: {
pdf
},
props: {
url: String
},
data() {
return {
rotate: 0,
loadedRatio: 0,
page: 1,
numPages: 0
}
},
computed: {
pdfWidth() {
return this.pdfHeight * 0.6667
},
pdfHeight() {
return window.innerHeight - 120
},
canGoNext() {
return this.page < this.numPages
},
canGoPrev() {
return this.page > 1
}
},
methods: {
numPagesLoaded(e) {
this.numPages = e
},
prev() {
if (this.page <= 1) return
this.page--
},
next() {
if (this.page >= this.numPages) return
this.page++
},
error(err) {
console.error(err)
}
},
mounted() {}
}
</script>
+35 -11
View File
@@ -1,5 +1,5 @@
<template> <template>
<div v-if="show" class="absolute top-0 left-0 w-full h-full bg-bg z-40 pt-8"> <div v-if="show" class="absolute top-0 left-0 w-full h-full bg-bg z-40 pt-8" :class="{ 'reader-player-open': !!playerLibraryItemId }">
<div class="h-8 w-full bg-primary flex items-center px-2 fixed top-0 left-0 z-30 box-shadow-sm"> <div class="h-8 w-full bg-primary flex items-center px-2 fixed top-0 left-0 z-30 box-shadow-sm">
<p class="w-5/6 truncate">{{ title }}</p> <p class="w-5/6 truncate">{{ title }}</p>
<div class="flex-grow" /> <div class="flex-grow" />
@@ -14,7 +14,10 @@ export default {
data() { data() {
return { return {
touchstartX: 0, touchstartX: 0,
touchendX: 0 touchstartY: 0,
touchendX: 0,
touchendY: 0,
touchstartTime: 0
} }
}, },
watch: { watch: {
@@ -53,6 +56,7 @@ export default {
if (this.ebookType === 'epub') return 'readers-epub-reader' if (this.ebookType === 'epub') return 'readers-epub-reader'
else if (this.ebookType === 'mobi') return 'readers-mobi-reader' else if (this.ebookType === 'mobi') return 'readers-mobi-reader'
else if (this.ebookType === 'comic') return 'readers-comic-reader' else if (this.ebookType === 'comic') return 'readers-comic-reader'
else if (this.ebookType === 'pdf') return 'readers-pdf-reader'
return null return null
}, },
folderId() { folderId() {
@@ -89,15 +93,22 @@ export default {
}, },
ebookUrl() { ebookUrl() {
if (!this.ebookFile) return null if (!this.ebookFile) return null
let filepath = ''
if (this.selectedLibraryItem.isFile) {
filepath = this.$encodeUriPath(this.ebookFile.metadata.filename)
} else {
const itemRelPath = this.selectedLibraryItem.relPath
if (itemRelPath.startsWith('/')) itemRelPath = itemRelPath.slice(1)
const relPath = this.ebookFile.metadata.relPath
if (relPath.startsWith('/')) relPath = relPath.slice(1)
var itemRelPath = this.selectedLibraryItem.relPath filepath = this.$encodeUriPath(`${itemRelPath}/${relPath}`)
if (itemRelPath.startsWith('/')) itemRelPath = itemRelPath.slice(1) }
const serverAddress = this.$store.getters['user/getServerAddress']
var relPath = this.ebookFile.metadata.relPath return `${serverAddress}/ebook/${this.libraryId}/${this.folderId}/${filepath}`
if (relPath.startsWith('/')) relPath = relPath.slice(1) },
playerLibraryItemId() {
var serverAddress = this.$store.getters['user/getServerAddress'] return this.$store.state.playerLibraryItemId
return `${serverAddress}/ebook/${this.libraryId}/${this.folderId}/${itemRelPath}/${relPath}`
} }
}, },
methods: { methods: {
@@ -112,9 +123,19 @@ export default {
} }
}, },
handleGesture() { handleGesture() {
// Touch must be less than 1s. Must be > 100px drag and X distance > Y distance
const touchTimeMs = Date.now() - this.touchstartTime
if (touchTimeMs >= 1000) {
console.log('Touch too long', touchTimeMs)
return
}
const touchDistanceX = Math.abs(this.touchendX - this.touchstartX)
const touchDistanceY = Math.abs(this.touchendY - this.touchstartY)
if (touchDistanceX < 100 || touchDistanceY > touchDistanceX) return
if (this.touchendX < this.touchstartX) { if (this.touchendX < this.touchstartX) {
console.log('swiped left') console.log('swiped left')
this.next() this.next()
} }
if (this.touchendX > this.touchstartX) { if (this.touchendX > this.touchstartX) {
@@ -124,9 +145,12 @@ export default {
}, },
touchstart(e) { touchstart(e) {
this.touchstartX = e.changedTouches[0].screenX this.touchstartX = e.changedTouches[0].screenX
this.touchstartY = e.changedTouches[0].screenY
this.touchstartTime = Date.now()
}, },
touchend(e) { touchend(e) {
this.touchendX = e.changedTouches[0].screenX this.touchendX = e.changedTouches[0].screenX
this.touchendY = e.changedTouches[0].screenY
this.handleGesture() this.handleGesture()
}, },
registerListeners() { registerListeners() {
+47
View File
@@ -0,0 +1,47 @@
<template>
<div class="w-full bg-primary bg-opacity-40">
<div class="w-full h-14 flex items-center px-4 bg-primary">
<p class="pr-4">Playlist Items</p>
<div class="w-6 h-6 md:w-7 md:h-7 bg-white bg-opacity-10 rounded-full flex items-center justify-center">
<span class="text-xs md:text-sm font-mono leading-none">{{ items.length }}</span>
</div>
<div class="flex-grow" />
<p v-if="totalDuration" class="text-sm text-gray-200">{{ totalDurationPretty }}</p>
</div>
<template v-for="item in items">
<tables-playlist-item-table-row :key="item.id" :item="item" :playlist-id="playlistId" />
</template>
</div>
</template>
<script>
export default {
props: {
playlistId: String,
items: {
type: Array,
default: () => []
}
},
data() {
return {}
},
computed: {
totalDuration() {
var _total = 0
this.items.forEach((item) => {
if (item.episode) _total += item.episode.duration
else _total += item.libraryItem.media.duration
})
return _total
},
totalDurationPretty() {
return this.$elapsedPrettyExtended(this.totalDuration)
}
},
methods: {},
mounted() {}
}
</script>
@@ -1,17 +1,17 @@
<template> <template>
<div class="w-full px-2 py-2 overflow-hidden relative"> <div class="w-full px-2 py-2 overflow-hidden relative">
<div v-if="book" class="flex w-full"> <nuxt-link v-if="book" :to="`/item/${book.id}`" class="flex w-full">
<div class="h-full relative" :style="{ width: bookWidth + 'px' }"> <div class="h-full relative" :style="{ width: bookWidth + 'px' }">
<covers-book-cover :library-item="book" :width="bookWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <covers-book-cover :library-item="book" :width="bookWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div> </div>
<div class="flex-grow book-table-content h-full px-2 flex items-center"> <div class="flex-grow book-table-content h-full px-2 flex items-center">
<div class="max-w-full"> <div class="max-w-full">
<nuxt-link :to="`/item/${book.id}`" class="truncate block text-sm">{{ bookTitle }}</nuxt-link> <p class="truncate block text-sm">{{ bookTitle }}</p>
<p class="truncate block text-gray-400 text-xs">{{ bookAuthor }}</p> <p class="truncate block text-gray-400 text-xs">{{ bookAuthor }}</p>
<p class="text-xxs text-gray-500">{{ bookDuration }}</p> <p class="text-xxs text-gray-500">{{ bookDuration }}</p>
</div> </div>
</div> </div>
</div> </nuxt-link>
</div> </div>
</template> </template>
@@ -50,7 +50,7 @@ export default {
return this.$elapsedPretty(this.media.duration) return this.$elapsedPretty(this.media.duration)
}, },
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio'] return this.$store.getters['libraries/getBookCoverAspectRatio']
}, },
bookWidth() { bookWidth() {
if (this.bookCoverAspectRatio === 1) return 50 if (this.bookCoverAspectRatio === 1) return 50
+103
View File
@@ -0,0 +1,103 @@
<template>
<div class="w-full px-2 py-2 overflow-hidden relative">
<nuxt-link v-if="libraryItem" :to="`/item/${libraryItem.id}`" class="flex w-full">
<div class="h-full relative" :style="{ width: '50px' }">
<covers-book-cover :library-item="libraryItem" :width="50" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
<div class="flex-grow item-table-content h-full px-2 flex items-center">
<div class="max-w-full">
<p class="truncate block text-sm">{{ itemTitle }}</p>
<p class="truncate block text-gray-400 text-xs">{{ bookAuthorName }}</p>
<p class="text-xxs text-gray-500">{{ itemDuration }}</p>
</div>
</div>
</nuxt-link>
</div>
</template>
<script>
export default {
props: {
playlistId: String,
item: {
type: Object,
default: () => {}
}
},
data() {
return {
isProcessingReadUpdate: false,
processingRemove: false
}
},
computed: {
libraryItem() {
return this.item.libraryItem || {}
},
episode() {
return this.item.episode
},
episodeId() {
return this.episode ? this.episode.id : null
},
media() {
return this.libraryItem.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
},
tracks() {
if (this.episode) return []
return this.media.tracks || []
},
itemTitle() {
if (this.episode) return this.episode.title
return this.mediaMetadata.title || ''
},
bookAuthors() {
if (this.episode) return []
return this.mediaMetadata.authors || []
},
bookAuthorName() {
return this.bookAuthors.map((au) => au.name).join(', ')
},
itemDuration() {
if (this.episode) return this.$elapsedPretty(this.episode.duration)
return this.$elapsedPretty(this.media.duration)
},
isMissing() {
return this.libraryItem.isMissing
},
isInvalid() {
return this.libraryItem.isInvalid
},
bookCoverAspectRatio() {
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
coverWidth() {
return 50
},
isMissing() {
return this.libraryItem.isMissing
},
isInvalid() {
return this.libraryItem.isInvalid
},
isStreaming() {
return this.$store.getters['getIsItemStreaming'](this.item.id)
},
showPlayBtn() {
return !this.isMissing && !this.isInvalid && !this.isStreaming && (this.tracks.length || this.episode)
}
},
methods: {},
mounted() {}
}
</script>
<style>
.item-table-content {
width: calc(100% - 50px);
max-width: calc(100% - 50px);
}
</style>
+68 -9
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="w-full px-0 py-4 overflow-hidden relative border-b border-white border-opacity-10"> <div class="w-full py-4 overflow-hidden relative border-b border-white border-opacity-10">
<div v-if="episode" class="w-full px-1"> <div v-if="episode" class="w-full px-1">
<!-- Help debug for testing --> <!-- Help debug for testing -->
<!-- <template> <!-- <template>
@@ -26,14 +26,28 @@
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" /> <ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
<div v-if="!isIos && userCanDownload"> <button v-if="!isLocal" class="mx-1.5 mt-1.5" @click="addToPlaylist">
<span class="material-icons text-2xl">playlist_add</span>
</button>
<div v-if="userCanDownload">
<span v-if="isLocal" class="material-icons-outlined px-2 text-success text-lg">audio_file</span> <span v-if="isLocal" class="material-icons-outlined px-2 text-success text-lg">audio_file</span>
<span v-else-if="!localEpisode" class="material-icons mx-1 mt-2" :class="downloadItem ? 'animate-bounce text-warning text-opacity-75 text-xl' : 'text-gray-300 text-xl'" @click="downloadClick">{{ downloadItem ? 'downloading' : 'download' }}</span> <span v-else-if="!localEpisode" class="material-icons mx-1.5 mt-2 text-xl" :class="downloadItem ? 'animate-bounce text-warning text-opacity-75' : ''" @click="downloadClick">{{ downloadItem ? 'downloading' : 'download' }}</span>
<span v-else class="material-icons px-2 text-success text-xl">download_done</span> <span v-else class="material-icons px-2 text-success text-xl">download_done</span>
</div> </div>
<div class="flex-grow" />
<button v-if="!isLocal && isAdminOrUp" class="mx-1.5 mt-1.5" @click="deleteEpisodeFromServerClick">
<span class="material-icons text-xl">delete_forever</span>
</button>
</div> </div>
</div> </div>
<div v-if="processing" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-30 flex items-center justify-center">
<widgets-loading-spinner size="la-lg" />
</div>
<div v-if="!userIsFinished" class="absolute bottom-0 left-0 h-0.5 bg-warning" :style="{ width: itemProgressPercent * 100 + '%' }" /> <div v-if="!userIsFinished" class="absolute bottom-0 left-0 h-0.5 bg-warning" :style="{ width: itemProgressPercent * 100 + '%' }" />
</div> </div>
</template> </template>
@@ -58,10 +72,14 @@ export default {
}, },
data() { data() {
return { return {
isProcessingReadUpdate: false isProcessingReadUpdate: false,
processing: false
} }
}, },
computed: { computed: {
isAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
isIos() { isIos() {
return this.$platform === 'ios' return this.$platform === 'ios'
}, },
@@ -134,6 +152,32 @@ export default {
} }
}, },
methods: { methods: {
async deleteEpisodeFromServerClick() {
const { value } = await Dialog.confirm({
title: 'Confirm',
message: `Are you sure you want to delete episode "${this.title}" from the server?\nWarning: This will delete the audio file.`
})
if (value) {
this.processing = true
this.$axios
.$delete(`/api/podcasts/${this.libraryItemId}/episode/${this.episode.id}?hard=1`)
.then(() => {
this.$toast.success('Episode deleted from server')
})
.catch((error) => {
const errorMsg = error.response && error.response.data ? error.response.data : 'Failed to delete episode'
console.error('Failed to delete episode', error)
this.$toast.error(errorMsg)
})
.finally(() => {
this.processing = false
})
}
},
addToPlaylist() {
this.$emit('addToPlaylist', this.episode)
},
async selectFolder() { async selectFolder() {
var folderObj = await AbsFileSystem.selectFolder({ mediaType: this.mediaType }) var folderObj = await AbsFileSystem.selectFolder({ mediaType: this.mediaType })
if (folderObj.error) { if (folderObj.error) {
@@ -141,9 +185,15 @@ export default {
} }
return folderObj return folderObj
}, },
downloadClick() { async downloadClick() {
if (this.downloadItem) return if (this.downloadItem) return
this.download() await this.$hapticsImpactMedium()
if (this.isIos) {
// no local folders on iOS
this.startDownload()
} else {
this.download()
}
}, },
async download(selectedLocalFolder = null) { async download(selectedLocalFolder = null) {
var localFolder = selectedLocalFolder var localFolder = selectedLocalFolder
@@ -183,14 +233,22 @@ export default {
} }
}, },
async startDownload(localFolder) { async startDownload(localFolder) {
var downloadRes = await AbsDownloader.downloadLibraryItem({ libraryItemId: this.libraryItemId, localFolderId: localFolder.id, episodeId: this.episode.id }) var payload = {
libraryItemId: this.libraryItemId,
episodeId: this.episode.id
}
if (localFolder) {
payload.localFolderId = localFolder.id
}
var downloadRes = await AbsDownloader.downloadLibraryItem(payload)
if (downloadRes && downloadRes.error) { if (downloadRes && downloadRes.error) {
var errorMsg = downloadRes.error || 'Unknown error' var errorMsg = downloadRes.error || 'Unknown error'
console.error('Download error', errorMsg) console.error('Download error', errorMsg)
this.$toast.error(errorMsg) this.$toast.error(errorMsg)
} }
}, },
playClick() { async playClick() {
await this.$hapticsImpactMedium()
if (this.streamIsPlaying) { if (this.streamIsPlaying) {
this.$eventBus.$emit('pause-item') this.$eventBus.$emit('pause-item')
} else { } else {
@@ -212,6 +270,7 @@ export default {
} }
}, },
async toggleFinished() { async toggleFinished() {
await this.$hapticsImpactMedium()
this.isProcessingReadUpdate = true this.isProcessingReadUpdate = true
if (this.isLocal || this.localEpisode) { if (this.isLocal || this.localEpisode) {
var isFinished = !this.userIsFinished var isFinished = !this.userIsFinished
@@ -259,4 +318,4 @@ export default {
} }
} }
} }
</script> </script>
+132 -6
View File
@@ -1,8 +1,32 @@
<template> <template>
<div class="w-full"> <div class="w-full">
<!-- Podcast episode downloads queue -->
<div v-if="episodeDownloadsQueued.length" class="px-4 py-2 my-2 bg-info bg-opacity-40 text-sm font-semibold rounded-md text-gray-100 relative w-full">
<div class="flex items-center">
<p class="text-sm py-1">{{ episodeDownloadsQueued.length }} Episode(s) queued for download</p>
<div class="flex-grow" />
<span v-if="isAdminOrUp" class="material-icons text-xl ml-3 cursor-pointer" @click="clearDownloadQueue">close</span>
</div>
</div>
<!-- Podcast episodes currently downloading -->
<div v-if="episodesDownloading.length" class="px-4 py-2 my-2 bg-success bg-opacity-20 text-sm font-semibold rounded-md text-gray-100 relative w-full">
<div v-for="episode in episodesDownloading" :key="episode.id" class="flex items-center">
<widgets-loading-spinner />
<p class="text-sm py-1 pl-4">Downloading episode "{{ episode.episodeDisplayTitle }}"</p>
</div>
</div>
<div class="flex items-center"> <div class="flex items-center">
<p class="text-lg mb-1 font-semibold">Episodes ({{ episodesFiltered.length }})</p> <p class="text-lg mb-1 font-semibold">Episodes ({{ episodesFiltered.length }})</p>
<div class="flex-grow" /> <div class="flex-grow" />
<button v-if="isAdminOrUp && !fetchingRSSFeed" class="outline:none mx-1 pt-0.5 relative" @click="searchEpisodes">
<span class="material-icons text-xl text-gray-200">search</span>
</button>
<widgets-loading-spinner v-else-if="fetchingRSSFeed" class="mx-1" />
<button class="outline:none mx-3 pt-0.5 relative" @click="showFilters"> <button class="outline:none mx-3 pt-0.5 relative" @click="showFilters">
<span class="material-icons text-xl text-gray-200">filter_alt</span> <span class="material-icons text-xl text-gray-200">filter_alt</span>
<div v-show="filterKey !== 'all' && episodesAreFiltered" class="absolute top-0 right-0 w-1.5 h-1.5 rounded-full bg-success border border-green-300 shadow-sm z-10 pointer-events-none" /> <div v-show="filterKey !== 'all' && episodesAreFiltered" class="absolute top-0 right-0 w-1.5 h-1.5 rounded-full bg-success border border-green-300 shadow-sm z-10 pointer-events-none" />
@@ -15,21 +39,28 @@
</div> </div>
<template v-for="episode in episodesSorted"> <template v-for="episode in episodesSorted">
<tables-podcast-episode-row :episode="episode" :local-episode="localEpisodeMap[episode.id]" :library-item-id="libraryItemId" :local-library-item-id="localLibraryItemId" :is-local="isLocal" :key="episode.id" /> <tables-podcast-episode-row :episode="episode" :local-episode="localEpisodeMap[episode.id]" :library-item-id="libraryItemId" :local-library-item-id="localLibraryItemId" :is-local="isLocal" :key="episode.id" @addToPlaylist="addEpisodeToPlaylist" />
</template> </template>
<!-- What in tarnation is going on here? <!-- Huhhh?
Without anything below the template it will not re-render --> Without anything below the template it will not re-render -->
<p>&nbsp;</p> <p>&nbsp;</p>
<modals-dialog v-model="showFiltersModal" title="Episode Filter" :items="filterItems" :selected="filterKey" @action="setFilter" /> <modals-dialog v-model="showFiltersModal" title="Episode Filter" :items="filterItems" :selected="filterKey" @action="setFilter" />
<modals-podcast-episodes-feed-modal v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" />
</div> </div>
</template> </template>
<script> <script>
import { Dialog } from '@capacitor/dialog'
export default { export default {
props: { props: {
libraryItemId: String, libraryItem: {
type: Object,
default: () => {}
},
episodes: { episodes: {
type: Array, type: Array,
default: () => [] default: () => []
@@ -83,7 +114,12 @@ export default {
text: 'Complete', text: 'Complete',
value: 'complete' value: 'complete'
} }
] ],
fetchingRSSFeed: false,
podcastFeedEpisodes: [],
showPodcastEpisodeFeed: false,
episodesDownloading: [],
episodeDownloadsQueued: []
} }
}, },
watch: { watch: {
@@ -95,6 +131,21 @@ export default {
} }
}, },
computed: { computed: {
isAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
networkConnected() {
return this.$store.state.networkConnected
},
libraryItemId() {
return this.libraryItem ? this.libraryItem.id : null
},
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
mediaMetadata() {
return this.media.metadata || {}
},
episodesAreFiltered() { episodesAreFiltered() {
return this.episodesFiltered.length !== this.episodesCopy.length return this.episodesFiltered.length !== this.episodesCopy.length
}, },
@@ -140,9 +191,58 @@ export default {
} }
}, },
methods: { methods: {
async clearDownloadQueue() {
const { value } = await Dialog.confirm({
title: 'Confirm',
message: `Are you sure you want to clear episode download queue?`
})
if (value) {
this.$axios
.$get(`/api/podcasts/${this.libraryItemId}/clear-queue`)
.then(() => {
this.$toast.success('Episode download queue cleared')
this.episodeDownloadQueued = []
})
.catch((error) => {
console.error('Failed to clear queue', error)
this.$toast.error('Failed to clear queue')
})
}
},
async searchEpisodes() {
if (!this.networkConnected) {
return this.$toast.error('No network connection')
}
if (!this.mediaMetadata.feedUrl) {
return this.$toast.error('Podcast does not have an RSS Feed')
}
this.fetchingRSSFeed = true
const payload = await this.$axios.$post(`/api/podcasts/feed`, { rssFeed: this.mediaMetadata.feedUrl }).catch((error) => {
console.error('Failed to get feed', error)
this.$toast.error('Failed to get podcast feed')
return null
})
this.fetchingRSSFeed = false
if (!payload) return
console.log('Podcast feed', payload)
const podcastfeed = payload.podcast
if (!podcastfeed.episodes || !podcastfeed.episodes.length) {
this.$toast.info('No episodes found in RSS feed')
return
}
this.podcastFeedEpisodes = podcastfeed.episodes
this.showPodcastEpisodeFeed = true
},
addEpisodeToPlaylist(episode) {
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode }])
this.$store.commit('globals/setShowPlaylistsAddCreateModal', true)
},
setFilter(filter) { setFilter(filter) {
this.filterKey = filter this.filterKey = filter
console.log('Set filter', this.filterKey)
this.showFiltersModal = false this.showFiltersModal = false
}, },
showFilters() { showFilters() {
@@ -159,8 +259,34 @@ export default {
this.episodesCopy = this.episodes.map((ep) => { this.episodesCopy = this.episodes.map((ep) => {
return { ...ep } return { ...ep }
}) })
},
episodeDownloadQueued(episodeDownload) {
if (episodeDownload.libraryItemId === this.libraryItemId) {
this.episodeDownloadsQueued.push(episodeDownload)
}
},
episodeDownloadStarted(episodeDownload) {
if (episodeDownload.libraryItemId === this.libraryItemId) {
this.episodeDownloadsQueued = this.episodeDownloadsQueued.filter((d) => d.id !== episodeDownload.id)
this.episodesDownloading.push(episodeDownload)
}
},
episodeDownloadFinished(episodeDownload) {
if (episodeDownload.libraryItemId === this.libraryItemId) {
this.episodeDownloadsQueued = this.episodeDownloadsQueued.filter((d) => d.id !== episodeDownload.id)
this.episodesDownloading = this.episodesDownloading.filter((d) => d.id !== episodeDownload.id)
}
} }
}, },
mounted() {} mounted() {
this.$socket.$on('episode_download_queued', this.episodeDownloadQueued)
this.$socket.$on('episode_download_started', this.episodeDownloadStarted)
this.$socket.$on('episode_download_finished', this.episodeDownloadFinished)
},
beforeDestroy() {
this.$socket.$off('episode_download_queued', this.episodeDownloadQueued)
this.$socket.$off('episode_download_started', this.episodeDownloadStarted)
this.$socket.$off('episode_download_finished', this.episodeDownloadFinished)
}
} }
</script> </script>
+4 -9
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="relative w-full" v-click-outside="clickOutsideObj"> <div class="relative w-full" v-click-outside="clickedOutside">
<p class="text-sm font-semibold" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p> <p class="text-sm font-semibold" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
<button type="button" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu"> <button type="button" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
<span class="flex items-center" :class="!selectedText ? 'text-gray-300' : 'text-white'"> <span class="flex items-center" :class="!selectedText ? 'text-gray-300' : 'text-white'">
<span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText || placeholder || '' }}</span> <span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText || placeholder || '' }}</span>
</span> </span>
@@ -11,9 +11,9 @@
</button> </button>
<transition name="menu"> <transition name="menu">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-gray-600 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox"> <ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-gray-600 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="listbox">
<template v-for="item in items"> <template v-for="item in items">
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)"> <li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption(item.value)">
<div class="flex items-center"> <div class="flex items-center">
<span class="font-normal ml-3 block truncate font-sans text-sm">{{ item.text }}</span> <span class="font-normal ml-3 block truncate font-sans text-sm">{{ item.text }}</span>
</div> </div>
@@ -42,11 +42,6 @@ export default {
}, },
data() { data() {
return { return {
clickOutsideObj: {
handler: this.clickedOutside,
events: ['mousedown'],
isActive: true
},
showMenu: false showMenu: false
} }
}, },
+1 -1
View File
@@ -4,7 +4,7 @@
<slot /> <slot />
</div> </div>
<transition name="menu"> <transition name="menu">
<ul ref="menu" v-show="showMenu" class="absolute z-50 -mt-px bg-primary border border-gray-600 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox" aria-activedescendant="listbox-option-3" style="width: 160px"> <ul ref="menu" v-show="showMenu" class="absolute z-50 -mt-px bg-primary border border-gray-600 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" style="width: 160px">
<template v-for="item in items"> <template v-for="item in items">
<nuxt-link :key="item.value" v-if="item.to" :to="item.to"> <nuxt-link :key="item.value" v-if="item.to" :to="item.to">
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)"> <li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">

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