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
advplyr 72744dcd29 iOS version bump 0.9.56 2022-08-11 19:11:25 -05:00
advplyr 6b374d923b Version bump 0.9.56 2022-08-11 19:08:18 -05:00
advplyr 03a47b06ee Fix:iOS sleep timer chapter end #291 2022-08-11 18:49:04 -05:00
advplyr 77d0974782 Update:default podcast episode table sort descending 2022-08-11 17:37:13 -05:00
advplyr 95e070a897 Add:Podcast item page play button to play next unfinished episode #139 2022-08-11 17:36:27 -05: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
advplyr a5f483f518 Fix:Home page podcast episode progress bar #264 2022-08-11 16:49:38 -05: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
advplyr b9f428fd77 Fix:Android auto separate all library items list from selected library items list 2022-08-10 18:56:12 -05:00
advplyr 94e8978ce6 Fix:Unregister android network listener on destroy 2022-08-10 18:48:47 -05:00
advplyr 62376871e3 Fix:Android get track offset out of bounds crash 2022-08-10 18:33:30 -05:00
advplyr 40b731534c Fix:Android crash when attempting sync after exoplayer failed 2022-08-10 18:27:37 -05:00
advplyr 1aea0a18a9 Fix:Android catch json parse exception from ffprobe caused by XMP id3 tag 2022-08-10 18:02:16 -05:00
advplyr b1be594932 Fix:Android catch exception when grantUriPermission fails 2022-08-10 17:31:38 -05: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
advplyr c1b8f8519f Fix:Android auto library items mixing with other libraries #309 2022-08-09 18:50:59 -05:00
advplyr 34d4a0b61b Fix:When stopping cast progress reverts back to initial progress #314 2022-08-09 18:14:07 -05:00
advplyr 7c3b6c9702 Fix:Starting cast player and stopping cast closing audio player in app #314 2022-08-09 17:35:42 -05: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
advplyr 56c73c12db Update:Android auto show progress bar 2022-08-07 17:52:37 -05:00
ronaldheft ad802c16ea Remove blocking realmQueue 2022-08-07 17:46:13 -04:00
advplyr afc04d22bf Remove old android splash screen 2022-08-07 12:11:38 -05:00
advplyr b78f004f29 Update:Sort local books shelf show unfinished books first #302 2022-08-07 11:00:35 -05:00
ronaldheft 849b6303e8 Display downloaded cover on items 2022-08-07 11:59:02 -04:00
advplyr 4a82ba7c86 Add:Subtitle to item page 2022-08-07 10:35:01 -05: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
advplyr 719db8d3f6 Add:Setting to disable shake to reset sleep timer #298 #169 2022-08-07 09:13:10 -05:00
advplyr db98492d4f Update:Remove limit on home page local media shelves #301 2022-08-07 08:38:30 -05:00
advplyr 5430c10424 Add:Filter by language #312 2022-08-07 08:21:17 -05: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
advplyr 92a31c246c Update:Collections page show total duration #303 2022-08-04 18:23:09 -05:00
advplyr 9082b76707 Update:Collections table row book count center #306 2022-08-04 18:16:05 -05:00
ronaldheft 33041608f8 Switch to async logic 2022-08-04 18:25:27 -04:00
advplyrandGitHub 408faf3665 Merge pull request #308 from benonymity/stats
Add Statistics
2022-08-04 17:23:51 -05:00
advplyr be6c675ded Show user stats nav only when connected to server, remove tooltip, remove icons on stats 2022-08-04 17:22:56 -05:00
ronaldheft 528854b285 Bump min ios version to support async 2022-08-04 16:24:41 -04:00
benonymity 87e277fad1 fix: lint comments 2022-08-03 22:20:20 -04:00
benonymity 014588180e feat: add stats page to mobile 2022-08-03 22:17:11 -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
advplyr 837df329e2 Update:List view/grid view icon swapped 2022-08-01 18:03:54 -05: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
advplyr 5662c1983a Update:Series book list view show sequence before title 2022-07-30 08:42:49 -05:00
advplyr 8cfefdcc0f Fix:Show duration in alt bookshelf view sort 2022-07-30 08:39:50 -05:00
advplyr 7d91b95320 iOS version bump 0.9.55 2022-07-28 19:45:23 -05:00
advplyr beb790ad17 iOS add getAllLocalMediaProgress capacitor func 2022-07-28 19:44:18 -05:00
advplyr 073110376a Fix:iOS sending multiple sync requests at the same time, Update:iOS sync every 5s 2022-07-28 19:30:18 -05:00
advplyr 16f6f3b7e9 iOS Realm migration to add new altViewEnabled device setting 2022-07-28 19:14:29 -05:00
advplyr 4758f3e557 Version bump 0.9.55-beta 2022-07-28 18:36:43 -05:00
advplyr 38cc2adf0f Comment out widget in dev 2022-07-28 18:29:20 -05:00
advplyr 80fbbfc2ef Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2022-07-28 18:25:47 -05:00
advplyr ca45956479 Fix:Media progress syncer to not sync media with currentTime 0 and to not sync when lastSyncTime is not set, cancel timer right away on stop/pause syncer #268 2022-07-28 18:25:36 -05:00
advplyrandGitHub 3d64c42b14 Merge pull request #299 from samblooms/master
Added alternative view setting
2022-07-27 18:24:25 -05:00
advplyr 97e7a12883 Updates to make alt bookshelf view look like the web app 2022-07-27 18:21:10 -05:00
Sam Bloomingdale e4d2b646e7 remove modified .gitignore 2022-07-27 15:01:30 -05:00
Sam Bloomingdale a5a8f3b6ac alt view series card 2022-07-27 14:54:29 -05:00
Sam Bloomingdale 41b0535213 fix typo in class name 2022-07-27 13:46:00 -05:00
Sam Bloomingdale a146a157b8 add alternative view to Shelf.vue 2022-07-27 13:37:20 -05:00
Sam Bloomingdale 96fff469fc add alternate view to LazybookShelf.vue 2022-07-27 13:29:07 -05:00
Sam Bloomingdale a60ff00f7e add getter method for 'enableAltView' 2022-07-27 13:15:03 -05:00
Sam Bloomingdale d52c326123 add 'Alternative Bookshelf View' setting 2022-07-27 12:58:06 -05:00
advplyr 76882780a4 Add:Start of app widget to control media playback 2022-07-23 18:09:10 -05:00
advplyr 97eb19da86 Fix:Load local media progress on launch before attempting server connection 2022-07-22 17:00:49 -05:00
advplyr 4ed9d702e9 Version bump 0.9.54-beta 2022-07-22 08:25:48 -05:00
advplyr 4c678836fb Update:Metered network connections to send progress syncs every 60s instead of every 5s and show metered/unmetered in connection status icon #238 2022-07-21 19:18:32 -05:00
advplyr fd134097a1 Fix:Android auto load libraries handle no libraries returned, Update:Local media items cover images width alignment #279 2022-07-21 18:06:33 -05:00
advplyr 2a87f1de28 Fix:Android check local media progress after a pause reverting to previous progress #290 2022-07-21 17:54:00 -05:00
advplyr 1f60a552e5 Version bump 0.9.53-beta 2022-07-20 18:02:07 -05:00
advplyr 3b34029ca2 Fix:M4B downloads adding MP3 extension by downloading to SimpleStorage v0.14.0 #292 2022-07-20 17:38:13 -05:00
advplyr 5f6a1ef7e9 Update:Show error icon on player cover when local media progress fails to sync & remove sync failure toast 2022-07-19 18:50:14 -05:00
advplyr b51f65d2a4 Fix:Closing playback first closes media progress syncer #268 2022-07-19 18:09:55 -05:00
advplyr dc9526beb3 Fix:Edit server config showing duplicate config already exists 2022-07-19 17:32:49 -05:00
advplyr 7188089f63 Fix:Set server settings after user logs in 2022-07-19 16:40:50 -05: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
advplyr db1d6da491 Version bump 0.9.52-beta 2022-07-15 19:59:51 -05:00
advplyr 329a51f886 Example of adding custom action icon for android auto 2022-07-15 19:48:36 -05:00
advplyr 0c0badbae4 Fix:Android Auto use saved playback speed #229 2022-07-15 18:40:20 -05:00
advplyr 2da272c270 Fix:Android auto starting items from continue listening #167 2022-07-15 18:13:04 -05:00
advplyr 25ec5b3dcb Fix:ANR with starting foreground service before starting session 2022-07-15 17:46:33 -05:00
advplyr d9cdc16ff9 Fix:Catch api handler invalid json response body 2022-07-15 16:22:44 -05:00
advplyr 066b72d54d Fix:Media progress sync wait for api request to return before stopping 2022-07-15 16:10:42 -05:00
advplyr e7c913643a Add:Last local media progress sync with server widget, Update:Remove local media progress with bad id 2022-07-13 19:17:34 -05:00
advplyr 9847ed9fcb Fix network connection indicator 2022-07-13 17:13:15 -05:00
advplyr 721f812df0 Update:Library widget UI 2022-07-13 17:10:47 -05:00
advplyr a58965306a Fix misleading plugin function name 2022-07-13 16:44:02 -05:00
advplyrandGitHub 2b5377db04 Merge pull request #278 from Jnewbon/master
Added ability to Give write permission to the SD card
2022-07-11 14:57:38 -05:00
Jnewbon 7fe3849705 Re Added read Permission check as i probably shouldn't have removed it 2022-07-11 00:43:36 +01:00
Jnewbon f998deb725 Added Storage Media type Write access 2022-07-11 00:26:53 +01: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
advplyr cb2aaede67 Fix:Socket reconnection on disconnect, Add:Connection indicator icon showing socket/cellular 2022-07-07 17:24:26 -05:00
benonymity 2ca9ce797d feat: iOS download groundwork 2022-07-06 10:09:17 -04:00
advplyr e07e7f70d6 Fix:Buffered progress bar for chapter track #250 2022-07-04 18:42:43 -05:00
advplyr 48313b60c1 iOS version bump 0.9.51 2022-07-04 16:56:04 -05:00
advplyr 3be13025a8 Fix:iOS swipe navigation to close modal 2022-07-04 16:55:34 -05:00
advplyr 505717e580 Version bump 0.9.51 2022-07-04 16:05:20 -05:00
advplyr b4bb6bf81b Fix:Back button to close modal #267 and back button to minimize fullscreen player #267 2022-07-04 15:42:59 -05:00
advplyr 6a8d901ce1 Update:Collections table row UI updates and include book duration #240 2022-07-04 14:59:56 -05:00
advplyr 68f3f0e276 Fix:Prevent crashes and catch exceptions for crash reports 2022-07-04 12:47:18 -05:00
advplyr ddf8e66a08 Fix:Remove server connection configs and prevent duplicate configs 2022-07-04 12:15:08 -05:00
advplyr 5b7a492a48 Update DB initializing to fix crashes for Paper db not initialized 2022-07-03 13:45:24 -05:00
advplyr 9be1ee1843 iOS version bump 0.9.50 2022-07-02 18:30:22 -05:00
advplyr b5c6acc2bc Add:iOS device settings for jump forward/backward 2022-07-02 18:29:41 -05:00
advplyr 39b137c7f1 Version bump 0.9.50 2022-07-02 16:40:31 -05:00
advplyr b16a7342aa Update:Tapping title/author in fullscreen player redirects to library item page #246 2022-07-02 15:40:37 -05:00
advplyr 3056c55d2f Fix:Update total track width on open fullscreen 2022-07-02 15:03:21 -05:00
advplyr 6b416e6fc1 Fix:Podcast author on item page and use html description for podcast episodes 2022-07-02 14:33:53 -05:00
advplyr e4c6093a82 Update:Notification player closed when closing in-app player and stop button on notification player #255, Add:Setting for disabling auto rewind 2022-07-02 14:18:21 -05:00
advplyr 3c1120ea48 Add settings page with adjustable jump forward/backward settings 2022-07-01 20:05:11 -05:00
advplyr a41e26e4c6 Fix:Fetch more library items per request to fill screen size #265 2022-07-01 17:01:40 -05:00
advplyr 317dc366e3 Add custom headers modal and model 2022-07-01 16:33:39 -05:00
advplyr cd4c280950 Add:Tap cover to show fullscreen cover image #224 2022-06-24 17:20:13 -05:00
advplyr 0ca8de5916 Fix:Audio player fullscreen in landscape mode #234 2022-06-23 19:27:41 -05:00
advplyr 3840f2f344 Update:Less aggressive seek back after pausing, first seek back starts on a 10s pause and jumps back 3s #230. Minimum seek back after a pause is the current chapter start time #244 2022-06-21 18:22:47 -05:00
advplyr f353a44fac Fix:Chromecast current track #253 2022-06-21 17:25:27 -05:00
advplyr a06796d9ca Fix initialize Paper for android auto 2022-06-19 12:03:50 -05:00
advplyr a69054fefa Fix crash when server requests fail 2022-06-19 11:57:19 -05:00
advplyrandGitHub 6805d7eb96 Update readme.md 2022-06-11 21:46:20 -05:00
advplyrandGitHub c77d0f1944 Merge pull request #233 from jmt-gh/add_server_version
Show server version in drawer
2022-06-09 17:36:56 -05:00
c457c8eaf7 Update components/app/SideDrawer.vue
Co-authored-by: advplyr <67830747+advplyr@users.noreply.github.com>
2022-06-09 15:28:49 -07:00
jmt-gh a391db5dc2 Change what is being called for version
If I tried to grab version directly in the computed section, it would
throw a null error on reload in browser (didn't see any issue on an
actual device). Doing it this way fixes that
2022-06-09 12:08:04 -07:00
jmt-gh 2180e2f649 Add server version to drawer
This commit adds the server version next to the URL. It also adds a new
style to ensure that the text wraps properly if the URL is too long.
Trying text-wrap didn't work.
2022-06-09 11:57:44 -07:00
advplyr fc6d16bdd9 Update:Make audio probe optional 2022-06-06 18:47:09 -05:00
advplyr 815529129a Fix:Save playback speed when using custom value #226 2022-06-06 17:19:02 -05:00
advplyr bad8d10a18 Fix:Android 9 and below scan folder for m4b #220 2022-06-05 16:46:38 -05:00
advplyr a4412da3ed Version bump 0.9.49-beta 2022-06-05 16:38:45 -05:00
advplyr 870774b408 Fix:Remove last saved library id when changing servers so home page shelves load on init 2022-06-05 16:32:28 -05:00
advplyr d7dcaa22a6 Fix:Logout button on account page #222 2022-06-05 16:01:05 -05:00
advplyr cc0b2943dd Fix:Hide play button for local podcast 2022-06-05 15:56:23 -05:00
advplyr 50ee0b2265 Fix:Local downloaded books crash when server progress check fails, fix check pause timeout length #219 2022-06-05 15:52:54 -05:00
advplyr 910b3a2a17 Version bump 0.9.48-beta 2022-06-05 09:55:22 -05:00
advplyr 916da91ccb Fix:Android auto check if server connection available and use first available server config, add ping api check with 3 second timeout #212 #167 2022-06-05 09:51:37 -05:00
advplyr d70a99254a Android version code bump 2022-06-04 17:20:14 -05:00
advplyr dcf5bb61a2 Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2022-06-04 17:12:14 -05:00
advplyr aa65bb10c1 Update:Android auto reset saved data if server config id changes 2022-06-04 17:12:10 -05:00
advplyr 158448f8a2 iOS version bump 0.9.47 2022-06-04 16:37:26 -05:00
advplyr f4be9b3e26 Update:Pass device info with play request 2022-06-04 16:36:49 -05:00
advplyr 15d68ca285 Version bump 0.9.47-beta 2022-06-04 15:11:49 -05:00
advplyr ad12e6a19d Add:Author, narrator and series links on item page #186 2022-06-04 10:19:31 -05:00
advplyr 1f1b2fe85a Add:Filter for podcast episodes 2022-06-03 19:46:43 -05:00
advplyr 480df58ce4 Fix:Check with server after pause of 1 minute or longer for updated media progress & show toast on client if progress sync is failing 2022-06-03 18:58:07 -05:00
advplyr 2decf532b2 Add:Not Finished progress filter 2022-06-02 18:20:52 -05:00
advplyr 3ba87419ae Update:Hide size on item landing page if not set 2022-06-02 18:14:29 -05:00
advplyr 1c78af37fa Update:Item page redesign with more details like narrator and chapters, decreasing cover size 2022-06-02 17:57:37 -05:00
advplyr 2b5373aedd Update:Rename Hard Delete 2022-06-02 17:02:30 -05:00
advplyr 99bf960b8a Add:Filter and sort for podcast episodes table, Update:Sync local media progress when media progress is updated on the server 2022-06-01 19:38:26 -05:00
advplyr c4aca22c28 Fix:Android auto reset server items if no longer connected to server #201 2022-05-29 18:43:53 -05:00
advplyr 58bd0e0cee Add:Click and drag player progress track #110 2022-05-29 18:13:25 -05:00
advplyr c1c56f8f52 Fix play request payload 2022-05-27 20:48:13 -05:00
advplyr f930ba1941 Update:Send android device info when opening playback sessions 2022-05-26 19:10:29 -05:00
advplyr 251116a5ce Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2022-05-24 09:08:28 -05:00
advplyr 06739c0401 Fix:Base64 encoding include URL_SAFE flag to prevent having / in local library item ids #192 2022-05-24 09:08:23 -05:00
249 changed files with 12098 additions and 2522 deletions
+15 -5
View File
@@ -21,7 +21,11 @@ kotlin {
}
android {
kotlinOptions {
buildFeatures {
viewBinding true
}
kotlinOptions {
freeCompilerArgs = ['-Xjvm-default=all']
}
compileSdkVersion rootProject.ext.compileSdkVersion
@@ -29,8 +33,8 @@ android {
applicationId "com.audiobookshelf.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 75
versionName "0.9.46-beta"
versionCode 91
versionName "0.9.60-beta"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -39,6 +43,12 @@ android {
}
}
buildTypes {
debug {
applicationIdSuffix ".debug"
versionNameSuffix "-debug"
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
@@ -111,13 +121,13 @@ dependencies {
implementation 'io.github.pilgr:paperdb:2.7.2'
// Simple Storage
implementation "com.anggrayudi:storage:0.13.0"
implementation "com.anggrayudi:storage:0.14.0"
// OK HTTP
implementation 'com.squareup.okhttp3:okhttp:4.9.2'
// Jackson for JSON
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.12.1'
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.12.2'
// FFMPEG-Kit
implementation 'com.arthenica:ffmpeg-kit-min:4.5.1'
@@ -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

+63 -52
View File
@@ -1,83 +1,94 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:dist="http://schemas.android.com/apk/distribution"
xmlns:tools="http://schemas.android.com/tools"
android:installLocation="preferExternal"
package="com.audiobookshelf.app">
xmlns:dist="http://schemas.android.com/apk/distribution"
xmlns:tools="http://schemas.android.com/tools"
package="com.audiobookshelf.app"
android:installLocation="preferExternal" >
<!-- Permissions -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config"
android:requestLegacyExternalStorage="true">
android:usesCleartextTraffic="true" >
<!-- <receiver-->
<!-- android:name=".NewAppWidget"-->
<!-- android:exported="true" >-->
<!-- <intent-filter>-->
<!-- <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />-->
<!-- </intent-filter>-->
<!-- <meta-data-->
<!-- android:name="android.appwidget.provider"-->
<!-- android:resource="@xml/new_app_widget_info" />-->
<!-- </receiver>-->
<!--Used by Android Auto-->
<meta-data android:name="com.google.android.gms.car.notification.SmallIcon"
android:resource="@drawable/icon" />
<!-- Used by Android Auto -->
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc"/>
<!-- Support for Cast -->
<meta-data android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.audiobookshelf.app.CastOptionsProvider"/>
android:name="com.google.android.gms.car.notification.SmallIcon"
android:resource="@drawable/icon" />
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" /> <!-- Support for Cast -->
<meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.audiobookshelf.app.CastOptionsProvider" />
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:name="com.audiobookshelf.app.MainActivity"
android:label="@string/title_activity_main"
android:exported="true"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask">
android:label="@string/title_activity_main"
android:launchMode="singleTask"
android:theme="@style/AppTheme.NoActionBarLaunch" >
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" />
<intent-filter>
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<!-- Register URL scheme -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="@string/custom_url_scheme" />
</intent-filter>
<!-- Register URL scheme -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="@string/custom_url_scheme" />
</intent-filter>
</activity>
<receiver android:name="androidx.media.session.MediaButtonReceiver" android:exported="false">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<receiver android:name="androidx.media.session.MediaButtonReceiver" >
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<service
android:exported="true"
android:enabled="true"
android:name=".player.PlayerNotificationService">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService"/>
</intent-filter>
</service>
<service
android:name=".player.PlayerNotificationService"
android:enabled="true"
android:exported="true" >
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
</application>
</manifest>
@@ -1,21 +1,24 @@
package com.audiobookshelf.app
import android.Manifest
import android.app.DownloadManager
import android.content.*
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.os.*
import android.os.Bundle
import android.os.IBinder
import android.util.Log
import androidx.core.app.ActivityCompat
import com.anggrayudi.storage.SimpleStorage
import com.anggrayudi.storage.SimpleStorageHelper
import com.audiobookshelf.app.data.AbsDatabase
import com.audiobookshelf.app.data.DbManager
import com.audiobookshelf.app.player.PlayerNotificationService
import com.audiobookshelf.app.plugins.AbsAudioPlayer
import com.audiobookshelf.app.plugins.AbsDownloader
import com.audiobookshelf.app.plugins.AbsFileSystem
import com.getcapacitor.BridgeActivity
import io.paperdb.Paper
class MainActivity : BridgeActivity() {
@@ -50,14 +53,11 @@ class MainActivity : BridgeActivity() {
// .build())
super.onCreate(savedInstanceState)
Log.d(tag, "onCreate")
// Grant full storage access for testing
// var ss = SimpleStorage(this)
// ss.requestFullStorageAccess()
DbManager.initialize(applicationContext)
var permission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
val permission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
if (permission != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this,
PERMISSIONS_ALL,
@@ -68,8 +68,6 @@ class MainActivity : BridgeActivity() {
registerPlugin(AbsDownloader::class.java)
registerPlugin(AbsFileSystem::class.java)
registerPlugin(AbsDatabase::class.java)
Paper.init(applicationContext)
}
override fun onDestroy() {
@@ -94,9 +92,7 @@ class MainActivity : BridgeActivity() {
foregroundService = mLocalBinder.getService()
// Let NativeAudio know foreground service is ready and setup event listener
if (pluginCallback != null) {
pluginCallback()
}
pluginCallback()
}
}
@@ -140,9 +136,4 @@ class MainActivity : BridgeActivity() {
// Mandatory for Activity, but not for Fragment & ComponentActivity
storageHelper.onRequestPermissionsResult(requestCode, permissions, grantResults)
}
// override fun onUserInteraction() {
// super.onUserInteraction()
// Log.d(tag, "USER INTERACTION")
// }
}
@@ -0,0 +1,96 @@
package com.audiobookshelf.app
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.support.v4.media.session.PlaybackStateCompat.ACTION_PLAY_PAUSE
import android.util.Log
import android.widget.RemoteViews
import androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.device.WidgetEventEmitter
import com.audiobookshelf.app.player.PlayerNotificationService
import com.bumptech.glide.Glide
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.request.target.AppWidgetTarget
import com.bumptech.glide.request.transition.Transition
/**
* Implementation of App Widget functionality.
*/
class NewAppWidget : AppWidgetProvider() {
val tag = "NewAppWidget"
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
// There may be multiple widgets active, so update all of them
for (appWidgetId in appWidgetIds) {
updateAppWidget(context, appWidgetManager, appWidgetId, null,false)
}
}
override fun onEnabled(context: Context) {
Log.w(tag, "onEnabled check context ${context.packageName}")
// Enter relevant functionality for when the first widget is created
DeviceManager.widgetUpdater = (object : WidgetEventEmitter {
override fun onPlayerChanged(pns:PlayerNotificationService) {
val isPlaying = pns.currentPlayer.isPlaying
Log.i(tag, "onPlayerChanged | Is Playing? $isPlaying")
val appWidgetManager = AppWidgetManager.getInstance(context)
val componentName = ComponentName(context, NewAppWidget::class.java)
val ids = appWidgetManager.getAppWidgetIds(componentName)
val playbackSession = pns.getCurrentPlaybackSessionCopy()
val cover = playbackSession?.getCoverUri()
for (widgetId in ids) {
updateAppWidget(context, appWidgetManager, widgetId, cover, isPlaying)
}
}
})
}
override fun onDisabled(context: Context) {
// Enter relevant functionality for when the last widget is disabled
}
}
internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, coverUri:Uri?, isPlaying:Boolean) {
val views = RemoteViews(context.packageName, R.layout.new_app_widget)
val playPausePI = buildMediaButtonPendingIntent(context, ACTION_PLAY_PAUSE)
views.setOnClickPendingIntent(R.id.playPauseIcon, playPausePI)
val wholeWidgetClickI = Intent(context, MainActivity::class.java)
wholeWidgetClickI.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
val wholeWidgetClickPI = PendingIntent.getActivity(
context,
System.currentTimeMillis().toInt(),
wholeWidgetClickI,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
views.setOnClickPendingIntent(R.id.appWidget, wholeWidgetClickPI)
val imageUri = coverUri ?: Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
val awt: AppWidgetTarget = object : AppWidgetTarget(context.applicationContext, R.id.imageView, views, appWidgetId) {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
super.onResourceReady(resource, transition)
}
}
val options = RequestOptions().override(300, 300).placeholder(R.drawable.icon).error(R.drawable.icon)
Glide.with(context.applicationContext).asBitmap().load(imageUri).apply(options).into(awt)
val playPauseResource = if (isPlaying) R.drawable.ic_media_pause_dark else R.drawable.ic_media_play_dark
views.setImageViewResource(R.id.playPauseIcon, playPauseResource)
// Instruct the widget manager to update the widget
appWidgetManager.updateAppWidget(appWidgetId, views)
}
@@ -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,71 +1,13 @@
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 com.audiobookshelf.app.R
import com.audiobookshelf.app.device.DeviceManager
import androidx.media.utils.MediaConstants
import com.audiobookshelf.app.media.MediaManager
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_ALBUM_ART_URI, getCoverUri().toString())
putString(MediaMetadataCompat.METADATA_KEY_ART_URI, getCoverUri().toString())
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, authorName)
}.build()
}
}
// This auto-detects whether it is a Book or Podcast
@JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION)
@JsonSubTypes(
@@ -110,7 +52,7 @@ class Podcast(
// Add new episodes
audioTracks.forEach { at ->
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)
}
}
@@ -123,7 +65,7 @@ class Podcast(
}
@JsonIgnore
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)
var index = 1
@@ -142,9 +84,16 @@ class Podcast(
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
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)
var index = 1
@@ -155,10 +104,14 @@ class Podcast(
return newEpisode
}
// 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)
fun getNextUnfinishedEpisode(libraryItemId:String, mediaManager: MediaManager):PodcastEpisode? {
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
}
}
@@ -182,10 +135,13 @@ class Book(
override fun setAudioTracks(audioTracks:MutableList<AudioTrack>) {
tracks = audioTracks
tracks?.sortBy { it.index }
// TODO: Is it necessary to calculate this each time? check if can remove safely
var totalDuration = 0.0
var currentStartOffset = 0.0
tracks?.forEach {
totalDuration += it.duration
it.startOffset = currentStartOffset
currentStartOffset += it.duration
}
duration = totalDuration
}
@@ -218,7 +174,6 @@ class Book(
}
duration = totalDuration
}
@JsonIgnore
override fun getLocalCopy(): Book {
return Book(metadata as BookMetadata,coverPath,tags, mutableListOf(),chapters,mutableListOf(),null,null, 0)
@@ -288,6 +243,8 @@ data class PodcastEpisode(
var title:String?,
var subtitle:String?,
var description:String?,
var pubDate:String?,
var publishedAt:Long?,
var audioFile:AudioFile?,
var audioTrack:AudioTrack?,
var duration:Double?,
@@ -295,25 +252,49 @@ data class PodcastEpisode(
var serverEpisodeId:String? // For local podcasts to match with server podcasts
) {
@JsonIgnore
fun getMediaMetadata(libraryItem:LibraryItemWrapper): MediaMetadataCompat {
var coverUri:Uri = Uri.EMPTY
val podcast = if(libraryItem is LocalLibraryItem) {
coverUri = libraryItem.getCoverUri()
libraryItem.media as Podcast
fun getMediaDescription(libraryItem:LibraryItemWrapper, progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat {
val coverUri = if (libraryItem is LocalLibraryItem) {
libraryItem.getCoverUri()
} else {
coverUri = (libraryItem as LibraryItem).getCoverUri()
(libraryItem as LibraryItem).media as Podcast
(libraryItem as LibraryItem).getCoverUri()
}
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, podcast.metadata.getAuthorDisplayName())
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, podcast.metadata.getAuthorDisplayName())
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString())
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 {
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()
}
}
@@ -363,57 +344,39 @@ data class Folder(
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)
data class BookChapter(
var id:Int,
var start:Double,
var end:Double,
var title:String?
)
) {
@get:JsonIgnore
val startMs get() = (start * 1000L).toLong()
@get:JsonIgnore
val endMs get() = (end * 1000L).toLong()
}
@JsonIgnoreProperties(ignoreUnknown = true)
data class MediaProgress(
class MediaProgress(
var id:String,
var libraryItemId:String,
var episodeId:String?,
var duration:Double, // seconds
var progress:Double, // 0 to 1
progress:Double, // 0 to 1
var currentTime:Double,
var isFinished:Boolean,
isFinished:Boolean,
var lastUpdate:Long,
var startedAt:Long,
var finishedAt:Long?
) : MediaProgressWrapper(isFinished, progress)
@JsonTypeInfo(use= JsonTypeInfo.Id.DEDUCTION, defaultImpl = MediaProgress::class)
@JsonSubTypes(
JsonSubTypes.Type(MediaProgress::class),
JsonSubTypes.Type(LocalMediaProgress::class)
)
open class MediaProgressWrapper(var isFinished:Boolean, var progress:Double)
// Helper class
data class LibraryItemWithEpisode(
@@ -1,16 +1,27 @@
package com.audiobookshelf.app.data
import android.content.Context
import android.util.Log
import com.audiobookshelf.app.plugins.AbsDownloader
import io.paperdb.Paper
import org.json.JSONObject
import java.io.File
class DbManager {
val tag = "DbManager"
companion object {
var isDbInitialized = false
fun initialize(ctx: Context) {
if (isDbInitialized) return
Paper.init(ctx)
isDbInitialized = true
Log.i("DbManager", "Initialized Paper db")
}
}
fun getDeviceData(): DeviceData {
return Paper.book("device").read("data") ?: DeviceData(mutableListOf(), null, null)
return Paper.book("device").read("data") ?: DeviceData(mutableListOf(), null, null, DeviceSettings.default())
}
fun saveDeviceData(deviceData:DeviceData) {
Paper.book("device").write("data", deviceData)
@@ -34,7 +45,7 @@ class DbManager {
}
}
fun getLocalLibraryItemByLLId(libraryItemId:String):LocalLibraryItem? {
fun getLocalLibraryItemByLId(libraryItemId:String):LocalLibraryItem? {
return getLocalLibraryItems().find { it.libraryItemId == libraryItemId }
}
@@ -147,6 +158,7 @@ class DbManager {
// Check local files
lli.localFiles = lli.localFiles.filter { localFile ->
val file = File(localFile.absolutePath)
if (!file.exists()) {
Log.d(tag, "cleanLocalLibraryItems: Local file ${localFile.absolutePath} was removed from library item ${lli.media.metadata.title}")
@@ -201,7 +213,11 @@ class DbManager {
val localLibraryItems = getLocalLibraryItems()
localMediaProgress.forEach {
val matchingLLI = localLibraryItems.find { lli -> lli.id == it.localLibraryItemId }
if (matchingLLI == null) {
if (!it.id.startsWith("local")) {
// A bug on the server when syncing local media progress was replacing the media progress id causing duplicate progress. Remove them.
Log.d(tag, "cleanLocalMediaProgress: Invalid local media progress does not start with 'local' (fixed on server 2.0.24)")
Paper.book("localMediaProgress").delete(it.id)
} else if (matchingLLI == null) {
Log.d(tag, "cleanLocalMediaProgress: No matching local library item for local media progress ${it.id} - removing")
Paper.book("localMediaProgress").delete(it.id)
} else if (matchingLLI.isPodcast) {
@@ -1,10 +1,15 @@
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.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonSubTypes
import com.fasterxml.jackson.annotation.JsonTypeInfo
import java.util.*
enum class LockOrientationSetting {
NONE, PORTRAIT, LANDSCAPE
}
data class ServerConnectionConfig(
var id:String,
@@ -13,13 +18,43 @@ data class ServerConnectionConfig(
var address:String,
var userId:String,
var username:String,
var token:String
var token:String,
var customHeaders:Map<String, String>?
)
data class DeviceSettings(
var disableAutoRewind:Boolean,
var enableAltView:Boolean,
var jumpBackwardsTime:Int,
var jumpForwardTime:Int,
var disableShakeToResetSleepTimer:Boolean,
var lockOrientation:LockOrientationSetting
) {
companion object {
// Static method to get default device settings
fun default():DeviceSettings {
return DeviceSettings(
disableAutoRewind = false,
enableAltView = false,
jumpBackwardsTime = 10,
jumpForwardTime = 10,
disableShakeToResetSleepTimer = false,
lockOrientation = LockOrientationSetting.NONE
)
}
}
@get:JsonIgnore
val jumpBackwardsTimeMs get() = (jumpBackwardsTime ?: default().jumpBackwardsTime) * 1000L
@get:JsonIgnore
val jumpForwardTimeMs get() = (jumpForwardTime ?: default().jumpForwardTime) * 1000L
}
data class DeviceData(
var serverConnectionConfigs:MutableList<ServerConnectionConfig>,
var lastServerConnectionConfigId:String?,
var currentLocalPlaybackSession:PlaybackSession? // Stored to open up where left off for local media
var currentLocalPlaybackSession:PlaybackSession?, // Stored to open up where left off for local media
var deviceSettings:DeviceSettings?
) {
@JsonIgnore
fun getLastServerConnectionConfig():ServerConnectionConfig? {
@@ -65,4 +100,24 @@ data class LocalFolder(
JsonSubTypes.Type(LibraryItem::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)
data class DeviceInfo(
var manufacturer:String,
var model:String,
var brand:String,
var sdkVersion:Int,
var clientVersion: String
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class PlayItemRequestPayload(
var mediaPlayer:String,
var forceDirectPlay:Boolean,
var forceTranscode:Boolean,
var deviceInfo: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,25 +1,24 @@
package com.audiobookshelf.app.data
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.ImageDecoder
import android.net.Uri
import android.support.v4.media.MediaMetadataCompat
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import android.support.v4.media.MediaDescriptionCompat
import android.util.Log
import androidx.media.utils.MediaConstants
import com.audiobookshelf.app.R
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.player.NOTIFICATION_LARGE_ICON_SIZE
import com.bumptech.glide.Glide
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.*
@JsonIgnoreProperties(ignoreUnknown = true)
data class LocalLibraryItem(
var id:String,
class LocalLibraryItem(
id:String,
var folderId:String,
var basePath:String,
var absolutePath:String,
@@ -36,7 +35,7 @@ data class LocalLibraryItem(
var serverAddress:String?,
var serverUserId:String?,
var libraryItemId:String?
) : LibraryItemWrapper() {
) : LibraryItemWrapper(id) {
@get:JsonIgnore
val title get() = media.metadata.title
@get:JsonIgnore
@@ -102,18 +101,55 @@ data class LocalLibraryItem(
}
@JsonIgnore
fun getMediaMetadata(ctx: Context): MediaMetadataCompat {
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx:Context?): MediaDescriptionCompat {
val coverUri = getCoverUri()
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, coverUri.toString())
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString())
putString(MediaMetadataCompat.METADATA_KEY_ART_URI, coverUri.toString())
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, authorName)
}.build()
var bitmap:Bitmap? = null
if (coverContentUrl != null) {
ctx?.let {
bitmap = if (Build.VERSION.SDK_INT < 28) {
MediaStore.Images.Media.getBitmap(it.contentResolver, coverUri)
} else {
val source: ImageDecoder.Source = ImageDecoder.createSource(it.contentResolver, coverUri)
ImageDecoder.decodeBitmap(source)
}
}
}
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()
}
}
@@ -2,16 +2,17 @@ package com.audiobookshelf.app.data
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import kotlin.math.roundToInt
@JsonIgnoreProperties(ignoreUnknown = true)
data class LocalMediaProgress(
class LocalMediaProgress(
var id:String,
var localLibraryItemId:String,
var localEpisodeId:String?,
var duration:Double,
var progress:Double, // 0 to 1
progress:Double, // 0 to 1
var currentTime:Double,
var isFinished:Boolean,
isFinished:Boolean,
var lastUpdate:Long,
var startedAt:Long,
var finishedAt:Long?,
@@ -21,7 +22,10 @@ data class LocalMediaProgress(
var serverUserId:String?,
var libraryItemId:String?,
var episodeId:String?
) {
) : MediaProgressWrapper(isFinished, progress) {
@get:JsonIgnore
val progressPercent get() = if (progress.isNaN()) 0 else (progress * 100).roundToInt()
@JsonIgnore
fun updateIsFinished(finished:Boolean) {
if (isFinished != finished) { // If finished changed then set progress
@@ -42,4 +46,15 @@ data class LocalMediaProgress(
isFinished = playbackSession.progress >= 0.99
finishedAt = if (isFinished) lastUpdate else null
}
@JsonIgnore
fun updateFromServerMediaProgress(serverMediaProgress:MediaProgress) {
isFinished = serverMediaProgress.isFinished
progress = serverMediaProgress.progress
currentTime = serverMediaProgress.currentTime
duration = serverMediaProgress.duration
lastUpdate = serverMediaProgress.lastUpdate
finishedAt = serverMediaProgress.finishedAt
startedAt = serverMediaProgress.startedAt
}
}
@@ -1,8 +1,11 @@
package com.audiobookshelf.app.data
import android.content.Context
import android.graphics.ImageDecoder
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.support.v4.media.MediaMetadataCompat
import com.audiobookshelf.app.R
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.player.MediaProgressSyncData
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.common.images.WebImage
// TODO: enum or something in kotlin?
val PLAYMETHOD_DIRECTPLAY = 0
val PLAYMETHOD_DIRECTSTREAM = 1
@@ -53,6 +57,8 @@ class PlaybackSession(
@get:JsonIgnore
val isLocal get() = playMethod == PLAYMETHOD_LOCAL
@get:JsonIgnore
val isPodcastEpisode get() = mediaType == "podcast"
@get:JsonIgnore
val currentTimeMs get() = (currentTime * 1000L).toLong()
@get:JsonIgnore
val totalDurationMs get() = (getTotalDuration() * 1000L).toLong()
@@ -62,18 +68,26 @@ class PlaybackSession(
val localMediaProgressId get() = if (episodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
@get:JsonIgnore
val progress get() = currentTime / getTotalDuration()
@get:JsonIgnore
val isLocalLibraryItemOnly get() = localLibraryItemId != "" && libraryItemId == null
@JsonIgnore
fun getCurrentTrackIndex():Int {
for (i in 0..(audioTracks.size - 1)) {
for (i in 0 until audioTracks.size) {
val track = audioTracks[i]
if (currentTimeMs >= track.startOffsetMs && (track.endOffsetMs) > currentTimeMs) {
if (currentTimeMs >= track.startOffsetMs && (track.endOffsetMs > currentTimeMs)) {
return i
}
}
return audioTracks.size - 1
}
@JsonIgnore
fun getChapterForTime(time:Long):BookChapter? {
if (chapters.isEmpty()) return null
return chapters.find { time >= it.startMs && it.endMs > time}
}
@JsonIgnore
fun getCurrentTrackTimeMs():Long {
val currentTrack = audioTracks[this.getCurrentTrackIndex()]
@@ -83,6 +97,7 @@ class PlaybackSession(
@JsonIgnore
fun getTrackStartOffsetMs(index:Int):Long {
if (index < 0 || index >= audioTracks.size) return 0L
val currentTrack = audioTracks[index]
return (currentTrack.startOffset * 1000L).toLong()
}
@@ -96,9 +111,9 @@ class PlaybackSession(
@JsonIgnore
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}")
}
@@ -109,15 +124,33 @@ class PlaybackSession(
}
@JsonIgnore
fun getMediaMetadataCompat(): MediaMetadataCompat {
fun getMediaMetadataCompat(ctx: Context): MediaMetadataCompat {
val metadataBuilder = MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, displayTitle)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, displayAuthor)
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, displayAuthor)
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, displayAuthor)
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, "series")
.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_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()
}
@@ -129,6 +162,9 @@ class PlaybackSession(
.setArtist(displayAuthor)
.setAlbumArtist(displayAuthor)
.setSubtitle(displayAuthor)
.setAlbumTitle(displayAuthor)
.setDescription(displayAuthor)
.setArtworkUri(getCoverUri())
val contentUri = this.getContentUri(audioTrack)
metadataBuilder.setMediaUri(contentUri)
@@ -162,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_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.putInt(com.google.android.gms.cast.MediaMetadata.KEY_TRACK_NUMBER, audioTrack.index)
return castMetadata
}
@@ -1,9 +1,12 @@
package com.audiobookshelf.app.device
import android.util.Log
import com.audiobookshelf.app.data.DbManager
import com.audiobookshelf.app.data.DeviceData
import com.audiobookshelf.app.data.ServerConnectionConfig
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.player.PlayerNotificationService
interface WidgetEventEmitter {
fun onPlayerChanged(pns:PlayerNotificationService)
}
object DeviceManager {
const val tag = "DeviceManager"
@@ -19,11 +22,18 @@ object DeviceManager {
val isConnectedToServer get() = serverConnectionConfig != null
val hasLastServerConnectionConfig get() = deviceData.getLastServerConnectionConfig() != null
var widgetUpdater:WidgetEventEmitter? = null
init {
Log.d(tag, "Device Manager Singleton invoked")
}
fun getBase64Id(id:String):String {
return android.util.Base64.encodeToString(id.toByteArray(), android.util.Base64.NO_WRAP)
return android.util.Base64.encodeToString(id.toByteArray(), android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP)
}
fun getServerConnectionConfig(id:String?):ServerConnectionConfig? {
if (id == null) return null
return deviceData.serverConnectionConfigs.find { it.id == id }
}
}
@@ -14,6 +14,7 @@ import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.getcapacitor.JSObject
import org.json.JSONException
class FolderScanner(var ctx: Context) {
private val tag = "FolderScanner"
@@ -105,7 +106,7 @@ class FolderScanner(var ctx: Context) {
var coverContentUrl:String? = null
var coverAbsolutePath:String? = null
val filesInFolder = itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4"))
val filesInFolder = itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream"))
val existingLocalFilesRemoved = existingLocalFiles.filter { elf ->
filesInFolder.find { fif -> DeviceManager.getBase64Id(fif.id) == elf.id } == null // File was not found in media item folder
@@ -157,11 +158,11 @@ class FolderScanner(var ctx: Context) {
audioTrackToAdd = existingAudioTrack
} else {
// Create new audio track
var track = AudioTrack(index, startOffset, audioProbeResult.duration, filename, localFile.contentUrl, mimeType, null, true, localFileId, audioProbeResult, null)
val track = AudioTrack(index, startOffset, audioProbeResult?.duration ?: 0.0, filename, localFile.contentUrl, mimeType, null, true, localFileId, audioProbeResult, null)
audioTrackToAdd = track
}
startOffset += audioProbeResult.duration
startOffset += audioProbeResult?.duration ?: 0.0
isNewOrUpdated = true
} else {
audioTrackToAdd = existingAudioTrack
@@ -248,7 +249,7 @@ class FolderScanner(var ctx: Context) {
val localLibraryItemId = getLocalLibraryItemId(itemFolderId)
Log.d(tag, "scanDownloadItem starting for ${downloadItem.itemFolderPath} | ${df.uri} | Item Folder Id:$itemFolderId | LLI Id:$localLibraryItemId")
// Search for files in media item folder
// m4b files showing as mimeType application/octet-stream on Android 10 and earlier see #154
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream"))
@@ -289,7 +290,7 @@ class FolderScanner(var ctx: Context) {
val audioProbeResult = probeAudioFile(localFile.absolutePath)
// Create new audio track
val track = AudioTrack(audioTrackFromServer.index, audioTrackFromServer.startOffset, audioProbeResult.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, audioTrackFromServer?.index ?: -1)
val track = AudioTrack(audioTrackFromServer.index, audioTrackFromServer.startOffset, audioProbeResult?.duration ?: 0.0, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, audioTrackFromServer.index)
audioTracks.add(track)
Log.d(tag, "scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}")
@@ -378,7 +379,7 @@ class FolderScanner(var ctx: Context) {
var wasUpdated = false
// Search for files in media item folder
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4"))
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream"))
Log.d(tag, "scanLocalLibraryItem ${filesFound.size} files found in ${localLibraryItem.absolutePath}")
filesFound.forEach {
@@ -431,7 +432,7 @@ class FolderScanner(var ctx: Context) {
// Create new audio track
val lastTrack = existingAudioTracks.lastOrNull()
val startOffset = (lastTrack?.startOffset ?: 0.0) + (lastTrack?.duration ?: 0.0)
val track = AudioTrack(existingAudioTracks.size, startOffset, audioProbeResult.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, null)
val track = AudioTrack(existingAudioTracks.size, startOffset, audioProbeResult?.duration ?: 0.0, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, null)
localLibraryItem.media.addAudioTrack(track)
Log.d(tag, "Added New Audio Track ${track.title}")
wasUpdated = true
@@ -463,12 +464,24 @@ class FolderScanner(var ctx: Context) {
return LocalLibraryItemScanResult(wasUpdated, localLibraryItem)
}
fun probeAudioFile(absolutePath:String):AudioProbeResult {
var session = FFprobeKit.execute("-i \"${absolutePath}\" -print_format json -show_format -show_streams -select_streams a -show_chapters -loglevel quiet")
Log.d(tag, "FFprobe output ${JSObject(session.output)}")
fun probeAudioFile(absolutePath:String):AudioProbeResult? {
val session = FFprobeKit.execute("-i \"${absolutePath}\" -print_format json -show_format -show_streams -select_streams a -show_chapters -loglevel quiet")
val audioProbeResult = jacksonMapper.readValue<AudioProbeResult>(session.output)
Log.d(tag, "Probe Result DATA ${audioProbeResult.duration} | ${audioProbeResult.size} | ${audioProbeResult.title} | ${audioProbeResult.artist}")
return audioProbeResult
var probeObject:JSObject? = null
try {
probeObject = JSObject(session.output)
} catch(error:JSONException) {
Log.e(tag, "Failed to parse probe result $error")
}
Log.d(tag, "FFprobe output $probeObject")
if (probeObject == null || !probeObject.has("streams")) { // Check if output is empty
Log.d(tag, "probeAudioFile Probe audio file $absolutePath failed or invalid")
return null
} else {
val audioProbeResult = jacksonMapper.readValue<AudioProbeResult>(session.output)
Log.d(tag, "Probe Result DATA ${audioProbeResult.duration} | ${audioProbeResult.size} | ${audioProbeResult.title} | ${audioProbeResult.artist}")
return audioProbeResult
}
}
}
@@ -1,59 +1,109 @@
package com.audiobookshelf.app.media
import android.bluetooth.BluetoothClass
import android.app.Activity
import android.content.Context
import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.MediaMetadataCompat
import android.util.Log
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.player.PlayerNotificationService
import com.audiobookshelf.app.server.ApiHandler
import com.getcapacitor.JSObject
import java.util.*
import io.paperdb.Paper
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runBlocking
import org.json.JSONException
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
val tag = "MediaManager"
var serverLibraryItems = listOf<LibraryItem>()
var serverLibraryItems = mutableListOf<LibraryItem>() // Store all items here
var selectedLibraryItems = mutableListOf<LibraryItem>()
var selectedLibraryId = ""
var selectedLibraryItemWrapper:LibraryItemWrapper? = null
var selectedPodcast:Podcast? = null
var selectedLibraryItemId:String? = null
var serverPodcastEpisodes = listOf<PodcastEpisode>()
var podcastEpisodeLibraryItemMap = mutableMapOf<String, LibraryItemWithEpisode>()
var serverLibraryCategories = listOf<LibraryCategory>()
var serverItemsInProgress = listOf<ItemInProgress>()
var serverLibraries = listOf<Library>()
var serverConfigIdUsed:String? = null
var serverConfigLastPing:Long = 0L
var serverUserMediaProgress:MutableList<MediaProgress> = mutableListOf()
fun initializeAndroidAuto() {
Log.d(tag, "Android Auto started when MainActivity was never started - initializing Paper")
Paper.init(ctx)
}
var userSettingsPlaybackRate:Float? = null
fun getIsLibrary(id:String) : Boolean {
return serverLibraries.find { it.id == id } != null
}
fun loadLibraryCategories(libraryId:String, cb: (List<LibraryCategory>) -> Unit) {
if (serverLibraryCategories.isNotEmpty()) {
cb(serverLibraryCategories)
fun getSavedPlaybackRate():Float {
if (userSettingsPlaybackRate != null) {
return userSettingsPlaybackRate ?: 1f
}
val sharedPrefs = ctx.getSharedPreferences("CapacitorStorage", Activity.MODE_PRIVATE)
if (sharedPrefs != null) {
val userSettingsPref = sharedPrefs.getString("userSettings", null)
if (userSettingsPref != null) {
try {
val userSettings = JSObject(userSettingsPref)
if (userSettings.has("playbackRate")) {
userSettingsPlaybackRate = userSettings.getDouble("playbackRate").toFloat()
return userSettingsPlaybackRate ?: 1f
}
} catch(je:JSONException) {
Log.e(tag, "Failed to parse userSettings JSON ${je.localizedMessage}")
}
}
}
return 1f
}
fun checkResetServerItems() {
// When opening android auto need to check if still connected to server
// and reset any server data already set
val serverConnConfig = if (DeviceManager.isConnectedToServer) DeviceManager.serverConnectionConfig else DeviceManager.deviceData.getLastServerConnectionConfig()
if (!DeviceManager.isConnectedToServer || !apiHandler.isOnline() || serverConnConfig == null || serverConnConfig.id !== serverConfigIdUsed) {
podcastEpisodeLibraryItemMap = mutableMapOf()
serverLibraryCategories = listOf()
serverLibraries = listOf()
serverLibraryItems = mutableListOf()
selectedLibraryItems = mutableListOf()
selectedLibraryId = ""
}
}
fun loadItemsInProgressForAllLibraries(cb: (List<ItemInProgress>) -> Unit) {
if (serverItemsInProgress.isNotEmpty()) {
cb(serverItemsInProgress)
} else {
apiHandler.getLibraryCategories(libraryId) {
serverLibraryCategories = it
cb(it)
apiHandler.getAllItemsInProgress { itemsInProgress ->
serverItemsInProgress = itemsInProgress
cb(serverItemsInProgress)
}
}
}
fun loadLibraryItemsWithAudio(libraryId:String, cb: (List<LibraryItem>) -> Unit) {
if (serverLibraryItems.isNotEmpty() && selectedLibraryId == libraryId) {
cb(serverLibraryItems)
if (selectedLibraryItems.isNotEmpty() && selectedLibraryId == libraryId) {
cb(selectedLibraryItems)
} else {
apiHandler.getLibraryItems(libraryId) { libraryItems ->
val libraryItemsWithAudio = libraryItems.filter { li -> li.checkHasTracks() }
if (libraryItemsWithAudio.isNotEmpty()) selectedLibraryId = libraryId
if (libraryItemsWithAudio.isNotEmpty()) {
selectedLibraryId = libraryId
}
serverLibraryItems = libraryItemsWithAudio
selectedLibraryItems = mutableListOf()
libraryItemsWithAudio.forEach { libraryItem ->
selectedLibraryItems.add(libraryItem)
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
serverLibraryItems.add(libraryItem)
}
}
cb(libraryItemsWithAudio)
}
}
@@ -71,41 +121,44 @@ 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 ->
Log.d(tag, "Loaded Podcast library item $libraryItemWrapper")
selectedLibraryItemWrapper = libraryItemWrapper
libraryItemWrapper?.let {
if (libraryItemWrapper is LocalLibraryItem) { // Local podcast episodes
if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) {
serverPodcastEpisodes = listOf()
cb(mutableListOf())
} else {
val podcast = libraryItemWrapper.media as Podcast
serverPodcastEpisodes = podcast.episodes ?: listOf()
selectedLibraryItemId = libraryItemWrapper.id
selectedPodcast = podcast
val children = podcast.episodes?.map { podcastEpisode ->
Log.d(tag, "Local Podcast Episode ${podcastEpisode.title} | ${podcastEpisode.id}")
MediaBrowserCompat.MediaItem(podcastEpisode.getMediaMetadata(libraryItemWrapper).description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
val progress = DeviceManager.dbManager.getLocalMediaProgress("${libraryItemWrapper.id}-${podcastEpisode.id}")
val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress, ctx)
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
}
children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
}
} else if (libraryItemWrapper is LibraryItem) { // Server podcast episodes
if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) {
serverPodcastEpisodes = listOf()
cb(mutableListOf())
} else {
val podcast = libraryItemWrapper.media as Podcast
serverPodcastEpisodes = podcast.episodes ?: listOf()
podcast.episodes?.forEach { podcastEpisode ->
podcastEpisodeLibraryItemMap[podcastEpisode.id] = LibraryItemWithEpisode(libraryItemWrapper, podcastEpisode)
}
selectedLibraryItemId = libraryItemWrapper.id
selectedPodcast = podcast
val children = podcast.episodes?.map { podcastEpisode ->
MediaBrowserCompat.MediaItem(podcastEpisode.getMediaMetadata(libraryItemWrapper).description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
val progress = serverUserMediaProgress.find { it.libraryItemId == libraryItemWrapper.id && it.episodeId == podcastEpisode.id }
val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress, ctx)
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
}
children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
}
@@ -125,54 +178,146 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
}
}
// TODO: Load currently listening category for local items
fun loadLocalCategory():List<LibraryCategory> {
val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast")
val cats = mutableListOf<LibraryCategory>()
if (localBooks.isNotEmpty()) {
cats.add(LibraryCategory("local-books", "Local Books", "book", localBooks, true))
suspend fun checkServerConnection(config:ServerConnectionConfig) : Boolean {
var successfulPing = false
suspendCoroutine<Boolean> { cont ->
apiHandler.pingServer(config) {
Log.d(tag, "checkServerConnection: Checked server conn for ${config.address} result = $it")
successfulPing = it
cont.resume(it)
}
}
if (localPodcasts.isNotEmpty()) {
cats.add(LibraryCategory("local-podcasts", "Local Podcasts", "podcast", localPodcasts, true))
}
return cats
return successfulPing
}
fun loadAndroidAutoItems(cb: (List<LibraryCategory>) -> Unit) {
Log.d(tag, "Load android auto items")
val cats = mutableListOf<LibraryCategory>()
val localCategories = loadLocalCategory()
cats.addAll(localCategories)
// Connected to server and has internet - load other cats
if (apiHandler.isOnline() && (DeviceManager.isConnectedToServer || DeviceManager.hasLastServerConnectionConfig)) {
if (!DeviceManager.isConnectedToServer) {
DeviceManager.serverConnectionConfig = DeviceManager.deviceData.getLastServerConnectionConfig()
Log.d(tag, "Not connected to server, set last server \"${DeviceManager.serverAddress}\"")
suspend fun authorize(config:ServerConnectionConfig) : MutableList<MediaProgress> {
var mediaProgress:MutableList<MediaProgress> = mutableListOf()
suspendCoroutine<MutableList<MediaProgress>> { cont ->
apiHandler.authorize(config) {
Log.d(tag, "authorize: Authorized server config ${config.address} result = $it")
if (!it.isNullOrEmpty()) {
mediaProgress = it
}
cont.resume(mediaProgress)
}
}
return mediaProgress
}
loadLibraries { libraries ->
val library = libraries[0]
Log.d(tag, "Loading categories for library ${library.name} - ${library.id} - ${library.mediaType}")
fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = runBlocking {
Log.d(tag, "checkSetValidServerConnectionConfig | $serverConfigIdUsed")
loadLibraryCategories(library.id) { libraryCategories ->
coroutineScope {
if (!apiHandler.isOnline()) {
serverUserMediaProgress = mutableListOf()
cb(false)
} else {
// Only using book or podcast library categories for now
libraryCategories.forEach {
// Log.d(tag, "Found library category ${it.label} with type ${it.type}")
if (it.type == library.mediaType) {
// Log.d(tag, "Using library category ${it.id}")
cats.add(it)
var hasValidConn = false
var lookupMediaProgress = true
if (!serverConfigIdUsed.isNullOrEmpty() && serverConfigLastPing > 0L && System.currentTimeMillis() - serverConfigLastPing < 5000) {
Log.d(tag, "checkSetValidServerConnectionConfig last ping less than a 5 seconds ago")
hasValidConn = true
lookupMediaProgress = false
} else {
serverUserMediaProgress = mutableListOf()
}
if (!hasValidConn) {
// First check if the current selected config is pingable
DeviceManager.serverConnectionConfig?.let {
hasValidConn = checkServerConnection(it)
Log.d(
tag,
"checkSetValidServerConnectionConfig: Current config ${DeviceManager.serverAddress} is pingable? $hasValidConn"
)
}
}
if (!hasValidConn) {
// Loop through available configs and check if can connect
for (config: ServerConnectionConfig in DeviceManager.deviceData.serverConnectionConfigs) {
val result = checkServerConnection(config)
if (result) {
hasValidConn = true
DeviceManager.serverConnectionConfig = config
Log.d(tag, "checkSetValidServerConnectionConfig: Set server connection config ${DeviceManager.serverConnectionConfigId}")
break
}
}
cb(cats)
}
if (hasValidConn) {
serverConfigLastPing = System.currentTimeMillis()
if (lookupMediaProgress) {
Log.d(tag, "Has valid conn now get user media progress")
DeviceManager.serverConnectionConfig?.let {
serverUserMediaProgress = authorize(it)
}
}
}
cb(hasValidConn)
}
}
}
fun loadServerUserMediaProgress(cb: () -> Unit) {
Log.d(tag, "Loading server media progress")
if (DeviceManager.serverConnectionConfig == null) {
return cb()
}
DeviceManager.serverConnectionConfig?.let { config ->
apiHandler.authorize(config) {
Log.d(tag, "loadServerUserMediaProgress: Authorized server config ${config.address} result = $it")
if (!it.isNullOrEmpty()) {
serverUserMediaProgress = it
}
cb()
}
}
}
fun loadAndroidAutoItems(cb: () -> Unit) {
Log.d(tag, "Load android auto items")
// Check if any valid server connection if not use locally downloaded books
checkSetValidServerConnectionConfig { isConnected ->
if (isConnected) {
serverConfigIdUsed = DeviceManager.serverConnectionConfigId
loadLibraries { libraries ->
if (libraries.isEmpty()) {
Log.w(tag, "No libraries returned from server request")
cb()
} else {
val library = libraries[0]
Log.d(tag, "Loading categories for library ${library.name} - ${library.id} - ${library.mediaType}")
loadItemsInProgressForAllLibraries { itemsInProgress ->
itemsInProgress.forEach {
val libraryItem = it.libraryItemWrapper as LibraryItem
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
serverLibraryItems.add(libraryItem)
}
if (it.episode != null) {
podcastEpisodeLibraryItemMap[it.episode.id] = LibraryItemWithEpisode(it.libraryItemWrapper, it.episode)
}
}
cb() // Fully loaded
}
}
}
} else { // Not connected to server
cb()
}
} else { // Not connected/no internet sent downloaded cats only
cb(cats)
}
}
@@ -189,12 +334,7 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
if (id.startsWith("local")) {
return DeviceManager.dbManager.getLocalLibraryItemWithEpisode(id)
} else {
val podcastEpisode = serverPodcastEpisodes.find { it.id == id }
return if (podcastEpisode != null && selectedLibraryItemWrapper != null) {
LibraryItemWithEpisode(selectedLibraryItemWrapper!!, podcastEpisode)
} else {
null
}
return podcastEpisodeLibraryItemMap[id]
}
}
@@ -213,16 +353,19 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
}
}
fun play(libraryItemWrapper:LibraryItemWrapper, episode:PodcastEpisode?, mediaPlayer:String, cb: (PlaybackSession) -> Unit) {
if (libraryItemWrapper is LocalLibraryItem) {
val localLibraryItem = libraryItemWrapper as LocalLibraryItem
cb(localLibraryItem.getPlaybackSession(episode))
} else {
val libraryItem = libraryItemWrapper as LibraryItem
apiHandler.playLibraryItem(libraryItem.id,episode?.id ?: "",false, mediaPlayer) {
cb(it)
}
}
fun play(libraryItemWrapper:LibraryItemWrapper, episode:PodcastEpisode?, playItemRequestPayload:PlayItemRequestPayload, cb: (PlaybackSession?) -> Unit) {
if (libraryItemWrapper is LocalLibraryItem) {
cb(libraryItemWrapper.getPlaybackSession(episode))
} else {
val libraryItem = libraryItemWrapper as LibraryItem
apiHandler.playLibraryItem(libraryItem.id,episode?.id ?: "", playItemRequestPayload) {
if (it == null) {
cb(null)
} else {
cb(it)
}
}
}
}
private fun levenshtein(lhs : CharSequence, rhs : CharSequence) : Int {
@@ -2,7 +2,10 @@ package com.audiobookshelf.app.player
import android.app.PendingIntent
import android.graphics.Bitmap
import android.graphics.ImageDecoder
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.support.v4.media.session.MediaControllerCompat
import android.util.Log
import com.audiobookshelf.app.R
@@ -21,9 +24,6 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
var currentIconUri: Uri? = null
var currentBitmap: Bitmap? = null
private val glideOptions = RequestOptions()
.fallback(R.drawable.icon)
.diskCacheStrategy(DiskCacheStrategy.DATA)
private val serviceJob = SupervisorJob()
private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob)
@@ -45,13 +45,25 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
// `getCurrentLargeIcon` don't cause the bitmap to be recreated.
currentIconUri = albumArtUri
Log.d(tag, "ART $currentIconUri")
serviceScope.launch {
currentBitmap = albumArtUri?.let {
resolveUriAsBitmap(it)
if (currentIconUri.toString().startsWith("content://")) {
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 {
currentBitmap
}
@@ -4,17 +4,13 @@ import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.support.v4.media.MediaMetadataCompat
import android.util.Log
import androidx.annotation.AnyRes
import com.audiobookshelf.app.R
import com.audiobookshelf.app.data.Library
import com.audiobookshelf.app.data.LibraryCategory
import com.audiobookshelf.app.data.LibraryItem
import com.audiobookshelf.app.data.LocalLibraryItem
import com.audiobookshelf.app.data.*
class BrowseTree(
val context: Context,
libraryCategories: List<LibraryCategory>,
itemsInProgress: List<ItemInProgress>,
libraries: List<Library>
) {
private val mediaIdToChildren = mutableMapOf<String, MutableList<MediaMetadataCompat>>()
@@ -25,8 +21,7 @@ class BrowseTree(
* @param drawableId - drawable res id
* @return - uri
*/
fun getUriToDrawable(context: Context,
@AnyRes drawableId: Int): Uri {
fun getUriToDrawable(@AnyRes drawableId: Int): Uri {
return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE
+ "://" + context.resources.getResourcePackageName(drawableId)
+ '/' + context.resources.getResourceTypeName(drawableId)
@@ -36,38 +31,26 @@ class BrowseTree(
init {
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_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()
val downloadsMetadata = MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, DOWNLOADS_ROOT)
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()
val librariesMetadata = MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, LIBRARIES_ROOT)
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()
// Server continue Listening cat
libraryCategories.find { it.id == "continue-listening" }?.let { continueListeningCategory ->
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 (!itemsInProgress.isEmpty()) {
rootList += continueListeningMetadata
}
if (libraries.isNotEmpty()) {
@@ -82,23 +65,6 @@ class BrowseTree(
}
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(context)
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(context)
mediaIdToChildren[DOWNLOADS_ROOT] = children
}
}
mediaIdToChildren[AUTO_BROWSE_ROOT] = rootList
}
@@ -88,7 +88,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
private var currentMediaItemIndex = 0
private var pendingMediaItemRemovalPosition:PositionInfo? = null
private var pendingSeekCount = 0
private var pendingSeekWindowIndex = 0
private var pendingSeekWindowIndex = C.INDEX_UNSET
private var pendingSeekPositionMs = 0L
init {
@@ -4,12 +4,12 @@ import android.os.Handler
import android.os.Looper
import android.util.Log
import com.audiobookshelf.app.data.LocalMediaProgress
import com.audiobookshelf.app.data.MediaProgress
import com.audiobookshelf.app.data.PlaybackSession
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.server.ApiHandler
import java.util.*
import kotlin.concurrent.schedule
import kotlin.math.roundToInt
data class MediaProgressSyncData(
var timeListened:Long, // seconds
@@ -19,11 +19,13 @@ data class MediaProgressSyncData(
class MediaProgressSyncer(val playerNotificationService:PlayerNotificationService, private val apiHandler: ApiHandler) {
private val tag = "MediaProgressSync"
private val METERED_CONNECTION_SYNC_INTERVAL = 60000
private var listeningTimerTask: TimerTask? = null
var listeningTimerRunning:Boolean = false
private var lastSyncTime:Long = 0
private var failedSyncs:Int = 0
var currentPlaybackSession: PlaybackSession? = null // copy of pb session currently syncing
var currentLocalMediaProgress: LocalMediaProgress? = null
@@ -41,67 +43,175 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
currentLocalMediaProgress = null
listeningTimerTask?.cancel()
lastSyncTime = 0L
Log.d(tag, "start: Set last sync time 0 $lastSyncTime")
failedSyncs = 0
} else {
return
}
} else if (playerNotificationService.getCurrentPlaybackSessionId() != currentSessionId) {
currentLocalMediaProgress = null
}
listeningTimerRunning = true
lastSyncTime = System.currentTimeMillis()
Log.d(tag, "start: init last sync time $lastSyncTime")
currentPlaybackSession = playerNotificationService.getCurrentPlaybackSessionCopy()
listeningTimerTask = Timer("ListeningTimer", false).schedule(0L, 5000L) {
Handler(Looper.getMainLooper()).post() {
if (playerNotificationService.currentPlayer.isPlaying) {
// Only sync with server on unmetered connection every 5s OR sync with server if last sync time is >= 60s
val shouldSyncServer = PlayerNotificationService.isUnmeteredNetwork || System.currentTimeMillis() - lastSyncTime >= METERED_CONNECTION_SYNC_INTERVAL
val currentTime = playerNotificationService.getCurrentTimeSeconds()
sync(currentTime)
if (currentTime > 0) {
sync(shouldSyncServer, currentTime) {
Log.d(tag, "Sync complete")
}
}
}
}
}
}
fun stop() {
fun stop(shouldSync:Boolean? = true, cb: () -> Unit) {
if (!listeningTimerRunning) return
listeningTimerTask?.cancel()
listeningTimerTask = null
listeningTimerRunning = false
Log.d(tag, "stop: Stopping listening for $currentDisplayTitle")
val currentTime = playerNotificationService.getCurrentTimeSeconds()
sync(currentTime)
reset()
val currentTime = if (shouldSync == true) playerNotificationService.getCurrentTimeSeconds() else 0.0
if (currentTime > 0) { // Current time should always be > 0 on stop
sync(true, currentTime) {
reset()
cb()
}
} else {
reset()
cb()
}
}
fun sync(currentTime:Double) {
val diffSinceLastSync = System.currentTimeMillis() - lastSyncTime
if (diffSinceLastSync < 1000L) {
fun pause(cb: () -> Unit) {
if (!listeningTimerRunning) return
listeningTimerTask?.cancel()
listeningTimerTask = null
listeningTimerRunning = false
Log.d(tag, "pause: Pausing progress syncer for $currentDisplayTitle")
Log.d(tag, "pause: Last sync time $lastSyncTime")
val currentTime = playerNotificationService.getCurrentTimeSeconds()
if (currentTime > 0) { // Current time should always be > 0 on pause
sync(true, currentTime) {
lastSyncTime = 0L
Log.d(tag, "pause: Set last sync time 0 $lastSyncTime")
failedSyncs = 0
cb()
}
} else {
lastSyncTime = 0L
Log.d(tag, "pause: Set last sync time 0 $lastSyncTime (current time < 0)")
failedSyncs = 0
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) {
currentPlaybackSession?.let {
it.updatedAt = mediaProgress.lastUpdate
it.currentTime = mediaProgress.currentTime
DeviceManager.dbManager.saveLocalPlaybackSession(it)
saveLocalProgress(it)
}
}
fun sync(shouldSyncServer:Boolean, currentTime:Double, cb: () -> Unit) {
if (lastSyncTime <= 0) {
Log.e(tag, "Last sync time is not set $lastSyncTime")
return
}
val diffSinceLastSync = System.currentTimeMillis() - lastSyncTime
if (diffSinceLastSync < 1000L) {
return cb()
}
val listeningTimeToAdd = diffSinceLastSync / 1000L
lastSyncTime = System.currentTimeMillis()
val syncData = MediaProgressSyncData(listeningTimeToAdd,currentPlaybackDuration,currentTime)
currentPlaybackSession?.syncData(syncData)
if (currentPlaybackSession?.progress?.isNaN() == true) {
Log.e(tag, "Current Playback Session invalid progress ${currentPlaybackSession?.progress} | Current Time: ${currentPlaybackSession?.currentTime} | Duration: ${currentPlaybackSession?.getTotalDuration()}")
return cb()
}
if (currentIsLocal) {
// Save local progress sync
currentPlaybackSession?.let {
DeviceManager.dbManager.saveLocalPlaybackSession(it)
saveLocalProgress(it)
lastSyncTime = System.currentTimeMillis()
// Local library item is linked to a server library item
if (!it.libraryItemId.isNullOrEmpty()) {
// Send sync to server also if connected to this server and local item belongs to this server
if (it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
apiHandler.sendLocalProgressSync(it) {
Log.d(
tag,
"Local progress sync data sent to server $currentDisplayTitle for time $currentTime"
)
// Send sync to server also if connected to this server and local item belongs to this server
if (shouldSyncServer && !it.libraryItemId.isNullOrEmpty() && it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
apiHandler.sendLocalProgressSync(it) { syncSuccess ->
Log.d(
tag,
"Local progress sync data sent to server $currentDisplayTitle for time $currentTime"
)
if (syncSuccess) {
failedSyncs = 0
playerNotificationService.alertSyncSuccess()
} else {
failedSyncs++
if (failedSyncs == 2) {
playerNotificationService.alertSyncFailing() // Show alert in client
failedSyncs = 0
}
Log.e(tag, "Local Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime")
}
cb()
}
} else {
cb()
}
}
} else {
} else if (shouldSyncServer) {
apiHandler.sendProgressSync(currentSessionId, syncData) {
Log.d(tag, "Progress sync data sent to server $currentDisplayTitle for time $currentTime")
if (it) {
Log.d(tag, "Progress sync data sent to server $currentDisplayTitle for time $currentTime")
failedSyncs = 0
playerNotificationService.alertSyncSuccess()
lastSyncTime = System.currentTimeMillis()
} else {
failedSyncs++
if (failedSyncs == 2) {
playerNotificationService.alertSyncFailing() // Show alert in client
failedSyncs = 0
}
Log.e(tag, "Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime")
}
cb()
}
} else {
cb()
}
}
@@ -117,18 +227,22 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
currentLocalMediaProgress?.updateFromPlaybackSession(playbackSession)
}
currentLocalMediaProgress?.let {
DeviceManager.dbManager.saveLocalMediaProgress(it)
playerNotificationService.clientEventEmitter?.onLocalMediaProgressUpdate(it)
Log.d(tag, "Saved Local Progress Current Time: ID ${it.id} | ${it.currentTime} | Duration ${it.duration} | Progress ${(it.progress * 100).roundToInt()}%")
if (it.progress.isNaN()) {
Log.e(tag, "Invalid progress on local media progress")
} else {
DeviceManager.dbManager.saveLocalMediaProgress(it)
playerNotificationService.clientEventEmitter?.onLocalMediaProgressUpdate(it)
Log.d(tag, "Saved Local Progress Current Time: ID ${it.id} | ${it.currentTime} | Duration ${it.duration} | Progress ${it.progressPercent}%")
}
}
}
fun reset() {
listeningTimerTask?.cancel()
listeningTimerTask = null
listeningTimerRunning = false
currentPlaybackSession = null
currentLocalMediaProgress = null
lastSyncTime = 0L
Log.d(tag, "reset: Set last sync time 0 $lastSyncTime")
failedSyncs = 0
}
}
@@ -9,12 +9,8 @@ import android.os.Message
import android.support.v4.media.session.MediaSessionCompat
import android.util.Log
import android.view.KeyEvent
import com.audiobookshelf.app.data.LibraryItem
import com.audiobookshelf.app.data.LibraryItemWrapper
import com.audiobookshelf.app.data.PodcastEpisode
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.util.*
import kotlin.concurrent.schedule
@@ -23,15 +19,18 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
private var mediaButtonClickCount: Int = 0
var mediaButtonClickTimeout: Long = 1000 //ms
var seekAmount: Long = 20000 //ms
override fun onPrepare() {
Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT")
playerNotificationService.mediaManager.getFirstItem()?.let { li ->
playerNotificationService.mediaManager.play(li, null, playerNotificationService.getMediaPlayer()) {
Log.d(tag, "About to prepare player with ${it.displayTitle}")
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it,true,null)
playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) {
if (it == null) {
Log.e(tag, "Failed to play library item")
} else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it,true, playbackRate)
}
}
}
}
@@ -50,10 +49,14 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
override fun onPlayFromSearch(query: String?, extras: Bundle?) {
Log.d(tag, "ON PLAY FROM SEARCH $query")
playerNotificationService.mediaManager.getFromSearch(query)?.let { li ->
playerNotificationService.mediaManager.play(li, null, playerNotificationService.getMediaPlayer()) {
Log.d(tag, "About to prepare player with ${it.displayTitle}")
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it,true,null)
playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) {
if (it == null) {
Log.e(tag, "Failed to play library item")
} else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it, true, playbackRate)
}
}
}
}
@@ -69,19 +72,19 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
}
override fun onSkipToPrevious() {
playerNotificationService.seekBackward(seekAmount)
playerNotificationService.skipToPrevious()
}
override fun onSkipToNext() {
playerNotificationService.seekForward(seekAmount)
playerNotificationService.skipToNext()
}
override fun onFastForward() {
playerNotificationService.seekForward(seekAmount)
playerNotificationService.jumpForward()
}
override fun onRewind() {
playerNotificationService.seekForward(seekAmount)
playerNotificationService.jumpBackward()
}
override fun onSeekTo(pos: Long) {
@@ -101,13 +104,21 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
podcastEpisode = libraryItemWithEpisode?.episode
} else {
libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId)
if (libraryItemWrapper == null) {
Log.e(tag, "onPlayFromMediaId: Media item not found $mediaId")
}
}
libraryItemWrapper?.let { li ->
playerNotificationService.mediaManager.play(li, podcastEpisode, playerNotificationService.getMediaPlayer()) {
Log.d(tag, "About to prepare player with ${it.displayTitle}")
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it,true,null)
playerNotificationService.mediaManager.play(li, podcastEpisode, playerNotificationService.getPlayItemRequestPayload(false)) {
if (it == null) {
Log.e(tag, "Failed to play library item")
} else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it, true, playbackRate)
}
}
}
}
@@ -118,11 +129,31 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
}
fun handleCallMediaButton(intent: Intent): Boolean {
Log.w(tag, "handleCallMediaButton $intent | ${intent.action}")
if(Intent.ACTION_MEDIA_BUTTON == intent.action) {
var keyEvent = intent.getParcelableExtra<KeyEvent>(Intent.EXTRA_KEY_EVENT)
if (keyEvent?.getAction() == KeyEvent.ACTION_UP) {
when (keyEvent?.getKeyCode()) {
val keyEvent = intent.getParcelableExtra<KeyEvent>(Intent.EXTRA_KEY_EVENT)
Log.d(tag, "handleCallMediaButton keyEvent = $keyEvent | action ${keyEvent?.action}")
if (keyEvent?.action == KeyEvent.ACTION_DOWN) {
Log.d(tag, "handleCallMediaButton: key action_down for ${keyEvent.keyCode}")
when (keyEvent.keyCode) {
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
Log.d(tag, "handleCallMediaButton: Media Play/Pause")
if (playerNotificationService.mPlayer.isPlaying) {
playerNotificationService.pause()
} else {
playerNotificationService.play()
}
}
}
}
if (keyEvent?.action == KeyEvent.ACTION_UP) {
Log.d(tag, "handleCallMediaButton: key action_up for ${keyEvent.keyCode}")
when (keyEvent.keyCode) {
KeyEvent.KEYCODE_HEADSETHOOK -> {
Log.d(tag, "handleCallMediaButton: Headset Hook")
if (0 == mediaButtonClickCount) {
if (playerNotificationService.mPlayer.isPlaying)
playerNotificationService.pause()
@@ -132,6 +163,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
handleMediaButtonClickCount()
}
KeyEvent.KEYCODE_MEDIA_PLAY -> {
Log.d(tag, "handleCallMediaButton: Media Play")
if (0 == mediaButtonClickCount) {
playerNotificationService.play()
playerNotificationService.sleepTimerManager.checkShouldExtendSleepTimer()
@@ -139,19 +171,27 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
handleMediaButtonClickCount()
}
KeyEvent.KEYCODE_MEDIA_PAUSE -> {
Log.d(tag, "handleCallMediaButton: Media Pause")
if (0 == mediaButtonClickCount) playerNotificationService.pause()
handleMediaButtonClickCount()
}
KeyEvent.KEYCODE_MEDIA_NEXT -> {
playerNotificationService.seekForward(seekAmount)
playerNotificationService.jumpForward()
}
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 -> {
playerNotificationService.closePlayback()
}
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
Log.d(tag, "handleCallMediaButton: Media Play/Pause")
if (playerNotificationService.mPlayer.isPlaying) {
if (0 == mediaButtonClickCount) playerNotificationService.pause()
handleMediaButtonClickCount()
@@ -164,7 +204,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
}
}
else -> {
Log.d(tag, "KeyCode:${keyEvent.getKeyCode()}")
Log.d(tag, "KeyCode:${keyEvent.keyCode}")
return false
}
}
@@ -173,7 +213,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
return true
}
fun handleMediaButtonClickCount() {
private fun handleMediaButtonClickCount() {
mediaButtonClickCount++
if (1 == mediaButtonClickCount) {
Timer().schedule(mediaButtonClickTimeout) {
@@ -189,14 +229,24 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
override fun handleMessage(msg: Message) {
super.handleMessage(msg)
if (2 == msg.what) {
playerNotificationService.seekBackward(seekAmount)
playerNotificationService.jumpBackward()
playerNotificationService.play()
}
else if (msg.what >= 3) {
playerNotificationService.seekForward(seekAmount)
playerNotificationService.jumpForward()
playerNotificationService.play()
}
}
}
override fun onCustomAction(action: String?, extras: Bundle?) {
super.onCustomAction(action, extras)
when (action) {
CUSTOM_ACTION_JUMP_FORWARD -> onFastForward()
CUSTOM_ACTION_JUMP_BACKWARD -> onRewind()
CUSTOM_ACTION_SKIP_FORWARD -> onSkipToNext()
CUSTOM_ACTION_SKIP_BACKWARD -> onSkipToPrevious()
}
}
}
@@ -30,9 +30,14 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
override fun onPrepare(playWhenReady: Boolean) {
Log.d(tag, "ON PREPARE $playWhenReady")
playerNotificationService.mediaManager.getFirstItem()?.let { li ->
playerNotificationService.mediaManager.play(li, null, playerNotificationService.getMediaPlayer()) {
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it,playWhenReady,null)
playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) {
if (it == null) {
Log.e(tag, "Failed to play library item")
} else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
}
}
}
}
@@ -53,10 +58,14 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
}
libraryItemWrapper?.let { li ->
playerNotificationService.mediaManager.play(li, podcastEpisode, playerNotificationService.getMediaPlayer()) {
Log.d(tag, "About to prepare player with ${it.displayTitle}")
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it,playWhenReady,null)
playerNotificationService.mediaManager.play(li, podcastEpisode, playerNotificationService.getPlayItemRequestPayload(false)) {
if (it == null) {
Log.e(tag, "Failed to play library item")
} else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
}
}
}
}
@@ -65,10 +74,14 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) {
Log.d(tag, "ON PREPARE FROM SEARCH $query")
playerNotificationService.mediaManager.getFromSearch(query)?.let { li ->
playerNotificationService.mediaManager.play(li, null, playerNotificationService.getMediaPlayer()) {
Log.d(tag, "About to prepare player with ${it.displayTitle}")
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it,playWhenReady,null)
playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) {
if (it == null) {
Log.e(tag, "Failed to play library item")
} else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
}
}
}
}
@@ -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";
@@ -2,9 +2,12 @@ package com.audiobookshelf.app.player
import android.util.Log
import com.audiobookshelf.app.data.PlayerState
import com.audiobookshelf.app.device.DeviceManager
import com.google.android.exoplayer2.PlaybackException
import com.google.android.exoplayer2.Player
const val PAUSE_LEN_BEFORE_RECHECK = 30000 // 30 seconds
class PlayerListener(var playerNotificationService:PlayerNotificationService) : Player.Listener {
var tag = "PlayerListener"
@@ -21,7 +24,7 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
}
override fun onEvents(player: Player, events: Player.Events) {
Log.d(tag, "onEvents ${player.deviceInfo} | ${playerNotificationService.getMediaPlayer()} | ${events.size()}")
Log.d(tag, "onEvents ${playerNotificationService.getMediaPlayer()} | ${events.size()}")
if (events.contains(Player.EVENT_POSITION_DISCONTINUITY)) {
Log.d(tag, "EVENT_POSITION_DISCONTINUITY")
@@ -49,6 +52,8 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
if (playerNotificationService.currentPlayer.playbackState == Player.STATE_ENDED) {
Log.d(tag, "STATE_ENDED")
playerNotificationService.sendClientMetadata(PlayerState.ENDED)
playerNotificationService.handlePlaybackEnded()
}
if (playerNotificationService.currentPlayer.playbackState == Player.STATE_IDLE) {
Log.d(tag, "STATE_IDLE")
@@ -67,20 +72,37 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
if (player.isPlaying) {
Log.d(tag, "SeekBackTime: Player is playing")
if (lastPauseTime > 0) {
if (lastPauseTime > 0 && DeviceManager.deviceData.deviceSettings?.disableAutoRewind != true) {
var seekBackTime = 0L
if (onSeekBack) onSeekBack = false
else {
Log.d(tag, "SeekBackTime: playing started now set seek back time $lastPauseTime")
var backTime = calcPauseSeekBackTime()
if (backTime > 0) {
if (backTime >= playerNotificationService.getCurrentTime()) backTime = playerNotificationService.getCurrentTime() - 500
Log.d(tag, "SeekBackTime $backTime")
seekBackTime = calcPauseSeekBackTime()
if (seekBackTime > 0) {
// Current chapter is used so that seek back does not go back to the previous chapter
val currentChapter = playerNotificationService.getCurrentBookChapter()
val minSeekBackTime = currentChapter?.startMs ?: 0
val currentTime = playerNotificationService.getCurrentTime()
val newTime = currentTime - seekBackTime
if (newTime < minSeekBackTime) {
seekBackTime = currentTime - minSeekBackTime
}
Log.d(tag, "SeekBackTime $seekBackTime")
onSeekBack = true
playerNotificationService.seekBackward(backTime)
} else {
Log.d(tag, "SeekBackTime: back time is 0")
}
}
// Check if playback session still exists or sync media progress if updated
val pauseLength: Long = System.currentTimeMillis() - lastPauseTime
if (pauseLength > PAUSE_LEN_BEFORE_RECHECK) {
val shouldCarryOn = playerNotificationService.checkCurrentSessionProgress(seekBackTime)
if (!shouldCarryOn) return
}
if (seekBackTime > 0L) {
playerNotificationService.seekBackward(seekBackTime)
}
}
} else {
Log.d(tag, "SeekBackTime: Player not playing set last pause time")
@@ -88,24 +110,28 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
}
// Start/stop progress sync interval
Log.d(tag, "Playing ${playerNotificationService.getCurrentBookTitle()}")
if (player.isPlaying) {
player.volume = 1F // Volume on sleep timer might have decreased this
playerNotificationService.mediaProgressSyncer.start()
} else {
playerNotificationService.mediaProgressSyncer.stop()
playerNotificationService.mediaProgressSyncer.pause {
Log.d(tag, "Media Progress Syncer paused and synced")
}
}
playerNotificationService.clientEventEmitter?.onPlayingUpdate(player.isPlaying)
DeviceManager.widgetUpdater?.onPlayerChanged(playerNotificationService)
}
}
private fun calcPauseSeekBackTime() : Long {
if (lastPauseTime <= 0) return 0
var time: Long = System.currentTimeMillis() - lastPauseTime
var seekback: Long
if (time < 3000) seekback = 0
else if (time < 300000) seekback = 10000 // 3s to 5m = jump back 10s
val time: Long = System.currentTimeMillis() - lastPauseTime
val seekback: Long
if (time < 10000) seekback = 0 // 10s or less = no seekback
else if (time < 60000) seekback = 3000 // 10s to 1m = jump back 3s
else if (time < 300000) seekback = 10000 // 1m to 5m = jump back 10s
else if (time < 1800000) seekback = 20000 // 5m to 30m = jump back 20s
else seekback = 29500 // 30m and up = jump back 30s
return seekback
@@ -14,6 +14,7 @@ class PlayerNotificationListener(var playerNotificationService:PlayerNotificatio
// Start foreground service
Log.d(tag, "Notification Posted $notificationId - Start Foreground | $notification")
PlayerNotificationService.isClosed = false
playerNotificationService.startForeground(notificationId, notification)
}
@@ -26,6 +27,17 @@ class PlayerNotificationListener(var playerNotificationService:PlayerNotificatio
playerNotificationService.stopSelf()
} else {
Log.d(tag, "onNotificationCancelled not dismissed by user")
// When stop button is pressed on the notification I guess it isn't considered "dismissedByUser" so we need to close playback ourselves
if (!PlayerNotificationService.isClosed && !PlayerNotificationService.isSwitchingPlayer) {
Log.d(tag, "PNS is not closed - closing it now")
playerNotificationService.closePlayback()
} else if (PlayerNotificationService.isSwitchingPlayer) {
// When switching from cast player to exo player and vice versa the notification is cancelled and posted again
// so we don't want to cancel the playback during this switch
Log.d(tag, "PNS is switching player")
PlayerNotificationService.isSwitchingPlayer = false
}
}
}
}
@@ -3,27 +3,41 @@ package com.audiobookshelf.app.player
import android.app.*
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.ImageDecoder
import android.hardware.Sensor
import android.hardware.SensorManager
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.*
import android.provider.MediaStore
import android.support.v4.media.MediaBrowserCompat
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.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.media.MediaBrowserServiceCompat
import androidx.media.utils.MediaConstants
import com.audiobookshelf.app.BuildConfig
import com.audiobookshelf.app.R
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.data.DeviceInfo
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.media.MediaManager
import com.audiobookshelf.app.server.ApiHandler
import com.fasterxml.jackson.annotation.JsonIgnore
import com.google.android.exoplayer2.*
import com.google.android.exoplayer2.audio.AudioAttributes
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.CustomActionProvider
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator
import com.google.android.exoplayer2.source.MediaSource
import com.google.android.exoplayer2.source.ProgressiveMediaSource
@@ -33,12 +47,18 @@ import com.google.android.exoplayer2.upstream.*
import java.util.*
import kotlin.concurrent.schedule
const val SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m
const val PLAYER_CAST = "cast-player";
const val PLAYER_EXO = "exo-player";
class PlayerNotificationService : MediaBrowserServiceCompat() {
companion object {
var isStarted = false
var isClosed = false
var isUnmeteredNetwork = false
var isSwitchingPlayer = false // Used when switching between cast player and exoplayer
}
interface ClientEventEmitter {
@@ -46,12 +66,14 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
fun onPlaybackClosed()
fun onPlayingUpdate(isPlaying: Boolean)
fun onMetadata(metadata: PlaybackMetadata)
fun onPrepare(audiobookId: String, playWhenReady: Boolean)
fun onSleepTimerEnded(currentPosition: Long)
fun onSleepTimerSet(sleepTimeRemaining: Int)
fun onLocalMediaProgressUpdate(localMediaProgress: LocalMediaProgress)
fun onPlaybackFailed(errorMessage:String)
fun onMediaPlayerChanged(mediaPlayer:String)
fun onProgressSyncFailing()
fun onProgressSyncSuccess()
fun onNetworkMeteredChanged(isUnmetered:Boolean)
}
private val tag = "PlayerService"
@@ -62,11 +84,11 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
private lateinit var ctx:Context
private lateinit var mediaSessionConnector: MediaSessionConnector
private lateinit var playerNotificationManager: PlayerNotificationManager
private lateinit var mediaSession: MediaSessionCompat
lateinit var mediaSession: MediaSessionCompat
private lateinit var transportControls:MediaControllerCompat.TransportControls
lateinit var mediaManager: MediaManager
private lateinit var apiHandler: ApiHandler
lateinit var apiHandler: ApiHandler
lateinit var mPlayer: ExoPlayer
lateinit var currentPlayer:Player
@@ -110,11 +132,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
fun getService(): PlayerNotificationService = this@PlayerNotificationService
}
fun stopService(context: Context) {
val stopIntent = Intent(context, PlayerNotificationService::class.java)
context.stopService(stopIntent)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
isStarted = true
Log.d(tag, "onStartCommand $startId")
@@ -139,6 +156,14 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
// detach player
override fun onDestroy() {
try {
val connectivityManager = getSystemService(ConnectivityManager::class.java) as ConnectivityManager
connectivityManager.unregisterNetworkCallback(networkCallback)
} catch(error:Exception) {
Log.e(tag, "Error unregistering network listening callback $error")
}
Log.d(tag, "onDestroy")
playerNotificationManager.setPlayer(null)
mPlayer.release()
castPlayer?.release()
@@ -157,30 +182,21 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
stopSelf()
}
override fun onCreate() {
Log.d(tag, "onCreate")
super.onCreate()
ctx = this
// Initialize player
val customLoadControl:LoadControl = DefaultLoadControl.Builder().setBufferDurationsMs(
1000 * 20, // 20s min buffer
1000 * 45, // 45s max buffer
1000 * 5, // 5s playback start
1000 * 20 // 20s playback rebuffer
).build()
mPlayer = ExoPlayer.Builder(this)
.setLoadControl(customLoadControl)
.setSeekBackIncrementMs(10000)
.setSeekForwardIncrementMs(10000)
// To listen for network change from metered to unmetered
val networkRequest = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
.build()
mPlayer.setHandleAudioBecomingNoisy(true)
mPlayer.addListener(PlayerListener(this))
val audioAttributes:AudioAttributes = AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.CONTENT_TYPE_SPEECH).build()
mPlayer.setAudioAttributes(audioAttributes, true)
val connectivityManager = getSystemService(ConnectivityManager::class.java) as ConnectivityManager
connectivityManager.registerNetworkCallback(networkRequest, networkCallback)
currentPlayer = mPlayer
DbManager.initialize(ctx)
// Initialize API
apiHandler = ApiHandler(ctx)
@@ -211,6 +227,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
.apply {
setSessionActivity(sessionActivityPendingIntent)
isActive = true
setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS)
}
val mediaController = MediaControllerCompat(ctx, mediaSession.sessionToken)
@@ -232,10 +249,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
playerNotificationManager.setUseNextAction(false)
playerNotificationManager.setUsePreviousAction(false)
playerNotificationManager.setUseChronometer(false)
playerNotificationManager.setUseStopAction(true)
playerNotificationManager.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX)
playerNotificationManager.setUseFastForwardActionInCompactView(true)
playerNotificationManager.setUseRewindActionInCompactView(true)
playerNotificationManager.setSmallIcon(R.drawable.exo_icon_localaudio)
// Unknown action
playerNotificationManager.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE)
@@ -244,22 +263,54 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
mediaSessionConnector = MediaSessionConnector(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 {
if (currentPlaybackSession == null) {
Log.e(tag,"Playback session is not set - returning blank MediaDescriptionCompat")
return MediaDescriptionCompat.Builder().build()
}
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
// https://stackoverflow.com/questions/64186578/android-11-mediastyle-notification-crash/64232958#64232958
ctx.grantUriPermission(
"com.android.systemui",
coverUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
try {
ctx.grantUriPermission(
"com.android.systemui",
coverUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
} catch(error:Exception) {
Log.e(tag, "Grant uri permission error $error")
}
return MediaDescriptionCompat.Builder()
.setMediaId(currentPlaybackSession!!.id)
val extra = Bundle()
extra.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, currentPlaybackSession!!.displayAuthor)
val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
.setExtras(extra)
.setTitle(currentPlaybackSession!!.displayTitle)
.setSubtitle(currentPlaybackSession!!.displayAuthor)
.setIconUri(coverUri).build()
.setIconUri(coverUri)
bitmap?.let {
mediaDescriptionBuilder.setIconBitmap(it)
}
return mediaDescriptionBuilder.build()
}
}
@@ -274,30 +325,85 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
)
mediaSessionConnector.setQueueNavigator(queueNavigator)
mediaSessionConnector.setPlaybackPreparer(MediaSessionPlaybackPreparer(this))
mediaSessionConnector.setPlayer(mPlayer)
mediaSession.setCallback(MediaSessionCallback(this))
initializeMPlayer()
currentPlayer = mPlayer
}
private fun initializeMPlayer() {
val customLoadControl:LoadControl = DefaultLoadControl.Builder().setBufferDurationsMs(
1000 * 20, // 20s min buffer
1000 * 45, // 45s max buffer
1000 * 5, // 5s playback start
1000 * 20 // 20s playback rebuffer
).build()
mPlayer = ExoPlayer.Builder(this)
.setLoadControl(customLoadControl)
.setSeekBackIncrementMs(deviceSettings.jumpBackwardsTimeMs)
.setSeekForwardIncrementMs(deviceSettings.jumpForwardTimeMs)
.build()
mPlayer.setHandleAudioBecomingNoisy(true)
mPlayer.addListener(PlayerListener(this))
val audioAttributes:AudioAttributes = AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.CONTENT_TYPE_SPEECH).build()
mPlayer.setAudioAttributes(audioAttributes, true)
//attach player to playerNotificationManager
playerNotificationManager.setPlayer(mPlayer)
mediaSession.setCallback(MediaSessionCallback(this))
mediaSessionConnector.setPlayer(mPlayer)
}
/*
User callable methods
*/
fun preparePlayer(playbackSession: PlaybackSession, playWhenReady:Boolean, playbackRate:Float?) {
if (!isStarted) {
Log.i(tag, "preparePlayer: foreground service not started - Starting service --")
Intent(ctx, PlayerNotificationService::class.java).also { intent ->
ContextCompat.startForegroundService(ctx, intent)
}
}
// 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
val customActionProviders = mutableListOf(
JumpBackwardCustomActionProvider(),
JumpForwardCustomActionProvider(),
)
val metadata = playbackSession.getMediaMetadataCompat(ctx)
mediaSession.setMetadata(metadata)
val mediaItems = playbackSession.getMediaItems()
val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f
initialPlaybackRate = playbackRate
if (playbackSession.mediaPlayer != PLAYER_CAST && mediaItems.size > 1) {
customActionProviders.addAll(listOf(
SkipBackwardCustomActionProvider(),
SkipForwardCustomActionProvider(),
))
}
mediaSessionConnector.setCustomActionProviders(*customActionProviders.toTypedArray());
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")
currentPlaybackSession = null
switchToPlayer(false)
playbackSession.mediaPlayer = getMediaPlayer()
}
if (playbackSession.mediaPlayer == "cast-player") {
if (playbackSession.mediaPlayer == PLAYER_CAST) {
// If cast-player is the first player to be used
mediaSessionConnector.setPlayer(castPlayer)
playerNotificationManager.setPlayer(castPlayer)
@@ -308,10 +414,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
clientEventEmitter?.onPlaybackSession(playbackSession)
val metadata = playbackSession.getMediaMetadataCompat()
mediaSession.setMetadata(metadata)
val mediaItems = playbackSession.getMediaItems()
if (mediaItems.isEmpty()) {
Log.e(tag, "Invalid playback session no media items to play")
currentPlaybackSession = null
@@ -358,7 +460,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
currentPlayer.setPlaybackSpeed(playbackRateToUse)
currentPlayer.prepare()
} else if (castPlayer != null) {
val currentTrackIndex = playbackSession.getCurrentTrackIndex()
val currentTrackTime = playbackSession.getCurrentTrackTimeMs()
@@ -373,19 +474,73 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
// On error and was attempting to direct play - fallback to transcode
currentPlaybackSession?.let { playbackSession ->
if (playbackSession.isDirectPlay) {
val mediaPlayer = getMediaPlayer()
Log.d(tag, "Fallback to transcode $mediaPlayer")
val playItemRequestPayload = getPlayItemRequestPayload(true)
Log.d(tag, "Fallback to transcode $playItemRequestPayload.mediaPlayer")
val libraryItemId = playbackSession.libraryItemId ?: "" // Must be true since direct play
val episodeId = playbackSession.episodeId
apiHandler.playLibraryItem(libraryItemId, episodeId, true, mediaPlayer) {
Handler(Looper.getMainLooper()).post {
preparePlayer(it, true, null)
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
if (it == null) { // Play request failed
clientEventEmitter?.onPlaybackFailed(errorMessage)
closePlayback(true)
} else {
Handler(Looper.getMainLooper()).post {
preparePlayer(it, true, null)
}
}
}
} else {
clientEventEmitter?.onPlaybackFailed(errorMessage)
closePlayback()
closePlayback(true)
}
}
}
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() {
currentPlaybackSession?.let { playbackSession ->
val forceTranscode = playbackSession.isHLS // If already HLS then force
val playItemRequestPayload = getPlayItemRequestPayload(forceTranscode)
val libraryItemId = playbackSession.libraryItemId ?: "" // Must be true since direct play
val episodeId = playbackSession.episodeId
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
if (it == null) {
Log.e(tag, "Failed to start new playback session")
} else {
Handler(Looper.getMainLooper()).post {
preparePlayer(it, true, null)
}
}
}
}
}
@@ -410,6 +565,18 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
}
}
if (currentPlaybackSession == null) {
Log.e(tag, "switchToPlayer: No Current playback session")
} else {
isSwitchingPlayer = true
}
// Playback session in progress syncer is a copy that is up-to-date so replace current here with that
// TODO: bad design here implemented to prevent the session in MediaProgressSyncer from changing while syncing
if (mediaProgressSyncer.currentPlaybackSession != null) {
currentPlaybackSession = mediaProgressSyncer.currentPlaybackSession?.clone()
}
currentPlayer = if (useCastPlayer) {
Log.d(tag, "switchToPlayer: Using Cast Player " + castPlayer?.deviceInfo)
mediaSessionConnector.setPlayer(castPlayer)
@@ -424,15 +591,13 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
clientEventEmitter?.onMediaPlayerChanged(getMediaPlayer())
if (currentPlaybackSession == null) {
Log.d(tag, "switchToPlayer: No Current playback session")
}
currentPlaybackSession?.let {
Log.d(tag, "switchToPlayer: Preparing current playback session ${it.displayTitle}")
Log.d(tag, "switchToPlayer: Starting new playback session ${it.displayTitle}")
if (wasPlaying) { // media is paused when switching players
clientEventEmitter?.onPlayingUpdate(false)
}
// TODO: Start a new playback session here instead of using the existing
preparePlayer(it, false, null)
}
}
@@ -469,10 +634,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
return currentPlaybackSession?.totalDurationMs ?: 0L
}
fun getCurrentBookTitle() : String? {
return currentPlaybackSession?.displayTitle
}
fun getCurrentPlaybackSessionCopy() :PlaybackSession? {
return currentPlaybackSession?.clone()
}
@@ -481,16 +642,102 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
return currentPlaybackSession?.id
}
fun getCurrentBookChapter():BookChapter? {
return currentPlaybackSession?.getChapterForTime(this.getCurrentTime())
}
// Called from PlayerListener play event
// check with server if progress has updated since last play and sync progress update
fun checkCurrentSessionProgress(seekBackTime:Long):Boolean {
if (currentPlaybackSession == null) return true
mediaProgressSyncer.currentPlaybackSession?.let { playbackSession ->
if (!apiHandler.isOnline() || playbackSession.isLocalLibraryItemOnly) {
return true // carry on
}
if (playbackSession.isLocal) {
// Make sure this connection config exists
val serverConnectionConfig = DeviceManager.getServerConnectionConfig(playbackSession.serverConnectionConfigId)
if (serverConnectionConfig == null) {
Log.d(tag, "checkCurrentSessionProgress: Local library item server connection config is not saved ${playbackSession.serverConnectionConfigId}")
return true // carry on
}
// Local playback session check if server has updated media progress
Log.d(tag, "checkCurrentSessionProgress: Checking if local media progress was updated on server")
apiHandler.getMediaProgress(playbackSession.libraryItemId!!, playbackSession.episodeId, serverConnectionConfig) { mediaProgress ->
if (mediaProgress != null && mediaProgress.lastUpdate > playbackSession.updatedAt && mediaProgress.currentTime != playbackSession.currentTime) {
Log.d(tag, "checkCurrentSessionProgress: Media progress was updated since last play time updating from ${playbackSession.currentTime} to ${mediaProgress.currentTime}")
mediaProgressSyncer.syncFromServerProgress(mediaProgress)
// Update current playback session stored in PNS since MediaProgressSyncer version is a copy
mediaProgressSyncer.currentPlaybackSession?.let { updatedPlaybackSession ->
currentPlaybackSession = updatedPlaybackSession
}
Handler(Looper.getMainLooper()).post {
seekPlayer(playbackSession.currentTimeMs)
// Should already be playing
currentPlayer.volume = 1F // Volume on sleep timer might have decreased this
mediaProgressSyncer.start()
clientEventEmitter?.onPlayingUpdate(true)
}
} else {
Handler(Looper.getMainLooper()).post {
if (seekBackTime > 0L) {
seekBackward(seekBackTime)
}
// Should already be playing
currentPlayer.volume = 1F // Volume on sleep timer might have decreased this
mediaProgressSyncer.start()
clientEventEmitter?.onPlayingUpdate(true)
}
}
}
} else {
// Streaming from server so check if playback session still exists on server
Log.d(
tag,
"checkCurrentSessionProgress: Checking if playback session ${playbackSession.id} for server stream is still available"
)
apiHandler.getPlaybackSession(playbackSession.id) {
if (it == null) {
Log.d(
tag,
"checkCurrentSessionProgress: Playback session does not exist on server - start new playback session"
)
Handler(Looper.getMainLooper()).post {
currentPlayer.pause()
startNewPlaybackSession()
}
} else {
Log.d(tag, "checkCurrentSessionProgress: Playback session still available on server")
Handler(Looper.getMainLooper()).post {
if (seekBackTime > 0L) {
seekBackward(seekBackTime)
}
currentPlayer.volume = 1F // Volume on sleep timer might have decreased this
mediaProgressSyncer.start()
clientEventEmitter?.onPlayingUpdate(true)
}
}
}
}
}
return false
}
fun play() {
if (currentPlayer.isPlaying) {
Log.d(tag, "Already playing")
return
}
currentPlayer.volume = 1F
if (currentPlayer == castPlayer) {
Log.d(tag, "CAST Player set on play ${currentPlayer.isLoading} || ${currentPlayer.duration} | ${currentPlayer.currentPosition}")
}
currentPlayer.play()
}
@@ -509,17 +756,42 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
}
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) {
currentPlaybackSession?.currentTime = time / 1000.0
currentPlaybackSession?.currentTime = timeToSeek / 1000.0
val newWindowIndex = currentPlaybackSession?.getCurrentTrackIndex() ?: 0
val newTimeOffset = currentPlaybackSession?.getCurrentTrackTimeMs() ?: 0
currentPlayer.seekTo(newWindowIndex, newTimeOffset)
} 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) {
seekPlayer(getCurrentTime() + amount)
}
@@ -532,12 +804,28 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
currentPlayer.setPlaybackSpeed(speed)
}
fun closePlayback() {
currentPlayer.clearMediaItems()
currentPlayer.stop()
fun closePlayback(calledOnError:Boolean? = false) {
Log.d(tag, "closePlayback")
if (mediaProgressSyncer.listeningTimerRunning) {
Log.i(tag, "About to close playback so stopping media progress syncer first")
mediaProgressSyncer.stop(calledOnError == false) { // If closing on error then do not sync progress (causes exception)
Log.d(tag, "Media Progress syncer stopped")
}
}
try {
currentPlayer.stop()
currentPlayer.clearMediaItems()
} catch(e:Exception) {
Log.e(tag, "Exception clearing exoplayer $e")
}
currentPlaybackSession = null
clientEventEmitter?.onPlaybackClosed()
PlayerListener.lastPauseTime = 0
isClosed = true
stopForeground(true)
stopSelf()
}
fun sendClientMetadata(playerState: PlayerState) {
@@ -546,13 +834,39 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
}
fun getMediaPlayer():String {
return if(currentPlayer == castPlayer) "cast-player" else "exo-player"
return if(currentPlayer == castPlayer) PLAYER_CAST else PLAYER_EXO
}
fun getDeviceInfo(): DeviceInfo {
/* EXAMPLE
manufacturer: Google
model: Pixel 6
brand: google
sdkVersion: 32
appVersion: 0.9.46-beta
*/
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 {
return PlayItemRequestPayload(getMediaPlayer(), !forceTranscode, forceTranscode, getDeviceInfo())
}
fun getContext():Context {
return ctx
}
fun alertSyncFailing() {
clientEventEmitter?.onProgressSyncFailing()
}
fun alertSyncSuccess() {
clientEventEmitter?.onProgressSyncSuccess()
}
//
// MEDIA BROWSER STUFF (ANDROID AUTO)
//
@@ -561,11 +875,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
private val ANDROID_WEARABLE_PKG_NAME = "com.google.android.wearable.app"
private val ANDROID_GSEARCH_PKG_NAME = "com.google.android.googlequicksearchbox"
private val ANDROID_AUTOMOTIVE_PKG_NAME = "com.google.android.carassistant"
private val VALID_MEDIA_BROWSERS = mutableListOf<String>(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 ALL_ROOT = "__ALL__"
private val LIBRARIES_ROOT = "__LIBRARIES__"
private val DOWNLOADS_ROOT = "__DOWNLOADS__"
private val CONTINUE_ROOT = "__CONTINUE__"
private lateinit var browseTree:BrowseTree
@@ -586,11 +901,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
// No further calls will be made to other media browsing methods.
null
} else {
if (!isStarted) {
Log.d(tag, "AA Not yet started")
mediaManager.initializeAndroidAuto()
isStarted = true
}
isStarted = true
mediaManager.checkResetServerItems() // Reset any server items if no longer connected to server
isAndroidAuto = true
@@ -611,56 +923,78 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
override fun onLoadChildren(parentMediaId: String, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
Log.d(tag, "ON LOAD CHILDREN $parentMediaId")
var flag = if (parentMediaId == AUTO_MEDIA_ROOT || parentMediaId == LIBRARIES_ROOT) MediaBrowserCompat.MediaItem.FLAG_BROWSABLE else MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
result.detach()
if (parentMediaId.startsWith("li_") || parentMediaId.startsWith("local_")) { // Show podcast episodes
Log.d(tag, "Loading podcast episodes")
mediaManager.loadPodcastEpisodeMediaBrowserItems(parentMediaId) {
mediaManager.loadPodcastEpisodeMediaBrowserItems(parentMediaId, ctx) {
result.sendResult(it)
}
} else if (::browseTree.isInitialized && browseTree[parentMediaId] == null && mediaManager.getIsLibrary(parentMediaId)) { // Load library items for library
mediaManager.loadLibraryItemsWithAudio(parentMediaId) { libraryItems ->
val children = libraryItems.map { libraryItem ->
val libraryItemMediaMetadata = libraryItem.getMediaMetadata()
if (libraryItem.mediaType == "podcast") { // Podcasts are browseable
flag = MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
val mediaDescription = libraryItem.getMediaDescription(null, ctx)
MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
} else {
val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.id }
val description = libraryItem.getMediaDescription(progress, ctx)
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
}
MediaBrowserCompat.MediaItem(libraryItemMediaMetadata.description, flag)
}
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 localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast")
val localBrowseItems:MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
localBooks.forEach { localLibraryItem ->
val mediaMetadata = localLibraryItem.getMediaMetadata(ctx)
localBrowseItems += MediaBrowserCompat.MediaItem(mediaMetadata.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
val progress = DeviceManager.dbManager.getLocalMediaProgress(localLibraryItem.id)
val description = localLibraryItem.getMediaDescription(progress, ctx)
localBrowseItems += MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
}
localPodcasts.forEach { localLibraryItem ->
val mediaMetadata = localLibraryItem.getMediaMetadata(ctx)
localBrowseItems += MediaBrowserCompat.MediaItem(mediaMetadata.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
val mediaDescription = localLibraryItem.getMediaDescription(null, ctx)
localBrowseItems += MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
}
result.sendResult(localBrowseItems)
} else { // Load categories
mediaManager.loadAndroidAutoItems() { libraryCategories ->
browseTree = BrowseTree(this, libraryCategories, mediaManager.serverLibraries)
} else if (parentMediaId == CONTINUE_ROOT) {
val localBrowseItems:MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
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 ->
Log.d(tag, "Loading Browser Media Item ${item.description.title} $flag")
MediaBrowserCompat.MediaItem(item.description, flag)
Log.d(tag, "Loading Browser Media Item ${item.description.title}")
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
}
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
}
@@ -669,10 +1003,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
override fun onSearch(query: String, extras: Bundle?, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
result.detach()
mediaManager.loadAndroidAutoItems() { libraryCategories ->
browseTree = BrowseTree(this, libraryCategories, mediaManager.serverLibraries)
val children = browseTree[ALL_ROOT]?.map { item ->
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
mediaManager.loadAndroidAutoItems {
browseTree = BrowseTree(this, mediaManager.serverItemsInProgress, mediaManager.serverLibraries)
val children = browseTree[LIBRARIES_ROOT]?.map { item ->
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
}
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
}
@@ -725,5 +1059,87 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
}
}
}
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
// Network capabilities have changed for the network
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
super.onCapabilitiesChanged(network, networkCapabilities)
val unmetered = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
Log.i(tag, "Network capabilities changed is unmetered = $unmetered")
isUnmeteredNetwork = 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()
}
}
}
@@ -3,6 +3,7 @@ package com.audiobookshelf.app.player
import android.content.Context
import android.os.*
import android.util.Log
import com.audiobookshelf.app.device.DeviceManager
import java.util.*
import kotlin.concurrent.schedule
import kotlin.math.roundToInt
@@ -203,8 +204,13 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
}
fun handleShake() {
Log.d(tag, "HANDLE SHAKE HERE")
if (sleepTimerRunning || sleepTimerFinishedAt > 0L) checkShouldExtendSleepTimer()
if (sleepTimerRunning || sleepTimerFinishedAt > 0L) {
if (DeviceManager.deviceData.deviceSettings?.disableShakeToResetSleepTimer == true) {
Log.d(tag, "Shake to reset sleep timer is disabled")
return
}
checkShouldExtendSleepTimer()
}
}
fun increaseSleepTime(time: Long) {
@@ -1,10 +1,8 @@
package com.audiobookshelf.app.plugins
import android.content.Intent
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.core.content.ContextCompat
import com.audiobookshelf.app.MainActivity
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.device.DeviceManager
@@ -59,13 +57,6 @@ class AbsAudioPlayer : Plugin() {
notifyListeners("onMetadata", JSObject(jacksonMapper.writeValueAsString(metadata)))
}
override fun onPrepare(audiobookId: String, playWhenReady: Boolean) {
val jsobj = JSObject()
jsobj.put("audiobookId", audiobookId)
jsobj.put("playWhenReady", playWhenReady)
notifyListeners("onPrepareMedia", jsobj)
}
override fun onSleepTimerEnded(currentPosition: Long) {
emit("onSleepTimerEnded", currentPosition)
}
@@ -85,6 +76,18 @@ class AbsAudioPlayer : Plugin() {
override fun onMediaPlayerChanged(mediaPlayer:String) {
emit("onMediaPlayerChanged", mediaPlayer)
}
override fun onProgressSyncFailing() {
emit("onProgressSyncFailing", "")
}
override fun onProgressSyncSuccess() {
emit("onProgressSyncSuccess", "")
}
override fun onNetworkMeteredChanged(isUnmetered:Boolean) {
emit("onNetworkMeteredChanged", isUnmetered)
}
})
}
mainActivity.pluginCallback = foregroundServiceReady
@@ -153,14 +156,6 @@ class AbsAudioPlayer : Plugin() {
@PluginMethod
fun prepareLibraryItem(call: PluginCall) {
// Need to make sure the player service has been started
if (!PlayerNotificationService.isStarted) {
Log.w(tag, "prepareLibraryItem: PlayerService not started - Starting foreground service --")
Intent(mainActivity, PlayerNotificationService::class.java).also { intent ->
ContextCompat.startForegroundService(mainActivity, intent)
}
}
val libraryItemId = call.getString("libraryItemId", "").toString()
val episodeId = call.getString("episodeId", "").toString()
val playWhenReady = call.getBoolean("playWhenReady") == true
@@ -168,7 +163,7 @@ class AbsAudioPlayer : Plugin() {
if (libraryItemId.isEmpty()) {
Log.e(tag, "Invalid call to play library item no library item id")
return call.resolve()
return call.resolve(JSObject("{\"error\":\"Invalid request\"}"))
}
if (libraryItemId.startsWith("local")) { // Play local media item
@@ -179,28 +174,56 @@ class AbsAudioPlayer : Plugin() {
episode = podcastMedia.episodes?.find { ep -> ep.id == episodeId }
if (episode == null) {
Log.e(tag, "prepareLibraryItem: Podcast episode not found $episodeId")
return call.resolve(JSObject())
return call.resolve(JSObject("{\"error\":\"Podcast episode not found\"}"))
}
}
Handler(Looper.getMainLooper()).post {
Log.d(tag, "prepareLibraryItem: Preparing Local Media item ${jacksonMapper.writeValueAsString(it)}")
val playbackSession = it.getPlaybackSession(episode)
playerNotificationService.preparePlayer(playbackSession, playWhenReady, playbackRate)
if (playerNotificationService.mediaProgressSyncer.listeningTimerRunning) { // If progress syncing then first stop before preparing next
playerNotificationService.mediaProgressSyncer.stop {
Log.d(tag, "Media progress syncer was already syncing - stopped")
Handler(Looper.getMainLooper()).post { // TODO: This was needed again which is probably a design a flaw
playerNotificationService.preparePlayer(
playbackSession,
playWhenReady,
playbackRate
)
}
}
} else {
playerNotificationService.preparePlayer(playbackSession, playWhenReady, playbackRate)
}
}
return call.resolve(JSObject())
}
} else { // Play library item from server
val mediaPlayer = playerNotificationService.getMediaPlayer()
val playItemRequestPayload = playerNotificationService.getPlayItemRequestPayload(false)
apiHandler.playLibraryItem(libraryItemId, episodeId, false, mediaPlayer) {
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
if (it == null) {
call.resolve(JSObject("{\"error\":\"Server play request failed\"}"))
} else {
Handler(Looper.getMainLooper()).post {
Log.d(tag, "Preparing Player TEST ${jacksonMapper.writeValueAsString(it)}")
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
Handler(Looper.getMainLooper()).post {
Log.d(tag, "Preparing Player playback session ${jacksonMapper.writeValueAsString(it)}")
if (playerNotificationService.mediaProgressSyncer.listeningTimerRunning) { // If progress syncing then first stop before preparing next
playerNotificationService.mediaProgressSyncer.stop {
Log.d(tag, "Media progress syncer was already syncing - stopped")
Handler(Looper.getMainLooper()).post { // TODO: This was needed again which is probably a design a flaw
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
}
}
} else {
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
}
}
call.resolve(JSObject(jacksonMapper.writeValueAsString(it)))
}
call.resolve(JSObject(jacksonMapper.writeValueAsString(it)))
}
}
}
@@ -6,12 +6,12 @@ import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.server.ApiHandler
import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.getcapacitor.*
import com.getcapacitor.annotation.CapacitorPlugin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.json.JSONObject
@CapacitorPlugin(name = "AbsDatabase")
class AbsDatabase : Plugin() {
@@ -24,6 +24,7 @@ class AbsDatabase : Plugin() {
data class LocalMediaProgressPayload(val value:List<LocalMediaProgress>)
data class LocalLibraryItemsPayload(val value:List<LocalLibraryItem>)
data class LocalFoldersPayload(val value:List<LocalFolder>)
data class ServerConnConfigPayload(val id:String?, val index:Int, val name:String?, val userId:String, val username:String, val token:String, val address:String?, val customHeaders:Map<String,String>?)
override fun load() {
mainActivity = (activity as MainActivity)
@@ -36,7 +37,7 @@ class AbsDatabase : Plugin() {
@PluginMethod
fun getDeviceData(call:PluginCall) {
GlobalScope.launch(Dispatchers.IO) {
var deviceData = DeviceManager.dbManager.getDeviceData()
val deviceData = DeviceManager.dbManager.getDeviceData()
call.resolve(JSObject(jacksonMapper.writeValueAsString(deviceData)))
}
}
@@ -44,17 +45,17 @@ class AbsDatabase : Plugin() {
@PluginMethod
fun getLocalFolders(call:PluginCall) {
GlobalScope.launch(Dispatchers.IO) {
var folders = DeviceManager.dbManager.getAllLocalFolders()
val folders = DeviceManager.dbManager.getAllLocalFolders()
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalFoldersPayload(folders))))
}
}
@PluginMethod
fun getLocalFolder(call:PluginCall) {
var folderId = call.getString("folderId", "").toString()
val folderId = call.getString("folderId", "").toString()
GlobalScope.launch(Dispatchers.IO) {
DeviceManager.dbManager.getLocalFolder(folderId)?.let {
var folderObj = jacksonMapper.writeValueAsString(it)
val folderObj = jacksonMapper.writeValueAsString(it)
call.resolve(JSObject(folderObj))
} ?: call.resolve()
}
@@ -62,10 +63,10 @@ class AbsDatabase : Plugin() {
@PluginMethod
fun getLocalLibraryItem(call:PluginCall) {
var id = call.getString("id", "").toString()
val id = call.getString("id", "").toString()
GlobalScope.launch(Dispatchers.IO) {
var localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(id)
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(id)
if (localLibraryItem == null) {
call.resolve()
} else {
@@ -75,10 +76,10 @@ class AbsDatabase : Plugin() {
}
@PluginMethod
fun getLocalLibraryItemByLLId(call:PluginCall) {
var libraryItemId = call.getString("libraryItemId", "").toString()
fun getLocalLibraryItemByLId(call:PluginCall) {
val libraryItemId = call.getString("libraryItemId", "").toString()
GlobalScope.launch(Dispatchers.IO) {
var localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLLId(libraryItemId)
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItemId)
if (localLibraryItem == null) {
call.resolve()
} else {
@@ -89,40 +90,41 @@ class AbsDatabase : Plugin() {
@PluginMethod
fun getLocalLibraryItems(call:PluginCall) {
var mediaType = call.getString("mediaType", "").toString()
val mediaType = call.getString("mediaType", "").toString()
GlobalScope.launch(Dispatchers.IO) {
var localLibraryItems = DeviceManager.dbManager.getLocalLibraryItems(mediaType)
val localLibraryItems = DeviceManager.dbManager.getLocalLibraryItems(mediaType)
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalLibraryItemsPayload(localLibraryItems))))
}
}
@PluginMethod
fun getLocalLibraryItemsInFolder(call:PluginCall) {
var folderId = call.getString("folderId", "").toString()
val folderId = call.getString("folderId", "").toString()
GlobalScope.launch(Dispatchers.IO) {
var localLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(folderId)
val localLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(folderId)
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalLibraryItemsPayload(localLibraryItems))))
}
}
@PluginMethod
fun setCurrentServerConnectionConfig(call:PluginCall) {
var serverConnectionConfigId = call.getString("id", "").toString()
var serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConnectionConfigId }
Log.d(tag, "setCurrentServerConnectionConfig ${call.data}")
val serverConfigPayload = jacksonMapper.readValue<ServerConnConfigPayload>(call.data.toString())
var serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConfigPayload.id }
var userId = call.getString("userId", "").toString()
var username = call.getString("username", "").toString()
var token = call.getString("token", "").toString()
val userId = serverConfigPayload.userId
val username = serverConfigPayload.username
val token = serverConfigPayload.token
GlobalScope.launch(Dispatchers.IO) {
if (serverConnectionConfig == null) { // New Server Connection
var serverAddress = call.getString("address", "").toString()
val serverAddress = call.getString("address", "").toString()
// Create new server connection config
var sscId = DeviceManager.getBase64Id("$serverAddress@$username")
var sscIndex = DeviceManager.deviceData.serverConnectionConfigs.size
serverConnectionConfig = ServerConnectionConfig(sscId, sscIndex, "$serverAddress ($username)", serverAddress, userId, username, token)
val sscId = DeviceManager.getBase64Id("$serverAddress@$username")
val sscIndex = DeviceManager.deviceData.serverConnectionConfigs.size
serverConnectionConfig = ServerConnectionConfig(sscId, sscIndex, "$serverAddress ($username)", serverAddress, userId, username, token, serverConfigPayload.customHeaders)
// Add and save
DeviceManager.deviceData.serverConnectionConfigs.add(serverConnectionConfig!!)
@@ -139,8 +141,8 @@ class AbsDatabase : Plugin() {
}
// Set last connection config
if (DeviceManager.deviceData.lastServerConnectionConfigId != serverConnectionConfigId) {
DeviceManager.deviceData.lastServerConnectionConfigId = serverConnectionConfigId
if (DeviceManager.deviceData.lastServerConnectionConfigId != serverConfigPayload.id) {
DeviceManager.deviceData.lastServerConnectionConfigId = serverConfigPayload.id
shouldSave = true
}
@@ -155,7 +157,7 @@ class AbsDatabase : Plugin() {
@PluginMethod
fun removeServerConnectionConfig(call:PluginCall) {
GlobalScope.launch(Dispatchers.IO) {
var serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
DeviceManager.deviceData.serverConnectionConfigs = DeviceManager.deviceData.serverConnectionConfigs.filter { it.id != serverConnectionConfigId } as MutableList<ServerConnectionConfig>
if (DeviceManager.deviceData.lastServerConnectionConfigId == serverConnectionConfigId) {
DeviceManager.deviceData.lastServerConnectionConfigId = null
@@ -181,14 +183,14 @@ class AbsDatabase : Plugin() {
@PluginMethod
fun getAllLocalMediaProgress(call:PluginCall) {
GlobalScope.launch(Dispatchers.IO) {
var localMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress()
val localMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress()
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalMediaProgressPayload(localMediaProgress))))
}
}
@PluginMethod
fun removeLocalMediaProgress(call:PluginCall) {
var localMediaProgressId = call.getString("localMediaProgressId", "").toString()
val localMediaProgressId = call.getString("localMediaProgressId", "").toString()
DeviceManager.dbManager.removeLocalMediaProgress(localMediaProgressId)
call.resolve()
}
@@ -204,6 +206,63 @@ class AbsDatabase : Plugin() {
}
}
// Updates received via web socket
// This function doesn't need to sync with the server also because this data is coming from the server
// If sending the localMediaProgressId then update existing media progress
// If sending localLibraryItemId then save new local media progress
@PluginMethod
fun syncServerMediaProgressWithLocalMediaProgress(call:PluginCall) {
val serverMediaProgress = call.getObject("mediaProgress").toString()
val localLibraryItemId = call.getString("localLibraryItemId", "").toString()
var localEpisodeId:String? = call.getString("localEpisodeId", "").toString()
if (localEpisodeId.isNullOrEmpty()) localEpisodeId = null
var localMediaProgressId = call.getString("localMediaProgressId") ?: ""
val mediaProgress = jacksonMapper.readValue<MediaProgress>(serverMediaProgress)
if (localMediaProgressId == "") {
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
if (localLibraryItem != null) {
localMediaProgressId = if (localEpisodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
val localMediaProgress = LocalMediaProgress(
id = localMediaProgressId,
localLibraryItemId = localLibraryItemId,
localEpisodeId = localEpisodeId,
duration = mediaProgress.duration,
progress = mediaProgress.progress,
currentTime = mediaProgress.currentTime,
isFinished = mediaProgress.isFinished,
lastUpdate = mediaProgress.lastUpdate,
startedAt = mediaProgress.startedAt,
finishedAt = mediaProgress.finishedAt,
serverConnectionConfigId = localLibraryItem.serverConnectionConfigId,
serverAddress = localLibraryItem.serverAddress,
serverUserId = localLibraryItem.serverUserId,
libraryItemId = localLibraryItem.libraryItemId,
episodeId = mediaProgress.episodeId)
Log.d(tag, "syncServerMediaProgressWithLocalMediaProgress: Saving new local media progress $localMediaProgress")
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
call.resolve(JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
} else {
Log.e(tag, "syncServerMediaProgressWithLocalMediaProgress: Local library item not found")
}
} else {
Log.d(tag, "syncServerMediaProgressWithLocalMediaProgress $localMediaProgressId")
val localMediaProgress = DeviceManager.dbManager.getLocalMediaProgress(localMediaProgressId)
if (localMediaProgress == null) {
Log.w(tag, "syncServerMediaProgressWithLocalMediaProgress Local media progress not found $localMediaProgressId")
call.resolve()
} else {
localMediaProgress.updateFromServerMediaProgress(mediaProgress)
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
call.resolve(JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
}
}
}
@PluginMethod
fun updateLocalMediaProgressFinished(call:PluginCall) {
val localLibraryItemId = call.getString("localLibraryItemId", "").toString()
@@ -273,13 +332,13 @@ class AbsDatabase : Plugin() {
// Send update to server media progress is linked to a server and user is logged into that server
localMediaProgress.serverConnectionConfigId?.let { configId ->
if (DeviceManager.serverConnectionConfigId == configId) {
var libraryItemId = localMediaProgress.libraryItemId ?: ""
var episodeId = localMediaProgress.episodeId ?: ""
var updatePayload = JSObject()
val libraryItemId = localMediaProgress.libraryItemId ?: ""
val episodeId = localMediaProgress.episodeId ?: ""
val updatePayload = JSObject()
updatePayload.put("isFinished", isFinished)
apiHandler.updateMediaProgress(libraryItemId,episodeId,updatePayload) {
Log.d(tag, "updateLocalMediaProgressFinished: Updated media progress isFinished on server")
var jsobj = JSObject()
val jsobj = JSObject()
jsobj.put("local", true)
jsobj.put("server", true)
jsobj.put("localMediaProgress", JSObject(lmpstring))
@@ -288,7 +347,7 @@ class AbsDatabase : Plugin() {
}
}
if (localMediaProgress.serverConnectionConfigId == null || DeviceManager.serverConnectionConfigId != localMediaProgress.serverConnectionConfigId) {
var jsobj = JSObject()
val jsobj = JSObject()
jsobj.put("local", true)
jsobj.put("server", false)
jsobj.put("localMediaProgress", JSObject(lmpstring))
@@ -298,25 +357,25 @@ class AbsDatabase : Plugin() {
@PluginMethod
fun updateLocalTrackOrder(call:PluginCall) {
var localLibraryItemId = call.getString("localLibraryItemId", "") ?: ""
var localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
val localLibraryItemId = call.getString("localLibraryItemId", "") ?: ""
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
if (localLibraryItem == null) {
call.resolve()
return
}
var audioTracks = localLibraryItem.media.getAudioTracks() as MutableList
val audioTracks = localLibraryItem.media.getAudioTracks() as MutableList
var tracks:JSArray = call.getArray("tracks") ?: JSArray()
val tracks:JSArray = call.getArray("tracks") ?: JSArray()
Log.d(tag, "updateLocalTrackOrder $tracks")
var index = 1
var hasUpdates = false
for (i in 0 until tracks.length()) {
var track = tracks.getJSONObject(i)
var localFileId = track.getString("localFileId")
val track = tracks.getJSONObject(i)
val localFileId = track.getString("localFileId")
var existingTrack = audioTracks.find{ it.localFileId == localFileId }
val existingTrack = audioTracks.find{ it.localFileId == localFileId }
if (existingTrack != null) {
Log.d(tag, "Found existing track ${existingTrack.localFileId} that has index ${existingTrack.index} should be index $index")
if (existingTrack.index != index) hasUpdates = true
@@ -336,4 +395,15 @@ class AbsDatabase : Plugin() {
call.resolve()
}
}
@PluginMethod
fun updateDeviceSettings(call:PluginCall) { // Returns device data
Log.d(tag, "updateDeviceSettings ${call.data}")
val newDeviceSettings = jacksonMapper.readValue<DeviceSettings>(call.data.toString())
GlobalScope.launch(Dispatchers.IO) {
DeviceManager.deviceData.deviceSettings = newDeviceSettings
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
call.resolve(JSObject(jacksonMapper.writeValueAsString(DeviceManager.deviceData)))
}
}
}
@@ -143,31 +143,35 @@ class AbsDownloader : Plugin() {
}
apiHandler.getLibraryItemWithProgress(libraryItemId, episodeId) { libraryItem ->
Log.d(tag, "Got library item from server ${libraryItem.id}")
if (libraryItem == null) {
call.resolve(JSObject("{\"error\":\"Server request failed\"}"))
} else {
Log.d(tag, "Got library item from server ${libraryItem.id}")
val localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
if (localFolder != null) {
val localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
if (localFolder != null) {
if (episodeId.isNotEmpty() && libraryItem.mediaType != "podcast") {
Log.e(tag, "Library item is not a podcast but episode was requested")
call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
} else if (episodeId.isNotEmpty()) {
val podcast = libraryItem.media as Podcast
val episode = podcast.episodes?.find { podcastEpisode ->
podcastEpisode.id == episodeId
}
if (episode == null) {
call.resolve(JSObject("{\"error\":\"Invalid podcast episode not found\"}"))
if (episodeId.isNotEmpty() && libraryItem.mediaType != "podcast") {
Log.e(tag, "Library item is not a podcast but episode was requested")
call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
} else if (episodeId.isNotEmpty()) {
val podcast = libraryItem.media as Podcast
val episode = podcast.episodes?.find { podcastEpisode ->
podcastEpisode.id == episodeId
}
if (episode == null) {
call.resolve(JSObject("{\"error\":\"Invalid podcast episode not found\"}"))
} else {
startLibraryItemDownload(libraryItem, localFolder, episode)
call.resolve()
}
} else {
startLibraryItemDownload(libraryItem, localFolder, episode)
startLibraryItemDownload(libraryItem, localFolder, null)
call.resolve()
}
} else {
startLibraryItemDownload(libraryItem, localFolder, null)
call.resolve()
call.resolve(JSObject("{\"error\":\"Local Folder Not Found\"}"))
}
} else {
call.resolve(JSObject("{\"error\":\"Local Folder Not Found\"}"))
}
}
}
@@ -397,9 +401,13 @@ class AbsDownloader : Plugin() {
Log.d(tag, "DOWNLOAD: Move file to final destination path: ${downloadItemPart.finalDestinationPath}")
val localFolderFile = DocumentFileCompat.fromUri(mainActivity,Uri.parse(downloadItemPart.localFolderUrl))
val mimetype = if (downloadItemPart.audioTrack != null) MimeType.AUDIO else MimeType.IMAGE
val fileDescription = FileDescription(downloadItemPart.filename, downloadItemPart.itemTitle, mimetype)
file?.moveFileTo(mainActivity,localFolderFile!!,fileDescription,fcb)
if (localFolderFile == null) {
Log.e(tag, "Local Folder File from uri is null")
} else {
val mimetype = if (downloadItemPart.audioTrack != null) MimeType.AUDIO else MimeType.IMAGE
val fileDescription = FileDescription(downloadItemPart.filename, downloadItemPart.itemTitle, mimetype)
file?.moveFileTo(mainActivity,localFolderFile,fileDescription,fcb)
}
} else {
// Why is kotlin requiring an else here..
}
@@ -1,5 +1,6 @@
package com.audiobookshelf.app.plugins
import android.app.AlertDialog
import android.database.Cursor
import android.net.Uri
import android.os.Build
@@ -65,39 +66,59 @@ class AbsFileSystem : Plugin() {
@PluginMethod
fun selectFolder(call: PluginCall) {
var mediaType = call.data.getString("mediaType", "book").toString()
val mediaType = call.data.getString("mediaType", "book").toString()
val REQUEST_CODE_SELECT_FOLDER = 6
val REQUEST_CODE_SDCARD_ACCESS = 7
mainActivity.storage.folderPickerCallback = object : FolderPickerCallback {
override fun onFolderSelected(requestCode: Int, folder: DocumentFile) {
Log.d(TAG, "ON FOLDER SELECTED ${folder.uri} ${folder.name}")
var absolutePath = folder.getAbsolutePath(activity)
var storageType = folder.getStorageType(activity)
var simplePath = folder.getSimplePath(activity)
var basePath = folder.getBasePath(activity)
var folderId = android.util.Base64.encodeToString(folder.id.toByteArray(), android.util.Base64.DEFAULT)
val absolutePath = folder.getAbsolutePath(activity)
val storageType = folder.getStorageType(activity)
val simplePath = folder.getSimplePath(activity)
val basePath = folder.getBasePath(activity)
val folderId = android.util.Base64.encodeToString(folder.id.toByteArray(), android.util.Base64.DEFAULT)
var localFolder = LocalFolder(folderId, folder.name ?: "", folder.uri.toString(),basePath,absolutePath, simplePath, storageType.toString(), mediaType)
val localFolder = LocalFolder(folderId, folder.name ?: "", folder.uri.toString(),basePath,absolutePath, simplePath, storageType.toString(), mediaType)
DeviceManager.dbManager.saveLocalFolder(localFolder)
call.resolve(JSObject(jacksonMapper.writeValueAsString(localFolder)))
}
override fun onStorageAccessDenied(requestCode: Int, folder: DocumentFile?, storageType: StorageType) {
Log.e(TAG, "STORAGE ACCESS DENIED")
var jsobj = JSObject()
jsobj.put("error", "Access Denied")
call.resolve(jsobj)
val jsobj = JSObject()
if (requestCode == REQUEST_CODE_SELECT_FOLDER) {
val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity)
builder.setMessage(
"You have no write access to this storage, thus selecting this folder is useless." +
"\nWould you like to grant access to this folder?")
builder.setNegativeButton("Dont Allow") { _, _ ->
run {
jsobj.put("error", "User Canceled, Access Denied")
call.resolve(jsobj)
}
}
builder.setPositiveButton("Allow.") { _, _ -> mainActivity.storageHelper.requestStorageAccess(REQUEST_CODE_SDCARD_ACCESS, storageType) }
builder.show()
} else {
Log.d(TAG, "STORAGE ACCESS DENIED $requestCode")
jsobj.put("error", "Access Denied")
call.resolve(jsobj)
}
}
override fun onStoragePermissionDenied(requestCode: Int) {
Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode")
var jsobj = JSObject()
val jsobj = JSObject()
jsobj.put("error", "Permission Denied")
call.resolve(jsobj)
}
}
mainActivity.storage.openFolderPicker(6)
mainActivity.storage.openFolderPicker(REQUEST_CODE_SELECT_FOLDER)
}
@RequiresApi(Build.VERSION_CODES.R)
@@ -1,7 +1,6 @@
package com.audiobookshelf.app.server
import android.content.Context
import android.content.SharedPreferences
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.util.Log
@@ -17,36 +16,43 @@ import com.getcapacitor.JSObject
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import java.io.IOException
import java.util.concurrent.TimeUnit
class ApiHandler(var ctx:Context) {
val tag = "ApiHandler"
private var client = OkHttpClient()
private var defaultClient = OkHttpClient()
private var pingClient = OkHttpClient.Builder().callTimeout(3, TimeUnit.SECONDS).build()
var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
var storageSharedPreferences: SharedPreferences? = null
data class LocalMediaProgressSyncPayload(val localMediaProgress:List<LocalMediaProgress>)
@JsonIgnoreProperties(ignoreUnknown = true)
data class MediaProgressSyncResponsePayload(val numServerProgressUpdates:Int, val localProgressUpdates:List<LocalMediaProgress>)
data class LocalMediaProgressSyncResultsPayload(var numLocalMediaProgressForServer:Int, var numServerProgressUpdates:Int, var numLocalProgressUpdates:Int)
fun getRequest(endpoint:String, cb: (JSObject) -> Unit) {
fun getRequest(endpoint:String, httpClient:OkHttpClient?, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
val address = config?.address ?: DeviceManager.serverAddress
val token = config?.token ?: DeviceManager.token
val request = Request.Builder()
.url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}")
.url("${address}$endpoint").addHeader("Authorization", "Bearer $token")
.build()
makeRequest(request, cb)
makeRequest(request, httpClient, cb)
}
fun postRequest(endpoint:String, payload: JSObject, cb: (JSObject) -> Unit) {
fun postRequest(endpoint:String, payload: JSObject, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
val address = config?.address ?: DeviceManager.serverAddress
val token = config?.token ?: DeviceManager.token
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = payload.toString().toRequestBody(mediaType)
val request = Request.Builder().post(requestBody)
.url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}")
.url("${address}$endpoint").addHeader("Authorization", "Bearer ${token}")
.build()
makeRequest(request, cb)
makeRequest(request, null, cb)
}
fun patchRequest(endpoint:String, payload: JSObject, cb: (JSObject) -> Unit) {
@@ -55,7 +61,7 @@ class ApiHandler(var ctx:Context) {
val request = Request.Builder().patch(requestBody)
.url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}")
.build()
makeRequest(request, cb)
makeRequest(request, null, cb)
}
fun isOnline(): Boolean {
@@ -76,30 +82,52 @@ class ApiHandler(var ctx:Context) {
return false
}
fun makeRequest(request:Request, cb: (JSObject) -> Unit) {
fun isUsingCellularData(): Boolean {
val connectivityManager = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val capabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
return capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true
}
fun makeRequest(request:Request, httpClient:OkHttpClient?, cb: (JSObject) -> Unit) {
val client = httpClient ?: defaultClient
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
Log.d(tag, "FAILURE TO CONNECT")
e.printStackTrace()
cb(JSObject())
val jsobj = JSObject()
jsobj.put("error", "Failed to connect")
cb(jsobj)
}
override fun onResponse(call: Call, response: Response) {
response.use {
if (!it.isSuccessful) throw IOException("Unexpected code $response")
if (!it.isSuccessful) {
val jsobj = JSObject()
jsobj.put("error", "Unexpected code $response")
cb(jsobj)
return
}
val bodyString = it.body!!.string()
val bodyString = it.body!!.string()
if (bodyString == "OK") {
cb(JSObject())
} else {
var jsonObj = JSObject()
if (bodyString.startsWith("[")) {
val array = JSArray(bodyString)
jsonObj.put("value", array)
} else {
jsonObj = JSObject(bodyString)
try {
var jsonObj = JSObject()
if (bodyString.startsWith("[")) {
val array = JSArray(bodyString)
jsonObj.put("value", array)
} else {
jsonObj = JSObject(bodyString)
}
cb(jsonObj)
} catch(je:JSONException) {
Log.e(tag, "Invalid JSON response ${je.localizedMessage} from body $bodyString")
val jsobj = JSObject()
jsobj.put("error", "Invalid response body")
cb(jsobj)
}
cb(jsonObj)
}
}
}
@@ -108,37 +136,51 @@ class ApiHandler(var ctx:Context) {
fun getLibraries(cb: (List<Library>) -> Unit) {
val mapper = jacksonMapper
getRequest("/api/libraries") {
getRequest("/api/libraries", null,null) {
val libraries = mutableListOf<Library>()
if (it.has("value")) {
val array = it.getJSONArray("value")
for (i in 0 until array.length()) {
val library = mapper.readValue<Library>(array.get(i).toString())
libraries.add(library)
}
var array = JSONArray()
if (it.has("libraries")) { // TODO: Server 2.2.9 changed to this
array = it.getJSONArray("libraries")
} 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)
}
}
fun getLibraryItem(libraryItemId:String, cb: (LibraryItem) -> Unit) {
getRequest("/api/items/$libraryItemId?expanded=1") {
val libraryItem = jacksonMapper.readValue<LibraryItem>(it.toString())
cb(libraryItem)
fun getLibraryItem(libraryItemId:String, cb: (LibraryItem?) -> Unit) {
getRequest("/api/items/$libraryItemId?expanded=1", null, null) {
if (it.has("error")) {
Log.e(tag, it.getString("error") ?: "getLibraryItem Failed")
cb(null)
} else {
val libraryItem = jacksonMapper.readValue<LibraryItem>(it.toString())
cb(libraryItem)
}
}
}
fun getLibraryItemWithProgress(libraryItemId:String, episodeId:String?, cb: (LibraryItem) -> Unit) {
fun getLibraryItemWithProgress(libraryItemId:String, episodeId:String?, cb: (LibraryItem?) -> Unit) {
var requestUrl = "/api/items/$libraryItemId?expanded=1&include=progress"
if (!episodeId.isNullOrEmpty()) requestUrl += "&episode=$episodeId"
getRequest(requestUrl) {
val libraryItem = jacksonMapper.readValue<LibraryItem>(it.toString())
cb(libraryItem)
getRequest(requestUrl, null, null) {
if (it.has("error")) {
Log.e(tag, it.getString("error") ?: "getLibraryItemWithProgress Failed")
cb(null)
} else {
val libraryItem = jacksonMapper.readValue<LibraryItem>(it.toString())
cb(libraryItem)
}
}
}
fun getLibraryItems(libraryId:String, cb: (List<LibraryItem>) -> Unit) {
getRequest("/api/libraries/$libraryId/items?limit=100&minified=1") {
getRequest("/api/libraries/$libraryId/items?limit=100&minified=1", null, null) {
val items = mutableListOf<LibraryItem>()
if (it.has("results")) {
val array = it.getJSONArray("results")
@@ -151,57 +193,60 @@ class ApiHandler(var ctx:Context) {
}
}
fun getLibraryCategories(libraryId:String, cb: (List<LibraryCategory>) -> Unit) {
getRequest("/api/libraries/$libraryId/personalized") {
val items = mutableListOf<LibraryCategory>()
if (it.has("value")) {
val array = it.getJSONArray("value")
fun getAllItemsInProgress(cb: (List<ItemInProgress>) -> Unit) {
getRequest("/api/me/items-in-progress", null, null) {
val items = mutableListOf<ItemInProgress>()
if (it.has("libraryItems")) {
val array = it.getJSONArray("libraryItems")
for (i in 0 until array.length()) {
val jsobj = array.get(i) as JSONObject
val type = jsobj.get("type").toString()
// Only support for podcast and book in android auto
if (type == "podcast" || type == "book") {
jsobj.put("isLocal", false)
val item = jacksonMapper.readValue<LibraryCategory>(jsobj.toString())
items.add(item)
}
val itemInProgress = ItemInProgress.makeFromServerObject(jsobj)
items.add(itemInProgress)
}
}
cb(items)
}
}
fun playLibraryItem(libraryItemId:String, episodeId:String?, forceTranscode:Boolean, mediaPlayer:String, cb: (PlaybackSession) -> Unit) {
val payload = JSObject()
payload.put("mediaPlayer", mediaPlayer)
// Only if direct play fails do we force transcode
if (!forceTranscode) payload.put("forceDirectPlay", true)
else payload.put("forceTranscode", true)
fun playLibraryItem(libraryItemId:String, episodeId:String?, playItemRequestPayload:PlayItemRequestPayload, cb: (PlaybackSession?) -> Unit) {
val payload = JSObject(jacksonMapper.writeValueAsString(playItemRequestPayload))
val endpoint = if (episodeId.isNullOrEmpty()) "/api/items/$libraryItemId/play" else "/api/items/$libraryItemId/play/$episodeId"
postRequest(endpoint, payload) {
it.put("serverConnectionConfigId", DeviceManager.serverConnectionConfig?.id)
it.put("serverAddress", DeviceManager.serverAddress)
val playbackSession = jacksonMapper.readValue<PlaybackSession>(it.toString())
cb(playbackSession)
postRequest(endpoint, payload, null) {
if (it.has("error")) {
Log.e(tag, it.getString("error") ?: "Play Library Item Failed")
cb(null)
} else {
it.put("serverConnectionConfigId", DeviceManager.serverConnectionConfig?.id)
it.put("serverAddress", DeviceManager.serverAddress)
val playbackSession = jacksonMapper.readValue<PlaybackSession>(it.toString())
cb(playbackSession)
}
}
}
fun sendProgressSync(sessionId:String, syncData: MediaProgressSyncData, cb: () -> Unit) {
fun sendProgressSync(sessionId:String, syncData: MediaProgressSyncData, cb: (Boolean) -> Unit) {
val payload = JSObject(jacksonMapper.writeValueAsString(syncData))
postRequest("/api/session/$sessionId/sync", payload) {
cb()
postRequest("/api/session/$sessionId/sync", payload, null) {
if (!it.getString("error").isNullOrEmpty()) {
cb(false)
} else {
cb(true)
}
}
}
fun sendLocalProgressSync(playbackSession:PlaybackSession, cb: () -> Unit) {
fun sendLocalProgressSync(playbackSession:PlaybackSession, cb: (Boolean) -> Unit) {
val payload = JSObject(jacksonMapper.writeValueAsString(playbackSession))
postRequest("/api/session/local", payload) {
cb()
postRequest("/api/session/local", payload, null) {
if (!it.getString("error").isNullOrEmpty()) {
cb(false)
} else {
cb(true)
}
}
}
@@ -222,11 +267,13 @@ class ApiHandler(var ctx:Context) {
if (localMediaProgress.isNotEmpty()) {
Log.d(tag, "Sending sync local progress request with ${localMediaProgress.size} progress items")
val payload = JSObject(jacksonMapper.writeValueAsString(LocalMediaProgressSyncPayload(localMediaProgress)))
postRequest("/api/me/sync-local-progress", payload) {
Log.d(tag, "Media Progress Sync payload $payload - response ${it.toString()}")
postRequest("/api/me/sync-local-progress", payload, null) {
Log.d(tag, "Media Progress Sync payload $payload - response ${it}")
if (it.toString() == "{}") {
Log.e(tag, "Progress sync received empty object")
} else if (it.has("error")) {
Log.e(tag, it.getString("error") ?: "Progress sync error")
} else {
val progressSyncResponsePayload = jacksonMapper.readValue<MediaProgressSyncResponsePayload>(it.toString())
@@ -257,4 +304,67 @@ class ApiHandler(var ctx:Context) {
cb()
}
}
fun getMediaProgress(libraryItemId:String, episodeId:String?, serverConnectionConfig:ServerConnectionConfig?, cb: (MediaProgress?) -> Unit) {
val endpoint = if(episodeId.isNullOrEmpty()) "/api/me/progress/$libraryItemId" else "/api/me/progress/$libraryItemId/$episodeId"
// TODO: Using ping client here allows for shorter timeout (3 seconds), maybe rename or make diff client for requests requiring quicker response
getRequest(endpoint, pingClient, serverConnectionConfig) {
if (it.has("error")) {
Log.e(tag, "getMediaProgress: Failed to get progress")
cb(null)
} else {
val progress = jacksonMapper.readValue<MediaProgress>(it.toString())
cb(progress)
}
}
}
fun getPlaybackSession(playbackSessionId:String, cb: (PlaybackSession?) -> Unit) {
Log.d(tag, "getPlaybackSession for $playbackSessionId for server ${DeviceManager.serverAddress}")
val endpoint = "/api/session/$playbackSessionId"
getRequest(endpoint, null, null) {
val err = it.getString("error")
if (!err.isNullOrEmpty()) {
cb(null)
} else {
cb(jacksonMapper.readValue<PlaybackSession>(it.toString()))
}
}
}
fun pingServer(config:ServerConnectionConfig, cb: (Boolean) -> Unit) {
Log.d(tag, "pingServer: Pinging ${config.address}")
getRequest("/ping", pingClient, config) {
val success = it.getString("success")
if (success.isNullOrEmpty()) {
Log.d(tag, "pingServer: Ping ${config.address} Failed")
cb(false)
} else {
Log.d(tag, "pingServer: Ping ${config.address} Successful")
cb(true)
}
}
}
fun authorize(config:ServerConnectionConfig, cb: (MutableList<MediaProgress>?) -> Unit) {
Log.d(tag, "authorize: Authorizing ${config.address}")
postRequest("/api/authorize", JSObject(), config) {
val error = it.getString("error")
if (!error.isNullOrEmpty()) {
Log.d(tag, "authorize: Authorize ${config.address} Failed: $error")
cb(null)
} else {
val mediaProgressList:MutableList<MediaProgress> = mutableListOf()
val user = it.getJSObject("user")
val mediaProgress = user?.getJSONArray("mediaProgress") ?: JSONArray()
for (i in 0 until mediaProgress.length()) {
val mediaProg = jacksonMapper.readValue<MediaProgress>(mediaProgress.getJSONObject(i).toString())
mediaProgressList.add(mediaProg)
}
Log.d(tag, "authorize: Authorize ${config.address} Successful")
cb(mediaProgressList)
}
}
}
}
@@ -11,11 +11,5 @@
<path
android:fillColor="@android:color/white"
android:pathData="M18,13c0,3.31 -2.69,6 -6,6s-6,-2.69 -6,-6s2.69,-6 6,-6v4l5,-5l-5,-5v4c-4.42,0 -8,3.58 -8,8c0,4.42 3.58,8 8,8s8,-3.58 8,-8H18z"/>
<path
android:fillColor="@android:color/white"
android:pathData="M10.86,15.94l0,-4.27l-0.09,0l-1.77,0.63l0,0.69l1.01,-0.31l0,3.26z"/>
<path
android:fillColor="@android:color/white"
android:pathData="M12.25,13.44v0.74c0,1.9 1.31,1.82 1.44,1.82c0.14,0 1.44,0.09 1.44,-1.82v-0.74c0,-1.9 -1.31,-1.82 -1.44,-1.82C13.55,11.62 12.25,11.53 12.25,13.44zM14.29,13.32v0.97c0,0.77 -0.21,1.03 -0.59,1.03c-0.38,0 -0.6,-0.26 -0.6,-1.03v-0.97c0,-0.75 0.22,-1.01 0.59,-1.01C14.07,12.3 14.29,12.57 14.29,13.32z"/>
</group>
</vector>
@@ -11,11 +11,5 @@
<path
android:fillColor="@android:color/white"
android:pathData="M11.99,5V1l-5,5l5,5V7c3.31,0 6,2.69 6,6s-2.69,6 -6,6s-6,-2.69 -6,-6h-2c0,4.42 3.58,8 8,8s8,-3.58 8,-8S16.41,5 11.99,5z"/>
<path
android:fillColor="@android:color/white"
android:pathData="M10.89,16h-0.85v-3.26l-1.01,0.31v-0.69l1.77,-0.63h0.09V16z"/>
<path
android:fillColor="@android:color/white"
android:pathData="M15.17,14.24c0,0.32 -0.03,0.6 -0.1,0.82s-0.17,0.42 -0.29,0.57s-0.28,0.26 -0.45,0.33s-0.37,0.1 -0.59,0.1s-0.41,-0.03 -0.59,-0.1s-0.33,-0.18 -0.46,-0.33s-0.23,-0.34 -0.3,-0.57s-0.11,-0.5 -0.11,-0.82V13.5c0,-0.32 0.03,-0.6 0.1,-0.82s0.17,-0.42 0.29,-0.57s0.28,-0.26 0.45,-0.33s0.37,-0.1 0.59,-0.1s0.41,0.03 0.59,0.1c0.18,0.07 0.33,0.18 0.46,0.33s0.23,0.34 0.3,0.57s0.11,0.5 0.11,0.82V14.24zM14.32,13.38c0,-0.19 -0.01,-0.35 -0.04,-0.48s-0.07,-0.23 -0.12,-0.31s-0.11,-0.14 -0.19,-0.17s-0.16,-0.05 -0.25,-0.05s-0.18,0.02 -0.25,0.05s-0.14,0.09 -0.19,0.17s-0.09,0.18 -0.12,0.31s-0.04,0.29 -0.04,0.48v0.97c0,0.19 0.01,0.35 0.04,0.48s0.07,0.24 0.12,0.32s0.11,0.14 0.19,0.17s0.16,0.05 0.25,0.05s0.18,-0.02 0.25,-0.05s0.14,-0.09 0.19,-0.17s0.09,-0.19 0.11,-0.32s0.04,-0.29 0.04,-0.48V13.38z"/>
</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,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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Background for widgets to make the rounded corners based on the
appWidgetRadius attribute value
-->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="?attr/appWidgetRadius" />
<solid android:color="?android:attr/colorBackground" />
</shape>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Background for views inside widgets to make the rounded corners based on the
appWidgetInnerRadius attribute value
-->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="?attr/appWidgetInnerRadius" />
<solid android:color="?android:attr/colorPrimaryDark" />
</shape>
@@ -1,34 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>
@@ -2,6 +2,4 @@
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M18,13c0,3.31 -2.69,6 -6,6s-6,-2.69 -6,-6s2.69,-6 6,-6v4l5,-5l-5,-5v4c-4.42,0 -8,3.58 -8,8c0,4.42 3.58,8 8,8s8,-3.58 8,-8H18z"/>
<path android:fillColor="@android:color/white" android:pathData="M10.86,15.94l0,-4.27l-0.09,0l-1.77,0.63l0,0.69l1.01,-0.31l0,3.26z"/>
<path android:fillColor="@android:color/white" android:pathData="M12.25,13.44v0.74c0,1.9 1.31,1.82 1.44,1.82c0.14,0 1.44,0.09 1.44,-1.82v-0.74c0,-1.9 -1.31,-1.82 -1.44,-1.82C13.55,11.62 12.25,11.53 12.25,13.44zM14.29,13.32v0.97c0,0.77 -0.21,1.03 -0.59,1.03c-0.38,0 -0.6,-0.26 -0.6,-1.03v-0.97c0,-0.75 0.22,-1.01 0.59,-1.01C14.07,12.3 14.29,12.57 14.29,13.32z"/>
</vector>
@@ -2,6 +2,4 @@
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M11.99,5V1l-5,5l5,5V7c3.31,0 6,2.69 6,6s-2.69,6 -6,6s-6,-2.69 -6,-6h-2c0,4.42 3.58,8 8,8s8,-3.58 8,-8S16.41,5 11.99,5z"/>
<path android:fillColor="@android:color/white" android:pathData="M10.89,16h-0.85v-3.26l-1.01,0.31v-0.69l1.77,-0.63h0.09V16z"/>
<path android:fillColor="@android:color/white" android:pathData="M15.17,14.24c0,0.32 -0.03,0.6 -0.1,0.82s-0.17,0.42 -0.29,0.57s-0.28,0.26 -0.45,0.33s-0.37,0.1 -0.59,0.1s-0.41,-0.03 -0.59,-0.1s-0.33,-0.18 -0.46,-0.33s-0.23,-0.34 -0.3,-0.57s-0.11,-0.5 -0.11,-0.82V13.5c0,-0.32 0.03,-0.6 0.1,-0.82s0.17,-0.42 0.29,-0.57s0.28,-0.26 0.45,-0.33s0.37,-0.1 0.59,-0.1s0.41,0.03 0.59,0.1c0.18,0.07 0.33,0.18 0.46,0.33s0.23,0.34 0.3,0.57s0.11,0.5 0.11,0.82V14.24zM14.32,13.38c0,-0.19 -0.01,-0.35 -0.04,-0.48s-0.07,-0.23 -0.12,-0.31s-0.11,-0.14 -0.19,-0.17s-0.16,-0.05 -0.25,-0.05s-0.18,0.02 -0.25,0.05s-0.14,0.09 -0.19,0.17s-0.09,0.18 -0.12,0.31s-0.04,0.29 -0.04,0.48v0.97c0,0.19 0.01,0.35 0.04,0.48s0.07,0.24 0.12,0.32s0.11,0.14 0.19,0.17s0.16,0.05 0.25,0.05s0.18,-0.02 0.25,-0.05s0.14,-0.09 0.19,-0.17s0.09,-0.19 0.11,-0.32s0.04,-0.29 0.04,-0.48V13.38z"/>
</vector>
@@ -1,170 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillColor="#26A69A"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
</vector>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

@@ -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>
@@ -0,0 +1,26 @@
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
style="@style/Widget.Android.AppWidget.Container"
android:id="@+id/appWidget"
android:theme="@style/AppTheme.AppWidgetContainer"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/imageView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_centerInParent="true"
android:adjustViewBounds="true"
android:alpha="0.75"
android:contentDescription="Cover image"
android:visibility="visible" />
<ImageView
android:id="@+id/playPauseIcon"
android:layout_width="71dp"
android:layout_height="55dp"
android:layout_centerInParent="true"
app:srcCompat="@drawable/cast_ic_expanded_controller_play" />
</RelativeLayout>
@@ -0,0 +1,32 @@
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</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 name="AppTheme.NoActionBarLaunch" parent="AppTheme.NoActionBar">
<!-- <item name="android:background">@drawable/screen</item>-->
</style>
<style name="Widget.Android.AppWidget.Container" parent="android:Widget">
<item name="android:padding">?attr/appWidgetPadding</item>
<item name="android:background">@drawable/app_widget_background</item>
</style>
<style name="Widget.Android.AppWidget.InnerView" parent="android:Widget">
<item name="android:padding">?attr/appWidgetPadding</item>
<item name="android:background">@drawable/app_widget_inner_view_background</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<resources>
<declare-styleable name="AppWidgetAttrs">
<attr name="appWidgetPadding" format="dimension" />
<attr name="appWidgetInnerRadius" format="dimension" />
<attr name="appWidgetRadius" format="dimension" />
</declare-styleable>
</resources>
@@ -0,0 +1,7 @@
<resources>
<color name="light_blue_50">#FFE1F5FE</color>
<color name="light_blue_200">#FF81D4FA</color>
<color name="light_blue_600">#FF039BE5</color>
<color name="light_blue_900">#FF01579B</color>
<color name="background_dark">#262626</color>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--
Refer to App Widget Documentation for margin information
http://developer.android.com/guide/topics/appwidgets/index.html#CreatingLayout
-->
<dimen name="widget_margin">0dp</dimen>
</resources>
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>
@@ -4,4 +4,10 @@
<string name="title_activity_main">audiobookshelf</string>
<string name="package_name">com.audiobookshelf.app</string>
<string name="custom_url_scheme">com.audiobookshelf.app</string>
<string name="add_widget">Add widget</string>
<string name="app_widget_description">Simple widget for audiobookshelf playback</string>
<string name="action_jump_forward">Jump Forward</string>
<string name="action_jump_backward">Jump Backward</string>
<string name="action_skip_forward">Skip Forward</string>
<string name="action_skip_backward">Skip Backward</string>
</resources>
+12 -1
View File
@@ -13,10 +13,21 @@
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</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 name="AppTheme.NoActionBarLaunch" parent="AppTheme.NoActionBar">
<item name="android:background">@drawable/screen</item>
<!-- <item name="android:background">@drawable/screen</item>-->
</style>
<style name="Widget.Android.AppWidget.Container" parent="android:Widget">
<item name="android:id">@android:id/background</item>
<item name="android:background">?android:attr/colorBackground</item>
</style>
<style name="Widget.Android.AppWidget.InnerView" parent="android:Widget">
<item name="android:background">?android:attr/colorBackground</item>
<item name="android:textColor">?android:attr/textColorPrimary</item>
</style>
</resources>
@@ -0,0 +1,17 @@
<resources>
<style name="AppTheme.AppWidgetContainerParent" parent="@android:style/Theme.DeviceDefault">
<!-- Radius of the outer bound of widgets to make the rounded corners -->
<item name="appWidgetRadius">16dp</item>
<!--
Radius of the inner view's bound of widgets to make the rounded corners.
It needs to be 8dp or less than the value of appWidgetRadius
-->
<item name="appWidgetInnerRadius">8dp</item>
</style>
<style name="AppTheme.AppWidgetContainer"
parent="AppTheme.AppWidgetContainerParent">
<!-- Apply padding to avoid the content of the widget colliding with the rounded corners -->
<item name="appWidgetPadding">16dp</item>
</style>
</resources>
+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">
<access origin="*" />
<feature name="CDVOrientation">
<param name="android-package" value="cordova.plugins.screenorientation.CDVOrientation"/>
</feature>
</widget>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="40dp"
android:updatePeriodMillis="86400000"
android:previewImage="@drawable/example_appwidget_preview"
android:initialLayout="@layout/new_app_widget"
android:description="@string/app_widget_description"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:initialKeyguardLayout="@layout/new_app_widget"
/>
+2 -2
View File
@@ -8,8 +8,8 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.google.gms:google-services:4.3.5'
classpath 'com.android.tools.build:gradle:7.2.0-beta04'
classpath 'com.google.gms:google-services:4.3.10'
classpath 'com.android.tools.build:gradle:7.2.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// NOTE: Do not place your application dependencies here; they belong
+1 -1
View File
@@ -1,7 +1,7 @@
ext {
minSdkVersion = 24
compileSdkVersion = 31
targetSdkVersion = 30
targetSdkVersion = 31
androidxActivityVersion = '1.2.0'
androidxAppCompatVersion = '1.4.1'
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";
}
+16
View File
@@ -1,4 +1,6 @@
@import "./fonts.css";
@import './defaultStyles.css';
@import './absicons.css';
body {
background-color: #262626;
@@ -62,6 +64,12 @@ body {
box-shadow: 2px 10px 8px #1111117e;
}
.altBookshelfDivider {
background: rgb(38 38 38);
/*background: linear-gradient(180deg, rgba(191, 193, 195, 1) 0%, rgb(156, 158, 159) 17%, rgb(114, 115, 117) 88%, rgb(120, 120, 122) 100%);*/
box-shadow: 2px 10px 8px #1111117e;
}
/*
Bookshelf Label
*/
@@ -77,6 +85,14 @@ Bookshelf Label
color: #fce3a6;
}
.altBookshelfLabel {
background-color: #2d3436;
background-image: linear-gradient(315deg, #19191a 0%, rgb(15, 15, 15) 74%);
border-color: rgb(255, 255, 255);
border-style: solid;
color: #ffffff;
}
.cover-bg {
width: calc(100% + 40px);
height: calc(100% + 40px);
+55
View File
@@ -0,0 +1,55 @@
/*
This is for setting regular html styles for places where embedding HTML will be
like podcast episode descriptions. Otherwise TailwindCSS will have stripped all default markup.
*/
.default-style p {
display: block;
margin-block-start: 1em;
margin-block-end: 1em;
margin-inline-start: 0px;
margin-inline-end: 0px;
}
.default-style a {
text-decoration: none;
color: #5985ff;
}
.default-style ul {
display: block;
list-style: circle;
list-style-type: disc;
margin-block-start: 1em;
margin-block-end: 1em;
margin-inline-start: 0px;
margin-inline-end: 0px;
padding-inline-start: 40px;
}
.default-style ol {
display: block;
list-style: decimal;
list-style-type: decimal;
margin-block-start: 1em;
margin-block-end: 1em;
margin-inline-start: 0px;
margin-inline-end: 0px;
padding-inline-start: 40px;
}
.default-style li {
display: list-item;
text-align: -webkit-match-parent;
}
.default-style li::marker {
unicode-bidi: isolate;
font-variant-numeric: tabular-nums;
text-transform: none;
text-indent: 0px !important;
text-align: start !important;
text-align-last: start !important;
}
+11 -8
View File
@@ -8,25 +8,28 @@
<span class="material-icons text-3xl text-white">arrow_back</span>
</a>
<div v-if="user && currentLibrary">
<div class="pl-3 pr-4 py-2 bg-bg bg-opacity-30 rounded-md flex items-center" @click="clickShowLibraryModal">
<widgets-library-icon :icon="currentLibraryIcon" :size="4" />
<p class="text-base font-book leading-4 ml-2 mt-0.5">{{ currentLibraryName }}</p>
<div class="pl-1.5 pr-2.5 py-2 bg-bg bg-opacity-30 rounded-md flex items-center" @click="clickShowLibraryModal">
<ui-library-icon :icon="currentLibraryIcon" :size="4" font-size="base" />
<p class="text-sm font-book leading-4 ml-2 mt-0.5 max-w-24 truncate">{{ currentLibraryName }}</p>
</div>
</div>
<widgets-connection-indicator />
<div class="flex-grow" />
<widgets-download-progress-indicator />
<!-- 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">
<span class="material-icons" :class="isCasting ? 'text-success' : ''" style="font-size: 1.6rem" @click="castClick">cast</span>
<div v-show="isCastAvailable && user" class="mx-2 cursor-pointer flex items-center pt-0.5" @click="castClick">
<span class="material-icons" :class="isCasting ? 'text-success' : ''">cast</span>
</div>
<nuxt-link v-if="user" class="h-7 mx-2" to="/search">
<span class="material-icons" style="font-size: 1.75rem">search</span>
<nuxt-link v-if="user" class="h-7 mx-1.5" style="padding-top: 3px" to="/search">
<span class="material-icons">search</span>
</nuxt-link>
<div class="h-7 mx-2">
<div class="h-7 mx-1.5">
<span class="material-icons" style="font-size: 1.75rem" @click="clickShowSideDrawer">menu</span>
</div>
</div>
+288 -106
View File
@@ -1,18 +1,16 @@
<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="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>
</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>
</div>
<div class="top-4 right-4 absolute cursor-pointer">
<ui-dropdown-menu ref="dropdownMenu" :items="menuItems" @action="clickMenuAction">
<span class="material-icons text-3xl">more_vert</span>
</ui-dropdown-menu>
<div class="top-6 right-4 absolute cursor-pointer">
<span class="material-icons text-3xl" @click="showMoreMenuDialog = true">more_vert</span>
</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 v-if="useChapterTrack && showFullscreen" class="absolute total-track w-full px-3 z-30">
@@ -30,13 +28,17 @@
</div>
</div>
<div class="cover-wrapper absolute z-30 pointer-events-auto" :class="bookCoverAspectRatio === 1 ? 'square-cover' : ''" @click="clickContainer">
<div class="cover-wrapper absolute z-30 pointer-events-auto" @click="clickContainer">
<div class="cover-container bookCoverWrapper bg-black bg-opacity-75 w-full h-full">
<covers-book-cover v-if="libraryItem || localLibraryItemCoverSrc" :library-item="libraryItem" :download-cover="localLibraryItemCoverSrc" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
<div v-if="syncStatus === $constants.SyncStatus.FAILED" class="absolute top-0 left-0 w-full h-full flex items-center justify-center z-30">
<span class="material-icons text-error text-3xl">error</span>
</div>
</div>
<div class="title-author-texts absolute z-30 left-0 right-0 overflow-hidden">
<div class="title-author-texts absolute z-30 left-0 right-0 overflow-hidden" @click="clickTitleAndAuthor">
<p class="title-text font-book truncate">{{ title }}</p>
<p class="author-text text-white text-opacity-75 truncate">by {{ authorName }}</p>
</div>
@@ -44,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 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">
<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 -->
<span v-else class="material-icons text-3xl text-white text-opacity-0">bookmark</span>
@@ -62,22 +64,24 @@
<div id="playerControls" class="absolute right-0 bottom-0 py-2">
<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 class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="backward10">replay_10</span>
<span v-show="showFullscreen && !lockUi" class="material-icons next-icon text-white text-opacity-75 cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
<span v-show="!lockUi" class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span>
<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>
<widgets-spinner-icon v-else class="h-8 w-8" />
</div>
<span class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="forward10">forward_10</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="!lockUi" class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">{{ jumpForwardIcon }}</span>
<span v-show="showFullscreen && !lockUi" class="material-icons next-icon text-white cursor-pointer" :class="nextChapter && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
</div>
</div>
<div id="playerTrack" class="absolute bottom-0 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' : ''" @click="clickTrack">
<div id="playerTrack" class="absolute left-0 w-full px-3">
<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="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="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 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>
@@ -90,6 +94,24 @@
</div>
<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>
</template>
@@ -104,10 +126,12 @@ export default {
default: () => []
},
sleepTimerRunning: Boolean,
sleepTimeRemaining: Number
sleepTimeRemaining: Number,
isServerItem: Boolean
},
data() {
return {
windowHeight: 0,
playbackSession: null,
showChapterModal: false,
showFullscreen: false,
@@ -121,43 +145,79 @@ export default {
isEnded: false,
volume: 0.5,
readyTrackWidth: 0,
playedTrackWidth: 0,
seekedTime: 0,
seekLoading: false,
onPlaybackSessionListener: null,
onPlaybackClosedListener: null,
onPlayingUpdateListener: null,
onMetadataListener: null,
onProgressSyncFailing: null,
onProgressSyncSuccess: null,
touchStartY: 0,
touchStartTime: 0,
touchEndY: 0,
useChapterTrack: false,
isLoading: false
lockUi: false,
isLoading: false,
touchTrackStart: false,
dragPercent: 0,
syncStatus: 0,
showMoreMenuDialog: false
}
},
watch: {
showFullscreen(val) {
this.updateScreenSize()
this.$store.commit('setPlayerFullscreen', !!val)
}
},
computed: {
menuItems() {
var items = []
items.push({
text: 'Chapter Track',
value: 'chapter_track',
icon: this.useChapterTrack ? 'check_box' : 'check_box_outline_blank'
})
items.push({
text: 'Close Player',
value: 'close',
icon: 'close'
})
var items = [
{
text: 'Chapter Track',
value: 'chapter_track',
icon: this.useChapterTrack ? 'check_box' : 'check_box_outline_blank'
},
{
text: this.lockUi ? 'Unlock Player' : 'Lock Player',
value: 'lock',
icon: this.lockUi ? 'lock' : 'lock_open'
},
{
text: 'Close Player',
value: 'close',
icon: 'close'
}
]
return items
},
jumpForwardIcon() {
return this.$store.getters['globals/getJumpForwardIcon'](this.jumpForwardTime)
},
jumpBackwardsIcon() {
return this.$store.getters['globals/getJumpBackwardsIcon'](this.jumpBackwardsTime)
},
jumpForwardTime() {
return this.$store.getters['getJumpForwardTime']
},
jumpBackwardsTime() {
return this.$store.getters['getJumpBackwardsTime']
},
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
bookCoverWidth() {
if (this.showFullscreen) return this.fullscreenBookCoverWidth
return 46 / this.bookCoverAspectRatio
},
fullscreenBookCoverWidth() {
var heightScale = (this.windowHeight - 200) / 651
if (this.bookCoverAspectRatio === 1) {
return this.showFullscreen ? 260 : 60
return 260 * heightScale
}
return this.showFullscreen ? 200 : 60
return 200 * heightScale
},
showCastBtn() {
return this.$store.state.isCastAvailable
@@ -265,14 +325,32 @@ export default {
} else {
return secondsRemaining + 's'
}
},
networkConnected() {
return this.$store.state.networkConnected
}
},
methods: {
selectChapter(chapter) {
clickTitleAndAuthor() {
if (!this.showFullscreen) return
const llid = this.libraryItem ? this.libraryItem.id : this.localLibraryItem ? this.localLibraryItem.id : null
if (llid) {
this.$router.push(`/item/${llid}`)
this.showFullscreen = false
}
},
async touchstartTrack(e) {
await this.$hapticsImpactMedium()
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen || this.lockUi) return
this.touchTrackStart = true
},
async selectChapter(chapter) {
await this.$hapticsImpactMedium()
this.seek(chapter.start)
this.showChapterModal = false
},
castClick() {
async castClick() {
await this.$hapticsImpactMedium()
if (this.isLocalPlayMethod) {
this.$eventBus.$emit('cast-local-item')
return
@@ -291,12 +369,14 @@ export default {
this.showFullscreen = false
this.forceCloseDropdownMenu()
},
jumpNextChapter() {
async jumpNextChapter() {
await this.$hapticsImpactMedium()
if (this.isLoading) return
if (!this.nextChapter) return
this.seek(this.nextChapter.start)
},
jumpChapterStart() {
async jumpChapterStart() {
await this.$hapticsImpactMedium()
if (this.isLoading) return
if (!this.currentChapter) {
return this.restart()
@@ -304,9 +384,9 @@ export default {
// If 4 seconds or less into current chapter, then go to previous
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) {
var prevChapter = this.chapters[currChapterIndex - 1]
const prevChapter = this.chapters[currChapterIndex - 1]
this.seek(prevChapter.start)
}
} else {
@@ -316,7 +396,8 @@ export default {
showSleepTimerModal() {
this.$emit('showSleepTimer')
},
setPlaybackSpeed(speed) {
async setPlaybackSpeed(speed) {
await this.$hapticsImpactMedium()
console.log(`[AudioPlayer] Set Playback Rate: ${speed}`)
this.currentPlaybackRate = speed
AbsAudioPlayer.setPlaybackSpeed({ value: speed })
@@ -324,32 +405,34 @@ export default {
restart() {
this.seek(0)
},
backward10() {
async jumpBackwards() {
await this.$hapticsImpactMedium()
if (this.isLoading) return
AbsAudioPlayer.seekBackward({ value: 10 })
AbsAudioPlayer.seekBackward({ value: this.jumpBackwardsTime })
},
forward10() {
async jumpForward() {
await this.$hapticsImpactMedium()
if (this.isLoading) return
AbsAudioPlayer.seekForward({ value: 10 })
AbsAudioPlayer.seekForward({ value: this.jumpForwardTime })
},
setStreamReady() {
this.readyTrackWidth = this.trackWidth
this.updateReadyTrack()
},
setChunksReady(chunks, numSegments) {
var largestSeg = 0
let largestSeg = 0
for (let i = 0; i < chunks.length; i++) {
var chunk = chunks[i]
const chunk = chunks[i]
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[1] > largestSeg) largestSeg = chunkRange[1]
} else if (chunk > largestSeg) {
largestSeg = chunk
}
}
var percentageReady = largestSeg / numSegments
var widthReady = Math.round(this.trackWidth * percentageReady)
const percentageReady = largestSeg / numSegments
const widthReady = Math.round(this.trackWidth * percentageReady)
if (this.readyTrackWidth === widthReady) {
return
}
@@ -401,23 +484,23 @@ export default {
},
updateTrack() {
// Update progress track UI
var percentDone = this.currentTime / this.totalDuration
var totalPercentDone = percentDone
var bufferedPercent = this.bufferedTime / this.totalDuration
var totalBufferedPercent = bufferedPercent
let percentDone = this.currentTime / this.totalDuration
const totalPercentDone = percentDone
let bufferedPercent = this.bufferedTime / this.totalDuration
const totalBufferedPercent = bufferedPercent
if (this.useChapterTrack && this.currentChapter) {
var currChapTime = this.currentTime - this.currentChapter.start
const currChapTime = this.currentTime - this.currentChapter.start
percentDone = currChapTime / this.currentChapterDuration
bufferedPercent = (this.bufferedTime - this.currentChapter.start) / this.currentChapterDuration
}
var ptWidth = Math.round(percentDone * this.trackWidth)
if (this.playedTrackWidth === ptWidth) {
return
bufferedPercent = Math.max(0, Math.min(1, (this.bufferedTime - this.currentChapter.start) / this.currentChapterDuration))
}
const ptWidth = Math.round(percentDone * this.trackWidth)
this.$refs.playedTrack.style.width = ptWidth + 'px'
this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px'
this.playedTrackWidth = ptWidth
if (this.$refs.trackCursor) {
this.$refs.trackCursor.style.left = ptWidth - 8 + 'px'
}
if (this.useChapterTrack) {
if (this.$refs.totalPlayedTrack) this.$refs.totalPlayedTrack.style.width = Math.round(totalPercentDone * this.trackWidth) + 'px'
@@ -440,14 +523,13 @@ export default {
var perc = time / this.totalDuration
var ptWidth = Math.round(perc * this.trackWidth)
this.$refs.playedTrack.style.width = ptWidth + 'px'
this.playedTrackWidth = ptWidth
this.$refs.playedTrack.classList.remove('bg-gray-200')
this.$refs.playedTrack.classList.add('bg-yellow-300')
}
},
clickTrack(e) {
if (this.isLoading) return
if (this.isLoading || this.lockUi) return
if (!this.showFullscreen) {
// Track not clickable on mini-player
return
@@ -469,6 +551,7 @@ export default {
this.seek(time)
},
async playPauseClick() {
await this.$hapticsImpactMedium()
if (this.isLoading) return
this.isPlaying = !!((await AbsAudioPlayer.playPause()) || {}).playing
@@ -490,7 +573,6 @@ export default {
var data = await AbsAudioPlayer.getCurrentTime()
this.currentTime = Number(data.value.toFixed(2))
this.bufferedTime = Number(data.bufferedTime.toFixed(2))
console.log('[AudioPlayer] Got Current Time', this.currentTime)
this.timeupdate()
}, 1000)
},
@@ -517,29 +599,79 @@ export default {
this.touchStartTime = Date.now()
},
touchend(e) {
if (!this.showFullscreen || !e.changedTouches) return
if (!e.changedTouches) return
this.touchEndY = e.changedTouches[0].screenY
var touchDuration = Date.now() - this.touchStartTime
if (touchDuration > 1200) {
// console.log('touch too long', touchDuration)
return
if (this.touchTrackStart) {
var touch = e.changedTouches[0]
const touchOnTrackPos = touch.pageX - 12
const dragPercent = Math.max(0, Math.min(1, touchOnTrackPos / this.trackWidth))
var seekToTime = 0
if (this.useChapterTrack && this.currentChapter) {
const currChapTime = dragPercent * this.currentChapterDuration
seekToTime = this.currentChapter.start + currChapTime
} else {
seekToTime = dragPercent * this.totalDuration
}
this.seek(seekToTime)
if (this.$refs.draggingTrack) {
this.$refs.draggingTrack.style.width = '0px'
}
this.touchTrackStart = false
} else if (this.showFullscreen) {
this.touchEndY = e.changedTouches[0].screenY
var touchDuration = Date.now() - this.touchStartTime
if (touchDuration > 1200) {
// console.log('touch too long', touchDuration)
return
}
this.handleGesture()
}
this.handleGesture()
},
clickMenuAction(action) {
if (action === 'chapter_track') {
this.useChapterTrack = !this.useChapterTrack
touchmove(e) {
if (!this.touchTrackStart) return
var touch = e.touches[0]
const touchOnTrackPos = touch.pageX - 12
const dragPercent = Math.max(0, Math.min(1, touchOnTrackPos / this.trackWidth))
this.dragPercent = dragPercent
if (this.$refs.draggingTrack) {
this.$refs.draggingTrack.style.width = this.dragPercent * this.trackWidth + 'px'
}
var ts = this.$refs.currentTimestamp
if (ts) {
var currTimeStr = ''
if (this.useChapterTrack && this.currentChapter) {
const currChapTime = dragPercent * this.currentChapterDuration
currTimeStr = this.$secondsToTimestamp(currChapTime)
} else {
const dragTime = dragPercent * this.totalDuration
currTimeStr = this.$secondsToTimestamp(dragTime)
}
ts.innerText = currTimeStr
}
},
async clickMenuAction(action) {
await this.$hapticsImpactMedium()
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.updateTrack()
this.updateReadyTrack()
})
this.$localStore.setUseChapterTrack(this.useChapterTrack)
} else if (action === 'close') {
this.closePlayback()
}
this.$localStore.setUseChapterTrack(this.useChapterTrack)
} else if (action === 'close') {
this.closePlayback()
}
})
},
forceCloseDropdownMenu() {
if (this.$refs.dropdownMenu && this.$refs.dropdownMenu.closeMenu) {
@@ -598,6 +730,7 @@ export default {
this.isEnded = false
this.isLoading = true
this.syncStatus = 0
this.$store.commit('setPlayerItem', this.playbackSession)
// Set track width
@@ -610,7 +743,6 @@ export default {
})
},
onPlaybackClosed() {
console.log('Received onPlaybackClosed evt')
this.endPlayback()
},
onPlaybackFailed(data) {
@@ -621,41 +753,97 @@ export default {
},
async init() {
this.useChapterTrack = await this.$localStore.getUseChapterTrack()
this.lockUi = await this.$localStore.getPlayerLock()
this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession)
this.onPlaybackClosedListener = AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed)
this.onPlaybackFailedListener = AbsAudioPlayer.addListener('onPlaybackFailed', this.onPlaybackFailed)
this.onPlayingUpdateListener = AbsAudioPlayer.addListener('onPlayingUpdate', this.onPlayingUpdate)
this.onMetadataListener = AbsAudioPlayer.addListener('onMetadata', this.onMetadata)
this.onProgressSyncFailing = AbsAudioPlayer.addListener('onProgressSyncFailing', this.showProgressSyncIsFailing)
this.onProgressSyncSuccess = AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess)
},
screenOrientationChange() {
setTimeout(() => {
this.updateScreenSize()
if (this.$refs.track) {
this.trackWidth = this.$refs.track.clientWidth
this.updateTrack()
this.updateReadyTrack()
}
}, 50)
},
updateScreenSize() {
this.windowHeight = window.innerHeight
const coverHeight = this.fullscreenBookCoverWidth * this.bookCoverAspectRatio
document.documentElement.style.setProperty('--cover-image-width', this.fullscreenBookCoverWidth + 'px')
document.documentElement.style.setProperty('--cover-image-height', coverHeight + 'px')
},
minimizePlayerEvt() {
this.showFullscreen = false
},
showProgressSyncIsFailing() {
this.syncStatus = this.$constants.SyncStatus.FAILED
},
showProgressSyncSuccess() {
this.syncStatus = this.$constants.SyncStatus.SUCCESS
}
},
mounted() {
this.updateScreenSize()
if (screen.orientation) {
// Not available on ios
screen.orientation.addEventListener('change', this.screenOrientationChange)
} else {
document.addEventListener('orientationchange', this.screenOrientationChange)
}
window.addEventListener('resize', this.screenOrientationChange)
this.$eventBus.$on('minimize-player', this.minimizePlayerEvt)
document.body.addEventListener('touchstart', this.touchstart)
document.body.addEventListener('touchend', this.touchend)
document.body.addEventListener('touchmove', this.touchmove)
this.$nextTick(this.init)
},
beforeDestroy() {
if (screen.orientation) {
// Not available on ios
screen.orientation.removeEventListener('change', this.screenOrientationChange)
} else {
document.removeEventListener('orientationchange', this.screenOrientationChange)
}
window.removeEventListener('resize', this.screenOrientationChange)
if (this.playbackSession) {
console.log('[AudioPlayer] Before destroy closing playback')
this.closePlayback()
}
this.forceCloseDropdownMenu()
this.$eventBus.$off('minimize-player', this.minimizePlayerEvt)
document.body.removeEventListener('touchstart', this.touchstart)
document.body.removeEventListener('touchend', this.touchend)
document.body.removeEventListener('touchmove', this.touchmove)
if (this.onPlayingUpdateListener) this.onPlayingUpdateListener.remove()
if (this.onMetadataListener) this.onMetadataListener.remove()
if (this.onPlaybackSessionListener) this.onPlaybackSessionListener.remove()
if (this.onPlaybackClosedListener) this.onPlaybackClosedListener.remove()
if (this.onPlaybackFailedListener) this.onPlaybackFailedListener.remove()
if (this.onProgressSyncFailing) this.onProgressSyncFailing.remove()
if (this.onProgressSyncSuccess) this.onProgressSyncSuccess.remove()
clearInterval(this.playInterval)
}
}
</script>
<style>
:root {
--cover-image-width: 0px;
--cover-image-height: 0px;
--cover-image-width-collapsed: 46px;
--cover-image-height-collapsed: 46px;
}
.bookCoverWrapper {
box-shadow: 3px -2px 5px #00000066;
}
@@ -672,10 +860,10 @@ export default {
#playerTrack {
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
transition-property: margin;
margin-bottom: 4px;
bottom: 10px;
}
.fullscreen #playerTrack {
margin-bottom: 18px;
bottom: 32px;
}
.ios-player #timestamp-row {
@@ -684,17 +872,14 @@ export default {
}
.cover-wrapper {
bottom: 44px;
bottom: 48px;
left: 12px;
height: 96px;
width: 60px;
height: var(--cover-image-height-collapsed);
width: var(--cover-image-width-collapsed);
transition: all 0.25s cubic-bezier(0.39, 0.575, 0.565, 1);
transition-property: left, bottom, width, height;
transform-origin: left bottom;
}
.cover-wrapper.square-cover {
height: 60px;
}
.total-track {
bottom: 215px;
@@ -708,8 +893,8 @@ export default {
transform-origin: left bottom;
width: 40%;
bottom: 50px;
left: 80px;
bottom: 56px;
left: 70px;
text-align: left;
}
.title-author-texts .title-text {
@@ -726,16 +911,18 @@ export default {
}
.fullscreen .title-author-texts {
bottom: 36%;
bottom: calc(50% - var(--cover-image-height) / 2 + 50px);
width: 80%;
left: 10%;
text-align: center;
padding-bottom: calc(((260px - var(--cover-image-height)) / 260) * 40);
pointer-events: auto;
}
.fullscreen .title-author-texts .title-text {
font-size: 1.2rem;
font-size: clamp(0.8rem, calc(var(--cover-image-height) / 260 * 20), 1.2rem);
}
.fullscreen .title-author-texts .author-text {
font-size: 1rem;
font-size: clamp(0.6rem, calc(var(--cover-image-height) / 260 * 16), 1rem);
}
#playerControls {
@@ -745,7 +932,7 @@ export default {
width: 140px;
padding-left: 12px;
padding-right: 12px;
bottom: 45px;
bottom: 50px;
}
#playerControls .jump-icon {
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
@@ -773,21 +960,16 @@ export default {
}
.fullscreen .cover-wrapper {
bottom: 46%;
left: calc(50% - 100px);
margin: 0 auto;
height: 320px;
width: 200px;
}
.fullscreen .cover-wrapper.square-cover {
height: 260px;
width: 260px;
left: calc(50% - 130px);
height: var(--cover-image-height);
width: var(--cover-image-width);
left: calc(50% - (calc(var(--cover-image-width)) / 2));
bottom: calc(50% + 120px - (calc(var(--cover-image-height)) / 2));
}
.fullscreen #playerControls {
width: 100%;
bottom: 100px;
bottom: 105px;
}
.fullscreen #playerControls .jump-icon {
margin: 0px 18px;
@@ -808,4 +990,4 @@ export default {
.fullscreen #playerControls .play-btn .material-icons {
font-size: 2.1rem;
}
</style>
</style>
+58 -48
View File
@@ -1,6 +1,6 @@
<template>
<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-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 {
data() {
return {
isReady: false,
settingsLoaded: false,
audioPlayerReady: false,
stream: null,
download: null,
@@ -32,24 +34,14 @@ export default {
onMediaPlayerChangedListener: null,
sleepInterval: null,
currentEndOfChapterTime: 0,
serverLibraryItemId: null
}
},
watch: {
socketConnected(newVal) {
if (newVal) {
console.log('Socket Connected set listeners')
this.setListeners()
}
serverLibraryItemId: null,
serverEpisodeId: null
}
},
computed: {
bookmarks() {
if (!this.serverLibraryItemId) return []
return this.$store.getters['user/getUserBookmarksForItem'](this.serverLibraryItemId)
},
socketConnected() {
return this.$store.state.socketConnected
}
},
methods: {
@@ -59,7 +51,7 @@ export default {
selectBookmark(bookmark) {
this.showBookmarksModal = false
if (!bookmark || isNaN(bookmark.time)) return
var bookmarkTime = Number(bookmark.time)
const bookmarkTime = Number(bookmark.time)
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.seek(bookmarkTime)
}
@@ -68,8 +60,6 @@ export default {
this.isSleepTimerRunning = false
if (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 }) {
@@ -150,17 +140,12 @@ export default {
console.log(`[AudioPlayerContainer] PlaybackRate Updated: ${this.playbackSpeed}`)
this.$refs.audioPlayer.setPlaybackSpeed(this.playbackSpeed)
}
},
setListeners() {
// if (!this.$server.socket) {
// console.error('Invalid server socket not set')
// return
// }
// 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)
// Settings have been loaded (at least once, so it's safe to kickoff onReady)
if (!this.settingsLoaded) {
this.settingsLoaded = true
this.notifyOnReady()
}
},
closeStreamOnly() {
// If user logs out or disconnects from server and not playing local
@@ -173,30 +158,37 @@ export default {
this.$toast.error(`Cannot cast locally downloaded media`)
} else {
// Change to server library item
this.playServerLibraryItemAndCast(this.serverLibraryItemId)
this.playServerLibraryItemAndCast(this.serverLibraryItemId, this.serverEpisodeId)
}
},
playServerLibraryItemAndCast(libraryItemId) {
playServerLibraryItemAndCast(libraryItemId, episodeId) {
var playbackRate = 1
if (this.$refs.audioPlayer) {
playbackRate = this.$refs.audioPlayer.currentPlaybackRate || 1
}
AbsAudioPlayer.prepareLibraryItem({ libraryItemId, episodeId: null, playWhenReady: false, playbackRate })
AbsAudioPlayer.prepareLibraryItem({ libraryItemId, episodeId, playWhenReady: false, playbackRate })
.then((data) => {
console.log('Library item play response', JSON.stringify(data))
AbsAudioPlayer.requestSession()
if (data.error) {
const errorMsg = data.error || 'Failed to play'
this.$toast.error(errorMsg)
} else {
console.log('Library item play response', JSON.stringify(data))
AbsAudioPlayer.requestSession()
}
})
.catch((error) => {
console.error('Failed', error)
this.$toast.error('Failed to play')
})
},
async playLibraryItem(payload) {
var libraryItemId = payload.libraryItemId
var episodeId = payload.episodeId
const libraryItemId = payload.libraryItemId
const episodeId = payload.episodeId
// 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
var serverLibraryItemId = payload.serverLibraryItemId || null
const serverLibraryItemId = payload.serverLibraryItemId || null
const serverEpisodeId = payload.serverEpisodeId || null
if (libraryItemId.startsWith('local') && this.$store.state.isCasting) {
const { value } = await Dialog.confirm({
@@ -209,8 +201,9 @@ export default {
}
this.serverLibraryItemId = null
this.serverEpisodeId = null
var playbackRate = 1
let playbackRate = 1
if (this.$refs.audioPlayer) {
playbackRate = this.$refs.audioPlayer.currentPlaybackRate || 1
}
@@ -218,15 +211,26 @@ export default {
console.log('Called playLibraryItem', libraryItemId)
AbsAudioPlayer.prepareLibraryItem({ libraryItemId, episodeId, playWhenReady: true, playbackRate })
.then((data) => {
console.log('Library item play response', JSON.stringify(data))
if (!libraryItemId.startsWith('local')) {
this.serverLibraryItemId = libraryItemId
if (data.error) {
const errorMsg = data.error || 'Failed to play'
this.$toast.error(errorMsg)
} else {
this.serverLibraryItemId = serverLibraryItemId
console.log('Library item play response', JSON.stringify(data))
if (!libraryItemId.startsWith('local')) {
this.serverLibraryItemId = libraryItemId
} else {
this.serverLibraryItemId = serverLibraryItemId
}
if (episodeId && !episodeId.startsWith('local')) {
this.serverEpisodeId = episodeId
} else {
this.serverEpisodeId = serverEpisodeId
}
}
})
.catch((error) => {
console.error('Failed', error)
this.$toast.error('Failed to play')
})
},
pauseItem() {
@@ -241,6 +245,18 @@ export default {
onMediaPlayerChanged(data) {
var mediaPlayer = data.value
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() {
@@ -252,7 +268,7 @@ export default {
this.playbackSpeed = this.$store.getters['user/getUserSetting']('playbackRate')
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('pause-item', this.pauseItem)
this.$eventBus.$on('close-stream', this.closeStreamOnly)
@@ -265,13 +281,7 @@ export default {
if (this.onSleepTimerSetListener) this.onSleepTimerSetListener.remove()
if (this.onMediaPlayerChangedListener) this.onMediaPlayerChangedListener.remove()
// if (this.$server.socket) {
// 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('abs-ui-ready', this.onReady)
this.$eventBus.$off('play-item', this.playLibraryItem)
this.$eventBus.$off('pause-item', this.pauseItem)
this.$eventBus.$off('close-stream', this.closeStreamOnly)
+30 -10
View File
@@ -19,7 +19,7 @@
</div>
<div class="absolute bottom-0 left-0 w-full py-6 px-6 text-gray-300">
<div v-if="serverConnectionConfig" class="mb-4 flex justify-center">
<p class="text-xs">{{ serverConnectionConfig.address }}</p>
<p class="text-xs text-gray-400" style="word-break: break-word">{{ serverConnectionConfig.address }} (v{{ serverSettings.version }})</p>
</div>
<div class="flex items-center">
<p class="text-xs">{{ $config.version }}</p>
@@ -71,6 +71,9 @@ export default {
serverConnectionConfig() {
return this.$store.state.user.serverConnectionConfig
},
serverSettings() {
return this.$store.state.serverSettings || {}
},
username() {
return this.user ? this.user.username : ''
},
@@ -96,6 +99,11 @@ export default {
text: 'Account',
to: '/account'
})
items.push({
icon: 'equalizer',
text: 'User Stats',
to: '/stats'
})
}
if (this.$platform !== 'ios') {
@@ -105,12 +113,19 @@ export default {
text: 'Local Media',
to: '/localMedia/folders'
})
// items.push({
// icon: 'settings',
// text: 'Settings',
// to: '/settings'
// })
} else {
items.push({
icon: 'download',
iconOutlined: false,
text: 'Downloads',
to: '/downloads'
})
}
items.push({
icon: 'settings',
text: 'Settings',
to: '/settings'
})
return items
},
currentRoutePath() {
@@ -122,11 +137,16 @@ export default {
this.show = false
},
async logout() {
await this.$axios.$post('/logout').catch((error) => {
console.error(error)
})
await this.$hapticsImpactMedium()
if (this.user) {
await this.$axios.$post('/logout').catch((error) => {
console.error(error)
})
}
this.$socket.logout()
await this.$db.logout()
this.$localStore.removeLastLibraryId()
this.$store.commit('user/logout')
this.$router.push('/connect')
},
@@ -155,4 +175,4 @@ export default {
this.show = false
}
}
</script>
</script>
+29 -17
View File
@@ -1,9 +1,9 @@
<template>
<div id="bookshelf" class="w-full max-w-full h-full">
<template v-for="shelf in totalShelves">
<div :key="shelf" class="w-full px-2 relative" :class="showBookshelfListView ? '' : 'bookshelfRow'" :id="`shelf-${shelf - 1}`" :style="{ height: shelfHeight + 'px' }">
<div v-if="!showBookshelfListView" class="bookshelfDivider w-full absolute bottom-0 left-0 z-30" style="min-height: 16px" :class="`h-${shelfDividerHeightIndex}`" />
<div v-else class="flex border-t border-white border-opacity-10" />
<div :key="shelf" class="w-full px-2 relative" :class="showBookshelfListView || altViewEnabled ? '' : 'bookshelfRow'" :id="`shelf-${shelf - 1}`" :style="{ height: shelfHeight + 'px' }">
<div v-if="!showBookshelfListView && !altViewEnabled" class="w-full absolute bottom-0 left-0 z-30 bookshelfDivider" style="min-height: 16px" :class="`h-${shelfDividerHeightIndex}`" />
<div v-else-if="showBookshelfListView" class="flex border-t border-white border-opacity-10" />
</div>
</template>
@@ -89,22 +89,22 @@ export default {
return this.bookCoverAspectRatio === 1
},
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
bookWidth() {
var coverSize = 100
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
},
bookHeight() {
if (this.isCoverSquareAspectRatio) return this.bookWidth
if (this.isCoverSquareAspectRatio || this.entityName === 'playlists') return this.bookWidth
return this.bookWidth * 1.6
},
entityWidth() {
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
},
entityHeight() {
@@ -119,12 +119,23 @@ export default {
},
shelfHeight() {
if (this.showBookshelfListView) return this.entityHeight + 16
if (this.altViewEnabled) {
var extraTitleSpace = this.isBookEntity ? 80 : 40
return this.entityHeight + extraTitleSpace * this.sizeMultiplier
}
return this.entityHeight + 40
},
totalEntityCardWidth() {
if (this.showBookshelfListView) return this.entityWidth
// Includes margin
return this.entityWidth + 24
},
altViewEnabled() {
return this.$store.getters['getAltViewEnabled']
},
sizeMultiplier() {
const baseSize = this.isCoverSquareAspectRatio ? 192 : 120
return this.entityWidth / baseSize
}
},
methods: {
@@ -134,7 +145,7 @@ export default {
})
},
async fetchEntities(page) {
var startIndex = page * this.booksPerFetch
const startIndex = page * this.booksPerFetch
this.isFetchingEntities = true
@@ -142,11 +153,11 @@ export default {
this.currentSFQueryString = this.buildSearchParams()
}
var entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `items` : this.entityName
var sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
var fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1`
const entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `items` : this.entityName
const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1`
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)
return null
})
@@ -168,13 +179,13 @@ export default {
}
for (let i = 0; i < payload.results.length; i++) {
var index = i + startIndex
const index = i + startIndex
this.entities[index] = payload.results[i]
if (this.entityComponentRefs[index]) {
this.entityComponentRefs[index].setEntity(this.entities[index])
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) {
this.entityComponentRefs[index].setLocalLibraryItem(localLibraryItem)
}
@@ -302,6 +313,9 @@ export default {
this.shelvesPerPage = Math.ceil(this.bookshelfHeight / this.shelfHeight) + 2
this.bookshelfMarginLeft = (this.bookshelfWidth - this.entitiesPerShelf * this.totalEntityCardWidth) / 2
const entitiesPerPage = this.shelvesPerPage * this.entitiesPerShelf
this.booksPerFetch = Math.ceil(entitiesPerPage / 20) * 20 // Round up to the nearest 20
if (this.totalEntities) {
this.totalShelves = Math.ceil(this.totalEntities / this.entitiesPerShelf)
}
@@ -342,8 +356,6 @@ export default {
let searchParams = new URLSearchParams()
if (this.page === 'series-books') {
searchParams.set('filter', `series.${this.$encode(this.seriesId)}`)
searchParams.set('sort', 'book.volumeNumber')
searchParams.set('desc', 0)
} else {
if (this.filterBy && this.filterBy !== 'all') {
searchParams.set('filter', this.filterBy)
@@ -382,7 +394,7 @@ export default {
this.resetEntities()
}
},
libraryChanged(libid) {
libraryChanged() {
if (this.hasFilter) {
this.clearFilter()
} else {
+34 -10
View File
@@ -1,20 +1,24 @@
<template>
<div class="w-full relative">
<div class="bookshelfRow flex items-end px-3 max-w-full overflow-x-auto" :style="{ height: shelfHeight + 'px' }">
<div v-if="altViewEnabled" class="px-5 pb-3 pt-4">
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ label }}</p>
</div>
<div class="flex items-end px-3 max-w-full overflow-x-auto" :class="altViewEnabled ? '' : 'bookshelfRow'" :style="{ height: shelfHeight + 'px', paddingBottom: entityPaddingBottom + 'px' }">
<template v-for="(entity, index) in entities">
<cards-lazy-book-card v-if="type === 'book' || type === 'podcast'" :key="entity.id" :index="index" :book-mount="entity" :width="bookWidth" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" is-categorized class="mx-2 relative" />
<cards-lazy-book-card v-if="type === 'episode'" :key="entity.recentEpisode.id" :index="index" :book-mount="entity" :width="bookWidth" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" is-categorized class="mx-2 relative" />
<cards-lazy-series-card v-else-if="type === 'series'" :key="entity.id" :index="index" :series-mount="entity" :width="bookWidth * 2" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" is-categorized class="mx-2 relative" />
<cards-lazy-book-card v-if="type === 'book' || type === 'podcast'" :key="entity.id" :index="index" :book-mount="entity" :width="bookWidth" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :is-alt-view-enabled="altViewEnabled" class="mx-2 relative" />
<cards-lazy-book-card v-if="type === 'episode'" :key="entity.recentEpisode.id" :index="index" :book-mount="entity" :width="bookWidth" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :is-alt-view-enabled="altViewEnabled" class="mx-2 relative" />
<cards-lazy-series-card v-else-if="type === 'series'" :key="entity.id" :index="index" :series-mount="entity" :width="bookWidth * 2" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :is-alt-view-enabled="altViewEnabled" is-categorized class="mx-2 relative" />
<cards-author-card v-else-if="type === 'authors'" :key="entity.id" :width="bookWidth / 1.25" :height="bookWidth" :author="entity" :size-multiplier="1" class="mx-2" />
</template>
</div>
<div class="absolute text-center categoryPlacard font-book transform z-30 bottom-0.5 left-4 md:left-8 w-36 rounded-md" style="height: 18px">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border">
<div v-if="!altViewEnabled" class="absolute text-center categoryPlacard font-book transform z-30 bottom-0.5 left-4 md:left-8 w-36 rounded-md" style="height: 18px">
<div class="w-full h-full flex items-center justify-center rounded-sm border shinyBlack">
<p class="transform text-xs">{{ label }}</p>
</div>
</div>
<div class="w-full h-5 z-40 bookshelfDivider"></div>
<div v-if="!altViewEnabled" class="w-full h-5 z-40 bookshelfDivider"></div>
</div>
</template>
@@ -32,23 +36,43 @@ export default {
return {}
},
computed: {
entityPaddingBottom() {
if (!this.altViewEnabled) return 0
if (this.type === 'authors') return 10
else if (this.type === 'series') return 40
return 60 * this.sizeMultiplier
},
shelfHeight() {
if (this.altViewEnabled) {
var extraTitleSpace = this.type === 'authors' ? 10 : this.type === 'series' ? 50 : 60
return this.entityHeight + extraTitleSpace * this.sizeMultiplier
}
return this.entityHeight + 40
},
bookWidth() {
var coverSize = 100
if (this.bookCoverAspectRatio === 1) return coverSize * 1.6
if (this.isCoverSquareAspectRatio) return coverSize * 1.6
return coverSize
},
bookHeight() {
if (this.bookCoverAspectRatio === 1) return this.bookWidth
if (this.isCoverSquareAspectRatio) return this.bookWidth
return this.bookWidth * 1.6
},
entityHeight() {
return this.bookHeight
},
sizeMultiplier() {
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
return this.bookWidth / baseSize
},
isCoverSquareAspectRatio() {
return this.bookCoverAspectRatio === 1
},
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
altViewEnabled() {
return this.$store.getters['getAltViewEnabled']
}
},
methods: {},

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