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
advplyr 92f069d1e6 iOS version bump 0.9.46 2022-05-22 17:51:35 -05:00
advplyr 62c5042ecc Fix:Cover image stretch to fill 2022-05-22 17:49:10 -05:00
advplyr 604b086c0b Android version bump 0.9.46-beta 2022-05-22 17:04:19 -05:00
advplyr 0e0b356f6b Fix:Reset volume when playing from notification after sleep timer decreases volume #182 2022-05-22 16:24:38 -05:00
advplyr 573768e2b2 Fix:Select podcast folder when none is set yet 2022-05-22 15:56:32 -05:00
advplyr 236fd09c94 Fix:Hide download button if user does not have download permission #189 2022-05-22 15:49:42 -05:00
advplyr cb6cb5f637 Fix:M4B downloads on Android 9 & 10 not detected as audio files (mimeType is showing as application/octet-stream) #154 2022-05-22 15:31:25 -05:00
advplyr eb916a3ab0 Add:Duration sort 2022-05-22 14:47:07 -05:00
advplyr 61b8ca1510 Add:Persist scroll position for bookshelves #196, Fix:Hide clear filter button for series and collections 2022-05-22 14:19:13 -05:00
advplyr 5d2da97dc5 Update:Android auto to show libraries and browseable podcasts, Fix:Download podcast set episodes and cover art correctly 2022-05-22 13:40:01 -05:00
advplyr d626686614 Add start of downloading library item on iOS 2022-05-14 11:50:53 -05:00
advplyr 60ee33cb72 Update:Show download button on iOS and call downloadLibraryItem without a folderId 2022-05-14 09:08:52 -05:00
advplyr 8d2498e96d Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2022-05-13 16:27:10 -05:00
advplyr 1794484bed Fix:Realtime updates for book cards when series sequence changes 2022-05-13 16:27:05 -05:00
advplyr f2b6331843 Add iOS AbsDownloader and AbsFileSystem plugins 2022-05-13 15:18:21 -05:00
advplyr 8d5f33245f Version bump 0.9.45 2022-05-13 13:53:37 -05:00
advplyr 1ee544b842 Fix:LazyBookshelf default to square cover before server settings are loaded 2022-05-13 13:42:38 -05:00
advplyr 0fd9463c7c Update:Set installLocation in android manifest to preferExternal for amazon app store 2022-05-13 11:04:52 -05:00
advplyr ad5edf3aee Fix:iOS audio player cutting off timestamps #173 2022-05-13 09:28:43 -05:00
advplyr d5fafd8cab Fix:Bookshelf list view wrong entity height #179 2022-05-12 18:24:36 -05:00
advplyr 92256f2fed Fix:Create bookmark was requiring a socket connection still 2022-05-11 18:59:24 -05:00
advplyr 5fa8d4c989 Fix:Check local library item is linked to a server item before sending playback session progress sync to server 2022-05-11 17:05:51 -05:00
advplyr 6b75f79f00 Fix wrong android version in comment 2022-05-08 15:01:36 -05:00
advplyr b0c9f29d90 Fix:Downloaded covers when used in media notification crashing android 11 #35 #164 2022-05-08 14:34:28 -05:00
advplyr 7721afc116 Remove unnecessary background-image 2022-05-07 20:19:49 -05:00
advplyr bc85e9bbfa Merge 2022-05-07 17:33:07 -05:00
advplyr e8b6602fe5 Remove app update package 2022-05-07 17:32:18 -05:00
265 changed files with 31385 additions and 2636 deletions
+15 -5
View File
@@ -21,7 +21,11 @@ kotlin {
} }
android { android {
kotlinOptions {
buildFeatures {
viewBinding true
}
kotlinOptions {
freeCompilerArgs = ['-Xjvm-default=all'] freeCompilerArgs = ['-Xjvm-default=all']
} }
compileSdkVersion rootProject.ext.compileSdkVersion compileSdkVersion rootProject.ext.compileSdkVersion
@@ -29,8 +33,8 @@ android {
applicationId "com.audiobookshelf.app" applicationId "com.audiobookshelf.app"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 73 versionCode 91
versionName "0.9.44-beta" versionName "0.9.60-beta"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -39,6 +43,12 @@ android {
} }
} }
buildTypes { buildTypes {
debug {
applicationIdSuffix ".debug"
versionNameSuffix "-debug"
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
release { release {
minifyEnabled false minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
@@ -111,13 +121,13 @@ dependencies {
implementation 'io.github.pilgr:paperdb:2.7.2' implementation 'io.github.pilgr:paperdb:2.7.2'
// Simple Storage // Simple Storage
implementation "com.anggrayudi:storage:0.13.0" implementation "com.anggrayudi:storage:0.14.0"
// OK HTTP // OK HTTP
implementation 'com.squareup.okhttp3:okhttp:4.9.2' implementation 'com.squareup.okhttp3:okhttp:4.9.2'
// Jackson for JSON // 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 // FFMPEG-Kit
implementation 'com.arthenica:ffmpeg-kit-min:4.5.1' implementation 'com.arthenica:ffmpeg-kit-min:4.5.1'
-1
View File
@@ -15,7 +15,6 @@ dependencies {
implementation project(':capacitor-network') implementation project(':capacitor-network')
implementation project(':capacitor-status-bar') implementation project(':capacitor-status-bar')
implementation project(':capacitor-storage') implementation project(':capacitor-storage')
implementation project(':robingenz-capacitor-app-update')
} }
@@ -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 -51
View File
@@ -1,82 +1,94 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:dist="http://schemas.android.com/apk/distribution" xmlns:dist="http://schemas.android.com/apk/distribution"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
package="com.audiobookshelf.app"> package="com.audiobookshelf.app"
android:installLocation="preferExternal" >
<!-- Permissions --> <!-- Permissions -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" /> android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<application <application
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/AppTheme" android:theme="@style/AppTheme"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true" >
android:networkSecurityConfig="@xml/network_security_config" <!-- <receiver-->
android:requestLegacyExternalStorage="true"> <!-- 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--> <!-- Used by Android Auto -->
<meta-data android:name="com.google.android.gms.car.notification.SmallIcon"
android:resource="@drawable/icon" />
<meta-data <meta-data
android:name="com.google.android.gms.car.application" android:name="com.google.android.gms.car.notification.SmallIcon"
android:resource="@xml/automotive_app_desc"/> android:resource="@drawable/icon" />
<meta-data
<!-- Support for Cast --> android:name="com.google.android.gms.car.application"
<meta-data android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME" android:resource="@xml/automotive_app_desc" /> <!-- Support for Cast -->
android:value="com.audiobookshelf.app.CastOptionsProvider"/> <meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.audiobookshelf.app.CastOptionsProvider" />
<activity <activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode" 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:exported="true"
android:theme="@style/AppTheme.NoActionBarLaunch" android:label="@string/title_activity_main"
android:launchMode="singleTask"> android:launchMode="singleTask"
android:theme="@style/AppTheme.NoActionBarLaunch" >
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<intent-filter> <category android:name="android.intent.category.LAUNCHER" />
<action android:name="android.intent.action.MAIN" /> </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> <category android:name="android.intent.category.DEFAULT" />
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" /> </intent-filter>
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<!-- Register URL scheme --> <!-- Register URL scheme -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.DEFAULT" />
<data android:scheme="@string/custom_url_scheme" /> <category android:name="android.intent.category.BROWSABLE" />
</intent-filter>
<data android:scheme="@string/custom_url_scheme" />
</intent-filter>
</activity> </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" > <service
<intent-filter> android:name=".player.PlayerNotificationService"
<action android:name="android.intent.action.MEDIA_BUTTON" /> android:enabled="true"
</intent-filter> android:exported="true" >
</receiver> <intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
<service </intent-filter>
android:exported="true" </service>
android:enabled="true"
android:name=".player.PlayerNotificationService">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService"/>
</intent-filter>
</service>
</application> </application>
</manifest> </manifest>
@@ -22,9 +22,5 @@
{ {
"pkg": "@capacitor/storage", "pkg": "@capacitor/storage",
"classpath": "com.capacitorjs.plugins.storage.StoragePlugin" "classpath": "com.capacitorjs.plugins.storage.StoragePlugin"
},
{
"pkg": "@robingenz/capacitor-app-update",
"classpath": "dev.robingenz.capacitor.appupdate.AppUpdatePlugin"
} }
] ]
@@ -1,21 +1,24 @@
package com.audiobookshelf.app package com.audiobookshelf.app
import android.Manifest import android.Manifest
import android.app.DownloadManager import android.content.ComponentName
import android.content.* import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.* import android.os.Bundle
import android.os.IBinder
import android.util.Log import android.util.Log
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import com.anggrayudi.storage.SimpleStorage import com.anggrayudi.storage.SimpleStorage
import com.anggrayudi.storage.SimpleStorageHelper import com.anggrayudi.storage.SimpleStorageHelper
import com.audiobookshelf.app.data.AbsDatabase import com.audiobookshelf.app.data.AbsDatabase
import com.audiobookshelf.app.data.DbManager
import com.audiobookshelf.app.player.PlayerNotificationService import com.audiobookshelf.app.player.PlayerNotificationService
import com.audiobookshelf.app.plugins.AbsAudioPlayer import com.audiobookshelf.app.plugins.AbsAudioPlayer
import com.audiobookshelf.app.plugins.AbsDownloader import com.audiobookshelf.app.plugins.AbsDownloader
import com.audiobookshelf.app.plugins.AbsFileSystem import com.audiobookshelf.app.plugins.AbsFileSystem
import com.getcapacitor.BridgeActivity import com.getcapacitor.BridgeActivity
import io.paperdb.Paper
class MainActivity : BridgeActivity() { class MainActivity : BridgeActivity() {
@@ -50,14 +53,11 @@ class MainActivity : BridgeActivity() {
// .build()) // .build())
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
Log.d(tag, "onCreate") Log.d(tag, "onCreate")
// Grant full storage access for testing DbManager.initialize(applicationContext)
// var ss = SimpleStorage(this)
// ss.requestFullStorageAccess()
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) { if (permission != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this, ActivityCompat.requestPermissions(this,
PERMISSIONS_ALL, PERMISSIONS_ALL,
@@ -68,8 +68,6 @@ class MainActivity : BridgeActivity() {
registerPlugin(AbsDownloader::class.java) registerPlugin(AbsDownloader::class.java)
registerPlugin(AbsFileSystem::class.java) registerPlugin(AbsFileSystem::class.java)
registerPlugin(AbsDatabase::class.java) registerPlugin(AbsDatabase::class.java)
Paper.init(applicationContext)
} }
override fun onDestroy() { override fun onDestroy() {
@@ -94,9 +92,7 @@ class MainActivity : BridgeActivity() {
foregroundService = mLocalBinder.getService() foregroundService = mLocalBinder.getService()
// Let NativeAudio know foreground service is ready and setup event listener // 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 // Mandatory for Activity, but not for Fragment & ComponentActivity
storageHelper.onRequestPermissionsResult(requestCode, permissions, grantResults) 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,62 +1,13 @@
package com.audiobookshelf.app.data package com.audiobookshelf.app.data
import android.net.Uri import android.content.Context
import android.os.Bundle
import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.MediaMetadataCompat
import com.audiobookshelf.app.R import androidx.media.utils.MediaConstants
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.media.MediaManager
import com.fasterxml.jackson.annotation.* import com.fasterxml.jackson.annotation.*
@JsonIgnoreProperties(ignoreUnknown = true)
data class LibraryItem(
var id:String,
var ino:String,
var libraryId:String,
var folderId:String,
var path:String,
var relPath:String,
var mtimeMs:Long,
var ctimeMs:Long,
var birthtimeMs:Long,
var addedAt:Long,
var updatedAt:Long,
var lastScan:Long?,
var scanVersion:String?,
var isMissing:Boolean,
var isInvalid:Boolean,
var mediaType:String,
var media:MediaType,
var libraryFiles:MutableList<LibraryFile>?,
var userMediaProgress:MediaProgress? // Only included when requesting library item with progress (for downloads)
) : LibraryItemWrapper() {
@get:JsonIgnore
val title get() = media.metadata.title
@get:JsonIgnore
val authorName get() = media.metadata.getAuthorDisplayName()
@JsonIgnore
fun getCoverUri():Uri {
if (media.coverPath == null) {
return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
}
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}")
}
@JsonIgnore
fun 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 // This auto-detects whether it is a Book or Podcast
@JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION) @JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION)
@JsonSubTypes( @JsonSubTypes(
@@ -74,6 +25,7 @@ open class MediaType(var metadata:MediaTypeMetadata, var coverPath:String?) {
open fun removeAudioTrack(localFileId:String) { } open fun removeAudioTrack(localFileId:String) { }
@JsonIgnore @JsonIgnore
open fun getLocalCopy():MediaType { return MediaType(MediaTypeMetadata(""),null) } open fun getLocalCopy():MediaType { return MediaType(MediaTypeMetadata(""),null) }
} }
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
@@ -82,7 +34,8 @@ class Podcast(
coverPath:String?, coverPath:String?,
var tags:MutableList<String>, var tags:MutableList<String>,
var episodes:MutableList<PodcastEpisode>?, var episodes:MutableList<PodcastEpisode>?,
var autoDownloadEpisodes:Boolean var autoDownloadEpisodes:Boolean,
var numEpisodes:Int?
) : MediaType(metadata, coverPath) { ) : MediaType(metadata, coverPath) {
@JsonIgnore @JsonIgnore
override fun getAudioTracks():List<AudioTrack> { override fun getAudioTracks():List<AudioTrack> {
@@ -99,7 +52,7 @@ class Podcast(
// Add new episodes // Add new episodes
audioTracks.forEach { at -> audioTracks.forEach { at ->
if (episodes?.find{ it.audioTrack?.localFileId == at.localFileId } == null) { if (episodes?.find{ it.audioTrack?.localFileId == at.localFileId } == null) {
val newEpisode = PodcastEpisode("local_" + at.localFileId,episodes?.size ?: 0 + 1,null,null,at.title,null,null,null,at,at.duration,0, null) val newEpisode = PodcastEpisode("local_ep_" + at.localFileId,(episodes?.size ?: 0) + 1,null,null,at.title,null,null,null, null, null, at,at.duration,0, null)
episodes?.add(newEpisode) episodes?.add(newEpisode)
} }
} }
@@ -112,7 +65,7 @@ class Podcast(
} }
@JsonIgnore @JsonIgnore
override fun addAudioTrack(audioTrack:AudioTrack) { override fun addAudioTrack(audioTrack:AudioTrack) {
val newEpisode = PodcastEpisode("local_" + audioTrack.localFileId,episodes?.size ?: 0 + 1,null,null,audioTrack.title,null,null,null,audioTrack,audioTrack.duration,0, null) val newEpisode = PodcastEpisode("local_ep_" + audioTrack.localFileId,(episodes?.size ?: 0) + 1,null,null,audioTrack.title,null,null,null, null, null,audioTrack,audioTrack.duration,0, null)
episodes?.add(newEpisode) episodes?.add(newEpisode)
var index = 1 var index = 1
@@ -131,9 +84,16 @@ class Podcast(
index++ index++
} }
} }
// Used for FolderScanner local podcast item to get copy of Podcast excluding episodes
@JsonIgnore
override fun getLocalCopy(): Podcast {
return Podcast(metadata as PodcastMetadata,coverPath,tags, mutableListOf(),autoDownloadEpisodes, 0)
}
@JsonIgnore @JsonIgnore
fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode { fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode {
val newEpisode = PodcastEpisode("local_" + episode.id,episodes?.size ?: 0 + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,audioTrack,audioTrack.duration,0, episode.id) val newEpisode = PodcastEpisode("local_ep_" + episode.id,(episodes?.size ?: 0) + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,null,null,audioTrack,audioTrack.duration,0, episode.id)
episodes?.add(newEpisode) episodes?.add(newEpisode)
var index = 1 var index = 1
@@ -144,10 +104,14 @@ class Podcast(
return newEpisode return newEpisode
} }
// Used for FolderScanner local podcast item to get copy of Podcast excluding episodes
@JsonIgnore @JsonIgnore
override fun getLocalCopy(): Podcast { fun getNextUnfinishedEpisode(libraryItemId:String, mediaManager: MediaManager):PodcastEpisode? {
return Podcast(metadata as PodcastMetadata,coverPath,tags, mutableListOf(),autoDownloadEpisodes) 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
} }
} }
@@ -160,7 +124,8 @@ class Book(
var chapters:List<BookChapter>?, var chapters:List<BookChapter>?,
var tracks:MutableList<AudioTrack>?, var tracks:MutableList<AudioTrack>?,
var size:Long?, var size:Long?,
var duration:Double? var duration:Double?,
var numTracks:Int?
) : MediaType(metadata, coverPath) { ) : MediaType(metadata, coverPath) {
@JsonIgnore @JsonIgnore
override fun getAudioTracks():List<AudioTrack> { override fun getAudioTracks():List<AudioTrack> {
@@ -170,10 +135,13 @@ class Book(
override fun setAudioTracks(audioTracks:MutableList<AudioTrack>) { override fun setAudioTracks(audioTracks:MutableList<AudioTrack>) {
tracks = audioTracks tracks = audioTracks
tracks?.sortBy { it.index } tracks?.sortBy { it.index }
// TODO: Is it necessary to calculate this each time? check if can remove safely
var totalDuration = 0.0 var totalDuration = 0.0
var currentStartOffset = 0.0
tracks?.forEach { tracks?.forEach {
totalDuration += it.duration totalDuration += it.duration
it.startOffset = currentStartOffset
currentStartOffset += it.duration
} }
duration = totalDuration duration = totalDuration
} }
@@ -206,10 +174,9 @@ class Book(
} }
duration = totalDuration duration = totalDuration
} }
@JsonIgnore @JsonIgnore
override fun getLocalCopy(): Book { override fun getLocalCopy(): Book {
return Book(metadata as BookMetadata,coverPath,tags, mutableListOf(),chapters,mutableListOf(),null,null) return Book(metadata as BookMetadata,coverPath,tags, mutableListOf(),chapters,mutableListOf(),null,null, 0)
} }
} }
@@ -276,12 +243,60 @@ data class PodcastEpisode(
var title:String?, var title:String?,
var subtitle:String?, var subtitle:String?,
var description:String?, var description:String?,
var pubDate:String?,
var publishedAt:Long?,
var audioFile:AudioFile?, var audioFile:AudioFile?,
var audioTrack:AudioTrack?, var audioTrack:AudioTrack?,
var duration:Double?, var duration:Double?,
var size:Long?, var size:Long?,
var serverEpisodeId:String? // For local podcasts to match with server podcasts var serverEpisodeId:String? // For local podcasts to match with server podcasts
) ) {
@JsonIgnore
fun getMediaDescription(libraryItem:LibraryItemWrapper, progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat {
val coverUri = if (libraryItem is LocalLibraryItem) {
libraryItem.getCoverUri()
} else {
(libraryItem as LibraryItem).getCoverUri()
}
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
)
}
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()
}
}
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class LibraryFile( data class LibraryFile(
@@ -312,7 +327,16 @@ data class Library(
var folders:MutableList<Folder>, var folders:MutableList<Folder>,
var icon:String, var icon:String,
var mediaType:String var mediaType:String
) ) {
@JsonIgnore
fun getMediaMetadata(): MediaMetadataCompat {
return MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, name)
putString(MediaMetadataCompat.METADATA_KEY_TITLE, name)
}.build()
}
}
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class Folder( data class Folder(
@@ -320,54 +344,42 @@ data class Folder(
var fullPath:String var fullPath:String
) )
@JsonIgnoreProperties(ignoreUnknown = true)
data class AudioTrack(
var index:Int,
var startOffset:Double,
var duration:Double,
var title:String,
var contentUrl:String,
var mimeType:String,
var metadata:FileMetadata?,
var isLocal:Boolean,
var localFileId:String?,
var audioProbeResult:AudioProbeResult?,
var serverIndex:Int? // Need to know if server track index is different
) {
@get:JsonIgnore
val startOffsetMs get() = (startOffset * 1000L).toLong()
@get:JsonIgnore
val durationMs get() = (duration * 1000L).toLong()
@get:JsonIgnore
val endOffsetMs get() = startOffsetMs + durationMs
@get:JsonIgnore
val relPath get() = metadata?.relPath ?: ""
@JsonIgnore
fun getBookChapter():BookChapter {
return BookChapter(index + 1,startOffset, startOffset + duration, title)
}
}
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class BookChapter( data class BookChapter(
var id:Int, var id:Int,
var start:Double, var start:Double,
var end:Double, var end:Double,
var title:String? var title:String?
) ) {
@get:JsonIgnore
val startMs get() = (start * 1000L).toLong()
@get:JsonIgnore
val endMs get() = (end * 1000L).toLong()
}
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class MediaProgress( class MediaProgress(
var id:String, var id:String,
var libraryItemId:String, var libraryItemId:String,
var episodeId:String?, var episodeId:String?,
var duration:Double, // seconds var duration:Double, // seconds
var progress:Double, // 0 to 1 progress:Double, // 0 to 1
var currentTime:Double, var currentTime:Double,
var isFinished:Boolean, isFinished:Boolean,
var lastUpdate:Long, var lastUpdate:Long,
var startedAt:Long, var startedAt:Long,
var finishedAt: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(
var libraryItemWrapper:LibraryItemWrapper,
var episode:PodcastEpisode
) )
@@ -1,16 +1,27 @@
package com.audiobookshelf.app.data package com.audiobookshelf.app.data
import android.content.Context
import android.util.Log import android.util.Log
import com.audiobookshelf.app.plugins.AbsDownloader import com.audiobookshelf.app.plugins.AbsDownloader
import io.paperdb.Paper import io.paperdb.Paper
import org.json.JSONObject
import java.io.File import java.io.File
class DbManager { class DbManager {
val tag = "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 { 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) { fun saveDeviceData(deviceData:DeviceData) {
Paper.book("device").write("data", 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 } return getLocalLibraryItems().find { it.libraryItemId == libraryItemId }
} }
@@ -42,6 +53,20 @@ class DbManager {
return Paper.book("localLibraryItems").read(localLibraryItemId) return Paper.book("localLibraryItems").read(localLibraryItemId)
} }
fun getLocalLibraryItemWithEpisode(podcastEpisodeId:String):LibraryItemWithEpisode? {
var podcastEpisode:PodcastEpisode? = null
val localLibraryItem = getLocalLibraryItems("podcast").find { localLibraryItem ->
val podcast = localLibraryItem.media as Podcast
podcastEpisode = podcast.episodes?.find { it.id == podcastEpisodeId }
podcastEpisode != null
}
return if (localLibraryItem != null) {
LibraryItemWithEpisode(localLibraryItem, podcastEpisode!!)
} else {
null
}
}
fun removeLocalLibraryItem(localLibraryItemId:String) { fun removeLocalLibraryItem(localLibraryItemId:String) {
Paper.book("localLibraryItems").delete(localLibraryItemId) Paper.book("localLibraryItems").delete(localLibraryItemId)
} }
@@ -133,6 +158,7 @@ class DbManager {
// Check local files // Check local files
lli.localFiles = lli.localFiles.filter { localFile -> lli.localFiles = lli.localFiles.filter { localFile ->
val file = File(localFile.absolutePath) val file = File(localFile.absolutePath)
if (!file.exists()) { if (!file.exists()) {
Log.d(tag, "cleanLocalLibraryItems: Local file ${localFile.absolutePath} was removed from library item ${lli.media.metadata.title}") Log.d(tag, "cleanLocalLibraryItems: Local file ${localFile.absolutePath} was removed from library item ${lli.media.metadata.title}")
@@ -187,7 +213,11 @@ class DbManager {
val localLibraryItems = getLocalLibraryItems() val localLibraryItems = getLocalLibraryItems()
localMediaProgress.forEach { localMediaProgress.forEach {
val matchingLLI = localLibraryItems.find { lli -> lli.id == it.localLibraryItemId } 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") Log.d(tag, "cleanLocalMediaProgress: No matching local library item for local media progress ${it.id} - removing")
Paper.book("localMediaProgress").delete(it.id) Paper.book("localMediaProgress").delete(it.id)
} else if (matchingLLI.isPodcast) { } else if (matchingLLI.isPodcast) {
@@ -1,10 +1,15 @@
package com.audiobookshelf.app.data package com.audiobookshelf.app.data
import android.content.Context
import android.support.v4.media.MediaDescriptionCompat
import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonSubTypes import com.fasterxml.jackson.annotation.JsonSubTypes
import com.fasterxml.jackson.annotation.JsonTypeInfo import com.fasterxml.jackson.annotation.JsonTypeInfo
import java.util.*
enum class LockOrientationSetting {
NONE, PORTRAIT, LANDSCAPE
}
data class ServerConnectionConfig( data class ServerConnectionConfig(
var id:String, var id:String,
@@ -13,13 +18,43 @@ data class ServerConnectionConfig(
var address:String, var address:String,
var userId:String, var userId:String,
var username: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( data class DeviceData(
var serverConnectionConfigs:MutableList<ServerConnectionConfig>, var serverConnectionConfigs:MutableList<ServerConnectionConfig>,
var lastServerConnectionConfigId:String?, 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 @JsonIgnore
fun getLastServerConnectionConfig():ServerConnectionConfig? { fun getLastServerConnectionConfig():ServerConnectionConfig? {
@@ -42,6 +77,8 @@ data class LocalFile(
) { ) {
@JsonIgnore @JsonIgnore
fun isAudioFile():Boolean { fun isAudioFile():Boolean {
if (mimeType == "application/octet-stream") return true
if (mimeType == "video/mp4") return true
return mimeType?.startsWith("audio") == true return mimeType?.startsWith("audio") == true
} }
} }
@@ -63,4 +100,24 @@ data class LocalFolder(
JsonSubTypes.Type(LibraryItem::class), JsonSubTypes.Type(LibraryItem::class),
JsonSubTypes.Type(LocalLibraryItem::class) JsonSubTypes.Type(LocalLibraryItem::class)
) )
open class LibraryItemWrapper() open class LibraryItemWrapper(var id:String) {
@JsonIgnore
open fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat { return MediaDescriptionCompat.Builder().build() }
}
@JsonIgnoreProperties(ignoreUnknown = true)
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,8 +1,15 @@
package com.audiobookshelf.app.data package com.audiobookshelf.app.data
import android.content.Context
import android.graphics.Bitmap
import android.graphics.ImageDecoder
import android.net.Uri 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 android.util.Log
import androidx.media.utils.MediaConstants
import com.audiobookshelf.app.R import com.audiobookshelf.app.R
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonIgnore
@@ -10,8 +17,8 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import java.util.* import java.util.*
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class LocalLibraryItem( class LocalLibraryItem(
var id:String, id:String,
var folderId:String, var folderId:String,
var basePath:String, var basePath:String,
var absolutePath:String, var absolutePath:String,
@@ -28,7 +35,7 @@ data class LocalLibraryItem(
var serverAddress:String?, var serverAddress:String?,
var serverUserId:String?, var serverUserId:String?,
var libraryItemId:String? var libraryItemId:String?
) : LibraryItemWrapper() { ) : LibraryItemWrapper(id) {
@get:JsonIgnore @get:JsonIgnore
val title get() = media.metadata.title val title get() = media.metadata.title
@get:JsonIgnore @get:JsonIgnore
@@ -44,7 +51,7 @@ data class LocalLibraryItem(
@JsonIgnore @JsonIgnore
fun getDuration():Double { fun getDuration():Double {
var total = 0.0 var total = 0.0
var audioTracks = media.getAudioTracks() val audioTracks = media.getAudioTracks()
audioTracks.forEach{ total += it.duration } audioTracks.forEach{ total += it.duration }
return total return total
} }
@@ -94,16 +101,55 @@ data class LocalLibraryItem(
} }
@JsonIgnore @JsonIgnore
fun getMediaMetadata(): MediaMetadataCompat { override fun getMediaDescription(progress:MediaProgressWrapper?, ctx:Context?): MediaDescriptionCompat {
return MediaMetadataCompat.Builder().apply { val coverUri = getCoverUri()
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title) var bitmap:Bitmap? = null
putString(MediaMetadataCompat.METADATA_KEY_TITLE, title) if (coverContentUrl != null) {
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, authorName) ctx?.let {
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, getCoverUri().toString()) bitmap = if (Build.VERSION.SDK_INT < 28) {
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getCoverUri().toString()) MediaStore.Images.Media.getBitmap(it.contentResolver, coverUri)
putString(MediaMetadataCompat.METADATA_KEY_ART_URI, getCoverUri().toString()) } else {
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, authorName) val source: ImageDecoder.Source = ImageDecoder.createSource(it.contentResolver, coverUri)
}.build() ImageDecoder.decodeBitmap(source)
}
}
}
val extras = Bundle()
if (progress != null) {
if (progress.isFinished) {
extras.putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED
)
} else {
extras.putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED
)
extras.putDouble(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress
)
}
} else if (mediaType != "podcast") {
extras.putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
)
}
val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
.setMediaId(id)
.setTitle(title)
.setIconUri(coverUri)
.setSubtitle(authorName)
.setExtras(extras)
bitmap?.let {
mediaDescriptionBuilder.setIconBitmap(bitmap)
}
return mediaDescriptionBuilder.build()
} }
} }
@@ -58,13 +58,13 @@ data class LocalMediaItem(
@JsonIgnore @JsonIgnore
fun getLocalLibraryItem():LocalLibraryItem { fun getLocalLibraryItem():LocalLibraryItem {
var mediaMetadata = getMediaMetadata() val mediaMetadata = getMediaMetadata()
if (mediaType == "book") { if (mediaType == "book") {
var chapters = getAudiobookChapters() val chapters = getAudiobookChapters()
var book = Book(mediaMetadata as BookMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), chapters,audioTracks,getTotalSize(),getDuration()) val book = Book(mediaMetadata as BookMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), chapters,audioTracks,getTotalSize(),getDuration(),audioTracks.size)
return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false,mediaType, book, localFiles, coverContentUrl, coverAbsolutePath,true,null,null,null,null) return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false,mediaType, book, localFiles, coverContentUrl, coverAbsolutePath,true,null,null,null,null)
} else { } else {
var podcast = Podcast(mediaMetadata as PodcastMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), false) val podcast = Podcast(mediaMetadata as PodcastMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), false, 0)
podcast.setAudioTracks(audioTracks) // Builds episodes from audio tracks podcast.setAudioTracks(audioTracks) // Builds episodes from audio tracks
return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false, mediaType, podcast,localFiles,coverContentUrl, coverAbsolutePath, true, null,null,null,null) return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false, mediaType, podcast,localFiles,coverContentUrl, coverAbsolutePath, true, null,null,null,null)
} }
@@ -2,16 +2,17 @@ package com.audiobookshelf.app.data
import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import kotlin.math.roundToInt
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class LocalMediaProgress( class LocalMediaProgress(
var id:String, var id:String,
var localLibraryItemId:String, var localLibraryItemId:String,
var localEpisodeId:String?, var localEpisodeId:String?,
var duration:Double, var duration:Double,
var progress:Double, // 0 to 1 progress:Double, // 0 to 1
var currentTime:Double, var currentTime:Double,
var isFinished:Boolean, isFinished:Boolean,
var lastUpdate:Long, var lastUpdate:Long,
var startedAt:Long, var startedAt:Long,
var finishedAt:Long?, var finishedAt:Long?,
@@ -21,7 +22,10 @@ data class LocalMediaProgress(
var serverUserId:String?, var serverUserId:String?,
var libraryItemId:String?, var libraryItemId:String?,
var episodeId:String? var episodeId:String?
) { ) : MediaProgressWrapper(isFinished, progress) {
@get:JsonIgnore
val progressPercent get() = if (progress.isNaN()) 0 else (progress * 100).roundToInt()
@JsonIgnore @JsonIgnore
fun updateIsFinished(finished:Boolean) { fun updateIsFinished(finished:Boolean) {
if (isFinished != finished) { // If finished changed then set progress if (isFinished != finished) { // If finished changed then set progress
@@ -42,4 +46,15 @@ data class LocalMediaProgress(
isFinished = playbackSession.progress >= 0.99 isFinished = playbackSession.progress >= 0.99
finishedAt = if (isFinished) lastUpdate else null 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,20 +1,22 @@
package com.audiobookshelf.app.data package com.audiobookshelf.app.data
import android.content.Context
import android.graphics.ImageDecoder
import android.net.Uri import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.MediaMetadataCompat
import androidx.core.app.NotificationCompat
import com.audiobookshelf.app.R
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.player.MediaProgressSyncData import com.audiobookshelf.app.player.MediaProgressSyncData
import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.MediaMetadata import com.google.android.exoplayer2.MediaMetadata
import com.google.android.gms.cast.MediaInfo import com.google.android.gms.cast.MediaInfo
import com.google.android.gms.cast.MediaQueueItem import com.google.android.gms.cast.MediaQueueItem
import com.google.android.gms.common.images.WebImage import com.google.android.gms.common.images.WebImage
// TODO: enum or something in kotlin? // TODO: enum or something in kotlin?
val PLAYMETHOD_DIRECTPLAY = 0 val PLAYMETHOD_DIRECTPLAY = 0
val PLAYMETHOD_DIRECTSTREAM = 1 val PLAYMETHOD_DIRECTSTREAM = 1
@@ -55,6 +57,8 @@ class PlaybackSession(
@get:JsonIgnore @get:JsonIgnore
val isLocal get() = playMethod == PLAYMETHOD_LOCAL val isLocal get() = playMethod == PLAYMETHOD_LOCAL
@get:JsonIgnore @get:JsonIgnore
val isPodcastEpisode get() = mediaType == "podcast"
@get:JsonIgnore
val currentTimeMs get() = (currentTime * 1000L).toLong() val currentTimeMs get() = (currentTime * 1000L).toLong()
@get:JsonIgnore @get:JsonIgnore
val totalDurationMs get() = (getTotalDuration() * 1000L).toLong() val totalDurationMs get() = (getTotalDuration() * 1000L).toLong()
@@ -64,28 +68,37 @@ class PlaybackSession(
val localMediaProgressId get() = if (episodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId" val localMediaProgressId get() = if (episodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
@get:JsonIgnore @get:JsonIgnore
val progress get() = currentTime / getTotalDuration() val progress get() = currentTime / getTotalDuration()
@get:JsonIgnore
val isLocalLibraryItemOnly get() = localLibraryItemId != "" && libraryItemId == null
@JsonIgnore @JsonIgnore
fun getCurrentTrackIndex():Int { fun getCurrentTrackIndex():Int {
for (i in 0..(audioTracks.size - 1)) { for (i in 0 until audioTracks.size) {
var track = audioTracks[i] val track = audioTracks[i]
if (currentTimeMs >= track.startOffsetMs && (track.endOffsetMs) > currentTimeMs) { if (currentTimeMs >= track.startOffsetMs && (track.endOffsetMs > currentTimeMs)) {
return i return i
} }
} }
return audioTracks.size - 1 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 @JsonIgnore
fun getCurrentTrackTimeMs():Long { fun getCurrentTrackTimeMs():Long {
var currentTrack = audioTracks[this.getCurrentTrackIndex()] val currentTrack = audioTracks[this.getCurrentTrackIndex()]
var time = currentTime - currentTrack.startOffset val time = currentTime - currentTrack.startOffset
return (time * 1000L).toLong() return (time * 1000L).toLong()
} }
@JsonIgnore @JsonIgnore
fun getTrackStartOffsetMs(index:Int):Long { fun getTrackStartOffsetMs(index:Int):Long {
var currentTrack = audioTracks[index] if (index < 0 || index >= audioTracks.size) return 0L
val currentTrack = audioTracks[index]
return (currentTrack.startOffset * 1000L).toLong() return (currentTrack.startOffset * 1000L).toLong()
} }
@@ -98,9 +111,9 @@ class PlaybackSession(
@JsonIgnore @JsonIgnore
fun getCoverUri(): Uri { fun getCoverUri(): Uri {
if (localLibraryItem?.coverContentUrl != null) return Uri.parse(localLibraryItem?.coverContentUrl) ?: Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon) if (localLibraryItem?.coverContentUrl != null) return Uri.parse(localLibraryItem?.coverContentUrl) ?: Uri.parse("android.resource://com.audiobookshelf.app/" + com.audiobookshelf.app.R.drawable.icon)
if (coverPath == null) return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon) if (coverPath == null) return Uri.parse("android.resource://com.audiobookshelf.app/" + com.audiobookshelf.app.R.drawable.icon)
return Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}") return Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")
} }
@@ -111,28 +124,49 @@ class PlaybackSession(
} }
@JsonIgnore @JsonIgnore
fun getMediaMetadataCompat(): MediaMetadataCompat { fun getMediaMetadataCompat(ctx: Context): MediaMetadataCompat {
var metadataBuilder = MediaMetadataCompat.Builder() val metadataBuilder = MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, displayTitle) .putString(MediaMetadataCompat.METADATA_KEY_TITLE, displayTitle)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle) .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, displayAuthor) .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, displayAuthor)
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, displayAuthor) .putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, displayAuthor)
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, displayAuthor) .putString(MediaMetadataCompat.METADATA_KEY_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_MEDIA_ID, id)
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getCoverUri().toString())
.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, getCoverUri().toString())
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, getCoverUri().toString())
// Local covers get bitmap
if (localLibraryItem?.coverContentUrl != null) {
val bitmap = if (Build.VERSION.SDK_INT < 28) {
MediaStore.Images.Media.getBitmap(ctx.contentResolver, getCoverUri())
} else {
val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, getCoverUri())
ImageDecoder.decodeBitmap(source)
}
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap)
}
return metadataBuilder.build() return metadataBuilder.build()
} }
@JsonIgnore @JsonIgnore
fun getExoMediaMetadata(audioTrack:AudioTrack): MediaMetadata { fun getExoMediaMetadata(audioTrack:AudioTrack): MediaMetadata {
var metadataBuilder = MediaMetadata.Builder() val metadataBuilder = MediaMetadata.Builder()
.setTitle(displayTitle) .setTitle(displayTitle)
.setDisplayTitle(displayTitle) .setDisplayTitle(displayTitle)
.setArtist(displayAuthor) .setArtist(displayAuthor)
.setAlbumArtist(displayAuthor) .setAlbumArtist(displayAuthor)
.setSubtitle(displayAuthor) .setSubtitle(displayAuthor)
.setAlbumTitle(displayAuthor)
.setDescription(displayAuthor)
.setArtworkUri(getCoverUri())
var contentUri = this.getContentUri(audioTrack) val contentUri = this.getContentUri(audioTrack)
metadataBuilder.setMediaUri(contentUri) metadataBuilder.setMediaUri(contentUri)
return metadataBuilder.build() return metadataBuilder.build()
@@ -140,15 +174,15 @@ class PlaybackSession(
@JsonIgnore @JsonIgnore
fun getMediaItems():List<MediaItem> { fun getMediaItems():List<MediaItem> {
var mediaItems:MutableList<MediaItem> = mutableListOf() val mediaItems:MutableList<MediaItem> = mutableListOf()
for (audioTrack in audioTracks) { for (audioTrack in audioTracks) {
var mediaMetadata = this.getExoMediaMetadata(audioTrack) val mediaMetadata = this.getExoMediaMetadata(audioTrack)
var mediaUri = this.getContentUri(audioTrack) val mediaUri = this.getContentUri(audioTrack)
var mimeType = audioTrack.mimeType val mimeType = audioTrack.mimeType
var queueItem = getQueueItem(audioTrack) // Queue item used in exo player CastManager val queueItem = getQueueItem(audioTrack) // Queue item used in exo player CastManager
var mediaItem = MediaItem.Builder().setUri(mediaUri).setTag(queueItem).setMediaMetadata(mediaMetadata).setMimeType(mimeType).build() val mediaItem = MediaItem.Builder().setUri(mediaUri).setTag(queueItem).setMediaMetadata(mediaMetadata).setMimeType(mimeType).build()
mediaItems.add(mediaItem) mediaItems.add(mediaItem)
} }
return mediaItems return mediaItems
@@ -156,7 +190,7 @@ class PlaybackSession(
@JsonIgnore @JsonIgnore
fun getCastMediaMetadata(audioTrack:AudioTrack):com.google.android.gms.cast.MediaMetadata { fun getCastMediaMetadata(audioTrack:AudioTrack):com.google.android.gms.cast.MediaMetadata {
var castMetadata = com.google.android.gms.cast.MediaMetadata(com.google.android.gms.cast.MediaMetadata.MEDIA_TYPE_AUDIOBOOK_CHAPTER) val castMetadata = com.google.android.gms.cast.MediaMetadata(com.google.android.gms.cast.MediaMetadata.MEDIA_TYPE_AUDIOBOOK_CHAPTER)
coverPath?.let { coverPath?.let {
castMetadata.addImage(WebImage(Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}"))) castMetadata.addImage(WebImage(Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")))
@@ -164,18 +198,20 @@ class PlaybackSession(
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_TITLE, displayTitle ?: "") castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_TITLE, displayTitle ?: "")
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_ARTIST, displayAuthor ?: "") castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_ARTIST, displayAuthor ?: "")
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_ALBUM_TITLE, displayAuthor ?: "")
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_CHAPTER_TITLE, audioTrack.title) castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_CHAPTER_TITLE, audioTrack.title)
castMetadata.putInt(com.google.android.gms.cast.MediaMetadata.KEY_TRACK_NUMBER, audioTrack.index) castMetadata.putInt(com.google.android.gms.cast.MediaMetadata.KEY_TRACK_NUMBER, audioTrack.index)
return castMetadata return castMetadata
} }
@JsonIgnore @JsonIgnore
fun getQueueItem(audioTrack:AudioTrack):MediaQueueItem { fun getQueueItem(audioTrack:AudioTrack):MediaQueueItem {
var castMetadata = getCastMediaMetadata(audioTrack) val castMetadata = getCastMediaMetadata(audioTrack)
var mediaUri = getContentUri(audioTrack) val mediaUri = getContentUri(audioTrack)
var mediaInfo = MediaInfo.Builder(mediaUri.toString()).apply { val mediaInfo = MediaInfo.Builder(mediaUri.toString()).apply {
setContentUrl(mediaUri.toString()) setContentUrl(mediaUri.toString())
setContentType(audioTrack.mimeType) setContentType(audioTrack.mimeType)
setMetadata(castMetadata) setMetadata(castMetadata)
@@ -1,9 +1,12 @@
package com.audiobookshelf.app.device package com.audiobookshelf.app.device
import android.util.Log import android.util.Log
import com.audiobookshelf.app.data.DbManager import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.data.DeviceData import com.audiobookshelf.app.player.PlayerNotificationService
import com.audiobookshelf.app.data.ServerConnectionConfig
interface WidgetEventEmitter {
fun onPlayerChanged(pns:PlayerNotificationService)
}
object DeviceManager { object DeviceManager {
const val tag = "DeviceManager" const val tag = "DeviceManager"
@@ -19,11 +22,18 @@ object DeviceManager {
val isConnectedToServer get() = serverConnectionConfig != null val isConnectedToServer get() = serverConnectionConfig != null
val hasLastServerConnectionConfig get() = deviceData.getLastServerConnectionConfig() != null val hasLastServerConnectionConfig get() = deviceData.getLastServerConnectionConfig() != null
var widgetUpdater:WidgetEventEmitter? = null
init { init {
Log.d(tag, "Device Manager Singleton invoked") Log.d(tag, "Device Manager Singleton invoked")
} }
fun getBase64Id(id:String):String { 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.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue import com.fasterxml.jackson.module.kotlin.readValue
import com.getcapacitor.JSObject import com.getcapacitor.JSObject
import org.json.JSONException
class FolderScanner(var ctx: Context) { class FolderScanner(var ctx: Context) {
private val tag = "FolderScanner" private val tag = "FolderScanner"
@@ -105,7 +106,7 @@ class FolderScanner(var ctx: Context) {
var coverContentUrl:String? = null var coverContentUrl:String? = null
var coverAbsolutePath: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 -> val existingLocalFilesRemoved = existingLocalFiles.filter { elf ->
filesInFolder.find { fif -> DeviceManager.getBase64Id(fif.id) == elf.id } == null // File was not found in media item folder 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 audioTrackToAdd = existingAudioTrack
} else { } else {
// Create new audio track // 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 audioTrackToAdd = track
} }
startOffset += audioProbeResult.duration startOffset += audioProbeResult?.duration ?: 0.0
isNewOrUpdated = true isNewOrUpdated = true
} else { } else {
audioTrackToAdd = existingAudioTrack audioTrackToAdd = existingAudioTrack
@@ -250,7 +251,8 @@ class FolderScanner(var ctx: Context) {
Log.d(tag, "scanDownloadItem starting for ${downloadItem.itemFolderPath} | ${df.uri} | Item Folder Id:$itemFolderId | LLI Id:$localLibraryItemId") Log.d(tag, "scanDownloadItem starting for ${downloadItem.itemFolderPath} | ${df.uri} | Item Folder Id:$itemFolderId | LLI Id:$localLibraryItemId")
// Search for files in media item folder // Search for files in media item folder
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4")) // 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"))
Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}") Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}")
var localEpisodeId:String? = null var localEpisodeId:String? = null
@@ -288,7 +290,7 @@ class FolderScanner(var ctx: Context) {
val audioProbeResult = probeAudioFile(localFile.absolutePath) val audioProbeResult = probeAudioFile(localFile.absolutePath)
// Create new audio track // Create new audio track
val track = AudioTrack(audioTrackFromServer?.index ?: -1, audioTrackFromServer?.startOffset ?: 0.0, 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) audioTracks.add(track)
Log.d(tag, "scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}") Log.d(tag, "scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}")
@@ -296,7 +298,7 @@ class FolderScanner(var ctx: Context) {
// Add podcast episodes to library // Add podcast episodes to library
itemPart.episode?.let { podcastEpisode -> itemPart.episode?.let { podcastEpisode ->
val podcast = localLibraryItem.media as Podcast val podcast = localLibraryItem.media as Podcast
var newEpisode = podcast.addEpisode(track, podcastEpisode) val newEpisode = podcast.addEpisode(track, podcastEpisode)
localEpisodeId = newEpisode.id localEpisodeId = newEpisode.id
Log.d(tag, "scanDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}") Log.d(tag, "scanDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}")
} }
@@ -366,7 +368,7 @@ class FolderScanner(var ctx: Context) {
} }
fun scanLocalLibraryItem(localLibraryItem:LocalLibraryItem, forceAudioProbe:Boolean):LocalLibraryItemScanResult? { fun scanLocalLibraryItem(localLibraryItem:LocalLibraryItem, forceAudioProbe:Boolean):LocalLibraryItemScanResult? {
var df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(localLibraryItem.contentUrl)) val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(localLibraryItem.contentUrl))
if (df == null) { if (df == null) {
Log.e(tag, "Item Folder Doc File Invalid ${localLibraryItem.absolutePath}") Log.e(tag, "Item Folder Doc File Invalid ${localLibraryItem.absolutePath}")
@@ -377,7 +379,7 @@ class FolderScanner(var ctx: Context) {
var wasUpdated = false var wasUpdated = false
// Search for files in media item folder // Search for files in media item folder
var 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}") Log.d(tag, "scanLocalLibraryItem ${filesFound.size} files found in ${localLibraryItem.absolutePath}")
filesFound.forEach { filesFound.forEach {
@@ -388,10 +390,10 @@ class FolderScanner(var ctx: Context) {
} }
} }
var existingAudioTracks = localLibraryItem.media.getAudioTracks() val existingAudioTracks = localLibraryItem.media.getAudioTracks()
// Remove any files no longer found in library item folder // Remove any files no longer found in library item folder
var existingLocalFileIds = localLibraryItem.localFiles.map { it.id } val existingLocalFileIds = localLibraryItem.localFiles.map { it.id }
existingLocalFileIds.forEach { localFileId -> existingLocalFileIds.forEach { localFileId ->
Log.d(tag, "Checking local file id is there $localFileId") Log.d(tag, "Checking local file id is there $localFileId")
if (filesFound.find { DeviceManager.getBase64Id(it.id) == localFileId } == null) { if (filesFound.find { DeviceManager.getBase64Id(it.id) == localFileId } == null) {
@@ -407,12 +409,12 @@ class FolderScanner(var ctx: Context) {
} }
filesFound.forEach { docFile -> filesFound.forEach { docFile ->
var localFileId = DeviceManager.getBase64Id(docFile.id) val localFileId = DeviceManager.getBase64Id(docFile.id)
var existingLocalFile = localLibraryItem.localFiles.find { it.id == localFileId } val existingLocalFile = localLibraryItem.localFiles.find { it.id == localFileId }
if (existingLocalFile == null || (existingLocalFile.isAudioFile() && forceAudioProbe)) { if (existingLocalFile == null || (existingLocalFile.isAudioFile() && forceAudioProbe)) {
var localFile = existingLocalFile ?: LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx), docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length()) val localFile = existingLocalFile ?: LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx), docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
if (existingLocalFile == null) { if (existingLocalFile == null) {
localLibraryItem.localFiles.add(localFile) localLibraryItem.localFiles.add(localFile)
Log.d(tag, "scanLocalLibraryItem new file found ${localFile.filename}") Log.d(tag, "scanLocalLibraryItem new file found ${localFile.filename}")
@@ -420,22 +422,26 @@ class FolderScanner(var ctx: Context) {
if (localFile.isAudioFile()) { if (localFile.isAudioFile()) {
// TODO: Make asynchronous // TODO: Make asynchronous
var audioProbeResult = probeAudioFile(localFile.absolutePath) val audioProbeResult = probeAudioFile(localFile.absolutePath)
var existingTrack = existingAudioTracks.find { audioTrack -> val existingTrack = existingAudioTracks.find { audioTrack ->
audioTrack.localFileId == localFile.id audioTrack.localFileId == localFile.id
} }
if (existingTrack == null) { if (existingTrack == null) {
// Create new audio track // Create new audio track
var lastTrack = existingAudioTracks.lastOrNull() val lastTrack = existingAudioTracks.lastOrNull()
var startOffset = (lastTrack?.startOffset ?: 0.0) + (lastTrack?.duration ?: 0.0) val startOffset = (lastTrack?.startOffset ?: 0.0) + (lastTrack?.duration ?: 0.0)
var 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) localLibraryItem.media.addAudioTrack(track)
Log.d(tag, "Added New Audio Track ${track.title}")
wasUpdated = true wasUpdated = true
} else { } else {
existingTrack.audioProbeResult = audioProbeResult existingTrack.audioProbeResult = audioProbeResult
// TODO: Update data found from probe // TODO: Update data found from probe
Log.d(tag, "Updated Audio Track Probe Data ${existingTrack.title}")
wasUpdated = true wasUpdated = true
} }
} else { // Check if cover is empty } else { // Check if cover is empty
@@ -458,12 +464,24 @@ class FolderScanner(var ctx: Context) {
return LocalLibraryItemScanResult(wasUpdated, localLibraryItem) return LocalLibraryItemScanResult(wasUpdated, localLibraryItem)
} }
fun probeAudioFile(absolutePath:String):AudioProbeResult { 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") val 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)}")
val audioProbeResult = jacksonMapper.readValue<AudioProbeResult>(session.output) var probeObject:JSObject? = null
Log.d(tag, "Probe Result DATA ${audioProbeResult.duration} | ${audioProbeResult.size} | ${audioProbeResult.title} | ${audioProbeResult.artist}") try {
return audioProbeResult 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,49 +1,172 @@
package com.audiobookshelf.app.media package com.audiobookshelf.app.media
import android.bluetooth.BluetoothClass import android.app.Activity
import android.content.Context import android.content.Context
import android.support.v4.media.MediaBrowserCompat
import android.util.Log import android.util.Log
import com.audiobookshelf.app.data.* import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.player.PlayerNotificationService
import com.audiobookshelf.app.server.ApiHandler import com.audiobookshelf.app.server.ApiHandler
import com.getcapacitor.JSObject
import java.util.* 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) { class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
val tag = "MediaManager" val tag = "MediaManager"
var serverLibraryItems = listOf<LibraryItem>() var serverLibraryItems = mutableListOf<LibraryItem>() // Store all items here
var serverLibraryCategories = listOf<LibraryCategory>() var selectedLibraryItems = mutableListOf<LibraryItem>()
var serverLibraries = listOf<Library>() var selectedLibraryId = ""
fun initializeAndroidAuto() { var selectedPodcast:Podcast? = null
Log.d(tag, "Android Auto started when MainActivity was never started - initializing Paper") var selectedLibraryItemId:String? = null
Paper.init(ctx) 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()
var userSettingsPlaybackRate:Float? = null
fun getIsLibrary(id:String) : Boolean {
return serverLibraries.find { it.id == id } != null
} }
fun loadLibraryCategories(libraryId:String, cb: (List<LibraryCategory>) -> Unit) { fun getSavedPlaybackRate():Float {
if (serverLibraryCategories.isNotEmpty()) { if (userSettingsPlaybackRate != null) {
cb(serverLibraryCategories) 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 { } else {
apiHandler.getLibraryCategories(libraryId) { apiHandler.getAllItemsInProgress { itemsInProgress ->
serverLibraryCategories = it serverItemsInProgress = itemsInProgress
cb(it) cb(serverItemsInProgress)
} }
} }
} }
fun loadLibraryItems(libraryId:String, cb: (List<LibraryItem>) -> Unit) { fun loadLibraryItemsWithAudio(libraryId:String, cb: (List<LibraryItem>) -> Unit) {
if (serverLibraryItems.isNotEmpty()) { if (selectedLibraryItems.isNotEmpty() && selectedLibraryId == libraryId) {
cb(serverLibraryItems) cb(selectedLibraryItems)
} else { } else {
apiHandler.getLibraryItems(libraryId) { libraryItems -> apiHandler.getLibraryItems(libraryId) { libraryItems ->
serverLibraryItems = libraryItems val libraryItemsWithAudio = libraryItems.filter { li -> li.checkHasTracks() }
cb(libraryItems) if (libraryItemsWithAudio.isNotEmpty()) {
selectedLibraryId = libraryId
}
selectedLibraryItems = mutableListOf()
libraryItemsWithAudio.forEach { libraryItem ->
selectedLibraryItems.add(libraryItem)
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
serverLibraryItems.add(libraryItem)
}
}
cb(libraryItemsWithAudio)
} }
} }
} }
fun loadLibraryItem(libraryItemId:String, cb: (LibraryItemWrapper?) -> Unit) {
if (libraryItemId.startsWith("local")) {
cb(DeviceManager.dbManager.getLocalLibraryItem(libraryItemId))
} else {
Log.d(tag, "loadLibraryItem: $libraryItemId")
apiHandler.getLibraryItem(libraryItemId) { libraryItem ->
Log.d(tag, "loadLibraryItem: Got library item $libraryItem")
cb(libraryItem)
}
}
}
fun loadPodcastEpisodeMediaBrowserItems(libraryItemId:String, ctx:Context, cb: (MutableList<MediaBrowserCompat.MediaItem>) -> Unit) {
loadLibraryItem(libraryItemId) { libraryItemWrapper ->
Log.d(tag, "Loaded Podcast library item $libraryItemWrapper")
libraryItemWrapper?.let {
if (libraryItemWrapper is LocalLibraryItem) { // Local podcast episodes
if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) {
cb(mutableListOf())
} else {
val podcast = libraryItemWrapper.media as Podcast
selectedLibraryItemId = libraryItemWrapper.id
selectedPodcast = podcast
val children = podcast.episodes?.map { podcastEpisode ->
Log.d(tag, "Local Podcast Episode ${podcastEpisode.title} | ${podcastEpisode.id}")
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()) {
cb(mutableListOf())
} else {
val podcast = libraryItemWrapper.media as Podcast
podcast.episodes?.forEach { podcastEpisode ->
podcastEpisodeLibraryItemMap[podcastEpisode.id] = LibraryItemWithEpisode(libraryItemWrapper, podcastEpisode)
}
selectedLibraryItemId = libraryItemWrapper.id
selectedPodcast = podcast
val children = podcast.episodes?.map { podcastEpisode ->
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())
}
}
}
}
}
fun loadLibraries(cb: (List<Library>) -> Unit) { fun loadLibraries(cb: (List<Library>) -> Unit) {
if (serverLibraries.isNotEmpty()) { if (serverLibraries.isNotEmpty()) {
cb(serverLibraries) cb(serverLibraries)
@@ -55,59 +178,146 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
} }
} }
// TODO: Load currently listening category for local items suspend fun checkServerConnection(config:ServerConnectionConfig) : Boolean {
fun loadLocalCategory():List<LibraryCategory> { var successfulPing = false
var localBooks = DeviceManager.dbManager.getLocalLibraryItems("book") suspendCoroutine<Boolean> { cont ->
var localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast") apiHandler.pingServer(config) {
var cats = mutableListOf<LibraryCategory>() Log.d(tag, "checkServerConnection: Checked server conn for ${config.address} result = $it")
if (localBooks.isNotEmpty()) { successfulPing = it
cats.add(LibraryCategory("local-books", "Local Books", "book", localBooks, true)) cont.resume(it)
}
} }
if (localPodcasts.isNotEmpty()) { return successfulPing
cats.add(LibraryCategory("local-podcasts", "Local Podcasts", "podcast", localPodcasts, true))
}
return cats
} }
fun loadAndroidAutoItems(libraryId:String, cb: (List<LibraryCategory>) -> Unit) { suspend fun authorize(config:ServerConnectionConfig) : MutableList<MediaProgress> {
Log.d(tag, "Load android auto items for library id $libraryId") var mediaProgress:MutableList<MediaProgress> = mutableListOf()
var cats = mutableListOf<LibraryCategory>() suspendCoroutine<MutableList<MediaProgress>> { cont ->
apiHandler.authorize(config) {
var localCategories = loadLocalCategory() Log.d(tag, "authorize: Authorized server config ${config.address} result = $it")
cats.addAll(localCategories) if (!it.isNullOrEmpty()) {
mediaProgress = it
// Connected to server and has internet - load other cats }
if (apiHandler.isOnline() && (DeviceManager.isConnectedToServer || DeviceManager.hasLastServerConnectionConfig)) { cont.resume(mediaProgress)
if (!DeviceManager.isConnectedToServer) {
DeviceManager.serverConnectionConfig = DeviceManager.deviceData.getLastServerConnectionConfig()
Log.d(tag, "Not connected to server, set last server \"${DeviceManager.serverAddress}\"")
} }
}
return mediaProgress
}
loadLibraries { libraries -> fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = runBlocking {
var library = libraries.find { it.id == libraryId } ?: libraries[0] Log.d(tag, "checkSetValidServerConnectionConfig | $serverConfigIdUsed")
Log.d(tag, "Loading categories for library ${library.name} - ${library.id} - ${library.mediaType}")
loadLibraryCategories(libraryId) { libraryCategories -> coroutineScope {
if (!apiHandler.isOnline()) {
serverUserMediaProgress = mutableListOf()
cb(false)
} else {
// Only using book or podcast library categories for now var hasValidConn = false
libraryCategories.forEach { var lookupMediaProgress = true
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)
}
}
loadLibraryItems(libraryId) { libraryItems -> if (!serverConfigIdUsed.isNullOrEmpty() && serverConfigLastPing > 0L && System.currentTimeMillis() - serverConfigLastPing < 5000) {
var mainCat = LibraryCategory("library", "Library", library.mediaType, libraryItems, false) Log.d(tag, "checkSetValidServerConnectionConfig last ping less than a 5 seconds ago")
cats.add(mainCat) hasValidConn = true
lookupMediaProgress = false
} else {
serverUserMediaProgress = mutableListOf()
}
cb(cats) 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
}
}
}
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)
} }
} }
@@ -115,11 +325,19 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
if (serverLibraryItems.isNotEmpty()) { if (serverLibraryItems.isNotEmpty()) {
return serverLibraryItems[0] return serverLibraryItems[0]
} else { } else {
var localBooks = DeviceManager.dbManager.getLocalLibraryItems("book") val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
return if (localBooks.isNotEmpty()) return localBooks[0] else null return if (localBooks.isNotEmpty()) return localBooks[0] else null
} }
} }
fun getPodcastWithEpisodeByEpisodeId(id:String) : LibraryItemWithEpisode? {
if (id.startsWith("local")) {
return DeviceManager.dbManager.getLocalLibraryItemWithEpisode(id)
} else {
return podcastEpisodeLibraryItemMap[id]
}
}
fun getById(id:String) : LibraryItemWrapper? { fun getById(id:String) : LibraryItemWrapper? {
if (id.startsWith("local")) { if (id.startsWith("local")) {
return DeviceManager.dbManager.getLocalLibraryItem(id) return DeviceManager.dbManager.getLocalLibraryItem(id)
@@ -135,16 +353,19 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
} }
} }
fun play(libraryItemWrapper:LibraryItemWrapper, mediaPlayer:String, cb: (PlaybackSession) -> Unit) { fun play(libraryItemWrapper:LibraryItemWrapper, episode:PodcastEpisode?, playItemRequestPayload:PlayItemRequestPayload, cb: (PlaybackSession?) -> Unit) {
if (libraryItemWrapper is LocalLibraryItem) { if (libraryItemWrapper is LocalLibraryItem) {
var localLibraryItem = libraryItemWrapper as LocalLibraryItem cb(libraryItemWrapper.getPlaybackSession(episode))
cb(localLibraryItem.getPlaybackSession(null)) } else {
} else { val libraryItem = libraryItemWrapper as LibraryItem
var libraryItem = libraryItemWrapper as LibraryItem apiHandler.playLibraryItem(libraryItem.id,episode?.id ?: "", playItemRequestPayload) {
apiHandler.playLibraryItem(libraryItem.id,"",false, mediaPlayer) { if (it == null) {
cb(it) cb(null)
} } else {
} cb(it)
}
}
}
} }
private fun levenshtein(lhs : CharSequence, rhs : CharSequence) : Int { private fun levenshtein(lhs : CharSequence, rhs : CharSequence) : Int {
@@ -2,12 +2,12 @@ package com.audiobookshelf.app.player
import android.app.PendingIntent import android.app.PendingIntent
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.ImageDecoder
import android.net.Uri import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.support.v4.media.session.MediaControllerCompat import android.support.v4.media.session.MediaControllerCompat
import android.util.Log import android.util.Log
import androidx.documentfile.provider.DocumentFile
import com.anggrayudi.storage.file.getAbsolutePath
import com.anggrayudi.storage.file.toRawFile
import com.audiobookshelf.app.R import com.audiobookshelf.app.R
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.engine.DiskCacheStrategy
@@ -24,9 +24,6 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
var currentIconUri: Uri? = null var currentIconUri: Uri? = null
var currentBitmap: Bitmap? = null var currentBitmap: Bitmap? = null
private val glideOptions = RequestOptions()
.fallback(R.drawable.icon)
.diskCacheStrategy(DiskCacheStrategy.DATA)
private val serviceJob = SupervisorJob() private val serviceJob = SupervisorJob()
private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob) private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob)
@@ -48,13 +45,25 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
// `getCurrentLargeIcon` don't cause the bitmap to be recreated. // `getCurrentLargeIcon` don't cause the bitmap to be recreated.
currentIconUri = albumArtUri currentIconUri = albumArtUri
Log.d(tag, "ART $currentIconUri") Log.d(tag, "ART $currentIconUri")
serviceScope.launch {
currentBitmap = albumArtUri?.let { if (currentIconUri.toString().startsWith("content://")) {
resolveUriAsBitmap(it) currentBitmap = if (Build.VERSION.SDK_INT < 28) {
MediaStore.Images.Media.getBitmap(playerNotificationService.contentResolver, currentIconUri)
} else {
val source: ImageDecoder.Source = ImageDecoder.createSource(playerNotificationService.contentResolver, currentIconUri!!)
ImageDecoder.decodeBitmap(source)
} }
currentBitmap?.let { callback.onBitmap(it) } currentBitmap
} else {
serviceScope.launch {
currentBitmap = albumArtUri?.let {
resolveUriAsBitmap(it)
}
currentBitmap?.let { callback.onBitmap(it) }
}
null
} }
null
} else { } else {
currentBitmap currentBitmap
} }
@@ -62,26 +71,10 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
private suspend fun resolveUriAsBitmap(uri: Uri): Bitmap? { private suspend fun resolveUriAsBitmap(uri: Uri): Bitmap? {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
// Block on downloading artwork.
val context = playerNotificationService.getContext()
// Fix attempt for #35 local cover crashing
// Convert content uri to a file and pass to Glide
var urival:Any = uri
if (uri.toString().startsWith("content:")) {
val imageDocFile = DocumentFile.fromSingleUri(context, uri)
Log.d(tag, "Converting local content url $uri to file with path ${imageDocFile?.getAbsolutePath(context)}")
val file = imageDocFile?.toRawFile(context)
file?.let {
Log.d(tag, "Using local file image instead of content uri ${it.absolutePath}")
urival = it
}
}
try { try {
Glide.with(context).applyDefaultRequestOptions(glideOptions) Glide.with(playerNotificationService)
.asBitmap() .asBitmap()
.load(urival) .load(uri)
.placeholder(R.drawable.icon) .placeholder(R.drawable.icon)
.error(R.drawable.icon) .error(R.drawable.icon)
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE) .submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
@@ -89,7 +82,7 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
Glide.with(context).applyDefaultRequestOptions(glideOptions) Glide.with(playerNotificationService)
.asBitmap() .asBitmap()
.load(Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)) .load(Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon))
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE) .submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
@@ -4,17 +4,14 @@ import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.MediaMetadataCompat
import android.util.Log
import androidx.annotation.AnyRes import androidx.annotation.AnyRes
import com.audiobookshelf.app.R import com.audiobookshelf.app.R
import com.audiobookshelf.app.data.LibraryCategory import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.data.LibraryItem
import com.audiobookshelf.app.data.LocalLibraryItem
class BrowseTree( class BrowseTree(
val context: Context, val context: Context,
libraryCategories: List<LibraryCategory> itemsInProgress: List<ItemInProgress>,
libraries: List<Library>
) { ) {
private val mediaIdToChildren = mutableMapOf<String, MutableList<MediaMetadataCompat>>() private val mediaIdToChildren = mutableMapOf<String, MutableList<MediaMetadataCompat>>()
@@ -24,8 +21,7 @@ class BrowseTree(
* @param drawableId - drawable res id * @param drawableId - drawable res id
* @return - uri * @return - uri
*/ */
fun getUriToDrawable(context: Context, fun getUriToDrawable(@AnyRes drawableId: Int): Uri {
@AnyRes drawableId: Int): Uri {
return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE
+ "://" + context.resources.getResourcePackageName(drawableId) + "://" + context.resources.getResourcePackageName(drawableId)
+ '/' + context.resources.getResourceTypeName(drawableId) + '/' + context.resources.getResourceTypeName(drawableId)
@@ -35,68 +31,41 @@ class BrowseTree(
init { init {
val rootList = mediaIdToChildren[AUTO_BROWSE_ROOT] ?: mutableListOf() val rootList = mediaIdToChildren[AUTO_BROWSE_ROOT] ?: mutableListOf()
val continueReadingMetadata = MediaMetadataCompat.Builder().apply { val continueListeningMetadata = MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, CONTINUE_ROOT) putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, CONTINUE_ROOT)
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Listening") putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Listening")
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_localaudio).toString()) putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.exo_icon_localaudio).toString())
}.build()
val allMetadata = MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, ALL_ROOT)
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Library Items")
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_books).toString())
}.build() }.build()
val downloadsMetadata = MediaMetadataCompat.Builder().apply { val downloadsMetadata = MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, DOWNLOADS_ROOT) putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, DOWNLOADS_ROOT)
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Downloads") putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Downloads")
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_downloaddone).toString()) putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.exo_icon_downloaddone).toString())
}.build() }.build()
// Server continue Listening cat val librariesMetadata = MediaMetadataCompat.Builder().apply {
libraryCategories.find { it.id == "continue-listening" }?.let { continueListeningCategory -> putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, LIBRARIES_ROOT)
var continueListeningMediaMetadata = continueListeningCategory.entities.map { liw -> putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Libraries")
var libraryItem = liw as LibraryItem putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.icon_library_folder).toString())
libraryItem.getMediaMetadata() }.build()
}
if (continueListeningMediaMetadata.isNotEmpty()) { if (!itemsInProgress.isEmpty()) {
rootList += continueReadingMetadata rootList += continueListeningMetadata
} }
continueListeningMediaMetadata.forEach {
val children = mediaIdToChildren[CONTINUE_ROOT] ?: mutableListOf() if (libraries.isNotEmpty()) {
children += it rootList += librariesMetadata
mediaIdToChildren[CONTINUE_ROOT] = children
libraries.forEach { library ->
val libraryMediaMetadata = library.getMediaMetadata()
val children = mediaIdToChildren[LIBRARIES_ROOT] ?: mutableListOf()
children += libraryMediaMetadata
mediaIdToChildren[LIBRARIES_ROOT] = children
} }
} }
rootList += allMetadata
rootList += downloadsMetadata rootList += downloadsMetadata
// Server library cat
libraryCategories.find { it.id == "library" }?.let { libraryCategory ->
var libraryMediaMetadata = libraryCategory.entities.map { libc ->
var libraryItem = libc as LibraryItem
libraryItem.getMediaMetadata()
}
libraryMediaMetadata.forEach {
val children = mediaIdToChildren[ALL_ROOT] ?: mutableListOf()
children += it
mediaIdToChildren[ALL_ROOT] = children
}
}
libraryCategories.find { it.id == "local-books" }?.let { localBooksCat ->
var localMediaMetadata = localBooksCat.entities.map { libc ->
var libraryItem = libc as LocalLibraryItem
libraryItem.getMediaMetadata()
}
localMediaMetadata.forEach {
val children = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf()
children += it
mediaIdToChildren[DOWNLOADS_ROOT] = children
}
}
mediaIdToChildren[AUTO_BROWSE_ROOT] = rootList mediaIdToChildren[AUTO_BROWSE_ROOT] = rootList
} }
@@ -104,6 +73,6 @@ class BrowseTree(
} }
const val AUTO_BROWSE_ROOT = "/" const val AUTO_BROWSE_ROOT = "/"
const val ALL_ROOT = "__ALL__"
const val CONTINUE_ROOT = "__CONTINUE__" const val CONTINUE_ROOT = "__CONTINUE__"
const val DOWNLOADS_ROOT = "__DOWNLOADS__" const val DOWNLOADS_ROOT = "__DOWNLOADS__"
const val LIBRARIES_ROOT = "__LIBRARIES__"
@@ -88,7 +88,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
private var currentMediaItemIndex = 0 private var currentMediaItemIndex = 0
private var pendingMediaItemRemovalPosition:PositionInfo? = null private var pendingMediaItemRemovalPosition:PositionInfo? = null
private var pendingSeekCount = 0 private var pendingSeekCount = 0
private var pendingSeekWindowIndex = 0 private var pendingSeekWindowIndex = C.INDEX_UNSET
private var pendingSeekPositionMs = 0L private var pendingSeekPositionMs = 0L
init { init {
@@ -4,12 +4,12 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import com.audiobookshelf.app.data.LocalMediaProgress import com.audiobookshelf.app.data.LocalMediaProgress
import com.audiobookshelf.app.data.MediaProgress
import com.audiobookshelf.app.data.PlaybackSession import com.audiobookshelf.app.data.PlaybackSession
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.server.ApiHandler import com.audiobookshelf.app.server.ApiHandler
import java.util.* import java.util.*
import kotlin.concurrent.schedule import kotlin.concurrent.schedule
import kotlin.math.roundToInt
data class MediaProgressSyncData( data class MediaProgressSyncData(
var timeListened:Long, // seconds var timeListened:Long, // seconds
@@ -17,15 +17,15 @@ data class MediaProgressSyncData(
var currentTime:Double // seconds var currentTime:Double // seconds
) )
class MediaProgressSyncer(playerNotificationService:PlayerNotificationService, apiHandler: ApiHandler) { class MediaProgressSyncer(val playerNotificationService:PlayerNotificationService, private val apiHandler: ApiHandler) {
private val tag = "MediaProgressSync" private val tag = "MediaProgressSync"
private val playerNotificationService:PlayerNotificationService = playerNotificationService private val METERED_CONNECTION_SYNC_INTERVAL = 60000
private val apiHandler = apiHandler
private var listeningTimerTask: TimerTask? = null private var listeningTimerTask: TimerTask? = null
var listeningTimerRunning:Boolean = false var listeningTimerRunning:Boolean = false
private var lastSyncTime:Long = 0 private var lastSyncTime:Long = 0
private var failedSyncs:Int = 0
var currentPlaybackSession: PlaybackSession? = null // copy of pb session currently syncing var currentPlaybackSession: PlaybackSession? = null // copy of pb session currently syncing
var currentLocalMediaProgress: LocalMediaProgress? = null var currentLocalMediaProgress: LocalMediaProgress? = null
@@ -43,67 +43,181 @@ class MediaProgressSyncer(playerNotificationService:PlayerNotificationService, a
currentLocalMediaProgress = null currentLocalMediaProgress = null
listeningTimerTask?.cancel() listeningTimerTask?.cancel()
lastSyncTime = 0L lastSyncTime = 0L
Log.d(tag, "start: Set last sync time 0 $lastSyncTime")
failedSyncs = 0
} else { } else {
return return
} }
} else if (playerNotificationService.getCurrentPlaybackSessionId() != currentSessionId) {
currentLocalMediaProgress = null
} }
listeningTimerRunning = true listeningTimerRunning = true
lastSyncTime = System.currentTimeMillis() lastSyncTime = System.currentTimeMillis()
Log.d(tag, "start: init last sync time $lastSyncTime")
currentPlaybackSession = playerNotificationService.getCurrentPlaybackSessionCopy() currentPlaybackSession = playerNotificationService.getCurrentPlaybackSessionCopy()
listeningTimerTask = Timer("ListeningTimer", false).schedule(0L, 5000L) { listeningTimerTask = Timer("ListeningTimer", false).schedule(0L, 5000L) {
Handler(Looper.getMainLooper()).post() { Handler(Looper.getMainLooper()).post() {
if (playerNotificationService.currentPlayer.isPlaying) { if (playerNotificationService.currentPlayer.isPlaying) {
var currentTime = playerNotificationService.getCurrentTimeSeconds() // Only sync with server on unmetered connection every 5s OR sync with server if last sync time is >= 60s
sync(currentTime) val shouldSyncServer = PlayerNotificationService.isUnmeteredNetwork || System.currentTimeMillis() - lastSyncTime >= METERED_CONNECTION_SYNC_INTERVAL
val currentTime = playerNotificationService.getCurrentTimeSeconds()
if (currentTime > 0) {
sync(shouldSyncServer, currentTime) {
Log.d(tag, "Sync complete")
}
}
} }
} }
} }
} }
fun stop() { fun stop(shouldSync:Boolean? = true, cb: () -> Unit) {
if (!listeningTimerRunning) return if (!listeningTimerRunning) return
listeningTimerTask?.cancel()
listeningTimerTask = null
listeningTimerRunning = false
Log.d(tag, "stop: Stopping listening for $currentDisplayTitle") Log.d(tag, "stop: Stopping listening for $currentDisplayTitle")
var currentTime = playerNotificationService.getCurrentTimeSeconds() val currentTime = if (shouldSync == true) playerNotificationService.getCurrentTimeSeconds() else 0.0
sync(currentTime) if (currentTime > 0) { // Current time should always be > 0 on stop
reset() sync(true, currentTime) {
reset()
cb()
}
} else {
reset()
cb()
}
} }
fun sync(currentTime:Double) { fun pause(cb: () -> Unit) {
var diffSinceLastSync = System.currentTimeMillis() - lastSyncTime if (!listeningTimerRunning) return
if (diffSinceLastSync < 1000L) { 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 return
} }
var listeningTimeToAdd = diffSinceLastSync / 1000L
lastSyncTime = System.currentTimeMillis()
var syncData = MediaProgressSyncData(listeningTimeToAdd,currentPlaybackDuration,currentTime) val diffSinceLastSync = System.currentTimeMillis() - lastSyncTime
if (diffSinceLastSync < 1000L) {
return cb()
}
val listeningTimeToAdd = diffSinceLastSync / 1000L
val syncData = MediaProgressSyncData(listeningTimeToAdd,currentPlaybackDuration,currentTime)
currentPlaybackSession?.syncData(syncData) 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) { if (currentIsLocal) {
// Save local progress sync // Save local progress sync
currentPlaybackSession?.let { currentPlaybackSession?.let {
DeviceManager.dbManager.saveLocalPlaybackSession(it) DeviceManager.dbManager.saveLocalPlaybackSession(it)
saveLocalProgress(it) saveLocalProgress(it)
lastSyncTime = System.currentTimeMillis()
// Local library item is linked to a server library item
// Send sync to server also if connected to this server and local item belongs to this server // 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) { if (shouldSyncServer && !it.libraryItemId.isNullOrEmpty() && it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
apiHandler.sendLocalProgressSync(it) { apiHandler.sendLocalProgressSync(it) { syncSuccess ->
Log.d(tag, "Local progress sync data sent to server $currentDisplayTitle for time $currentTime") 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) { 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()
} }
} }
private fun saveLocalProgress(playbackSession:PlaybackSession) { private fun saveLocalProgress(playbackSession:PlaybackSession) {
if (currentLocalMediaProgress == null) { if (currentLocalMediaProgress == null) {
var mediaProgress = DeviceManager.dbManager.getLocalMediaProgress(playbackSession.localMediaProgressId) val mediaProgress = DeviceManager.dbManager.getLocalMediaProgress(playbackSession.localMediaProgressId)
if (mediaProgress == null) { if (mediaProgress == null) {
currentLocalMediaProgress = playbackSession.getNewLocalMediaProgress() currentLocalMediaProgress = playbackSession.getNewLocalMediaProgress()
} else { } else {
@@ -113,18 +227,22 @@ class MediaProgressSyncer(playerNotificationService:PlayerNotificationService, a
currentLocalMediaProgress?.updateFromPlaybackSession(playbackSession) currentLocalMediaProgress?.updateFromPlaybackSession(playbackSession)
} }
currentLocalMediaProgress?.let { currentLocalMediaProgress?.let {
DeviceManager.dbManager.saveLocalMediaProgress(it) if (it.progress.isNaN()) {
playerNotificationService.clientEventEmitter?.onLocalMediaProgressUpdate(it) Log.e(tag, "Invalid progress on local media progress")
Log.d(tag, "Saved Local Progress Current Time: ID ${it.id} | ${it.currentTime} | Duration ${it.duration} | Progress ${(it.progress * 100).roundToInt()}%") } 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() { fun reset() {
listeningTimerTask?.cancel()
listeningTimerTask = null
listeningTimerRunning = false
currentPlaybackSession = null currentPlaybackSession = null
currentLocalMediaProgress = null currentLocalMediaProgress = null
lastSyncTime = 0L lastSyncTime = 0L
Log.d(tag, "reset: Set last sync time 0 $lastSyncTime")
failedSyncs = 0
} }
} }
@@ -9,11 +9,8 @@ import android.os.Message
import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.MediaSessionCompat
import android.util.Log import android.util.Log
import android.view.KeyEvent import android.view.KeyEvent
import com.audiobookshelf.app.data.LibraryItem
import com.audiobookshelf.app.data.LibraryItemWrapper import com.audiobookshelf.app.data.LibraryItemWrapper
import kotlinx.coroutines.Dispatchers import com.audiobookshelf.app.data.PodcastEpisode
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.util.* import java.util.*
import kotlin.concurrent.schedule import kotlin.concurrent.schedule
@@ -22,15 +19,18 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
private var mediaButtonClickCount: Int = 0 private var mediaButtonClickCount: Int = 0
var mediaButtonClickTimeout: Long = 1000 //ms var mediaButtonClickTimeout: Long = 1000 //ms
var seekAmount: Long = 20000 //ms
override fun onPrepare() { override fun onPrepare() {
Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT") Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT")
playerNotificationService.mediaManager.getFirstItem()?.let { li -> playerNotificationService.mediaManager.getFirstItem()?.let { li ->
playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) { playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) {
Log.d(tag, "About to prepare player with ${it.displayTitle}") if (it == null) {
Handler(Looper.getMainLooper()).post() { Log.e(tag, "Failed to play library item")
playerNotificationService.preparePlayer(it,true,null) } else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it,true, playbackRate)
}
} }
} }
} }
@@ -49,10 +49,14 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
override fun onPlayFromSearch(query: String?, extras: Bundle?) { override fun onPlayFromSearch(query: String?, extras: Bundle?) {
Log.d(tag, "ON PLAY FROM SEARCH $query") Log.d(tag, "ON PLAY FROM SEARCH $query")
playerNotificationService.mediaManager.getFromSearch(query)?.let { li -> playerNotificationService.mediaManager.getFromSearch(query)?.let { li ->
playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) { playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) {
Log.d(tag, "About to prepare player with ${it.displayTitle}") if (it == null) {
Handler(Looper.getMainLooper()).post() { Log.e(tag, "Failed to play library item")
playerNotificationService.preparePlayer(it,true,null) } else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it, true, playbackRate)
}
} }
} }
} }
@@ -68,19 +72,19 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
} }
override fun onSkipToPrevious() { override fun onSkipToPrevious() {
playerNotificationService.seekBackward(seekAmount) playerNotificationService.skipToPrevious()
} }
override fun onSkipToNext() { override fun onSkipToNext() {
playerNotificationService.seekForward(seekAmount) playerNotificationService.skipToNext()
} }
override fun onFastForward() { override fun onFastForward() {
playerNotificationService.seekForward(seekAmount) playerNotificationService.jumpForward()
} }
override fun onRewind() { override fun onRewind() {
playerNotificationService.seekForward(seekAmount) playerNotificationService.jumpBackward()
} }
override fun onSeekTo(pos: Long) { override fun onSeekTo(pos: Long) {
@@ -90,17 +94,31 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
Log.d(tag, "ON PLAY FROM MEDIA ID $mediaId") Log.d(tag, "ON PLAY FROM MEDIA ID $mediaId")
var libraryItemWrapper: LibraryItemWrapper? = null var libraryItemWrapper: LibraryItemWrapper? = null
var podcastEpisode: PodcastEpisode? = null
if (mediaId.isNullOrEmpty()) { if (mediaId.isNullOrEmpty()) {
libraryItemWrapper = playerNotificationService.mediaManager.getFirstItem() libraryItemWrapper = playerNotificationService.mediaManager.getFirstItem()
} else if (mediaId.startsWith("ep_") || mediaId.startsWith("local_ep_")) { // Playing podcast episode
val libraryItemWithEpisode = playerNotificationService.mediaManager.getPodcastWithEpisodeByEpisodeId(mediaId)
libraryItemWrapper = libraryItemWithEpisode?.libraryItemWrapper
podcastEpisode = libraryItemWithEpisode?.episode
} else { } else {
libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId) libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId)
if (libraryItemWrapper == null) {
Log.e(tag, "onPlayFromMediaId: Media item not found $mediaId")
}
} }
libraryItemWrapper?.let { li -> libraryItemWrapper?.let { li ->
playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) { playerNotificationService.mediaManager.play(li, podcastEpisode, playerNotificationService.getPlayItemRequestPayload(false)) {
Log.d(tag, "About to prepare player with ${it.displayTitle}") if (it == null) {
Handler(Looper.getMainLooper()).post() { Log.e(tag, "Failed to play library item")
playerNotificationService.preparePlayer(it,true,null) } else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it, true, playbackRate)
}
} }
} }
} }
@@ -111,11 +129,31 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
} }
fun handleCallMediaButton(intent: Intent): Boolean { fun handleCallMediaButton(intent: Intent): Boolean {
Log.w(tag, "handleCallMediaButton $intent | ${intent.action}")
if(Intent.ACTION_MEDIA_BUTTON == intent.action) { if(Intent.ACTION_MEDIA_BUTTON == intent.action) {
var keyEvent = intent.getParcelableExtra<KeyEvent>(Intent.EXTRA_KEY_EVENT) val keyEvent = intent.getParcelableExtra<KeyEvent>(Intent.EXTRA_KEY_EVENT)
if (keyEvent?.getAction() == KeyEvent.ACTION_UP) { Log.d(tag, "handleCallMediaButton keyEvent = $keyEvent | action ${keyEvent?.action}")
when (keyEvent?.getKeyCode()) {
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 -> { KeyEvent.KEYCODE_HEADSETHOOK -> {
Log.d(tag, "handleCallMediaButton: Headset Hook")
if (0 == mediaButtonClickCount) { if (0 == mediaButtonClickCount) {
if (playerNotificationService.mPlayer.isPlaying) if (playerNotificationService.mPlayer.isPlaying)
playerNotificationService.pause() playerNotificationService.pause()
@@ -125,6 +163,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
handleMediaButtonClickCount() handleMediaButtonClickCount()
} }
KeyEvent.KEYCODE_MEDIA_PLAY -> { KeyEvent.KEYCODE_MEDIA_PLAY -> {
Log.d(tag, "handleCallMediaButton: Media Play")
if (0 == mediaButtonClickCount) { if (0 == mediaButtonClickCount) {
playerNotificationService.play() playerNotificationService.play()
playerNotificationService.sleepTimerManager.checkShouldExtendSleepTimer() playerNotificationService.sleepTimerManager.checkShouldExtendSleepTimer()
@@ -132,19 +171,27 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
handleMediaButtonClickCount() handleMediaButtonClickCount()
} }
KeyEvent.KEYCODE_MEDIA_PAUSE -> { KeyEvent.KEYCODE_MEDIA_PAUSE -> {
Log.d(tag, "handleCallMediaButton: Media Pause")
if (0 == mediaButtonClickCount) playerNotificationService.pause() if (0 == mediaButtonClickCount) playerNotificationService.pause()
handleMediaButtonClickCount() handleMediaButtonClickCount()
} }
KeyEvent.KEYCODE_MEDIA_NEXT -> { KeyEvent.KEYCODE_MEDIA_NEXT -> {
playerNotificationService.seekForward(seekAmount) playerNotificationService.jumpForward()
} }
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> { KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
playerNotificationService.seekBackward(seekAmount) playerNotificationService.jumpBackward()
}
KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
playerNotificationService.jumpForward()
}
KeyEvent.KEYCODE_MEDIA_REWIND -> {
playerNotificationService.jumpBackward()
} }
KeyEvent.KEYCODE_MEDIA_STOP -> { KeyEvent.KEYCODE_MEDIA_STOP -> {
playerNotificationService.closePlayback() playerNotificationService.closePlayback()
} }
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> { KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
Log.d(tag, "handleCallMediaButton: Media Play/Pause")
if (playerNotificationService.mPlayer.isPlaying) { if (playerNotificationService.mPlayer.isPlaying) {
if (0 == mediaButtonClickCount) playerNotificationService.pause() if (0 == mediaButtonClickCount) playerNotificationService.pause()
handleMediaButtonClickCount() handleMediaButtonClickCount()
@@ -157,7 +204,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
} }
} }
else -> { else -> {
Log.d(tag, "KeyCode:${keyEvent.getKeyCode()}") Log.d(tag, "KeyCode:${keyEvent.keyCode}")
return false return false
} }
} }
@@ -166,7 +213,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
return true return true
} }
fun handleMediaButtonClickCount() { private fun handleMediaButtonClickCount() {
mediaButtonClickCount++ mediaButtonClickCount++
if (1 == mediaButtonClickCount) { if (1 == mediaButtonClickCount) {
Timer().schedule(mediaButtonClickTimeout) { Timer().schedule(mediaButtonClickTimeout) {
@@ -182,14 +229,24 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
override fun handleMessage(msg: Message) { override fun handleMessage(msg: Message) {
super.handleMessage(msg) super.handleMessage(msg)
if (2 == msg.what) { if (2 == msg.what) {
playerNotificationService.seekBackward(seekAmount) playerNotificationService.jumpBackward()
playerNotificationService.play() playerNotificationService.play()
} }
else if (msg.what >= 3) { else if (msg.what >= 3) {
playerNotificationService.seekForward(seekAmount) playerNotificationService.jumpForward()
playerNotificationService.play() playerNotificationService.play()
} }
} }
} }
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()
}
}
} }
@@ -7,8 +7,8 @@ import android.os.Looper
import android.os.ResultReceiver import android.os.ResultReceiver
import android.support.v4.media.session.PlaybackStateCompat import android.support.v4.media.session.PlaybackStateCompat
import android.util.Log import android.util.Log
import com.audiobookshelf.app.data.LibraryItem
import com.audiobookshelf.app.data.LibraryItemWrapper import com.audiobookshelf.app.data.LibraryItemWrapper
import com.audiobookshelf.app.data.PodcastEpisode
import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
@@ -30,9 +30,14 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
override fun onPrepare(playWhenReady: Boolean) { override fun onPrepare(playWhenReady: Boolean) {
Log.d(tag, "ON PREPARE $playWhenReady") Log.d(tag, "ON PREPARE $playWhenReady")
playerNotificationService.mediaManager.getFirstItem()?.let { li -> playerNotificationService.mediaManager.getFirstItem()?.let { li ->
playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) { playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) {
Handler(Looper.getMainLooper()).post() { if (it == null) {
playerNotificationService.preparePlayer(it,playWhenReady,null) Log.e(tag, "Failed to play library item")
} else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
}
} }
} }
} }
@@ -41,12 +46,26 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) { override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) {
Log.d(tag, "ON PREPARE FROM MEDIA ID $mediaId $playWhenReady") Log.d(tag, "ON PREPARE FROM MEDIA ID $mediaId $playWhenReady")
var libraryItemWrapper: LibraryItemWrapper? = playerNotificationService.mediaManager.getById(mediaId) var libraryItemWrapper: LibraryItemWrapper? = null
var podcastEpisode: PodcastEpisode? = null
if (mediaId.startsWith("ep_") || mediaId.startsWith("local_ep_")) { // Playing podcast episode
val libraryItemWithEpisode = playerNotificationService.mediaManager.getPodcastWithEpisodeByEpisodeId(mediaId)
libraryItemWrapper = libraryItemWithEpisode?.libraryItemWrapper
podcastEpisode = libraryItemWithEpisode?.episode
} else {
libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId)
}
libraryItemWrapper?.let { li -> libraryItemWrapper?.let { li ->
playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) { playerNotificationService.mediaManager.play(li, podcastEpisode, playerNotificationService.getPlayItemRequestPayload(false)) {
Log.d(tag, "About to prepare player with ${it.displayTitle}") if (it == null) {
Handler(Looper.getMainLooper()).post() { Log.e(tag, "Failed to play library item")
playerNotificationService.preparePlayer(it,playWhenReady,null) } else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
}
} }
} }
} }
@@ -55,10 +74,14 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) { override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) {
Log.d(tag, "ON PREPARE FROM SEARCH $query") Log.d(tag, "ON PREPARE FROM SEARCH $query")
playerNotificationService.mediaManager.getFromSearch(query)?.let { li -> playerNotificationService.mediaManager.getFromSearch(query)?.let { li ->
playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) { playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) {
Log.d(tag, "About to prepare player with ${it.displayTitle}") if (it == null) {
Handler(Looper.getMainLooper()).post() { Log.e(tag, "Failed to play library item")
playerNotificationService.preparePlayer(it,playWhenReady,null) } 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 android.util.Log
import com.audiobookshelf.app.data.PlayerState import com.audiobookshelf.app.data.PlayerState
import com.audiobookshelf.app.device.DeviceManager
import com.google.android.exoplayer2.PlaybackException import com.google.android.exoplayer2.PlaybackException
import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.Player
const val PAUSE_LEN_BEFORE_RECHECK = 30000 // 30 seconds
class PlayerListener(var playerNotificationService:PlayerNotificationService) : Player.Listener { class PlayerListener(var playerNotificationService:PlayerNotificationService) : Player.Listener {
var tag = "PlayerListener" var tag = "PlayerListener"
@@ -15,13 +18,13 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
private var onSeekBack: Boolean = false private var onSeekBack: Boolean = false
override fun onPlayerError(error: PlaybackException) { override fun onPlayerError(error: PlaybackException) {
var errorMessage = error.message ?: "Unknown Error" val errorMessage = error.message ?: "Unknown Error"
Log.e(tag, "onPlayerError $errorMessage") Log.e(tag, "onPlayerError $errorMessage")
playerNotificationService.handlePlayerPlaybackError(errorMessage) // If was direct playing session, fallback to transcode playerNotificationService.handlePlayerPlaybackError(errorMessage) // If was direct playing session, fallback to transcode
} }
override fun onEvents(player: Player, events: Player.Events) { 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)) { if (events.contains(Player.EVENT_POSITION_DISCONTINUITY)) {
Log.d(tag, "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) { if (playerNotificationService.currentPlayer.playbackState == Player.STATE_ENDED) {
Log.d(tag, "STATE_ENDED") Log.d(tag, "STATE_ENDED")
playerNotificationService.sendClientMetadata(PlayerState.ENDED) playerNotificationService.sendClientMetadata(PlayerState.ENDED)
playerNotificationService.handlePlaybackEnded()
} }
if (playerNotificationService.currentPlayer.playbackState == Player.STATE_IDLE) { if (playerNotificationService.currentPlayer.playbackState == Player.STATE_IDLE) {
Log.d(tag, "STATE_IDLE") Log.d(tag, "STATE_IDLE")
@@ -67,20 +72,37 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
if (player.isPlaying) { if (player.isPlaying) {
Log.d(tag, "SeekBackTime: Player is playing") 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 if (onSeekBack) onSeekBack = false
else { else {
Log.d(tag, "SeekBackTime: playing started now set seek back time $lastPauseTime") Log.d(tag, "SeekBackTime: playing started now set seek back time $lastPauseTime")
var backTime = calcPauseSeekBackTime() seekBackTime = calcPauseSeekBackTime()
if (backTime > 0) { if (seekBackTime > 0) {
if (backTime >= playerNotificationService.getCurrentTime()) backTime = playerNotificationService.getCurrentTime() - 500 // Current chapter is used so that seek back does not go back to the previous chapter
Log.d(tag, "SeekBackTime $backTime") 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 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 { } else {
Log.d(tag, "SeekBackTime: Player not playing set last pause time") Log.d(tag, "SeekBackTime: Player not playing set last pause time")
@@ -88,23 +110,28 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
} }
// Start/stop progress sync interval // Start/stop progress sync interval
Log.d(tag, "Playing ${playerNotificationService.getCurrentBookTitle()}")
if (player.isPlaying) { if (player.isPlaying) {
player.volume = 1F // Volume on sleep timer might have decreased this
playerNotificationService.mediaProgressSyncer.start() playerNotificationService.mediaProgressSyncer.start()
} else { } else {
playerNotificationService.mediaProgressSyncer.stop() playerNotificationService.mediaProgressSyncer.pause {
Log.d(tag, "Media Progress Syncer paused and synced")
}
} }
playerNotificationService.clientEventEmitter?.onPlayingUpdate(player.isPlaying) playerNotificationService.clientEventEmitter?.onPlayingUpdate(player.isPlaying)
DeviceManager.widgetUpdater?.onPlayerChanged(playerNotificationService)
} }
} }
private fun calcPauseSeekBackTime() : Long { private fun calcPauseSeekBackTime() : Long {
if (lastPauseTime <= 0) return 0 if (lastPauseTime <= 0) return 0
var time: Long = System.currentTimeMillis() - lastPauseTime val time: Long = System.currentTimeMillis() - lastPauseTime
var seekback: Long val seekback: Long
if (time < 3000) seekback = 0 if (time < 10000) seekback = 0 // 10s or less = no seekback
else if (time < 300000) seekback = 10000 // 3s to 5m = jump back 10s 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 if (time < 1800000) seekback = 20000 // 5m to 30m = jump back 20s
else seekback = 29500 // 30m and up = jump back 30s else seekback = 29500 // 30m and up = jump back 30s
return seekback return seekback
@@ -14,6 +14,7 @@ class PlayerNotificationListener(var playerNotificationService:PlayerNotificatio
// Start foreground service // Start foreground service
Log.d(tag, "Notification Posted $notificationId - Start Foreground | $notification") Log.d(tag, "Notification Posted $notificationId - Start Foreground | $notification")
PlayerNotificationService.isClosed = false
playerNotificationService.startForeground(notificationId, notification) playerNotificationService.startForeground(notificationId, notification)
} }
@@ -26,6 +27,17 @@ class PlayerNotificationListener(var playerNotificationService:PlayerNotificatio
playerNotificationService.stopSelf() playerNotificationService.stopSelf()
} else { } else {
Log.d(tag, "onNotificationCancelled not dismissed by user") Log.d(tag, "onNotificationCancelled not dismissed by user")
// When stop button is pressed on the notification I guess it isn't considered "dismissedByUser" so we need to close playback ourselves
if (!PlayerNotificationService.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.app.*
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Color import android.graphics.Color
import android.graphics.ImageDecoder
import android.hardware.Sensor import android.hardware.Sensor
import android.hardware.SensorManager import android.hardware.SensorManager
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.* import android.os.*
import android.provider.MediaStore
import android.support.v4.media.MediaBrowserCompat import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaControllerCompat import android.support.v4.media.session.MediaControllerCompat
import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat import android.support.v4.media.session.PlaybackStateCompat
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.media.MediaBrowserServiceCompat import androidx.media.MediaBrowserServiceCompat
import androidx.media.utils.MediaConstants import androidx.media.utils.MediaConstants
import com.audiobookshelf.app.BuildConfig
import com.audiobookshelf.app.R
import com.audiobookshelf.app.data.* import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.data.DeviceInfo
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.media.MediaManager import com.audiobookshelf.app.media.MediaManager
import com.audiobookshelf.app.server.ApiHandler import com.audiobookshelf.app.server.ApiHandler
import com.fasterxml.jackson.annotation.JsonIgnore
import com.google.android.exoplayer2.* import com.google.android.exoplayer2.*
import com.google.android.exoplayer2.audio.AudioAttributes import com.google.android.exoplayer2.audio.AudioAttributes
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.CustomActionProvider
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator
import com.google.android.exoplayer2.source.MediaSource import com.google.android.exoplayer2.source.MediaSource
import com.google.android.exoplayer2.source.ProgressiveMediaSource import com.google.android.exoplayer2.source.ProgressiveMediaSource
@@ -33,12 +47,18 @@ import com.google.android.exoplayer2.upstream.*
import java.util.* import java.util.*
import kotlin.concurrent.schedule import kotlin.concurrent.schedule
const val SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m const val SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m
const val PLAYER_CAST = "cast-player";
const val PLAYER_EXO = "exo-player";
class PlayerNotificationService : MediaBrowserServiceCompat() { class PlayerNotificationService : MediaBrowserServiceCompat() {
companion object { companion object {
var isStarted = false var isStarted = false
var isClosed = false
var isUnmeteredNetwork = false
var isSwitchingPlayer = false // Used when switching between cast player and exoplayer
} }
interface ClientEventEmitter { interface ClientEventEmitter {
@@ -46,12 +66,14 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
fun onPlaybackClosed() fun onPlaybackClosed()
fun onPlayingUpdate(isPlaying: Boolean) fun onPlayingUpdate(isPlaying: Boolean)
fun onMetadata(metadata: PlaybackMetadata) fun onMetadata(metadata: PlaybackMetadata)
fun onPrepare(audiobookId: String, playWhenReady: Boolean)
fun onSleepTimerEnded(currentPosition: Long) fun onSleepTimerEnded(currentPosition: Long)
fun onSleepTimerSet(sleepTimeRemaining: Int) fun onSleepTimerSet(sleepTimeRemaining: Int)
fun onLocalMediaProgressUpdate(localMediaProgress: LocalMediaProgress) fun onLocalMediaProgressUpdate(localMediaProgress: LocalMediaProgress)
fun onPlaybackFailed(errorMessage:String) fun onPlaybackFailed(errorMessage:String)
fun onMediaPlayerChanged(mediaPlayer:String) fun onMediaPlayerChanged(mediaPlayer:String)
fun onProgressSyncFailing()
fun onProgressSyncSuccess()
fun onNetworkMeteredChanged(isUnmetered:Boolean)
} }
private val tag = "PlayerService" private val tag = "PlayerService"
@@ -62,7 +84,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
private lateinit var ctx:Context private lateinit var ctx:Context
private lateinit var mediaSessionConnector: MediaSessionConnector private lateinit var mediaSessionConnector: MediaSessionConnector
private lateinit var playerNotificationManager: PlayerNotificationManager private lateinit var playerNotificationManager: PlayerNotificationManager
private lateinit var mediaSession: MediaSessionCompat lateinit var mediaSession: MediaSessionCompat
private lateinit var transportControls:MediaControllerCompat.TransportControls private lateinit var transportControls:MediaControllerCompat.TransportControls
lateinit var mediaManager: MediaManager lateinit var mediaManager: MediaManager
@@ -75,7 +97,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
lateinit var sleepTimerManager:SleepTimerManager lateinit var sleepTimerManager:SleepTimerManager
lateinit var mediaProgressSyncer:MediaProgressSyncer lateinit var mediaProgressSyncer:MediaProgressSyncer
private var notificationId = 10; private var notificationId = 10
private var channelId = "audiobookshelf_channel" private var channelId = "audiobookshelf_channel"
private var channelName = "Audiobookshelf Channel" private var channelName = "Audiobookshelf Channel"
@@ -100,7 +122,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
// Android Auto Media Browser Service // Android Auto Media Browser Service
if (SERVICE_INTERFACE == intent.action) { if (SERVICE_INTERFACE == intent.action) {
Log.d(tag, "Is Media Browser Service") Log.d(tag, "Is Media Browser Service")
return super.onBind(intent); return super.onBind(intent)
} }
return binder return binder
} }
@@ -110,11 +132,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
fun getService(): PlayerNotificationService = this@PlayerNotificationService 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 { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
isStarted = true isStarted = true
Log.d(tag, "onStartCommand $startId") Log.d(tag, "onStartCommand $startId")
@@ -139,6 +156,14 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
// detach player // detach player
override fun onDestroy() { 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) playerNotificationManager.setPlayer(null)
mPlayer.release() mPlayer.release()
castPlayer?.release() castPlayer?.release()
@@ -157,30 +182,21 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
stopSelf() stopSelf()
} }
override fun onCreate() { override fun onCreate() {
Log.d(tag, "onCreate")
super.onCreate() super.onCreate()
ctx = this ctx = this
// Initialize player // To listen for network change from metered to unmetered
val customLoadControl:LoadControl = DefaultLoadControl.Builder().setBufferDurationsMs( val networkRequest = NetworkRequest.Builder()
1000 * 20, // 20s min buffer .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
1000 * 45, // 45s max buffer .addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
1000 * 5, // 5s playback start .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
1000 * 20 // 20s playback rebuffer
).build()
mPlayer = ExoPlayer.Builder(this)
.setLoadControl(customLoadControl)
.setSeekBackIncrementMs(10000)
.setSeekForwardIncrementMs(10000)
.build() .build()
mPlayer.setHandleAudioBecomingNoisy(true) val connectivityManager = getSystemService(ConnectivityManager::class.java) as ConnectivityManager
mPlayer.addListener(PlayerListener(this)) connectivityManager.registerNetworkCallback(networkRequest, networkCallback)
val audioAttributes:AudioAttributes = AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.CONTENT_TYPE_SPEECH).build()
mPlayer.setAudioAttributes(audioAttributes, true)
currentPlayer = mPlayer DbManager.initialize(ctx)
// Initialize API // Initialize API
apiHandler = ApiHandler(ctx) apiHandler = ApiHandler(ctx)
@@ -211,6 +227,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
.apply { .apply {
setSessionActivity(sessionActivityPendingIntent) setSessionActivity(sessionActivityPendingIntent)
isActive = true isActive = true
setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS)
} }
val mediaController = MediaControllerCompat(ctx, mediaSession.sessionToken) val mediaController = MediaControllerCompat(ctx, mediaSession.sessionToken)
@@ -232,10 +249,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
playerNotificationManager.setUseNextAction(false) playerNotificationManager.setUseNextAction(false)
playerNotificationManager.setUsePreviousAction(false) playerNotificationManager.setUsePreviousAction(false)
playerNotificationManager.setUseChronometer(false) playerNotificationManager.setUseChronometer(false)
playerNotificationManager.setUseStopAction(true)
playerNotificationManager.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) playerNotificationManager.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX) playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX)
playerNotificationManager.setUseFastForwardActionInCompactView(true) playerNotificationManager.setUseFastForwardActionInCompactView(true)
playerNotificationManager.setUseRewindActionInCompactView(true) playerNotificationManager.setUseRewindActionInCompactView(true)
playerNotificationManager.setSmallIcon(R.drawable.exo_icon_localaudio)
// Unknown action // Unknown action
playerNotificationManager.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE) playerNotificationManager.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE)
@@ -244,12 +263,54 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
mediaSessionConnector = MediaSessionConnector(mediaSession) mediaSessionConnector = MediaSessionConnector(mediaSession)
val queueNavigator: TimelineQueueNavigator = object : TimelineQueueNavigator(mediaSession) { val queueNavigator: TimelineQueueNavigator = object : TimelineQueueNavigator(mediaSession) {
override fun getSupportedQueueNavigatorActions(player: Player): Long {
return PlaybackStateCompat.ACTION_PLAY_PAUSE or PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PAUSE
}
override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat { override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat {
return MediaDescriptionCompat.Builder() if (currentPlaybackSession == null) {
.setMediaId(currentPlaybackSession!!.id) 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
try {
ctx.grantUriPermission(
"com.android.systemui",
coverUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
} catch(error:Exception) {
Log.e(tag, "Grant uri permission error $error")
}
val extra = Bundle()
extra.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, currentPlaybackSession!!.displayAuthor)
val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
.setExtras(extra)
.setTitle(currentPlaybackSession!!.displayTitle) .setTitle(currentPlaybackSession!!.displayTitle)
.setSubtitle(currentPlaybackSession!!.displayAuthor) .setIconUri(coverUri)
.setIconUri(currentPlaybackSession!!.getCoverUri()).build()
bitmap?.let {
mediaDescriptionBuilder.setIconBitmap(it)
}
return mediaDescriptionBuilder.build()
} }
} }
@@ -264,30 +325,85 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
) )
mediaSessionConnector.setQueueNavigator(queueNavigator) mediaSessionConnector.setQueueNavigator(queueNavigator)
mediaSessionConnector.setPlaybackPreparer(MediaSessionPlaybackPreparer(this)) 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 //attach player to playerNotificationManager
playerNotificationManager.setPlayer(mPlayer) playerNotificationManager.setPlayer(mPlayer)
mediaSession.setCallback(MediaSessionCallback(this))
mediaSessionConnector.setPlayer(mPlayer)
} }
/* /*
User callable methods User callable methods
*/ */
fun preparePlayer(playbackSession: PlaybackSession, playWhenReady:Boolean, playbackRate:Float?) { 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 val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f
initialPlaybackRate = playbackRate initialPlaybackRate = playbackRate
if (playbackSession.mediaPlayer != PLAYER_CAST && mediaItems.size > 1) {
customActionProviders.addAll(listOf(
SkipBackwardCustomActionProvider(),
SkipForwardCustomActionProvider(),
))
}
mediaSessionConnector.setCustomActionProviders(*customActionProviders.toTypedArray());
playbackSession.mediaPlayer = getMediaPlayer() playbackSession.mediaPlayer = getMediaPlayer()
if (playbackSession.mediaPlayer == "cast-player" && playbackSession.isLocal) { if (playbackSession.mediaPlayer == PLAYER_CAST && playbackSession.isLocal) {
Log.w(tag, "Cannot cast local media item - switching player") Log.w(tag, "Cannot cast local media item - switching player")
currentPlaybackSession = null currentPlaybackSession = null
switchToPlayer(false) switchToPlayer(false)
playbackSession.mediaPlayer = getMediaPlayer() playbackSession.mediaPlayer = getMediaPlayer()
} }
if (playbackSession.mediaPlayer == "cast-player") { if (playbackSession.mediaPlayer == PLAYER_CAST) {
// If cast-player is the first player to be used // If cast-player is the first player to be used
mediaSessionConnector.setPlayer(castPlayer) mediaSessionConnector.setPlayer(castPlayer)
playerNotificationManager.setPlayer(castPlayer) playerNotificationManager.setPlayer(castPlayer)
@@ -298,10 +414,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
clientEventEmitter?.onPlaybackSession(playbackSession) clientEventEmitter?.onPlaybackSession(playbackSession)
val metadata = playbackSession.getMediaMetadataCompat()
mediaSession.setMetadata(metadata)
val mediaItems = playbackSession.getMediaItems()
if (mediaItems.isEmpty()) { if (mediaItems.isEmpty()) {
Log.e(tag, "Invalid playback session no media items to play") Log.e(tag, "Invalid playback session no media items to play")
currentPlaybackSession = null currentPlaybackSession = null
@@ -346,6 +458,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
Log.d(tag, "Prepare complete for session ${currentPlaybackSession?.displayTitle} | ${currentPlayer.mediaItemCount}") Log.d(tag, "Prepare complete for session ${currentPlaybackSession?.displayTitle} | ${currentPlayer.mediaItemCount}")
currentPlayer.playWhenReady = playWhenReady currentPlayer.playWhenReady = playWhenReady
currentPlayer.setPlaybackSpeed(playbackRateToUse) currentPlayer.setPlaybackSpeed(playbackRateToUse)
currentPlayer.prepare() currentPlayer.prepare()
} else if (castPlayer != null) { } else if (castPlayer != null) {
val currentTrackIndex = playbackSession.getCurrentTrackIndex() val currentTrackIndex = playbackSession.getCurrentTrackIndex()
@@ -361,19 +474,73 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
// On error and was attempting to direct play - fallback to transcode // On error and was attempting to direct play - fallback to transcode
currentPlaybackSession?.let { playbackSession -> currentPlaybackSession?.let { playbackSession ->
if (playbackSession.isDirectPlay) { if (playbackSession.isDirectPlay) {
val mediaPlayer = getMediaPlayer() val playItemRequestPayload = getPlayItemRequestPayload(true)
Log.d(tag, "Fallback to transcode $mediaPlayer") Log.d(tag, "Fallback to transcode $playItemRequestPayload.mediaPlayer")
val libraryItemId = playbackSession.libraryItemId ?: "" // Must be true since direct play val libraryItemId = playbackSession.libraryItemId ?: "" // Must be true since direct play
val episodeId = playbackSession.episodeId val episodeId = playbackSession.episodeId
apiHandler.playLibraryItem(libraryItemId, episodeId, true, mediaPlayer) { apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
Handler(Looper.getMainLooper()).post() { if (it == null) { // Play request failed
preparePlayer(it, true, null) clientEventEmitter?.onPlaybackFailed(errorMessage)
closePlayback(true)
} else {
Handler(Looper.getMainLooper()).post {
preparePlayer(it, true, null)
}
} }
} }
} else { } else {
clientEventEmitter?.onPlaybackFailed(errorMessage) 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)
}
}
} }
} }
} }
@@ -398,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) { currentPlayer = if (useCastPlayer) {
Log.d(tag, "switchToPlayer: Using Cast Player " + castPlayer?.deviceInfo) Log.d(tag, "switchToPlayer: Using Cast Player " + castPlayer?.deviceInfo)
mediaSessionConnector.setPlayer(castPlayer) mediaSessionConnector.setPlayer(castPlayer)
@@ -412,15 +591,13 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
clientEventEmitter?.onMediaPlayerChanged(getMediaPlayer()) clientEventEmitter?.onMediaPlayerChanged(getMediaPlayer())
if (currentPlaybackSession == null) {
Log.d(tag, "switchToPlayer: No Current playback session")
}
currentPlaybackSession?.let { 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 if (wasPlaying) { // media is paused when switching players
clientEventEmitter?.onPlayingUpdate(false) clientEventEmitter?.onPlayingUpdate(false)
} }
// TODO: Start a new playback session here instead of using the existing
preparePlayer(it, false, null) preparePlayer(it, false, null)
} }
} }
@@ -457,10 +634,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
return currentPlaybackSession?.totalDurationMs ?: 0L return currentPlaybackSession?.totalDurationMs ?: 0L
} }
fun getCurrentBookTitle() : String? {
return currentPlaybackSession?.displayTitle
}
fun getCurrentPlaybackSessionCopy() :PlaybackSession? { fun getCurrentPlaybackSessionCopy() :PlaybackSession? {
return currentPlaybackSession?.clone() return currentPlaybackSession?.clone()
} }
@@ -469,16 +642,102 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
return currentPlaybackSession?.id 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() { fun play() {
if (currentPlayer.isPlaying) { if (currentPlayer.isPlaying) {
Log.d(tag, "Already playing") Log.d(tag, "Already playing")
return return
} }
currentPlayer.volume = 1F currentPlayer.volume = 1F
if (currentPlayer == castPlayer) {
Log.d(tag, "CAST Player set on play ${currentPlayer.isLoading} || ${currentPlayer.duration} | ${currentPlayer.currentPosition}")
}
currentPlayer.play() currentPlayer.play()
} }
@@ -497,37 +756,76 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
fun seekPlayer(time: Long) { fun seekPlayer(time: Long) {
Log.d(tag, "seekPlayer mediaCount = ${currentPlayer.mediaItemCount} | $time") var timeToSeek = time
Log.d(tag, "seekPlayer mediaCount = ${currentPlayer.mediaItemCount} | $timeToSeek")
if (timeToSeek < 0) {
Log.w(tag, "seekPlayer invalid time ${timeToSeek} - setting to 0")
timeToSeek = 0L
} else if (timeToSeek > getDuration()) {
Log.w(tag, "seekPlayer invalid time ${timeToSeek} - setting to MAX - 2000")
timeToSeek = getDuration() - 2000L
}
if (currentPlayer.mediaItemCount > 1) { if (currentPlayer.mediaItemCount > 1) {
currentPlaybackSession?.currentTime = time / 1000.0 currentPlaybackSession?.currentTime = timeToSeek / 1000.0
val newWindowIndex = currentPlaybackSession?.getCurrentTrackIndex() ?: 0 val newWindowIndex = currentPlaybackSession?.getCurrentTrackIndex() ?: 0
val newTimeOffset = currentPlaybackSession?.getCurrentTrackTimeMs() ?: 0 val newTimeOffset = currentPlaybackSession?.getCurrentTrackTimeMs() ?: 0
currentPlayer.seekTo(newWindowIndex, newTimeOffset) currentPlayer.seekTo(newWindowIndex, newTimeOffset)
} else { } else {
currentPlayer.seekTo(time) currentPlayer.seekTo(timeToSeek)
} }
} }
fun skipToPrevious() {
currentPlayer.seekToPrevious()
}
fun skipToNext() {
currentPlayer.seekToNext()
}
fun jumpForward() {
seekForward(deviceSettings.jumpForwardTimeMs)
}
fun jumpBackward() {
seekBackward(deviceSettings.jumpBackwardsTimeMs)
}
fun seekForward(amount: Long) { fun seekForward(amount: Long) {
seekPlayer(getCurrentTime() + amount) seekPlayer(getCurrentTime() + amount)
// currentPlayer.seekTo(currentPlayer.currentPosition + amount)
} }
fun seekBackward(amount: Long) { fun seekBackward(amount: Long) {
seekPlayer(getCurrentTime() - amount) seekPlayer(getCurrentTime() - amount)
// currentPlayer.seekTo(currentPlayer.currentPosition - amount)
} }
fun setPlaybackSpeed(speed: Float) { fun setPlaybackSpeed(speed: Float) {
currentPlayer.setPlaybackSpeed(speed) currentPlayer.setPlaybackSpeed(speed)
} }
fun closePlayback() { fun closePlayback(calledOnError:Boolean? = false) {
currentPlayer.clearMediaItems() Log.d(tag, "closePlayback")
currentPlayer.stop() 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 currentPlaybackSession = null
clientEventEmitter?.onPlaybackClosed() clientEventEmitter?.onPlaybackClosed()
PlayerListener.lastPauseTime = 0 PlayerListener.lastPauseTime = 0
isClosed = true
stopForeground(true)
stopSelf()
} }
fun sendClientMetadata(playerState: PlayerState) { fun sendClientMetadata(playerState: PlayerState) {
@@ -536,13 +834,39 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
fun getMediaPlayer():String { fun getMediaPlayer():String {
return if(currentPlayer == castPlayer) "cast-player" else "exo-player" return if(currentPlayer == castPlayer) PLAYER_CAST else PLAYER_EXO
}
fun getDeviceInfo(): DeviceInfo {
/* 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 { fun getContext():Context {
return ctx return ctx
} }
fun alertSyncFailing() {
clientEventEmitter?.onProgressSyncFailing()
}
fun alertSyncSuccess() {
clientEventEmitter?.onProgressSyncSuccess()
}
// //
// MEDIA BROWSER STUFF (ANDROID AUTO) // MEDIA BROWSER STUFF (ANDROID AUTO)
// //
@@ -551,10 +875,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
private val ANDROID_WEARABLE_PKG_NAME = "com.google.android.wearable.app" private val ANDROID_WEARABLE_PKG_NAME = "com.google.android.wearable.app"
private val ANDROID_GSEARCH_PKG_NAME = "com.google.android.googlequicksearchbox" private val ANDROID_GSEARCH_PKG_NAME = "com.google.android.googlequicksearchbox"
private val ANDROID_AUTOMOTIVE_PKG_NAME = "com.google.android.carassistant" 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 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 private lateinit var browseTree:BrowseTree
@@ -575,11 +901,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
// No further calls will be made to other media browsing methods. // No further calls will be made to other media browsing methods.
null null
} else { } else {
if (!isStarted) { isStarted = true
Log.d(tag, "AA Not yet started") mediaManager.checkResetServerItems() // Reset any server items if no longer connected to server
mediaManager.initializeAndroidAuto()
isStarted = true
}
isAndroidAuto = true isAndroidAuto = true
@@ -600,34 +923,90 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
override fun onLoadChildren(parentMediaId: String, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) { override fun onLoadChildren(parentMediaId: String, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
Log.d(tag, "ON LOAD CHILDREN $parentMediaId") Log.d(tag, "ON LOAD CHILDREN $parentMediaId")
val flag = if (parentMediaId == AUTO_MEDIA_ROOT) MediaBrowserCompat.MediaItem.FLAG_BROWSABLE else MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
result.detach() result.detach()
mediaManager.loadAndroidAutoItems("main") { libraryCategories -> if (parentMediaId.startsWith("li_") || parentMediaId.startsWith("local_")) { // Show podcast episodes
browseTree = BrowseTree(this, libraryCategories) Log.d(tag, "Loading podcast episodes")
val children = browseTree[parentMediaId]?.map { item -> mediaManager.loadPodcastEpisodeMediaBrowserItems(parentMediaId, ctx) {
MediaBrowserCompat.MediaItem(item.description, flag) result.sendResult(it)
} }
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?) } else if (::browseTree.isInitialized && browseTree[parentMediaId] == null && mediaManager.getIsLibrary(parentMediaId)) { // Load library items for library
}
// TODO: For using sub menus. Check if this is the root menu: mediaManager.loadLibraryItemsWithAudio(parentMediaId) { libraryItems ->
// if (AUTO_MEDIA_ROOT == parentMediaId) { val children = libraryItems.map { libraryItem ->
// build the MediaItem objects for the top level, if (libraryItem.mediaType == "podcast") { // Podcasts are browseable
// and put them in the mediaItems list val mediaDescription = libraryItem.getMediaDescription(null, ctx)
// } else { MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
// examine the passed parentMediaId to see which submenu we're at, } else {
// and put the children of that menu in the mediaItems list val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.id }
// } val description = libraryItem.getMediaDescription(progress, ctx)
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
}
}
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
}
} else if (parentMediaId == DOWNLOADS_ROOT) { // Load downloads
val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast")
val localBrowseItems:MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
localBooks.forEach { localLibraryItem ->
val progress = DeviceManager.dbManager.getLocalMediaProgress(localLibraryItem.id)
val description = localLibraryItem.getMediaDescription(progress, ctx)
localBrowseItems += MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
}
localPodcasts.forEach { localLibraryItem ->
val mediaDescription = localLibraryItem.getMediaDescription(null, ctx)
localBrowseItems += MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
}
result.sendResult(localBrowseItems)
} 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}")
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
}
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
}
}
} }
override fun onSearch(query: String, extras: Bundle?, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) { override fun onSearch(query: String, extras: Bundle?, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
result.detach() result.detach()
mediaManager.loadAndroidAutoItems("main") { libraryCategories -> mediaManager.loadAndroidAutoItems {
browseTree = BrowseTree(this, libraryCategories) browseTree = BrowseTree(this, mediaManager.serverItemsInProgress, mediaManager.serverLibraries)
val children = browseTree[ALL_ROOT]?.map { item -> val children = browseTree[LIBRARIES_ROOT]?.map { item ->
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
} }
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?) result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
} }
@@ -680,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.content.Context
import android.os.* import android.os.*
import android.util.Log import android.util.Log
import com.audiobookshelf.app.device.DeviceManager
import java.util.* import java.util.*
import kotlin.concurrent.schedule import kotlin.concurrent.schedule
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -203,8 +204,13 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
} }
fun handleShake() { fun handleShake() {
Log.d(tag, "HANDLE SHAKE HERE") if (sleepTimerRunning || sleepTimerFinishedAt > 0L) {
if (sleepTimerRunning || sleepTimerFinishedAt > 0L) checkShouldExtendSleepTimer() if (DeviceManager.deviceData.deviceSettings?.disableShakeToResetSleepTimer == true) {
Log.d(tag, "Shake to reset sleep timer is disabled")
return
}
checkShouldExtendSleepTimer()
}
} }
fun increaseSleepTime(time: Long) { fun increaseSleepTime(time: Long) {
@@ -1,10 +1,8 @@
package com.audiobookshelf.app.plugins package com.audiobookshelf.app.plugins
import android.content.Intent
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat
import com.audiobookshelf.app.MainActivity import com.audiobookshelf.app.MainActivity
import com.audiobookshelf.app.data.* import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
@@ -59,13 +57,6 @@ class AbsAudioPlayer : Plugin() {
notifyListeners("onMetadata", JSObject(jacksonMapper.writeValueAsString(metadata))) 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) { override fun onSleepTimerEnded(currentPosition: Long) {
emit("onSleepTimerEnded", currentPosition) emit("onSleepTimerEnded", currentPosition)
} }
@@ -85,6 +76,18 @@ class AbsAudioPlayer : Plugin() {
override fun onMediaPlayerChanged(mediaPlayer:String) { override fun onMediaPlayerChanged(mediaPlayer:String) {
emit("onMediaPlayerChanged", mediaPlayer) emit("onMediaPlayerChanged", mediaPlayer)
} }
override fun onProgressSyncFailing() {
emit("onProgressSyncFailing", "")
}
override fun onProgressSyncSuccess() {
emit("onProgressSyncSuccess", "")
}
override fun onNetworkMeteredChanged(isUnmetered:Boolean) {
emit("onNetworkMeteredChanged", isUnmetered)
}
}) })
} }
mainActivity.pluginCallback = foregroundServiceReady mainActivity.pluginCallback = foregroundServiceReady
@@ -153,14 +156,6 @@ class AbsAudioPlayer : Plugin() {
@PluginMethod @PluginMethod
fun prepareLibraryItem(call: PluginCall) { 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 libraryItemId = call.getString("libraryItemId", "").toString()
val episodeId = call.getString("episodeId", "").toString() val episodeId = call.getString("episodeId", "").toString()
val playWhenReady = call.getBoolean("playWhenReady") == true val playWhenReady = call.getBoolean("playWhenReady") == true
@@ -168,7 +163,7 @@ class AbsAudioPlayer : Plugin() {
if (libraryItemId.isEmpty()) { if (libraryItemId.isEmpty()) {
Log.e(tag, "Invalid call to play library item no library item id") 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 if (libraryItemId.startsWith("local")) { // Play local media item
@@ -179,28 +174,56 @@ class AbsAudioPlayer : Plugin() {
episode = podcastMedia.episodes?.find { ep -> ep.id == episodeId } episode = podcastMedia.episodes?.find { ep -> ep.id == episodeId }
if (episode == null) { if (episode == null) {
Log.e(tag, "prepareLibraryItem: Podcast episode not found $episodeId") 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 { Handler(Looper.getMainLooper()).post {
Log.d(tag, "prepareLibraryItem: Preparing Local Media item ${jacksonMapper.writeValueAsString(it)}") Log.d(tag, "prepareLibraryItem: Preparing Local Media item ${jacksonMapper.writeValueAsString(it)}")
val playbackSession = it.getPlaybackSession(episode) val playbackSession = it.getPlaybackSession(episode)
playerNotificationService.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()) return call.resolve(JSObject())
} }
} else { // Play library item from server } 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 { Handler(Looper.getMainLooper()).post {
Log.d(tag, "Preparing Player TEST ${jacksonMapper.writeValueAsString(it)}") Log.d(tag, "Preparing Player playback session ${jacksonMapper.writeValueAsString(it)}")
playerNotificationService.preparePlayer(it, 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(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.audiobookshelf.app.server.ApiHandler
import com.fasterxml.jackson.core.json.JsonReadFeature import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.getcapacitor.* import com.getcapacitor.*
import com.getcapacitor.annotation.CapacitorPlugin import com.getcapacitor.annotation.CapacitorPlugin
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.json.JSONObject
@CapacitorPlugin(name = "AbsDatabase") @CapacitorPlugin(name = "AbsDatabase")
class AbsDatabase : Plugin() { class AbsDatabase : Plugin() {
@@ -24,6 +24,7 @@ class AbsDatabase : Plugin() {
data class LocalMediaProgressPayload(val value:List<LocalMediaProgress>) data class LocalMediaProgressPayload(val value:List<LocalMediaProgress>)
data class LocalLibraryItemsPayload(val value:List<LocalLibraryItem>) data class LocalLibraryItemsPayload(val value:List<LocalLibraryItem>)
data class LocalFoldersPayload(val value:List<LocalFolder>) 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() { override fun load() {
mainActivity = (activity as MainActivity) mainActivity = (activity as MainActivity)
@@ -36,7 +37,7 @@ class AbsDatabase : Plugin() {
@PluginMethod @PluginMethod
fun getDeviceData(call:PluginCall) { fun getDeviceData(call:PluginCall) {
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
var deviceData = DeviceManager.dbManager.getDeviceData() val deviceData = DeviceManager.dbManager.getDeviceData()
call.resolve(JSObject(jacksonMapper.writeValueAsString(deviceData))) call.resolve(JSObject(jacksonMapper.writeValueAsString(deviceData)))
} }
} }
@@ -44,17 +45,17 @@ class AbsDatabase : Plugin() {
@PluginMethod @PluginMethod
fun getLocalFolders(call:PluginCall) { fun getLocalFolders(call:PluginCall) {
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
var folders = DeviceManager.dbManager.getAllLocalFolders() val folders = DeviceManager.dbManager.getAllLocalFolders()
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalFoldersPayload(folders)))) call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalFoldersPayload(folders))))
} }
} }
@PluginMethod @PluginMethod
fun getLocalFolder(call:PluginCall) { fun getLocalFolder(call:PluginCall) {
var folderId = call.getString("folderId", "").toString() val folderId = call.getString("folderId", "").toString()
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
DeviceManager.dbManager.getLocalFolder(folderId)?.let { DeviceManager.dbManager.getLocalFolder(folderId)?.let {
var folderObj = jacksonMapper.writeValueAsString(it) val folderObj = jacksonMapper.writeValueAsString(it)
call.resolve(JSObject(folderObj)) call.resolve(JSObject(folderObj))
} ?: call.resolve() } ?: call.resolve()
} }
@@ -62,10 +63,10 @@ class AbsDatabase : Plugin() {
@PluginMethod @PluginMethod
fun getLocalLibraryItem(call:PluginCall) { fun getLocalLibraryItem(call:PluginCall) {
var id = call.getString("id", "").toString() val id = call.getString("id", "").toString()
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
var localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(id) val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(id)
if (localLibraryItem == null) { if (localLibraryItem == null) {
call.resolve() call.resolve()
} else { } else {
@@ -75,10 +76,10 @@ class AbsDatabase : Plugin() {
} }
@PluginMethod @PluginMethod
fun getLocalLibraryItemByLLId(call:PluginCall) { fun getLocalLibraryItemByLId(call:PluginCall) {
var libraryItemId = call.getString("libraryItemId", "").toString() val libraryItemId = call.getString("libraryItemId", "").toString()
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
var localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLLId(libraryItemId) val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItemId)
if (localLibraryItem == null) { if (localLibraryItem == null) {
call.resolve() call.resolve()
} else { } else {
@@ -89,40 +90,41 @@ class AbsDatabase : Plugin() {
@PluginMethod @PluginMethod
fun getLocalLibraryItems(call:PluginCall) { fun getLocalLibraryItems(call:PluginCall) {
var mediaType = call.getString("mediaType", "").toString() val mediaType = call.getString("mediaType", "").toString()
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
var localLibraryItems = DeviceManager.dbManager.getLocalLibraryItems(mediaType) val localLibraryItems = DeviceManager.dbManager.getLocalLibraryItems(mediaType)
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalLibraryItemsPayload(localLibraryItems)))) call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalLibraryItemsPayload(localLibraryItems))))
} }
} }
@PluginMethod @PluginMethod
fun getLocalLibraryItemsInFolder(call:PluginCall) { fun getLocalLibraryItemsInFolder(call:PluginCall) {
var folderId = call.getString("folderId", "").toString() val folderId = call.getString("folderId", "").toString()
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
var localLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(folderId) val localLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(folderId)
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalLibraryItemsPayload(localLibraryItems)))) call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalLibraryItemsPayload(localLibraryItems))))
} }
} }
@PluginMethod @PluginMethod
fun setCurrentServerConnectionConfig(call:PluginCall) { fun setCurrentServerConnectionConfig(call:PluginCall) {
var serverConnectionConfigId = call.getString("id", "").toString() Log.d(tag, "setCurrentServerConnectionConfig ${call.data}")
var serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConnectionConfigId } val serverConfigPayload = jacksonMapper.readValue<ServerConnConfigPayload>(call.data.toString())
var serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConfigPayload.id }
var userId = call.getString("userId", "").toString() val userId = serverConfigPayload.userId
var username = call.getString("username", "").toString() val username = serverConfigPayload.username
var token = call.getString("token", "").toString() val token = serverConfigPayload.token
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
if (serverConnectionConfig == null) { // New Server Connection if (serverConnectionConfig == null) { // New Server Connection
var serverAddress = call.getString("address", "").toString() val serverAddress = call.getString("address", "").toString()
// Create new server connection config // Create new server connection config
var sscId = DeviceManager.getBase64Id("$serverAddress@$username") val sscId = DeviceManager.getBase64Id("$serverAddress@$username")
var sscIndex = DeviceManager.deviceData.serverConnectionConfigs.size val sscIndex = DeviceManager.deviceData.serverConnectionConfigs.size
serverConnectionConfig = ServerConnectionConfig(sscId, sscIndex, "$serverAddress ($username)", serverAddress, userId, username, token) serverConnectionConfig = ServerConnectionConfig(sscId, sscIndex, "$serverAddress ($username)", serverAddress, userId, username, token, serverConfigPayload.customHeaders)
// Add and save // Add and save
DeviceManager.deviceData.serverConnectionConfigs.add(serverConnectionConfig!!) DeviceManager.deviceData.serverConnectionConfigs.add(serverConnectionConfig!!)
@@ -139,8 +141,8 @@ class AbsDatabase : Plugin() {
} }
// Set last connection config // Set last connection config
if (DeviceManager.deviceData.lastServerConnectionConfigId != serverConnectionConfigId) { if (DeviceManager.deviceData.lastServerConnectionConfigId != serverConfigPayload.id) {
DeviceManager.deviceData.lastServerConnectionConfigId = serverConnectionConfigId DeviceManager.deviceData.lastServerConnectionConfigId = serverConfigPayload.id
shouldSave = true shouldSave = true
} }
@@ -155,7 +157,7 @@ class AbsDatabase : Plugin() {
@PluginMethod @PluginMethod
fun removeServerConnectionConfig(call:PluginCall) { fun removeServerConnectionConfig(call:PluginCall) {
GlobalScope.launch(Dispatchers.IO) { 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> DeviceManager.deviceData.serverConnectionConfigs = DeviceManager.deviceData.serverConnectionConfigs.filter { it.id != serverConnectionConfigId } as MutableList<ServerConnectionConfig>
if (DeviceManager.deviceData.lastServerConnectionConfigId == serverConnectionConfigId) { if (DeviceManager.deviceData.lastServerConnectionConfigId == serverConnectionConfigId) {
DeviceManager.deviceData.lastServerConnectionConfigId = null DeviceManager.deviceData.lastServerConnectionConfigId = null
@@ -181,14 +183,14 @@ class AbsDatabase : Plugin() {
@PluginMethod @PluginMethod
fun getAllLocalMediaProgress(call:PluginCall) { fun getAllLocalMediaProgress(call:PluginCall) {
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
var localMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress() val localMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress()
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalMediaProgressPayload(localMediaProgress)))) call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalMediaProgressPayload(localMediaProgress))))
} }
} }
@PluginMethod @PluginMethod
fun removeLocalMediaProgress(call:PluginCall) { fun removeLocalMediaProgress(call:PluginCall) {
var localMediaProgressId = call.getString("localMediaProgressId", "").toString() val localMediaProgressId = call.getString("localMediaProgressId", "").toString()
DeviceManager.dbManager.removeLocalMediaProgress(localMediaProgressId) DeviceManager.dbManager.removeLocalMediaProgress(localMediaProgressId)
call.resolve() 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 @PluginMethod
fun updateLocalMediaProgressFinished(call:PluginCall) { fun updateLocalMediaProgressFinished(call:PluginCall) {
val localLibraryItemId = call.getString("localLibraryItemId", "").toString() 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 // Send update to server media progress is linked to a server and user is logged into that server
localMediaProgress.serverConnectionConfigId?.let { configId -> localMediaProgress.serverConnectionConfigId?.let { configId ->
if (DeviceManager.serverConnectionConfigId == configId) { if (DeviceManager.serverConnectionConfigId == configId) {
var libraryItemId = localMediaProgress.libraryItemId ?: "" val libraryItemId = localMediaProgress.libraryItemId ?: ""
var episodeId = localMediaProgress.episodeId ?: "" val episodeId = localMediaProgress.episodeId ?: ""
var updatePayload = JSObject() val updatePayload = JSObject()
updatePayload.put("isFinished", isFinished) updatePayload.put("isFinished", isFinished)
apiHandler.updateMediaProgress(libraryItemId,episodeId,updatePayload) { apiHandler.updateMediaProgress(libraryItemId,episodeId,updatePayload) {
Log.d(tag, "updateLocalMediaProgressFinished: Updated media progress isFinished on server") Log.d(tag, "updateLocalMediaProgressFinished: Updated media progress isFinished on server")
var jsobj = JSObject() val jsobj = JSObject()
jsobj.put("local", true) jsobj.put("local", true)
jsobj.put("server", true) jsobj.put("server", true)
jsobj.put("localMediaProgress", JSObject(lmpstring)) jsobj.put("localMediaProgress", JSObject(lmpstring))
@@ -288,7 +347,7 @@ class AbsDatabase : Plugin() {
} }
} }
if (localMediaProgress.serverConnectionConfigId == null || DeviceManager.serverConnectionConfigId != localMediaProgress.serverConnectionConfigId) { if (localMediaProgress.serverConnectionConfigId == null || DeviceManager.serverConnectionConfigId != localMediaProgress.serverConnectionConfigId) {
var jsobj = JSObject() val jsobj = JSObject()
jsobj.put("local", true) jsobj.put("local", true)
jsobj.put("server", false) jsobj.put("server", false)
jsobj.put("localMediaProgress", JSObject(lmpstring)) jsobj.put("localMediaProgress", JSObject(lmpstring))
@@ -298,25 +357,25 @@ class AbsDatabase : Plugin() {
@PluginMethod @PluginMethod
fun updateLocalTrackOrder(call:PluginCall) { fun updateLocalTrackOrder(call:PluginCall) {
var localLibraryItemId = call.getString("localLibraryItemId", "") ?: "" val localLibraryItemId = call.getString("localLibraryItemId", "") ?: ""
var localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId) val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
if (localLibraryItem == null) { if (localLibraryItem == null) {
call.resolve() call.resolve()
return 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") Log.d(tag, "updateLocalTrackOrder $tracks")
var index = 1 var index = 1
var hasUpdates = false var hasUpdates = false
for (i in 0 until tracks.length()) { for (i in 0 until tracks.length()) {
var track = tracks.getJSONObject(i) val track = tracks.getJSONObject(i)
var localFileId = track.getString("localFileId") val localFileId = track.getString("localFileId")
var existingTrack = audioTracks.find{ it.localFileId == localFileId } val existingTrack = audioTracks.find{ it.localFileId == localFileId }
if (existingTrack != null) { if (existingTrack != null) {
Log.d(tag, "Found existing track ${existingTrack.localFileId} that has index ${existingTrack.index} should be index $index") Log.d(tag, "Found existing track ${existingTrack.localFileId} that has index ${existingTrack.index} should be index $index")
if (existingTrack.index != index) hasUpdates = true if (existingTrack.index != index) hasUpdates = true
@@ -336,4 +395,15 @@ class AbsDatabase : Plugin() {
call.resolve() 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 -> 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) val localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
if (localFolder != null) { if (localFolder != null) {
if (episodeId.isNotEmpty() && libraryItem.mediaType != "podcast") { if (episodeId.isNotEmpty() && libraryItem.mediaType != "podcast") {
Log.e(tag, "Library item is not a podcast but episode was requested") Log.e(tag, "Library item is not a podcast but episode was requested")
call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}")) call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
} else if (episodeId.isNotEmpty()) { } else if (episodeId.isNotEmpty()) {
val podcast = libraryItem.media as Podcast val podcast = libraryItem.media as Podcast
val episode = podcast.episodes?.find { podcastEpisode -> val episode = podcast.episodes?.find { podcastEpisode ->
podcastEpisode.id == episodeId podcastEpisode.id == episodeId
} }
if (episode == null) { if (episode == null) {
call.resolve(JSObject("{\"error\":\"Invalid podcast episode not found\"}")) call.resolve(JSObject("{\"error\":\"Invalid podcast episode not found\"}"))
} else {
startLibraryItemDownload(libraryItem, localFolder, episode)
call.resolve()
}
} else { } else {
startLibraryItemDownload(libraryItem, localFolder, episode) startLibraryItemDownload(libraryItem, localFolder, null)
call.resolve() call.resolve()
} }
} else { } else {
startLibraryItemDownload(libraryItem, localFolder, null) call.resolve(JSObject("{\"error\":\"Local Folder Not Found\"}"))
call.resolve()
} }
} else {
call.resolve(JSObject("{\"error\":\"Local Folder Not Found\"}"))
} }
} }
} }
@@ -277,7 +281,7 @@ class AbsDownloader : Plugin() {
finalDestinationFile.delete() finalDestinationFile.delete()
} }
var downloadItemPart = DownloadItemPart.make(destinationFilename,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,audioTrack,null) var downloadItemPart = DownloadItemPart.make(destinationFilename,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,audioTrack,episode)
downloadItem.downloadItemParts.add(downloadItemPart) downloadItem.downloadItemParts.add(downloadItemPart)
var dlRequest = downloadItemPart.getDownloadRequest() var dlRequest = downloadItemPart.getDownloadRequest()
@@ -294,7 +298,7 @@ class AbsDownloader : Plugin() {
if (finalDestinationFile.exists()) { if (finalDestinationFile.exists()) {
Log.d(tag, "Podcast cover already exists - not downloading cover again") Log.d(tag, "Podcast cover already exists - not downloading cover again")
} else { } else {
downloadItemPart = DownloadItemPart.make(destinationFilename,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,audioTrack,null) downloadItemPart = DownloadItemPart.make(destinationFilename,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null)
downloadItem.downloadItemParts.add(downloadItemPart) downloadItem.downloadItemParts.add(downloadItemPart)
dlRequest = downloadItemPart.getDownloadRequest() dlRequest = downloadItemPart.getDownloadRequest()
@@ -397,9 +401,13 @@ class AbsDownloader : Plugin() {
Log.d(tag, "DOWNLOAD: Move file to final destination path: ${downloadItemPart.finalDestinationPath}") Log.d(tag, "DOWNLOAD: Move file to final destination path: ${downloadItemPart.finalDestinationPath}")
val localFolderFile = DocumentFileCompat.fromUri(mainActivity,Uri.parse(downloadItemPart.localFolderUrl)) val localFolderFile = DocumentFileCompat.fromUri(mainActivity,Uri.parse(downloadItemPart.localFolderUrl))
val mimetype = if (downloadItemPart.audioTrack != null) MimeType.AUDIO else MimeType.IMAGE if (localFolderFile == null) {
val fileDescription = FileDescription(downloadItemPart.filename, downloadItemPart.itemTitle, mimetype) Log.e(tag, "Local Folder File from uri is null")
file?.moveFileTo(mainActivity,localFolderFile!!,fileDescription,fcb) } 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 { } else {
// Why is kotlin requiring an else here.. // Why is kotlin requiring an else here..
} }
@@ -1,5 +1,6 @@
package com.audiobookshelf.app.plugins package com.audiobookshelf.app.plugins
import android.app.AlertDialog
import android.database.Cursor import android.database.Cursor
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
@@ -65,39 +66,59 @@ class AbsFileSystem : Plugin() {
@PluginMethod @PluginMethod
fun selectFolder(call: PluginCall) { 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 { mainActivity.storage.folderPickerCallback = object : FolderPickerCallback {
override fun onFolderSelected(requestCode: Int, folder: DocumentFile) { override fun onFolderSelected(requestCode: Int, folder: DocumentFile) {
Log.d(TAG, "ON FOLDER SELECTED ${folder.uri} ${folder.name}") Log.d(TAG, "ON FOLDER SELECTED ${folder.uri} ${folder.name}")
var absolutePath = folder.getAbsolutePath(activity) val absolutePath = folder.getAbsolutePath(activity)
var storageType = folder.getStorageType(activity) val storageType = folder.getStorageType(activity)
var simplePath = folder.getSimplePath(activity) val simplePath = folder.getSimplePath(activity)
var basePath = folder.getBasePath(activity) val basePath = folder.getBasePath(activity)
var folderId = android.util.Base64.encodeToString(folder.id.toByteArray(), android.util.Base64.DEFAULT) 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) DeviceManager.dbManager.saveLocalFolder(localFolder)
call.resolve(JSObject(jacksonMapper.writeValueAsString(localFolder))) call.resolve(JSObject(jacksonMapper.writeValueAsString(localFolder)))
} }
override fun onStorageAccessDenied(requestCode: Int, folder: DocumentFile?, storageType: StorageType) { override fun onStorageAccessDenied(requestCode: Int, folder: DocumentFile?, storageType: StorageType) {
Log.e(TAG, "STORAGE ACCESS DENIED") val jsobj = JSObject()
var jsobj = JSObject() if (requestCode == REQUEST_CODE_SELECT_FOLDER) {
jsobj.put("error", "Access Denied")
call.resolve(jsobj) 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) { override fun onStoragePermissionDenied(requestCode: Int) {
Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode") Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode")
var jsobj = JSObject() val jsobj = JSObject()
jsobj.put("error", "Permission Denied") jsobj.put("error", "Permission Denied")
call.resolve(jsobj) call.resolve(jsobj)
} }
} }
mainActivity.storage.openFolderPicker(6) mainActivity.storage.openFolderPicker(REQUEST_CODE_SELECT_FOLDER)
} }
@RequiresApi(Build.VERSION_CODES.R) @RequiresApi(Build.VERSION_CODES.R)
@@ -1,7 +1,6 @@
package com.audiobookshelf.app.server package com.audiobookshelf.app.server
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.util.Log import android.util.Log
@@ -17,36 +16,43 @@ import com.getcapacitor.JSObject
import okhttp3.* import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject import org.json.JSONObject
import java.io.IOException import java.io.IOException
import java.util.concurrent.TimeUnit
class ApiHandler(var ctx:Context) { class ApiHandler(var ctx:Context) {
val tag = "ApiHandler" 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 jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
var storageSharedPreferences: SharedPreferences? = null
data class LocalMediaProgressSyncPayload(val localMediaProgress:List<LocalMediaProgress>) data class LocalMediaProgressSyncPayload(val localMediaProgress:List<LocalMediaProgress>)
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class MediaProgressSyncResponsePayload(val numServerProgressUpdates:Int, val localProgressUpdates:List<LocalMediaProgress>) data class MediaProgressSyncResponsePayload(val numServerProgressUpdates:Int, val localProgressUpdates:List<LocalMediaProgress>)
data class LocalMediaProgressSyncResultsPayload(var numLocalMediaProgressForServer:Int, var numServerProgressUpdates:Int, var numLocalProgressUpdates:Int) 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() val request = Request.Builder()
.url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}") .url("${address}$endpoint").addHeader("Authorization", "Bearer $token")
.build() .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 mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = payload.toString().toRequestBody(mediaType) val requestBody = payload.toString().toRequestBody(mediaType)
val request = Request.Builder().post(requestBody) val request = Request.Builder().post(requestBody)
.url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}") .url("${address}$endpoint").addHeader("Authorization", "Bearer ${token}")
.build() .build()
makeRequest(request, cb) makeRequest(request, null, cb)
} }
fun patchRequest(endpoint:String, payload: JSObject, cb: (JSObject) -> Unit) { fun patchRequest(endpoint:String, payload: JSObject, cb: (JSObject) -> Unit) {
@@ -55,7 +61,7 @@ class ApiHandler(var ctx:Context) {
val request = Request.Builder().patch(requestBody) val request = Request.Builder().patch(requestBody)
.url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}") .url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}")
.build() .build()
makeRequest(request, cb) makeRequest(request, null, cb)
} }
fun isOnline(): Boolean { fun isOnline(): Boolean {
@@ -76,30 +82,52 @@ class ApiHandler(var ctx:Context) {
return false 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 { client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) { override fun onFailure(call: Call, e: IOException) {
Log.d(tag, "FAILURE TO CONNECT") Log.d(tag, "FAILURE TO CONNECT")
e.printStackTrace() e.printStackTrace()
cb(JSObject())
val jsobj = JSObject()
jsobj.put("error", "Failed to connect")
cb(jsobj)
} }
override fun onResponse(call: Call, response: Response) { override fun onResponse(call: Call, response: Response) {
response.use { 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") { if (bodyString == "OK") {
cb(JSObject()) cb(JSObject())
} else { } else {
var jsonObj = JSObject() try {
if (bodyString.startsWith("[")) { var jsonObj = JSObject()
val array = JSArray(bodyString) if (bodyString.startsWith("[")) {
jsonObj.put("value", array) val array = JSArray(bodyString)
} else { jsonObj.put("value", array)
jsonObj = JSObject(bodyString) } 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) { fun getLibraries(cb: (List<Library>) -> Unit) {
val mapper = jacksonMapper val mapper = jacksonMapper
getRequest("/api/libraries") { getRequest("/api/libraries", null,null) {
val libraries = mutableListOf<Library>() val libraries = mutableListOf<Library>()
if (it.has("value")) {
val array = it.getJSONArray("value") var array = JSONArray()
for (i in 0 until array.length()) { if (it.has("libraries")) { // TODO: Server 2.2.9 changed to this
val library = mapper.readValue<Library>(array.get(i).toString()) array = it.getJSONArray("libraries")
libraries.add(library) } else if (it.has("value")) {
} array = it.getJSONArray("value")
}
for (i in 0 until array.length()) {
libraries.add(mapper.readValue(array.get(i).toString()))
} }
cb(libraries) cb(libraries)
} }
} }
fun getLibraryItem(libraryItemId:String, cb: (LibraryItem) -> Unit) { fun getLibraryItem(libraryItemId:String, cb: (LibraryItem?) -> Unit) {
getRequest("/api/items/$libraryItemId?expanded=1") { getRequest("/api/items/$libraryItemId?expanded=1", null, null) {
val libraryItem = jacksonMapper.readValue<LibraryItem>(it.toString()) if (it.has("error")) {
cb(libraryItem) 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" var requestUrl = "/api/items/$libraryItemId?expanded=1&include=progress"
if (!episodeId.isNullOrEmpty()) requestUrl += "&episode=$episodeId" if (!episodeId.isNullOrEmpty()) requestUrl += "&episode=$episodeId"
getRequest(requestUrl) { getRequest(requestUrl, null, null) {
val libraryItem = jacksonMapper.readValue<LibraryItem>(it.toString()) if (it.has("error")) {
cb(libraryItem) 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) { 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>() val items = mutableListOf<LibraryItem>()
if (it.has("results")) { if (it.has("results")) {
val array = it.getJSONArray("results") val array = it.getJSONArray("results")
@@ -151,57 +193,60 @@ class ApiHandler(var ctx:Context) {
} }
} }
fun getLibraryCategories(libraryId:String, cb: (List<LibraryCategory>) -> Unit) { fun getAllItemsInProgress(cb: (List<ItemInProgress>) -> Unit) {
getRequest("/api/libraries/$libraryId/personalized") { getRequest("/api/me/items-in-progress", null, null) {
val items = mutableListOf<LibraryCategory>() val items = mutableListOf<ItemInProgress>()
if (it.has("value")) { if (it.has("libraryItems")) {
val array = it.getJSONArray("value") val array = it.getJSONArray("libraryItems")
for (i in 0 until array.length()) { for (i in 0 until array.length()) {
val jsobj = array.get(i) as JSONObject val jsobj = array.get(i) as JSONObject
val type = jsobj.get("type").toString() val itemInProgress = ItemInProgress.makeFromServerObject(jsobj)
// Only support for podcast and book in android auto items.add(itemInProgress)
if (type == "podcast" || type == "book") {
jsobj.put("isLocal", false)
val item = jacksonMapper.readValue<LibraryCategory>(jsobj.toString())
items.add(item)
}
} }
} }
cb(items) cb(items)
} }
} }
fun playLibraryItem(libraryItemId:String, episodeId:String?, forceTranscode:Boolean, mediaPlayer:String, cb: (PlaybackSession) -> Unit) { fun playLibraryItem(libraryItemId:String, episodeId:String?, playItemRequestPayload:PlayItemRequestPayload, cb: (PlaybackSession?) -> Unit) {
val payload = JSObject() val payload = JSObject(jacksonMapper.writeValueAsString(playItemRequestPayload))
payload.put("mediaPlayer", mediaPlayer)
// Only if direct play fails do we force transcode
if (!forceTranscode) payload.put("forceDirectPlay", true)
else payload.put("forceTranscode", true)
val endpoint = if (episodeId.isNullOrEmpty()) "/api/items/$libraryItemId/play" else "/api/items/$libraryItemId/play/$episodeId" val endpoint = if (episodeId.isNullOrEmpty()) "/api/items/$libraryItemId/play" else "/api/items/$libraryItemId/play/$episodeId"
postRequest(endpoint, payload) { postRequest(endpoint, payload, null) {
it.put("serverConnectionConfigId", DeviceManager.serverConnectionConfig?.id) if (it.has("error")) {
it.put("serverAddress", DeviceManager.serverAddress) Log.e(tag, it.getString("error") ?: "Play Library Item Failed")
val playbackSession = jacksonMapper.readValue<PlaybackSession>(it.toString()) cb(null)
cb(playbackSession) } 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)) val payload = JSObject(jacksonMapper.writeValueAsString(syncData))
postRequest("/api/session/$sessionId/sync", payload) { postRequest("/api/session/$sessionId/sync", payload, null) {
cb() 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)) val payload = JSObject(jacksonMapper.writeValueAsString(playbackSession))
postRequest("/api/session/local", payload) { postRequest("/api/session/local", payload, null) {
cb() if (!it.getString("error").isNullOrEmpty()) {
cb(false)
} else {
cb(true)
}
} }
} }
@@ -222,11 +267,13 @@ class ApiHandler(var ctx:Context) {
if (localMediaProgress.isNotEmpty()) { if (localMediaProgress.isNotEmpty()) {
Log.d(tag, "Sending sync local progress request with ${localMediaProgress.size} progress items") Log.d(tag, "Sending sync local progress request with ${localMediaProgress.size} progress items")
val payload = JSObject(jacksonMapper.writeValueAsString(LocalMediaProgressSyncPayload(localMediaProgress))) val payload = JSObject(jacksonMapper.writeValueAsString(LocalMediaProgressSyncPayload(localMediaProgress)))
postRequest("/api/me/sync-local-progress", payload) { postRequest("/api/me/sync-local-progress", payload, null) {
Log.d(tag, "Media Progress Sync payload $payload - response ${it.toString()}") Log.d(tag, "Media Progress Sync payload $payload - response ${it}")
if (it.toString() == "{}") { if (it.toString() == "{}") {
Log.e(tag, "Progress sync received empty object") Log.e(tag, "Progress sync received empty object")
} else if (it.has("error")) {
Log.e(tag, it.getString("error") ?: "Progress sync error")
} else { } else {
val progressSyncResponsePayload = jacksonMapper.readValue<MediaProgressSyncResponsePayload>(it.toString()) val progressSyncResponsePayload = jacksonMapper.readValue<MediaProgressSyncResponsePayload>(it.toString())
@@ -257,4 +304,67 @@ class ApiHandler(var ctx:Context) {
cb() 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 <path
android:fillColor="@android:color/white" 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"/> 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> </group>
</vector> </vector>
@@ -11,11 +11,5 @@
<path <path
android:fillColor="@android:color/white" 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"/> 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> </group>
</vector> </vector>
@@ -10,9 +10,6 @@
android:translateY="-1.5294118"> android:translateY="-1.5294118">
<path <path
android:fillColor="@android:color/white" android:fillColor="@android:color/white"
android:pathData="M4,6H2v14c0,1.1 0.9,2 2,2h14v-2H4V6z"/> android:pathData="M10,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V8c0,-1.1 -0.9,-2 -2,-2h-8l-2,-2z"/>
<path
android:fillColor="@android:color/white"
android:pathData="M20,2L8,2c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM20,12l-2.5,-1.5L15,12L15,4h5v8z"/>
</group> </group>
</vector> </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: 276 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 B

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.

Before

Width:  |  Height:  |  Size: 199 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 B

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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 309 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 430 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 B

@@ -2,6 +2,4 @@
android:viewportHeight="24" android:viewportWidth="24" android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android"> 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="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> </vector>
@@ -2,6 +2,4 @@
android:viewportHeight="24" android:viewportWidth="24" android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android"> 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="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> </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="title_activity_main">audiobookshelf</string>
<string name="package_name">com.audiobookshelf.app</string> <string name="package_name">com.audiobookshelf.app</string>
<string name="custom_url_scheme">com.audiobookshelf.app</string> <string name="custom_url_scheme">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> </resources>
+12 -1
View File
@@ -13,10 +13,21 @@
<item name="windowActionBar">false</item> <item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item> <item name="windowNoTitle">true</item>
<item name="android:background">@null</item> <item name="android:background">@null</item>
<item name="android:statusBarColor">@color/background_dark</item>
<item name="android:navigationBarColor">@color/background_dark</item>
</style> </style>
<style name="AppTheme.NoActionBarLaunch" parent="AppTheme.NoActionBar"> <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> </style>
</resources> </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"> <widget version="1.0.0" xmlns="http://www.w3.org/ns/widgets" xmlns:cdv="http://cordova.apache.org/ns/1.0">
<access origin="*" /> <access origin="*" />
<feature name="CDVOrientation">
<param name="android-package" value="cordova.plugins.screenorientation.CDVOrientation"/>
</feature>
</widget> </widget>

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