Compare commits

...
Author SHA1 Message Date
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
ronaldheft 2ab9dbc72d Set encoder and init methods 2022-07-17 21:50:19 -04:00
ronaldheft 52d0890032 Refactor to use extensions for objects 2022-07-16 21:46:49 -04:00
ronaldheft b7725c455b Prototype response to app 2022-07-16 20:25:04 -04:00
ronaldheft 175e642081 First pass at attempting to save library items 2022-07-09 18:37:32 -04:00
ronaldheft e620f53705 Get something saving in the database 2022-07-09 17:35:59 -04:00
ronaldheft f56561b898 Refactor into smaller functions 2022-07-09 15:57:45 -04:00
ronaldheft a29a7669e1 Add unrealm to use Swift object in db 2022-07-09 15:19:20 -04:00
ronaldheft 29f08a5e5d Fix use of optionals 2022-07-09 15:18:59 -04:00
ronaldheft f40da562a6 Fix missing braces 2022-07-09 15:18:45 -04:00
ronaldheft affcdea09b Fix class name collisions 2022-07-09 15:18:34 -04:00
ronaldheft 16ca7460f4 Convert back to Swift objects 2022-07-09 15:18:15 -04:00
benonymity 2ca9ce797d feat: iOS download groundwork 2022-07-06 10:09:17 -04:00
111 changed files with 5125 additions and 1280 deletions
+3 -3
View File
@@ -33,8 +33,8 @@ android {
applicationId "com.audiobookshelf.app" applicationId "com.audiobookshelf.app"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 86 versionCode 88
versionName "0.9.55-beta" versionName "0.9.57-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.
@@ -121,7 +121,7 @@ dependencies {
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,71 +1,11 @@
package com.audiobookshelf.app.data package com.audiobookshelf.app.data
import android.net.Uri 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.fasterxml.jackson.annotation.* import com.fasterxml.jackson.annotation.*
@JsonIgnoreProperties(ignoreUnknown = true)
data class LibraryItem(
var id:String,
var ino:String,
var libraryId:String,
var folderId:String,
var path:String,
var relPath:String,
var mtimeMs:Long,
var ctimeMs:Long,
var birthtimeMs:Long,
var addedAt:Long,
var updatedAt:Long,
var lastScan:Long?,
var scanVersion:String?,
var isMissing:Boolean,
var isInvalid:Boolean,
var mediaType:String,
var media:MediaType,
var libraryFiles:MutableList<LibraryFile>?,
var userMediaProgress:MediaProgress? // Only included when requesting library item with progress (for downloads)
) : LibraryItemWrapper() {
@get:JsonIgnore
val title get() = media.metadata.title
@get:JsonIgnore
val authorName get() = media.metadata.getAuthorDisplayName()
@JsonIgnore
fun getCoverUri():Uri {
if (media.coverPath == null) {
return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
}
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}")
}
@JsonIgnore
fun checkHasTracks():Boolean {
return if (mediaType == "podcast") {
((media as Podcast).numEpisodes ?: 0) > 0
} else {
((media as Book).numTracks ?: 0) > 0
}
}
@JsonIgnore
fun getMediaMetadata(): MediaMetadataCompat {
return MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, authorName)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, getCoverUri().toString())
putString(MediaMetadataCompat.METADATA_KEY_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(
@@ -123,7 +63,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_" + audioTrack.localFileId,(episodes?.size ?: 0) + 1,null,null,audioTrack.title,null,null,null,audioTrack,audioTrack.duration,0, null)
episodes?.add(newEpisode) episodes?.add(newEpisode)
var index = 1 var index = 1
@@ -144,7 +84,7 @@ class Podcast(
} }
@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_" + episode.id,(episodes?.size ?: 0) + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,audioTrack,audioTrack.duration,0, episode.id)
episodes?.add(newEpisode) episodes?.add(newEpisode)
var index = 1 var index = 1
@@ -295,25 +235,51 @@ data class PodcastEpisode(
var serverEpisodeId:String? // For local podcasts to match with server podcasts var serverEpisodeId:String? // For local podcasts to match with server podcasts
) { ) {
@JsonIgnore @JsonIgnore
fun getMediaMetadata(libraryItem:LibraryItemWrapper): MediaMetadataCompat { fun getMediaDescription(libraryItem:LibraryItemWrapper, progress:MediaProgressWrapper?): MediaDescriptionCompat {
var coverUri:Uri = Uri.EMPTY val coverUri = if(libraryItem is LocalLibraryItem) {
val podcast = if(libraryItem is LocalLibraryItem) { libraryItem.getCoverUri()
coverUri = libraryItem.getCoverUri()
libraryItem.media as Podcast
} else { } else {
coverUri = (libraryItem as LibraryItem).getCoverUri() (libraryItem as LibraryItem).getCoverUri()
(libraryItem as LibraryItem).media as Podcast
} }
return MediaMetadataCompat.Builder().apply { val extras = Bundle()
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id) if (progress != null) {
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title) if (progress.isFinished) {
putString(MediaMetadataCompat.METADATA_KEY_TITLE, title) extras.putInt(
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, podcast.metadata.getAuthorDisplayName()) MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, podcast.metadata.getAuthorDisplayName()) MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString()) )
} else {
}.build() 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
)
}
// 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_AUTHOR, podcast.metadata.getAuthorDisplayName())
// putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString())
//
// }.build()
val libraryItemDescription = libraryItem.getMediaDescription(null)
return MediaDescriptionCompat.Builder()
.setMediaId(id)
.setTitle(title)
.setIconUri(coverUri)
.setSubtitle(libraryItemDescription.title)
.setExtras(extras)
.build()
} }
} }
@@ -407,18 +373,25 @@ data class BookChapter(
} }
@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 // Helper class
data class LibraryItemWithEpisode( data class LibraryItemWithEpisode(
@@ -1,5 +1,6 @@
package com.audiobookshelf.app.data package com.audiobookshelf.app.data
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
@@ -20,7 +21,8 @@ data class DeviceSettings(
var disableAutoRewind:Boolean, var disableAutoRewind:Boolean,
var enableAltView:Boolean, var enableAltView:Boolean,
var jumpBackwardsTime:Int, var jumpBackwardsTime:Int,
var jumpForwardTime:Int var jumpForwardTime:Int,
var disableShakeToResetSleepTimer:Boolean
) { ) {
companion object { companion object {
// Static method to get default device settings // Static method to get default device settings
@@ -29,15 +31,16 @@ data class DeviceSettings(
disableAutoRewind = false, disableAutoRewind = false,
enableAltView = false, enableAltView = false,
jumpBackwardsTime = 10, jumpBackwardsTime = 10,
jumpForwardTime = 10 jumpForwardTime = 10,
disableShakeToResetSleepTimer = false
) )
} }
} }
@get:JsonIgnore @get:JsonIgnore
val jumpBackwardsTimeMs get() = jumpBackwardsTime * 1000L val jumpBackwardsTimeMs get() = (jumpBackwardsTime ?: default().jumpBackwardsTime) * 1000L
@get:JsonIgnore @get:JsonIgnore
val jumpForwardTimeMs get() = jumpForwardTime * 1000L val jumpForwardTimeMs get() = (jumpForwardTime ?: default().jumpBackwardsTime) * 1000L
} }
data class DeviceData( data class DeviceData(
@@ -90,7 +93,10 @@ data class LocalFolder(
JsonSubTypes.Type(LibraryItem::class), JsonSubTypes.Type(LibraryItem::class),
JsonSubTypes.Type(LocalLibraryItem::class) JsonSubTypes.Type(LocalLibraryItem::class)
) )
open class LibraryItemWrapper() open class LibraryItemWrapper(var id:String) {
@JsonIgnore
open fun getMediaDescription(progress:MediaProgressWrapper?): MediaDescriptionCompat { return MediaDescriptionCompat.Builder().build() }
}
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class DeviceInfo( data class DeviceInfo(
@@ -0,0 +1,34 @@
/*
Used in Android Auto to represent a podcast episode or an audiobook in progress
*/
package com.audiobookshelf.app.data
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import org.json.JSONObject
@JsonIgnoreProperties(ignoreUnknown = true)
data class ItemInProgress(
val libraryItemWrapper: LibraryItemWrapper,
val episode: PodcastEpisode?,
val progressLastUpdate: Long,
val isLocal: Boolean
) {
companion object {
fun makeFromServerObject(serverItem: JSONObject):ItemInProgress {
val jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
val libraryItem = jacksonMapper.readValue<LibraryItem>(serverItem.toString())
var episode:PodcastEpisode? = null
if (serverItem.has("recentEpisode")) {
episode = jacksonMapper.readValue<PodcastEpisode>(serverItem.get("recentEpisode").toString())
}
val progressLastUpdate:Long = serverItem.getLong("progressLastUpdate")
return ItemInProgress(libraryItem, episode, progressLastUpdate, false)
}
}
}
@@ -0,0 +1,90 @@
package com.audiobookshelf.app.data
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?): 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,9 +1,10 @@
package com.audiobookshelf.app.data package com.audiobookshelf.app.data
import android.content.Context
import android.net.Uri import android.net.Uri
import android.support.v4.media.MediaMetadataCompat import android.os.Bundle
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
@@ -11,8 +12,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,
@@ -29,7 +30,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
@@ -95,18 +96,38 @@ data class LocalLibraryItem(
} }
@JsonIgnore @JsonIgnore
fun getMediaMetadata(ctx: Context): MediaMetadataCompat { override fun getMediaDescription(progress:MediaProgressWrapper?): MediaDescriptionCompat {
val coverUri = getCoverUri() val coverUri = getCoverUri()
return MediaMetadataCompat.Builder().apply { val extras = Bundle()
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id) if (progress != null) {
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title) if (progress.isFinished) {
putString(MediaMetadataCompat.METADATA_KEY_TITLE, title) extras.putInt(
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, authorName) MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString()) MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString()) )
putString(MediaMetadataCompat.METADATA_KEY_ART_URI, coverUri.toString()) } else {
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, authorName) extras.putInt(
}.build() 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(coverUri)
.setSubtitle(authorName)
.setExtras(extras)
.build()
} }
} }
@@ -5,14 +5,14 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import kotlin.math.roundToInt 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?,
@@ -22,7 +22,7 @@ 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 @get:JsonIgnore
val progressPercent get() = if (progress.isNaN()) 0 else (progress * 100).roundToInt() val progressPercent get() = if (progress.isNaN()) 0 else (progress * 100).roundToInt()
@@ -91,6 +91,7 @@ class PlaybackSession(
@JsonIgnore @JsonIgnore
fun getTrackStartOffsetMs(index:Int):Long { fun getTrackStartOffsetMs(index:Int):Long {
if (index < 0 || index >= audioTracks.size) return 0L
val currentTrack = audioTracks[index] val currentTrack = audioTracks[index]
return (currentTrack.startOffset * 1000L).toLong() return (currentTrack.startOffset * 1000L).toLong()
} }
@@ -123,8 +124,6 @@ class PlaybackSession(
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle) .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, displayAuthor) .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, displayAuthor)
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, displayAuthor) .putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, displayAuthor)
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, displayAuthor)
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, "series")
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id) .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
return metadataBuilder.build() return metadataBuilder.build()
} }
@@ -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"
@@ -465,11 +466,17 @@ class FolderScanner(var ctx: Context) {
fun probeAudioFile(absolutePath:String):AudioProbeResult? { fun probeAudioFile(absolutePath:String):AudioProbeResult? {
val session = FFprobeKit.execute("-i \"${absolutePath}\" -print_format json -show_format -show_streams -select_streams a -show_chapters -loglevel quiet") val 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 probeObject = JSObject(session.output) var probeObject:JSObject? = null
if (!probeObject.has("streams")) { // Check if output is empty try {
Log.d(tag, "probeAudioFile Probe audio file $absolutePath is empty") 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 return null
} else { } else {
val audioProbeResult = jacksonMapper.readValue<AudioProbeResult>(session.output) val audioProbeResult = jacksonMapper.readValue<AudioProbeResult>(session.output)
@@ -18,16 +18,19 @@ 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 = mutableListOf<LibraryItem>() var serverLibraryItems = mutableListOf<LibraryItem>() // Store all items here
var selectedLibraryItems = mutableListOf<LibraryItem>()
var selectedLibraryId = "" var selectedLibraryId = ""
var selectedLibraryItemWrapper:LibraryItemWrapper? = null
var selectedPodcast:Podcast? = null var selectedPodcast:Podcast? = null
var selectedLibraryItemId:String? = null var selectedLibraryItemId:String? = null
var serverPodcastEpisodes = listOf<PodcastEpisode>() var podcastEpisodeLibraryItemMap = mutableMapOf<String, LibraryItemWithEpisode>()
var serverLibraryCategories = listOf<LibraryCategory>() var serverLibraryCategories = listOf<LibraryCategory>()
var serverItemsInProgress = listOf<ItemInProgress>()
var serverLibraries = listOf<Library>() var serverLibraries = listOf<Library>()
var serverConfigIdUsed:String? = null var serverConfigIdUsed:String? = null
var serverConfigLastPing:Long = 0L
var serverUserMediaProgress:MutableList<MediaProgress> = mutableListOf()
var userSettingsPlaybackRate:Float? = null var userSettingsPlaybackRate:Float? = null
@@ -64,34 +67,39 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
val serverConnConfig = if (DeviceManager.isConnectedToServer) DeviceManager.serverConnectionConfig else DeviceManager.deviceData.getLastServerConnectionConfig() val serverConnConfig = if (DeviceManager.isConnectedToServer) DeviceManager.serverConnectionConfig else DeviceManager.deviceData.getLastServerConnectionConfig()
if (!DeviceManager.isConnectedToServer || !apiHandler.isOnline() || serverConnConfig == null || serverConnConfig.id !== serverConfigIdUsed) { if (!DeviceManager.isConnectedToServer || !apiHandler.isOnline() || serverConnConfig == null || serverConnConfig.id !== serverConfigIdUsed) {
serverPodcastEpisodes = listOf() podcastEpisodeLibraryItemMap = mutableMapOf()
serverLibraryCategories = listOf() serverLibraryCategories = listOf()
serverLibraries = listOf() serverLibraries = listOf()
serverLibraryItems = mutableListOf() serverLibraryItems = mutableListOf()
selectedLibraryItems = mutableListOf()
selectedLibraryId = "" selectedLibraryId = ""
} }
} }
fun loadLibraryCategories(libraryId:String, cb: (List<LibraryCategory>) -> Unit) { fun loadItemsInProgressForAllLibraries(cb: (List<ItemInProgress>) -> Unit) {
if (serverLibraryCategories.isNotEmpty()) { if (serverItemsInProgress.isNotEmpty()) {
cb(serverLibraryCategories) cb(serverItemsInProgress)
} else { } else {
apiHandler.getLibraryCategories(libraryId) { apiHandler.getAllItemsInProgress { itemsInProgress ->
serverLibraryCategories = it serverItemsInProgress = itemsInProgress
cb(it) cb(serverItemsInProgress)
} }
} }
} }
fun loadLibraryItemsWithAudio(libraryId:String, cb: (List<LibraryItem>) -> Unit) { fun loadLibraryItemsWithAudio(libraryId:String, cb: (List<LibraryItem>) -> Unit) {
if (serverLibraryItems.isNotEmpty() && selectedLibraryId == libraryId) { if (selectedLibraryItems.isNotEmpty() && selectedLibraryId == libraryId) {
cb(serverLibraryItems) cb(selectedLibraryItems)
} else { } else {
apiHandler.getLibraryItems(libraryId) { libraryItems -> apiHandler.getLibraryItems(libraryId) { libraryItems ->
val libraryItemsWithAudio = libraryItems.filter { li -> li.checkHasTracks() } val libraryItemsWithAudio = libraryItems.filter { li -> li.checkHasTracks() }
if (libraryItemsWithAudio.isNotEmpty()) selectedLibraryId = libraryId if (libraryItemsWithAudio.isNotEmpty()) {
selectedLibraryId = libraryId
}
selectedLibraryItems = mutableListOf()
libraryItemsWithAudio.forEach { libraryItem -> libraryItemsWithAudio.forEach { libraryItem ->
selectedLibraryItems.add(libraryItem)
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) { if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
serverLibraryItems.add(libraryItem) serverLibraryItems.add(libraryItem)
} }
@@ -117,37 +125,40 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
loadLibraryItem(libraryItemId) { libraryItemWrapper -> loadLibraryItem(libraryItemId) { libraryItemWrapper ->
Log.d(tag, "Loaded Podcast library item $libraryItemWrapper") Log.d(tag, "Loaded Podcast library item $libraryItemWrapper")
selectedLibraryItemWrapper = libraryItemWrapper
libraryItemWrapper?.let { libraryItemWrapper?.let {
if (libraryItemWrapper is LocalLibraryItem) { // Local podcast episodes if (libraryItemWrapper is LocalLibraryItem) { // Local podcast episodes
if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) { if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) {
serverPodcastEpisodes = listOf()
cb(mutableListOf()) cb(mutableListOf())
} else { } else {
val podcast = libraryItemWrapper.media as Podcast val podcast = libraryItemWrapper.media as Podcast
serverPodcastEpisodes = podcast.episodes ?: listOf()
selectedLibraryItemId = libraryItemWrapper.id selectedLibraryItemId = libraryItemWrapper.id
selectedPodcast = podcast selectedPodcast = podcast
val children = podcast.episodes?.map { podcastEpisode -> val children = podcast.episodes?.map { podcastEpisode ->
Log.d(tag, "Local Podcast Episode ${podcastEpisode.title} | ${podcastEpisode.id}") Log.d(tag, "Local Podcast Episode ${podcastEpisode.title} | ${podcastEpisode.id}")
MediaBrowserCompat.MediaItem(podcastEpisode.getMediaMetadata(libraryItemWrapper).description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
val progress = DeviceManager.dbManager.getLocalMediaProgress("${libraryItemWrapper.id}-${podcastEpisode.id}")
val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress)
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
} }
children?.let { cb(children as MutableList) } ?: cb(mutableListOf()) children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
} }
} else if (libraryItemWrapper is LibraryItem) { // Server podcast episodes } else if (libraryItemWrapper is LibraryItem) { // Server podcast episodes
if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) { if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) {
serverPodcastEpisodes = listOf()
cb(mutableListOf()) cb(mutableListOf())
} else { } else {
val podcast = libraryItemWrapper.media as Podcast val podcast = libraryItemWrapper.media as Podcast
serverPodcastEpisodes = podcast.episodes ?: listOf() podcast.episodes?.forEach { podcastEpisode ->
podcastEpisodeLibraryItemMap[podcastEpisode.id] = LibraryItemWithEpisode(libraryItemWrapper, podcastEpisode)
}
selectedLibraryItemId = libraryItemWrapper.id selectedLibraryItemId = libraryItemWrapper.id
selectedPodcast = podcast selectedPodcast = podcast
val children = podcast.episodes?.map { podcastEpisode -> val children = podcast.episodes?.map { podcastEpisode ->
MediaBrowserCompat.MediaItem(podcastEpisode.getMediaMetadata(libraryItemWrapper).description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
val progress = serverUserMediaProgress.find { it.libraryItemId == libraryItemWrapper.id && it.episodeId == podcastEpisode.id }
val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress)
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
} }
children?.let { cb(children as MutableList) } ?: cb(mutableListOf()) children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
} }
@@ -179,16 +190,49 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
return successfulPing return successfulPing
} }
fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = runBlocking { suspend fun authorize(config:ServerConnectionConfig) : MutableList<MediaProgress> {
if (!apiHandler.isOnline()) cb(false) var mediaProgress:MutableList<MediaProgress> = mutableListOf()
else { suspendCoroutine<MutableList<MediaProgress>> { cont ->
coroutineScope { apiHandler.authorize(config) {
var hasValidConn = false Log.d(tag, "authorize: Authorized server config ${config.address} result = $it")
if (!it.isNullOrEmpty()) {
mediaProgress = it
}
cont.resume(mediaProgress)
}
}
return mediaProgress
}
// First check if the current selected config is pingable fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = runBlocking {
DeviceManager.serverConnectionConfig?.let { Log.d(tag, "checkSetValidServerConnectionConfig | $serverConfigIdUsed")
hasValidConn = checkServerConnection(it)
Log.d(tag, "checkSetValidServerConnectionConfig: Current config ${DeviceManager.serverAddress} is pingable? $hasValidConn") coroutineScope {
if (!apiHandler.isOnline()) {
serverUserMediaProgress = mutableListOf()
cb(false)
} else {
var hasValidConn = false
var lookupMediaProgress = true
if (!serverConfigIdUsed.isNullOrEmpty() && serverConfigLastPing > 0L && System.currentTimeMillis() - serverConfigLastPing < 5000) {
Log.d(tag, "checkSetValidServerConnectionConfig last ping less than a 5 seconds ago")
hasValidConn = true
lookupMediaProgress = false
} else {
serverUserMediaProgress = mutableListOf()
}
if (!hasValidConn) {
// First check if the current selected config is pingable
DeviceManager.serverConnectionConfig?.let {
hasValidConn = checkServerConnection(it)
Log.d(
tag,
"checkSetValidServerConnectionConfig: Current config ${DeviceManager.serverAddress} is pingable? $hasValidConn"
)
}
} }
if (!hasValidConn) { if (!hasValidConn) {
@@ -205,31 +249,25 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
} }
} }
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) cb(hasValidConn)
} }
} }
} }
// TODO: Load currently listening category for local items fun loadAndroidAutoItems(cb: () -> Unit) {
fun loadLocalCategory():List<LibraryCategory> {
val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast")
val cats = mutableListOf<LibraryCategory>()
if (localBooks.isNotEmpty()) {
cats.add(LibraryCategory("local-books", "Local Books", "book", localBooks, true))
}
if (localPodcasts.isNotEmpty()) {
cats.add(LibraryCategory("local-podcasts", "Local Podcasts", "podcast", localPodcasts, true))
}
return cats
}
fun loadAndroidAutoItems(cb: (List<LibraryCategory>) -> Unit) {
Log.d(tag, "Load android auto items") Log.d(tag, "Load android auto items")
val cats = mutableListOf<LibraryCategory>()
val localCategories = loadLocalCategory()
cats.addAll(localCategories)
// Check if any valid server connection if not use locally downloaded books // Check if any valid server connection if not use locally downloaded books
checkSetValidServerConnectionConfig { isConnected -> checkSetValidServerConnectionConfig { isConnected ->
@@ -239,39 +277,29 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
loadLibraries { libraries -> loadLibraries { libraries ->
if (libraries.isEmpty()) { if (libraries.isEmpty()) {
Log.w(tag, "No libraries returned from server request") Log.w(tag, "No libraries returned from server request")
cb(cats) // Return download category only cb()
} else { } else {
val library = libraries[0] val library = libraries[0]
Log.d(tag, "Loading categories for library ${library.name} - ${library.id} - ${library.mediaType}") Log.d(tag, "Loading categories for library ${library.name} - ${library.id} - ${library.mediaType}")
loadLibraryCategories(library.id) { libraryCategories -> loadItemsInProgressForAllLibraries { itemsInProgress ->
itemsInProgress.forEach {
// Only using book or podcast library categories for now val libraryItem = it.libraryItemWrapper as LibraryItem
libraryCategories.forEach { if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
serverLibraryItems.add(libraryItem)
// Add items in continue listening to serverLibraryItems
if (it.id == "continue-listening") {
it.entities.forEach { libraryItemWrapper ->
val libraryItem = libraryItemWrapper as LibraryItem
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
serverLibraryItems.add(libraryItem)
}
}
} }
// Log.d(tag, "Found library category ${it.label} with type ${it.type}") if (it.episode != null) {
if (it.type == library.mediaType) { podcastEpisodeLibraryItemMap[it.episode.id] = LibraryItemWithEpisode(it.libraryItemWrapper, it.episode)
// Log.d(tag, "Using library category ${it.id}")
cats.add(it)
} }
} }
cb(cats) cb() // Fully loaded
} }
} }
} }
} else { // Not connected/no internet sent downloaded cats only } else { // Not connected to server
cb(cats) cb()
} }
} }
} }
@@ -289,12 +317,7 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
if (id.startsWith("local")) { if (id.startsWith("local")) {
return DeviceManager.dbManager.getLocalLibraryItemWithEpisode(id) return DeviceManager.dbManager.getLocalLibraryItemWithEpisode(id)
} else { } else {
val podcastEpisode = serverPodcastEpisodes.find { it.id == id } return podcastEpisodeLibraryItemMap[id]
return if (podcastEpisode != null && selectedLibraryItemWrapper != null) {
LibraryItemWithEpisode(selectedLibraryItemWrapper!!, podcastEpisode)
} else {
null
}
} }
} }
@@ -6,14 +6,11 @@ import android.net.Uri
import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.MediaMetadataCompat
import androidx.annotation.AnyRes import androidx.annotation.AnyRes
import com.audiobookshelf.app.R import com.audiobookshelf.app.R
import com.audiobookshelf.app.data.Library import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.data.LibraryCategory
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> 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,38 +31,26 @@ class BrowseTree(
init { init {
val rootList = mediaIdToChildren[AUTO_BROWSE_ROOT] ?: mutableListOf() val rootList = mediaIdToChildren[AUTO_BROWSE_ROOT] ?: mutableListOf()
val continueReadingMetadata = MediaMetadataCompat.Builder().apply { val continueListeningMetadata = MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, CONTINUE_ROOT) putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, CONTINUE_ROOT)
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Listening") putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Listening")
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_localaudio).toString()) putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.exo_icon_localaudio).toString())
}.build() }.build()
val downloadsMetadata = MediaMetadataCompat.Builder().apply { val downloadsMetadata = MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, DOWNLOADS_ROOT) putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, DOWNLOADS_ROOT)
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Downloads") putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Downloads")
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_downloaddone).toString()) putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.exo_icon_downloaddone).toString())
}.build() }.build()
val librariesMetadata = MediaMetadataCompat.Builder().apply { val librariesMetadata = MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, LIBRARIES_ROOT) putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, LIBRARIES_ROOT)
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Libraries") putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Libraries")
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.icon_library_folder).toString()) putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.icon_library_folder).toString())
}.build() }.build()
// Server continue Listening cat if (!itemsInProgress.isEmpty()) {
libraryCategories.find { it.id == "continue-listening" }?.let { continueListeningCategory -> rootList += continueListeningMetadata
val continueListeningMediaMetadata = continueListeningCategory.entities.map { liw ->
val libraryItem = liw as LibraryItem
libraryItem.getMediaMetadata()
}
if (continueListeningMediaMetadata.isNotEmpty()) {
rootList += continueReadingMetadata
}
continueListeningMediaMetadata.forEach {
val children = mediaIdToChildren[CONTINUE_ROOT] ?: mutableListOf()
children += it
mediaIdToChildren[CONTINUE_ROOT] = children
}
} }
if (libraries.isNotEmpty()) { if (libraries.isNotEmpty()) {
@@ -81,23 +65,6 @@ class BrowseTree(
} }
rootList += downloadsMetadata rootList += downloadsMetadata
libraryCategories.find { it.id == "local-books" }?.let { localBooksCat ->
localBooksCat.entities.forEach { libc ->
val libraryItem = libc as LocalLibraryItem
val children = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf()
children += libraryItem.getMediaMetadata(context)
mediaIdToChildren[DOWNLOADS_ROOT] = children
}
}
libraryCategories.find { it.id == "local-podcasts" }?.let { localPodcastsCat ->
localPodcastsCat.entities.forEach { libc ->
val libraryItem = libc as LocalLibraryItem
val children = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf()
children += libraryItem.getMediaMetadata(context)
mediaIdToChildren[DOWNLOADS_ROOT] = children
}
}
mediaIdToChildren[AUTO_BROWSE_ROOT] = rootList mediaIdToChildren[AUTO_BROWSE_ROOT] = rootList
} }
@@ -74,14 +74,14 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
} }
} }
fun stop(cb: () -> Unit) { fun stop(shouldSync:Boolean? = true, cb: () -> Unit) {
if (!listeningTimerRunning) return if (!listeningTimerRunning) return
listeningTimerTask?.cancel() listeningTimerTask?.cancel()
listeningTimerTask = null listeningTimerTask = null
listeningTimerRunning = false listeningTimerRunning = false
Log.d(tag, "stop: Stopping listening for $currentDisplayTitle") Log.d(tag, "stop: Stopping listening for $currentDisplayTitle")
val currentTime = playerNotificationService.getCurrentTimeSeconds() val currentTime = if (shouldSync == true) playerNotificationService.getCurrentTimeSeconds() else 0.0
if (currentTime > 0) { // Current time should always be > 0 on stop if (currentTime > 0) { // Current time should always be > 0 on stop
sync(true, currentTime) { sync(true, currentTime) {
reset() reset()
@@ -7,10 +7,8 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import android.os.Message import android.os.Message
import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import android.util.Log import android.util.Log
import android.view.KeyEvent import android.view.KeyEvent
import com.audiobookshelf.app.R
import com.audiobookshelf.app.data.LibraryItemWrapper import com.audiobookshelf.app.data.LibraryItemWrapper
import com.audiobookshelf.app.data.PodcastEpisode import com.audiobookshelf.app.data.PodcastEpisode
import java.util.* import java.util.*
@@ -21,7 +19,6 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
private var mediaButtonClickCount: Int = 0 private var mediaButtonClickCount: Int = 0
var mediaButtonClickTimeout: Long = 1000 //ms var mediaButtonClickTimeout: Long = 1000 //ms
var seekAmount: Long = 20000 //ms
override fun onPrepare() { override fun onPrepare() {
Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT") Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT")
@@ -75,19 +72,19 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
} }
override fun onSkipToPrevious() { override fun onSkipToPrevious() {
playerNotificationService.seekBackward(seekAmount) playerNotificationService.skipToPrevious()
} }
override fun onSkipToNext() { override fun onSkipToNext() {
playerNotificationService.seekForward(seekAmount) playerNotificationService.skipToNext()
} }
override fun onFastForward() { override fun onFastForward() {
playerNotificationService.seekForward(seekAmount) playerNotificationService.jumpForward()
} }
override fun onRewind() { override fun onRewind() {
playerNotificationService.seekForward(seekAmount) playerNotificationService.jumpBackward()
} }
override fun onSeekTo(pos: Long) { override fun onSeekTo(pos: Long) {
@@ -179,10 +176,10 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
handleMediaButtonClickCount() handleMediaButtonClickCount()
} }
KeyEvent.KEYCODE_MEDIA_NEXT -> { KeyEvent.KEYCODE_MEDIA_NEXT -> {
playerNotificationService.seekForward(seekAmount) playerNotificationService.jumpForward()
} }
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> { KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
playerNotificationService.seekBackward(seekAmount) playerNotificationService.jumpBackward()
} }
KeyEvent.KEYCODE_MEDIA_STOP -> { KeyEvent.KEYCODE_MEDIA_STOP -> {
playerNotificationService.closePlayback() playerNotificationService.closePlayback()
@@ -226,22 +223,22 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
override fun handleMessage(msg: Message) { override fun handleMessage(msg: Message) {
super.handleMessage(msg) super.handleMessage(msg)
if (2 == msg.what) { if (2 == msg.what) {
playerNotificationService.seekBackward(seekAmount) playerNotificationService.jumpBackward()
playerNotificationService.play() playerNotificationService.play()
} }
else if (msg.what >= 3) { else if (msg.what >= 3) {
playerNotificationService.seekForward(seekAmount) playerNotificationService.jumpForward()
playerNotificationService.play() playerNotificationService.play()
} }
} }
} }
// Example Using a custom action in android auto override fun onCustomAction(action: String?, extras: Bundle?) {
// override fun onCustomAction(action: String?, extras: Bundle?) { super.onCustomAction(action, extras)
// super.onCustomAction(action, extras)
// when (action) {
// if ("com.audiobookshelf.app.PLAYBACK_RATE" == action) { CUSTOM_ACTION_JUMP_FORWARD -> onFastForward()
// CUSTOM_ACTION_JUMP_BACKWARD -> onRewind()
// } }
// } }
} }
@@ -0,0 +1,4 @@
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";
@@ -29,9 +29,14 @@ class PlayerNotificationListener(var playerNotificationService:PlayerNotificatio
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 // 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) { if (!PlayerNotificationService.isClosed && !PlayerNotificationService.isSwitchingPlayer) {
Log.d(tag, "PNS is not closed - closing it now") Log.d(tag, "PNS is not closed - closing it now")
playerNotificationService.closePlayback() 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
} }
} }
} }
@@ -20,7 +20,6 @@ import android.util.Log
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat
import androidx.media.MediaBrowserServiceCompat import androidx.media.MediaBrowserServiceCompat
import androidx.media.utils.MediaConstants import androidx.media.utils.MediaConstants
import com.audiobookshelf.app.BuildConfig import com.audiobookshelf.app.BuildConfig
@@ -30,9 +29,11 @@ import com.audiobookshelf.app.data.DeviceInfo
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.media.MediaManager import com.audiobookshelf.app.media.MediaManager
import com.audiobookshelf.app.server.ApiHandler import com.audiobookshelf.app.server.ApiHandler
import com.fasterxml.jackson.annotation.JsonIgnore
import com.google.android.exoplayer2.* import com.google.android.exoplayer2.*
import com.google.android.exoplayer2.audio.AudioAttributes import com.google.android.exoplayer2.audio.AudioAttributes
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.CustomActionProvider
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator
import com.google.android.exoplayer2.source.MediaSource import com.google.android.exoplayer2.source.MediaSource
import com.google.android.exoplayer2.source.ProgressiveMediaSource import com.google.android.exoplayer2.source.ProgressiveMediaSource
@@ -42,6 +43,7 @@ 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
class PlayerNotificationService : MediaBrowserServiceCompat() { class PlayerNotificationService : MediaBrowserServiceCompat() {
@@ -50,6 +52,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
var isStarted = false var isStarted = false
var isClosed = false var isClosed = false
var isUnmeteredNetwork = false var isUnmeteredNetwork = false
var isSwitchingPlayer = false // Used when switching between cast player and exoplayer
} }
interface ClientEventEmitter { interface ClientEventEmitter {
@@ -147,6 +150,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()
@@ -255,11 +266,15 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
// Fix for local images crashing on Android 11 for specific devices // Fix for local images crashing on Android 11 for specific devices
// https://stackoverflow.com/questions/64186578/android-11-mediastyle-notification-crash/64232958#64232958 // https://stackoverflow.com/questions/64186578/android-11-mediastyle-notification-crash/64232958#64232958
ctx.grantUriPermission( try {
"com.android.systemui", ctx.grantUriPermission(
coverUri, "com.android.systemui",
Intent.FLAG_GRANT_READ_URI_PERMISSION coverUri,
) Intent.FLAG_GRANT_READ_URI_PERMISSION
)
} catch(error:Exception) {
Log.e(tag, "Grant uri permission error $error")
}
return MediaDescriptionCompat.Builder() return MediaDescriptionCompat.Builder()
.setMediaId(currentPlaybackSession!!.id) .setMediaId(currentPlaybackSession!!.id)
@@ -281,17 +296,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
mediaSessionConnector.setQueueNavigator(queueNavigator) mediaSessionConnector.setQueueNavigator(queueNavigator)
mediaSessionConnector.setPlaybackPreparer(MediaSessionPlaybackPreparer(this)) mediaSessionConnector.setPlaybackPreparer(MediaSessionPlaybackPreparer(this))
// Example adding custom action with icon in android auto mediaSessionConnector.setCustomActionProviders(
// mediaSessionConnector.setCustomActionProviders(object : MediaSessionConnector.CustomActionProvider { JumpForwardCustomActionProvider(),
// override fun onCustomAction(player: Player, action: String, extras: Bundle?) { JumpBackwardCustomActionProvider(),
// } )
// override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? {
// var icon = R.drawable.exo_icon_rewind
// return PlaybackStateCompat.CustomAction.Builder(
// "com.audiobookshelf.app.PLAYBACK_RATE", "Playback Rate", icon)
// .build()
// }
// })
mediaSession.setCallback(MediaSessionCallback(this)) mediaSession.setCallback(MediaSessionCallback(this))
@@ -307,15 +315,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
1000 * 20 // 20s playback rebuffer 1000 * 20 // 20s playback rebuffer
).build() ).build()
val seekBackTime = DeviceManager.deviceData.deviceSettings?.jumpBackwardsTimeMs ?: 10000
val seekForwardTime = DeviceManager.deviceData.deviceSettings?.jumpForwardTimeMs ?: 10000
Log.d(tag, "Seek Back Time $seekBackTime")
Log.d(tag, "Seek Forward Time $seekForwardTime")
mPlayer = ExoPlayer.Builder(this) mPlayer = ExoPlayer.Builder(this)
.setLoadControl(customLoadControl) .setLoadControl(customLoadControl)
.setSeekBackIncrementMs(seekBackTime) .setSeekBackIncrementMs(deviceSettings.jumpBackwardsTimeMs)
.setSeekForwardIncrementMs(seekForwardTime) .setSeekForwardIncrementMs(deviceSettings.jumpForwardTimeMs)
.build() .build()
mPlayer.setHandleAudioBecomingNoisy(true) mPlayer.setHandleAudioBecomingNoisy(true)
mPlayer.addListener(PlayerListener(this)) mPlayer.addListener(PlayerListener(this))
@@ -413,7 +416,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
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()
val currentTrackTime = playbackSession.getCurrentTrackTimeMs() val currentTrackTime = playbackSession.getCurrentTrackTimeMs()
@@ -436,7 +438,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) { apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
if (it == null) { // Play request failed if (it == null) { // Play request failed
clientEventEmitter?.onPlaybackFailed(errorMessage) clientEventEmitter?.onPlaybackFailed(errorMessage)
closePlayback() closePlayback(true)
} else { } else {
Handler(Looper.getMainLooper()).post { Handler(Looper.getMainLooper()).post {
preparePlayer(it, true, null) preparePlayer(it, true, null)
@@ -445,7 +447,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
} else { } else {
clientEventEmitter?.onPlaybackFailed(errorMessage) clientEventEmitter?.onPlaybackFailed(errorMessage)
closePlayback() closePlayback(true)
} }
} }
} }
@@ -489,6 +491,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)
@@ -503,15 +517,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)
} }
} }
@@ -548,10 +560,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()
} }
@@ -656,10 +664,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
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()
} }
@@ -689,6 +693,22 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
} }
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)
} }
@@ -701,12 +721,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
currentPlayer.setPlaybackSpeed(speed) currentPlayer.setPlaybackSpeed(speed)
} }
fun closePlayback() { fun closePlayback(calledOnError:Boolean? = false) {
Log.d(tag, "closePlayback") Log.d(tag, "closePlayback")
if (mediaProgressSyncer.listeningTimerRunning) { if (mediaProgressSyncer.listeningTimerRunning) {
Log.i(tag, "About to close playback so stopping media progress syncer first") Log.i(tag, "About to close playback so stopping media progress syncer first")
mediaProgressSyncer.stop { mediaProgressSyncer.stop(calledOnError == false) { // If closing on error then do not sync progress (causes exception)
Log.d(tag, "Media Progress syncer stopped and synced") Log.d(tag, "Media Progress syncer stopped")
} }
} }
@@ -745,6 +765,9 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
return DeviceInfo(Build.MANUFACTURER, Build.MODEL, Build.BRAND, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME) return DeviceInfo(Build.MANUFACTURER, Build.MODEL, Build.BRAND, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
} }
@get:JsonIgnore
val deviceSettings get() = DeviceManager.deviceData.deviceSettings ?: DeviceSettings.default()
fun getPlayItemRequestPayload(forceTranscode:Boolean):PlayItemRequestPayload { fun getPlayItemRequestPayload(forceTranscode:Boolean):PlayItemRequestPayload {
return PlayItemRequestPayload(getMediaPlayer(), !forceTranscode, forceTranscode, getDeviceInfo()) return PlayItemRequestPayload(getMediaPlayer(), !forceTranscode, forceTranscode, getDeviceInfo())
} }
@@ -772,8 +795,9 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
private val VALID_MEDIA_BROWSERS = mutableListOf("com.audiobookshelf.app", ANDROID_AUTO_PKG_NAME, ANDROID_AUTO_SIMULATOR_PKG_NAME, ANDROID_WEARABLE_PKG_NAME, ANDROID_GSEARCH_PKG_NAME, ANDROID_AUTOMOTIVE_PKG_NAME) private val VALID_MEDIA_BROWSERS = mutableListOf("com.audiobookshelf.app", ANDROID_AUTO_PKG_NAME, ANDROID_AUTO_SIMULATOR_PKG_NAME, ANDROID_WEARABLE_PKG_NAME, ANDROID_GSEARCH_PKG_NAME, ANDROID_AUTOMOTIVE_PKG_NAME)
private val AUTO_MEDIA_ROOT = "/" private val AUTO_MEDIA_ROOT = "/"
private val ALL_ROOT = "__ALL__"
private val LIBRARIES_ROOT = "__LIBRARIES__" private val LIBRARIES_ROOT = "__LIBRARIES__"
private val DOWNLOADS_ROOT = "__DOWNLOADS__"
private val CONTINUE_ROOT = "__CONTINUE__"
private lateinit var browseTree:BrowseTree private lateinit var browseTree:BrowseTree
@@ -816,8 +840,6 @@ 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")
var flag = if (parentMediaId == AUTO_MEDIA_ROOT || parentMediaId == LIBRARIES_ROOT) MediaBrowserCompat.MediaItem.FLAG_BROWSABLE else MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
result.detach() result.detach()
if (parentMediaId.startsWith("li_") || parentMediaId.startsWith("local_")) { // Show podcast episodes if (parentMediaId.startsWith("li_") || parentMediaId.startsWith("local_")) { // Show podcast episodes
@@ -829,43 +851,67 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
mediaManager.loadLibraryItemsWithAudio(parentMediaId) { libraryItems -> mediaManager.loadLibraryItemsWithAudio(parentMediaId) { libraryItems ->
val children = libraryItems.map { libraryItem -> val children = libraryItems.map { libraryItem ->
val libraryItemMediaMetadata = libraryItem.getMediaMetadata()
if (libraryItem.mediaType == "podcast") { // Podcasts are browseable if (libraryItem.mediaType == "podcast") { // Podcasts are browseable
flag = MediaBrowserCompat.MediaItem.FLAG_BROWSABLE val mediaDescription = libraryItem.getMediaDescription(null)
MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
} else {
val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.id }
val description = libraryItem.getMediaDescription(progress)
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
} }
MediaBrowserCompat.MediaItem(libraryItemMediaMetadata.description, flag)
} }
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?) result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
} }
} else if (parentMediaId == "__DOWNLOADS__") { // Load downloads } else if (parentMediaId == DOWNLOADS_ROOT) { // Load downloads
val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book") val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast") val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast")
val localBrowseItems:MutableList<MediaBrowserCompat.MediaItem> = mutableListOf() val localBrowseItems:MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
localBooks.forEach { localLibraryItem -> localBooks.forEach { localLibraryItem ->
val mediaMetadata = localLibraryItem.getMediaMetadata(ctx) val progress = DeviceManager.dbManager.getLocalMediaProgress(localLibraryItem.id)
localBrowseItems += MediaBrowserCompat.MediaItem(mediaMetadata.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) val description = localLibraryItem.getMediaDescription(progress)
localBrowseItems += MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
} }
localPodcasts.forEach { localLibraryItem -> localPodcasts.forEach { localLibraryItem ->
val mediaMetadata = localLibraryItem.getMediaMetadata(ctx) val mediaDescription = localLibraryItem.getMediaDescription(null)
localBrowseItems += MediaBrowserCompat.MediaItem(mediaMetadata.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) localBrowseItems += MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
} }
result.sendResult(localBrowseItems) result.sendResult(localBrowseItems)
} else { // Load categories } else if (parentMediaId == CONTINUE_ROOT) {
val localBrowseItems:MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
mediaManager.loadAndroidAutoItems() { libraryCategories -> mediaManager.serverItemsInProgress.forEach { itemInProgress ->
browseTree = BrowseTree(this, libraryCategories, mediaManager.serverLibraries) 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)
} 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)
}
localBrowseItems += MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
}
result.sendResult(localBrowseItems)
} else {
mediaManager.loadAndroidAutoItems {
browseTree = BrowseTree(this, mediaManager.serverItemsInProgress, mediaManager.serverLibraries)
val children = browseTree[parentMediaId]?.map { item -> val children = browseTree[parentMediaId]?.map { item ->
Log.d(tag, "Loading Browser Media Item ${item.description.title} $flag") Log.d(tag, "Loading Browser Media Item ${item.description.title}")
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
MediaBrowserCompat.MediaItem(item.description, flag)
} }
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?) result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
} }
@@ -874,10 +920,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
override fun onSearch(query: String, extras: Bundle?, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) { override fun onSearch(query: String, extras: Bundle?, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
result.detach() result.detach()
mediaManager.loadAndroidAutoItems() { libraryCategories -> mediaManager.loadAndroidAutoItems {
browseTree = BrowseTree(this, libraryCategories, mediaManager.serverLibraries) browseTree = BrowseTree(this, mediaManager.serverItemsInProgress, mediaManager.serverLibraries)
val children = browseTree[ALL_ROOT]?.map { item -> val children = browseTree[LIBRARIES_ROOT]?.map { item ->
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
} }
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?) result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
} }
@@ -944,5 +990,39 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
clientEventEmitter?.onNetworkMeteredChanged(unmetered) clientEventEmitter?.onNetworkMeteredChanged(unmetered)
} }
} }
inner class JumpBackwardCustomActionProvider : CustomActionProvider {
override fun onCustomAction(player: Player, action: String, extras: Bundle?) {
/*
This does not appear to ever get called. Instead, MediaSessionCallback.onCustomAction() is
responsible to reacting to a custom action.
*/
}
override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? {
return PlaybackStateCompat.CustomAction.Builder(
CUSTOM_ACTION_JUMP_BACKWARD,
getContext().getString(R.string.action_jump_backward),
R.drawable.exo_icon_rewind
).build()
}
}
inner class JumpForwardCustomActionProvider : CustomActionProvider {
override fun onCustomAction(player: Player, action: String, extras: Bundle?) {
/*
This does not appear to ever get called. Instead, MediaSessionCallback.onCustomAction() is
responsible to reacting to a custom action.
*/
}
override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? {
return PlaybackStateCompat.CustomAction.Builder(
CUSTOM_ACTION_JUMP_FORWARD,
getContext().getString(R.string.action_jump_forward),
R.drawable.exo_icon_fastforward
).build()
}
}
} }
@@ -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) {
@@ -16,6 +16,7 @@ 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.JSONException
import org.json.JSONObject import org.json.JSONObject
import java.io.IOException import java.io.IOException
@@ -43,11 +44,13 @@ class ApiHandler(var ctx:Context) {
makeRequest(request, httpClient, 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, null, cb) makeRequest(request, null, cb)
} }
@@ -186,21 +189,16 @@ class ApiHandler(var ctx:Context) {
} }
} }
fun getLibraryCategories(libraryId:String, cb: (List<LibraryCategory>) -> Unit) { fun getAllItemsInProgress(cb: (List<ItemInProgress>) -> Unit) {
getRequest("/api/libraries/$libraryId/personalized", null, null) { getRequest("/api/me/items-in-progress", null, null) {
val items = mutableListOf<LibraryCategory>() val items = mutableListOf<ItemInProgress>()
if (it.has("value")) { if (it.has("libraryItems")) {
val array = it.getJSONArray("value") val array = it.getJSONArray("libraryItems")
for (i in 0 until array.length()) { for (i in 0 until array.length()) {
val jsobj = array.get(i) as JSONObject val jsobj = array.get(i) as JSONObject
val type = jsobj.get("type").toString() val itemInProgress = ItemInProgress.makeFromServerObject(jsobj)
// Only support for podcast and book in android auto items.add(itemInProgress)
if (type == "podcast" || type == "book") {
jsobj.put("isLocal", false)
val item = jacksonMapper.readValue<LibraryCategory>(jsobj.toString())
items.add(item)
}
} }
} }
cb(items) cb(items)
@@ -211,7 +209,7 @@ class ApiHandler(var ctx:Context) {
val payload = JSObject(jacksonMapper.writeValueAsString(playItemRequestPayload)) val payload = JSObject(jacksonMapper.writeValueAsString(playItemRequestPayload))
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) {
if (it.has("error")) { if (it.has("error")) {
Log.e(tag, it.getString("error") ?: "Play Library Item Failed") Log.e(tag, it.getString("error") ?: "Play Library Item Failed")
cb(null) cb(null)
@@ -227,7 +225,7 @@ class ApiHandler(var ctx:Context) {
fun sendProgressSync(sessionId:String, syncData: MediaProgressSyncData, cb: (Boolean) -> 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) {
if (!it.getString("error").isNullOrEmpty()) { if (!it.getString("error").isNullOrEmpty()) {
cb(false) cb(false)
} else { } else {
@@ -239,7 +237,7 @@ class ApiHandler(var ctx:Context) {
fun sendLocalProgressSync(playbackSession:PlaybackSession, cb: (Boolean) -> 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) {
if (!it.getString("error").isNullOrEmpty()) { if (!it.getString("error").isNullOrEmpty()) {
cb(false) cb(false)
} else { } else {
@@ -265,7 +263,7 @@ 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}") Log.d(tag, "Media Progress Sync payload $payload - response ${it}")
if (it.toString() == "{}") { if (it.toString() == "{}") {
@@ -343,4 +341,25 @@ class ApiHandler(var ctx:Context) {
} }
} }
} }
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)
}
}
}
} }
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: 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

@@ -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>
@@ -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

@@ -1,4 +1,23 @@
<resources> <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>
</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"> <style name="Widget.Android.AppWidget.Container" parent="android:Widget">
<item name="android:padding">?attr/appWidgetPadding</item> <item name="android:padding">?attr/appWidgetPadding</item>
<item name="android:background">@drawable/app_widget_background</item> <item name="android:background">@drawable/app_widget_background</item>
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>
@@ -6,4 +6,6 @@
<string name="custom_url_scheme">com.audiobookshelf.app</string> <string name="custom_url_scheme">com.audiobookshelf.app</string>
<string name="add_widget">Add widget</string> <string name="add_widget">Add widget</string>
<string name="app_widget_description">Simple widget for audiobookshelf playback</string> <string name="app_widget_description">Simple widget for audiobookshelf playback</string>
<string name="action_jump_forward">Jump Forward</string>
<string name="action_jump_backward">Jump Backward</string>
</resources> </resources>
+1 -1
View File
@@ -17,7 +17,7 @@
<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>
<style name="Widget.Android.AppWidget.Container" parent="android:Widget"> <style name="Widget.Android.AppWidget.Container" parent="android:Widget">
<item name="android:id">@android:id/background</item> <item name="android:id">@android:id/background</item>
+2 -2
View File
@@ -8,8 +8,8 @@ buildscript {
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath 'com.google.gms:google-services:4.3.5' classpath 'com.google.gms:google-services:4.3.10'
classpath 'com.android.tools.build:gradle:7.2.0-beta04' classpath 'com.android.tools.build:gradle:7.2.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// NOTE: Do not place your application dependencies here; they belong // NOTE: Do not place your application dependencies here; they belong
+73 -30
View File
@@ -8,9 +8,7 @@
<span class="material-icons text-3xl" :class="isCasting ? 'text-success' : ''" @click="castClick">cast</span> <span class="material-icons text-3xl" :class="isCasting ? 'text-success' : ''" @click="castClick">cast</span>
</div> </div>
<div class="top-4 right-4 absolute cursor-pointer"> <div class="top-4 right-4 absolute cursor-pointer">
<ui-dropdown-menu ref="dropdownMenu" :items="menuItems" @action="clickMenuAction"> <span class="material-icons text-3xl" @click="showMoreMenuDialog = true">more_vert</span>
<span class="material-icons text-3xl">more_vert</span>
</ui-dropdown-menu>
</div> </div>
<p class="top-2 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" style="font-size: 10px" :class="{ 'text-success': isLocalPlayMethod, 'text-accent': !isLocalPlayMethod }">{{ isDirectPlayMethod ? 'Direct' : isLocalPlayMethod ? 'Local' : 'Transcode' }}</p> <p class="top-2 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" style="font-size: 10px" :class="{ 'text-success': isLocalPlayMethod, 'text-accent': !isLocalPlayMethod }">{{ isDirectPlayMethod ? 'Direct' : isLocalPlayMethod ? 'Local' : 'Transcode' }}</p>
</div> </div>
@@ -66,23 +64,24 @@
<div id="playerControls" class="absolute right-0 bottom-0 py-2"> <div id="playerControls" class="absolute right-0 bottom-0 py-2">
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<span v-show="showFullscreen" class="material-icons next-icon text-white text-opacity-75 cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span> <span v-show="showFullscreen && !lockUi" class="material-icons next-icon text-white text-opacity-75 cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
<span class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span> <span v-show="!lockUi" class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span>
<div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4" :class="{ 'animate-spin': seekLoading, 'bg-accent': !isLocalPlayMethod, 'bg-success': isLocalPlayMethod }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick"> <div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4" :class="{ 'animate-spin': seekLoading, 'bg-accent': !isLocalPlayMethod, 'bg-success': isLocalPlayMethod }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
<span v-if="!isLoading" class="material-icons">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span> <span v-if="!isLoading" class="material-icons">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
<widgets-spinner-icon v-else class="h-8 w-8" /> <widgets-spinner-icon v-else class="h-8 w-8" />
</div> </div>
<span class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">{{ jumpForwardIcon }}</span> <span v-show="!lockUi" class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">{{ jumpForwardIcon }}</span>
<span v-show="showFullscreen" class="material-icons next-icon text-white cursor-pointer" :class="nextChapter && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span> <span v-show="showFullscreen && !lockUi" class="material-icons next-icon text-white cursor-pointer" :class="nextChapter && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
</div> </div>
</div> </div>
<div id="playerTrack" class="absolute bottom-0 left-0 w-full px-3"> <div id="playerTrack" class="absolute bottom-0 left-0 w-full px-3">
<div ref="track" class="h-2 w-full bg-gray-500 bg-opacity-50 relative" :class="isLoading ? 'animate-pulse' : ''" @touchstart="touchstartTrack" @click="clickTrack"> <div ref="track" class="h-1.5 w-full bg-gray-500 bg-opacity-50 relative" :class="{ 'animate-pulse': isLoading }" @touchstart="touchstartTrack" @click="clickTrack">
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" /> <div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
<div ref="bufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 pointer-events-none" /> <div ref="bufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 pointer-events-none" />
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" /> <div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" />
<div ref="draggingTrack" class="h-full bg-warning bg-opacity-25 absolute top-0 left-0 pointer-events-none" /> <div ref="draggingTrack" class="h-full bg-warning bg-opacity-25 absolute top-0 left-0 pointer-events-none" />
<div ref="trackCursor" class="h-3.5 w-3.5 rounded-full bg-gray-200 absolute -top-1 pointer-events-none" :class="{ 'opacity-0': lockUi }" />
</div> </div>
<div id="timestamp-row" class="flex pt-0.5"> <div id="timestamp-row" class="flex pt-0.5">
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem" ref="currentTimestamp">0:00</p> <p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem" ref="currentTimestamp">0:00</p>
@@ -95,6 +94,24 @@
</div> </div>
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" /> <modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
<modals-dialog v-model="showMoreMenuDialog" :items="menuItems" @action="clickMenuAction">
<template v-slot:chapter_track="{ item }">
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickMenuAction(item.value)">
<div class="flex items-center px-3">
<span v-if="item.icon" class="material-icons-outlined text-xl mr-2 text-white text-opacity-80">{{ item.icon }}</span>
<span class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</span>
</div>
</li>
</template>
<template v-slot:lock="{ item }">
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickMenuAction(item.value)">
<div class="flex items-center px-3">
<span v-if="item.icon" class="material-icons-outlined text-xl mr-2 text-opacity-80" :class="{ 'text-red-500': lockUi, 'text-white': !lockUi }">{{ item.icon }}</span>
<span class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</span>
</div>
</li>
</template>
</modals-dialog>
</div> </div>
</template> </template>
@@ -139,10 +156,12 @@ export default {
touchStartTime: 0, touchStartTime: 0,
touchEndY: 0, touchEndY: 0,
useChapterTrack: false, useChapterTrack: false,
lockUi: false,
isLoading: false, isLoading: false,
touchTrackStart: false, touchTrackStart: false,
dragPercent: 0, dragPercent: 0,
syncStatus: 0 syncStatus: 0,
showMoreMenuDialog: false
} }
}, },
watch: { watch: {
@@ -153,17 +172,24 @@ export default {
}, },
computed: { computed: {
menuItems() { menuItems() {
var items = [] var items = [
items.push({ {
text: 'Chapter Track', text: 'Chapter Track',
value: 'chapter_track', value: 'chapter_track',
icon: this.useChapterTrack ? 'check_box' : 'check_box_outline_blank' icon: this.useChapterTrack ? 'check_box' : 'check_box_outline_blank'
}) },
items.push({ {
text: 'Close Player', text: this.lockUi ? 'Unlock Player' : 'Lock Player',
value: 'close', value: 'lock',
icon: 'close' icon: this.lockUi ? 'lock' : 'lock_open'
}) },
{
text: 'Close Player',
value: 'close',
icon: 'close'
}
]
return items return items
}, },
jumpForwardIcon() { jumpForwardIcon() {
@@ -310,7 +336,7 @@ export default {
} }
}, },
touchstartTrack(e) { touchstartTrack(e) {
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen) return if (!e || !e.touches || !this.$refs.track || !this.showFullscreen || this.lockUi) return
this.touchTrackStart = true this.touchTrackStart = true
}, },
selectChapter(chapter) { selectChapter(chapter) {
@@ -460,6 +486,10 @@ export default {
this.$refs.playedTrack.style.width = ptWidth + 'px' this.$refs.playedTrack.style.width = ptWidth + 'px'
this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px' this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px'
if (this.$refs.trackCursor) {
this.$refs.trackCursor.style.left = ptWidth - 8 + 'px'
}
if (this.useChapterTrack) { if (this.useChapterTrack) {
if (this.$refs.totalPlayedTrack) this.$refs.totalPlayedTrack.style.width = Math.round(totalPercentDone * this.trackWidth) + 'px' if (this.$refs.totalPlayedTrack) this.$refs.totalPlayedTrack.style.width = Math.round(totalPercentDone * this.trackWidth) + 'px'
if (this.$refs.totalBufferedTrack) this.$refs.totalBufferedTrack.style.width = Math.round(totalBufferedPercent * this.trackWidth) + 'px' if (this.$refs.totalBufferedTrack) this.$refs.totalBufferedTrack.style.width = Math.round(totalBufferedPercent * this.trackWidth) + 'px'
@@ -487,7 +517,7 @@ export default {
} }
}, },
clickTrack(e) { clickTrack(e) {
if (this.isLoading) return if (this.isLoading || this.lockUi) return
if (!this.showFullscreen) { if (!this.showFullscreen) {
// Track not clickable on mini-player // Track not clickable on mini-player
return return
@@ -612,18 +642,22 @@ export default {
} }
}, },
clickMenuAction(action) { clickMenuAction(action) {
if (action === 'chapter_track') { this.showMoreMenuDialog = false
this.useChapterTrack = !this.useChapterTrack this.$nextTick(() => {
if (action === 'lock') {
this.lockUi = !this.lockUi
this.$localStore.setPlayerLock(this.lockUi)
} else if (action === 'chapter_track') {
this.useChapterTrack = !this.useChapterTrack
this.$nextTick(() => {
this.updateTimestamp() this.updateTimestamp()
this.updateTrack() this.updateTrack()
this.updateReadyTrack() this.updateReadyTrack()
}) this.$localStore.setUseChapterTrack(this.useChapterTrack)
this.$localStore.setUseChapterTrack(this.useChapterTrack) } else if (action === 'close') {
} else if (action === 'close') { this.closePlayback()
this.closePlayback() }
} })
}, },
forceCloseDropdownMenu() { forceCloseDropdownMenu() {
if (this.$refs.dropdownMenu && this.$refs.dropdownMenu.closeMenu) { if (this.$refs.dropdownMenu && this.$refs.dropdownMenu.closeMenu) {
@@ -705,6 +739,7 @@ export default {
}, },
async init() { async init() {
this.useChapterTrack = await this.$localStore.getUseChapterTrack() this.useChapterTrack = await this.$localStore.getUseChapterTrack()
this.lockUi = await this.$localStore.getPlayerLock()
this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession) this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession)
this.onPlaybackClosedListener = AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed) this.onPlaybackClosedListener = AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed)
@@ -737,8 +772,12 @@ export default {
mounted() { mounted() {
this.updateScreenSize() this.updateScreenSize()
if (screen.orientation) { if (screen.orientation) {
// Not available on ios
screen.orientation.addEventListener('change', this.screenOrientationChange) screen.orientation.addEventListener('change', this.screenOrientationChange)
} else {
document.addEventListener('orientationchange', this.screenOrientationChange)
} }
window.addEventListener('resize', this.screenOrientationChange)
this.$eventBus.$on('minimize-player', this.minimizePlayerEvt) this.$eventBus.$on('minimize-player', this.minimizePlayerEvt)
document.body.addEventListener('touchstart', this.touchstart) document.body.addEventListener('touchstart', this.touchstart)
@@ -748,8 +787,12 @@ export default {
}, },
beforeDestroy() { beforeDestroy() {
if (screen.orientation) { if (screen.orientation) {
// Not available on ios
screen.orientation.removeEventListener('change', this.screenOrientationChange) screen.orientation.removeEventListener('change', this.screenOrientationChange)
} else {
document.removeEventListener('orientationchange', this.screenOrientationChange)
} }
window.removeEventListener('resize', this.screenOrientationChange)
if (this.playbackSession) { if (this.playbackSession) {
console.log('[AudioPlayer] Before destroy closing playback') console.log('[AudioPlayer] Before destroy closing playback')
+32 -4
View File
@@ -15,6 +15,8 @@ import { Dialog } from '@capacitor/dialog'
export default { export default {
data() { data() {
return { return {
isReady: false,
settingsLoaded: false,
audioPlayerReady: false, audioPlayerReady: false,
stream: null, stream: null,
download: null, download: null,
@@ -32,7 +34,8 @@ export default {
onMediaPlayerChangedListener: null, onMediaPlayerChangedListener: null,
sleepInterval: null, sleepInterval: null,
currentEndOfChapterTime: 0, currentEndOfChapterTime: 0,
serverLibraryItemId: null serverLibraryItemId: null,
serverEpisodeId: null
} }
}, },
watch: { watch: {
@@ -150,6 +153,10 @@ export default {
console.log(`[AudioPlayerContainer] PlaybackRate Updated: ${this.playbackSpeed}`) console.log(`[AudioPlayerContainer] PlaybackRate Updated: ${this.playbackSpeed}`)
this.$refs.audioPlayer.setPlaybackSpeed(this.playbackSpeed) this.$refs.audioPlayer.setPlaybackSpeed(this.playbackSpeed)
} }
// Settings have been loaded (at least once, so it's safe to kickoff onReady)
this.settingsLoaded = true
this.notifyOnReady()
}, },
setListeners() { setListeners() {
// if (!this.$server.socket) { // if (!this.$server.socket) {
@@ -173,15 +180,15 @@ export default {
this.$toast.error(`Cannot cast locally downloaded media`) this.$toast.error(`Cannot cast locally downloaded media`)
} else { } else {
// Change to server library item // Change to server library item
this.playServerLibraryItemAndCast(this.serverLibraryItemId) this.playServerLibraryItemAndCast(this.serverLibraryItemId, this.serverEpisodeId)
} }
}, },
playServerLibraryItemAndCast(libraryItemId) { playServerLibraryItemAndCast(libraryItemId, episodeId) {
var playbackRate = 1 var playbackRate = 1
if (this.$refs.audioPlayer) { if (this.$refs.audioPlayer) {
playbackRate = this.$refs.audioPlayer.currentPlaybackRate || 1 playbackRate = this.$refs.audioPlayer.currentPlaybackRate || 1
} }
AbsAudioPlayer.prepareLibraryItem({ libraryItemId, episodeId: null, playWhenReady: false, playbackRate }) AbsAudioPlayer.prepareLibraryItem({ libraryItemId, episodeId, playWhenReady: false, playbackRate })
.then((data) => { .then((data) => {
if (data.error) { if (data.error) {
const errorMsg = data.error || 'Failed to play' const errorMsg = data.error || 'Failed to play'
@@ -203,6 +210,7 @@ export default {
// When playing local library item and can also play this item from the server // When playing local library item and can also play this item from the server
// then store the server library item id so it can be used if a cast is made // then store the server library item id so it can be used if a cast is made
var serverLibraryItemId = payload.serverLibraryItemId || null var serverLibraryItemId = payload.serverLibraryItemId || null
var serverEpisodeId = payload.serverEpisodeId || null
if (libraryItemId.startsWith('local') && this.$store.state.isCasting) { if (libraryItemId.startsWith('local') && this.$store.state.isCasting) {
const { value } = await Dialog.confirm({ const { value } = await Dialog.confirm({
@@ -215,6 +223,7 @@ export default {
} }
this.serverLibraryItemId = null this.serverLibraryItemId = null
this.serverEpisodeId = null
var playbackRate = 1 var playbackRate = 1
if (this.$refs.audioPlayer) { if (this.$refs.audioPlayer) {
@@ -234,6 +243,11 @@ export default {
} else { } else {
this.serverLibraryItemId = serverLibraryItemId this.serverLibraryItemId = serverLibraryItemId
} }
if (episodeId && !episodeId.startsWith('local')) {
this.serverEpisodeId = episodeId
} else {
this.serverEpisodeId = serverEpisodeId
}
} }
}) })
.catch((error) => { .catch((error) => {
@@ -253,6 +267,18 @@ export default {
onMediaPlayerChanged(data) { onMediaPlayerChanged(data) {
var mediaPlayer = data.value var mediaPlayer = data.value
this.$store.commit('setMediaPlayer', mediaPlayer) this.$store.commit('setMediaPlayer', mediaPlayer)
},
onReady() {
// The UI is reporting elsewhere we are ready
this.isReady = true
this.notifyOnReady()
},
notifyOnReady() {
// If settings aren't loaded yet, native player will receive incorrect settings
console.log('Notify on ready... settingsLoaded:', this.settingsLoaded, 'isReady:', this.isReady)
if ( this.settingsLoaded && this.isReady ) {
AbsAudioPlayer.onReady()
}
} }
}, },
mounted() { mounted() {
@@ -265,6 +291,7 @@ export default {
console.log(`[AudioPlayerContainer] Init Playback Speed: ${this.playbackSpeed}`) console.log(`[AudioPlayerContainer] Init Playback Speed: ${this.playbackSpeed}`)
this.setListeners() this.setListeners()
this.$eventBus.$on('abs-ui-ready', this.onReady)
this.$eventBus.$on('play-item', this.playLibraryItem) this.$eventBus.$on('play-item', this.playLibraryItem)
this.$eventBus.$on('pause-item', this.pauseItem) this.$eventBus.$on('pause-item', this.pauseItem)
this.$eventBus.$on('close-stream', this.closeStreamOnly) this.$eventBus.$on('close-stream', this.closeStreamOnly)
@@ -284,6 +311,7 @@ export default {
// this.$server.socket.off('stream_ready', this.streamReady) // this.$server.socket.off('stream_ready', this.streamReady)
// this.$server.socket.off('stream_reset', this.streamReset) // this.$server.socket.off('stream_reset', this.streamReset)
// } // }
this.$eventBus.$off('abs-ui-ready', this.onReady)
this.$eventBus.$off('play-item', this.playLibraryItem) this.$eventBus.$off('play-item', this.playLibraryItem)
this.$eventBus.$off('pause-item', this.pauseItem) this.$eventBus.$off('pause-item', this.pauseItem)
this.$eventBus.$off('close-stream', this.closeStreamOnly) this.$eventBus.$off('close-stream', this.closeStreamOnly)
+13 -1
View File
@@ -99,6 +99,11 @@ export default {
text: 'Account', text: 'Account',
to: '/account' to: '/account'
}) })
items.push({
icon: 'equalizer',
text: 'User Stats',
to: '/stats'
})
} }
if (this.$platform !== 'ios') { if (this.$platform !== 'ios') {
@@ -108,6 +113,13 @@ export default {
text: 'Local Media', text: 'Local Media',
to: '/localMedia/folders' to: '/localMedia/folders'
}) })
} else {
items.push({
icon: 'download',
iconOutlined: false,
text: 'Downloads',
to: '/downloads'
})
} }
items.push({ items.push({
icon: 'settings', icon: 'settings',
@@ -162,4 +174,4 @@ export default {
this.show = false this.show = false
} }
} }
</script> </script>
+35 -5
View File
@@ -42,7 +42,7 @@
<!-- No progress shown for collapsed series in library --> <!-- No progress shown for collapsed series in library -->
<div v-if="!collapsedSeries && (!isPodcast || recentEpisode)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div> <div v-if="!collapsedSeries && (!isPodcast || recentEpisode)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
<div v-if="localLibraryItem || isLocal" class="absolute top-0 right-0 z-20" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }"> <div v-if="showHasLocalDownload" class="absolute right-0 top-0 z-20" :style="{ top: (isPodcast ? 1.75 : 0.375) * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<span class="material-icons text-2xl text-success">{{ isLocalOnly ? 'task' : 'download_done' }}</span> <span class="material-icons text-2xl text-success">{{ isLocalOnly ? 'task' : 'download_done' }}</span>
</div> </div>
@@ -164,6 +164,7 @@ export default {
return this.media.numTracks return this.media.numTracks
}, },
numEpisodes() { numEpisodes() {
if (this.isLocal && this.isPodcast && this.media.episodes) return this.media.episodes.length
return this.media.numEpisodes return this.media.numEpisodes
}, },
processingBatch() { processingBatch() {
@@ -239,7 +240,7 @@ export default {
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs) if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs)
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs) if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs)
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt) if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt)
if (this.orderBy === 'duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false) if (this.orderBy === 'media.duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size) if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
return null return null
}, },
@@ -250,7 +251,7 @@ export default {
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.recentEpisode.id) return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.recentEpisode.id)
}, },
userProgress() { userProgress() {
if (this.episodeProgress) return this.episodeProgress if (this.recentEpisode) return this.episodeProgress || null
if (this.isLocal) return this.store.getters['globals/getLocalMediaProgressById'](this.libraryItemId) if (this.isLocal) return this.store.getters['globals/getLocalMediaProgressById'](this.libraryItemId)
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId) return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
}, },
@@ -351,6 +352,17 @@ export default {
if (!this.isAltViewEnabled) return 0 if (!this.isAltViewEnabled) return 0
else if (!this.displaySortLine) return 3 * this.sizeMultiplier else if (!this.displaySortLine) return 3 * this.sizeMultiplier
return 4.25 * this.sizeMultiplier return 4.25 * this.sizeMultiplier
},
showHasLocalDownload() {
if (this.localLibraryItem || this.isLocal) {
if (this.recentEpisode && !this.isLocal) {
const localEpisode = this.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId === this.recentEpisode.id)
return !!localEpisode
} else {
return true
}
}
return false
} }
}, },
methods: { methods: {
@@ -399,8 +411,26 @@ export default {
this.selectBtnClick() this.selectBtnClick()
} else if (this.recentEpisode) { } else if (this.recentEpisode) {
var eventBus = this.$eventBus || this.$nuxt.$eventBus var eventBus = this.$eventBus || this.$nuxt.$eventBus
if (this.streamIsPlaying) eventBus.$emit('pause-item') if (this.streamIsPlaying) {
else eventBus.$emit('play-item', { libraryItemId: this.libraryItemId, episodeId: this.recentEpisode.id }) eventBus.$emit('pause-item')
return
}
if (this.localLibraryItem) {
const localEpisode = this.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId === this.recentEpisode.id)
if (localEpisode) {
// Play episode locally
eventBus.$emit('play-item', {
libraryItemId: this.localLibraryItemId,
episodeId: localEpisode.id,
serverLibraryItemId: this.libraryItemId,
serverEpisodeId: this.recentEpisode.id
})
return
}
}
eventBus.$emit('play-item', { libraryItemId: this.libraryItemId, episodeId: this.recentEpisode.id })
} else { } else {
var router = this.$router || this.$nuxt.$router var router = this.$router || this.$nuxt.$router
if (router) { if (router) {
+3 -3
View File
@@ -16,7 +16,7 @@
</div> </div>
<div class="flex-grow px-2"> <div class="flex-grow px-2">
<p class="whitespace-normal" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }"> <p class="whitespace-normal" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">
{{ displayTitle }}<span v-if="seriesSequence">&nbsp;#{{ seriesSequence }}</span> <span v-if="seriesSequence">#{{ seriesSequence }}&nbsp;</span>{{ displayTitle }}
</p> </p>
<p class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">by {{ displayAuthor }}</p> <p class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">by {{ displayAuthor }}</p>
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p> <p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
@@ -109,8 +109,8 @@ export default {
}, },
episodes() { episodes() {
if (this.isPodcast) { if (this.isPodcast) {
if (this.media.numEpisodes==1) { if (this.media.numEpisodes == 1) {
return "1 episode" return '1 episode'
} else { } else {
return this.media.numEpisodes + ' episodes' return this.media.numEpisodes + ' episodes'
} }
+1 -1
View File
@@ -8,7 +8,7 @@
<p v-show="!selectedSeriesName" class="font-book pt-1">{{ totalEntities }} {{ entityTitle }}</p> <p v-show="!selectedSeriesName" class="font-book pt-1">{{ totalEntities }} {{ entityTitle }}</p>
<p v-show="selectedSeriesName" class="ml-2 font-book pt-1">{{ selectedSeriesName }} ({{ totalEntities }})</p> <p v-show="selectedSeriesName" class="ml-2 font-book pt-1">{{ selectedSeriesName }} ({{ totalEntities }})</p>
<div class="flex-grow" /> <div class="flex-grow" />
<span v-if="page == 'library' || seriesBookPage" class="material-icons px-2" @click="bookshelfListView = !bookshelfListView">{{ bookshelfListView ? 'view_list' : 'grid_view' }}</span> <span v-if="page == 'library' || seriesBookPage" class="material-icons px-2" @click="bookshelfListView = !bookshelfListView">{{ !bookshelfListView ? 'view_list' : 'grid_view' }}</span>
<template v-if="page === 'library'"> <template v-if="page === 'library'">
<div class="relative flex items-center px-2"> <div class="relative flex items-center px-2">
<span class="material-icons" @click="showFilterModal = true">filter_alt</span> <span class="material-icons" @click="showFilterModal = true">filter_alt</span>
+7 -5
View File
@@ -10,11 +10,13 @@
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop> <div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label"> <ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items"> <template v-for="item in items">
<li :key="item.value" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" role="option" @click="clickedOption(item.value)"> <slot :name="item.value" :item="item" :selected="item.value === selected">
<div class="relative flex items-center px-3"> <li :key="item.value" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" role="option" @click="clickedOption(item.value)">
<p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p> <div class="relative flex items-center px-3">
</div> <p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p>
</li> </div>
</li>
</slot>
</template> </template>
</ul> </ul>
</div> </div>
+8
View File
@@ -86,6 +86,11 @@ export default {
value: 'narrators', value: 'narrators',
sublist: true sublist: true
}, },
{
text: 'Language',
value: 'languages',
sublist: true
},
{ {
text: 'Progress', text: 'Progress',
value: 'progress', value: 'progress',
@@ -165,6 +170,9 @@ export default {
narrators() { narrators() {
return this.filterData.narrators || [] return this.filterData.narrators || []
}, },
languages() {
return this.filterData.languages || []
},
progress() { progress() {
return ['Finished', 'In Progress', 'Not Started', 'Not Finished'] return ['Finished', 'In Progress', 'Not Started', 'Not Finished']
}, },
+2 -1
View File
@@ -50,8 +50,9 @@ export default {
methods: { methods: {
async clickedOption(lib) { async clickedOption(lib) {
this.show = false this.show = false
if (lib.id === this.currentLibraryId) return
await this.$store.dispatch('libraries/fetch', lib.id) await this.$store.dispatch('libraries/fetch', lib.id)
this.$eventBus.$emit('library-changed') this.$eventBus.$emit('library-changed', lib.id)
this.$localStore.setLastLibraryId(lib.id) this.$localStore.setLastLibraryId(lib.id)
} }
}, },
+4
View File
@@ -39,6 +39,10 @@ export default {
text: 'Author (Last, First)', text: 'Author (Last, First)',
value: 'media.metadata.authorNameLF' value: 'media.metadata.authorNameLF'
}, },
{
text: 'Published Year',
value: 'media.metadata.publishedYear'
},
{ {
text: 'Added At', text: 'Added At',
value: 'addedAt' value: 'addedAt'
+37 -5
View File
@@ -6,9 +6,26 @@
</div> </div>
</template> </template>
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false"> <div
class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center"
@click="
show = false
manualTimerModal = false
"
>
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop> <div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
<ul v-if="!sleepTimerRunning" class="h-full w-full" role="listbox" aria-labelledby="listbox-label"> <div v-if="manualTimerModal" class="p-4">
<div class="flex mb-4" @click="manualTimerModal = false">
<span class="material-icons text-3xl">arrow_back</span>
</div>
<div class="flex my-2 justify-between">
<ui-btn @click="decreaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">remove</span></ui-btn>
<p class="text-2xl font-mono text-center">{{ manualTimeoutMin }} min</p>
<ui-btn @click="increaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">add</span></ui-btn>
</div>
<ui-btn @click="clickedOption(manualTimeoutMin)" class="w-full">Set Timer</ui-btn>
</div>
<ul v-else-if="!sleepTimerRunning" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="timeout in timeouts"> <template v-for="timeout in timeouts">
<li :key="timeout" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption(timeout)"> <li :key="timeout" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption(timeout)">
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
@@ -21,8 +38,13 @@
<span class="font-normal block truncate text-lg text-center">End of Chapter</span> <span class="font-normal block truncate text-lg text-center">End of Chapter</span>
</div> </div>
</li> </li>
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="manualTimerModal = true">
<div class="flex items-center justify-center">
<span class="font-normal block truncate text-lg text-center">Manual sleep timer</span>
</div>
</li>
</ul> </ul>
<div v-else class="px-2 py-4"> <div v-else class="p-4">
<div class="flex my-2 justify-between"> <div class="flex my-2 justify-between">
<ui-btn @click="decreaseSleepTime" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">remove</span></ui-btn> <ui-btn @click="decreaseSleepTime" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">remove</span></ui-btn>
<p class="text-2xl font-mono text-center">{{ timeRemainingPretty }}</p> <p class="text-2xl font-mono text-center">{{ timeRemainingPretty }}</p>
@@ -45,7 +67,10 @@ export default {
currentEndOfChapterTime: Number currentEndOfChapterTime: Number
}, },
data() { data() {
return {} return {
manualTimerModal: null,
manualTimeoutMin: 1
}
}, },
computed: { computed: {
show: { show: {
@@ -57,7 +82,7 @@ export default {
} }
}, },
timeouts() { timeouts() {
return [1, 5, 10, 15, 30, 45, 60, 90] return [5, 10, 15, 30, 45, 60, 90]
}, },
timeRemainingPretty() { timeRemainingPretty() {
return this.$secondsToTimestamp(this.currentTime) return this.$secondsToTimestamp(this.currentTime)
@@ -71,6 +96,7 @@ export default {
clickedOption(timeoutMin) { clickedOption(timeoutMin) {
var timeout = timeoutMin * 1000 * 60 var timeout = timeoutMin * 1000 * 60
this.show = false this.show = false
this.manualTimerModal = false
this.$nextTick(() => this.$emit('change', { time: timeout, isChapterTime: false })) this.$nextTick(() => this.$emit('change', { time: timeout, isChapterTime: false }))
}, },
cancelSleepTimer() { cancelSleepTimer() {
@@ -82,6 +108,12 @@ export default {
}, },
decreaseSleepTime() { decreaseSleepTime() {
this.$emit('decrease') this.$emit('decrease')
},
increaseManualTimeout() {
this.manualTimeoutMin++
},
decreaseManualTimeout() {
if (this.manualTimeoutMin > 1) this.manualTimeoutMin--
} }
}, },
mounted() {} mounted() {}
+219
View File
@@ -0,0 +1,219 @@
<template>
<div class="w-96 my-6 mx-auto">
<h1 class="text-2xl mb-4 font-book">Minutes Listening <span class="text-white text-opacity-60 text-lg">(Last 7 days)</span></h1>
<div class="relative w-96 h-72">
<div class="absolute top-0 left-0">
<template v-for="lbl in yAxisLabels">
<div :key="lbl" :style="{ height: lineSpacing + 'px' }" class="flex items-center justify-end">
<p class="text-xs font-semibold">{{ lbl }}</p>
</div>
</template>
</div>
<template v-for="n in 7">
<div :key="n" class="absolute pointer-events-none left-0 h-px bg-white bg-opacity-10" :style="{ top: n * lineSpacing - lineSpacing / 2 + 'px', width: '360px', marginLeft: '24px' }" />
<div :key="`dot-${n}`" class="absolute z-10" :style="{ left: points[n - 1].x + 'px', bottom: points[n - 1].y + 'px' }">
<div class="h-2 w-2 bg-yellow-400 hover:bg-yellow-300 rounded-full transform duration-150 transition-transform hover:scale-125" />
</div>
</template>
<template v-for="(line, index) in pointLines">
<div :key="`line-${index}`" class="absolute h-0.5 bg-yellow-400 origin-bottom-left pointer-events-none" :style="{ width: line.width + 'px', left: line.x + 'px', bottom: line.y + 'px', transform: `rotate(${line.angle}deg)` }" />
</template>
<div class="absolute -bottom-2 left-0 flex ml-6">
<template v-for="dayObj in last7Days">
<div :key="dayObj.date" :style="{ width: daySpacing + daySpacing / 14 + 'px' }">
<p class="text-sm font-book">{{ dayObj.dayOfWeek.slice(0, 3) }}</p>
</div>
</template>
</div>
</div>
<div class="flex justify-between pt-12">
<div>
<p class="text-sm text-center">Week Listening</p>
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ totalMinutesListeningThisWeek }}</p>
<p class="text-sm text-center">minutes</p>
</div>
<div>
<p class="text-sm text-center">Daily Average</p>
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ averageMinutesPerDay }}</p>
<p class="text-sm text-center">minutes</p>
</div>
<div>
<p class="text-sm text-center">Best Day</p>
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ mostListenedDay }}</p>
<p class="text-sm text-center">minutes</p>
</div>
<div>
<p class="text-sm text-center">Days</p>
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ daysInARow }}</p>
<p class="text-sm text-center">in a row</p>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
listeningStats: {
type: Object,
default: () => {}
}
},
data() {
return {
// test: [111, 120, 4, 156, 273, 76, 12],
chartHeight: 288,
chartWidth: 384,
chartContentWidth: 360,
chartContentHeight: 268
}
},
computed: {
yAxisLabels() {
var lbls = []
for (let i = 6; i >= 0; i--) {
lbls.push(i * this.yAxisFactor)
}
return lbls
},
chartContentMarginLeft() {
return this.chartWidth - this.chartContentWidth
},
chartContentMarginBottom() {
return this.chartHeight - this.chartContentHeight
},
lineSpacing() {
return this.chartHeight / 7
},
daySpacing() {
return this.chartContentWidth / 7
},
linePositions() {
var poses = []
for (let i = 7; i > 0; i--) {
poses.push(i * this.lineSpacing)
}
poses.push(0)
return poses
},
last7Days() {
var days = []
for (let i = 6; i >= 0; i--) {
var _date = this.$addDaysToToday(i * -1)
days.push({
dayOfWeek: this.$formatJsDate(_date, 'EEEE'),
date: this.$formatJsDate(_date, 'yyyy-MM-dd')
})
}
return days
},
last7DaysOfListening() {
var listeningDays = {}
var _index = 0
this.last7Days.forEach((dayObj) => {
listeningDays[_index++] = {
dayOfWeek: dayObj.dayOfWeek,
// minutesListening: this.test[_index - 1]
minutesListening: this.getMinutesListeningForDate(dayObj.date)
}
})
return listeningDays
},
mostListenedDay() {
var sorted = Object.values(this.last7DaysOfListening)
.map((dl) => ({ ...dl }))
.sort((a, b) => b.minutesListening - a.minutesListening)
return sorted[0].minutesListening
},
yAxisFactor() {
var factor = Math.ceil(this.mostListenedDay / 5)
if (factor > 25) {
// Use nearest multiple of 5
return Math.ceil(factor / 5) * 5
}
return Math.max(1, factor)
},
points() {
var data = []
for (let i = 0; i < 7; i++) {
var listeningObj = this.last7DaysOfListening[String(i)]
var minutesListening = listeningObj.minutesListening || 0
var yPercent = minutesListening / (this.yAxisFactor * 7)
data.push({
x: 4 + this.chartContentMarginLeft + (this.daySpacing + this.daySpacing / 14) * i,
y: this.chartContentMarginBottom + this.chartHeight * yPercent - 2
})
}
return data
},
pointLines() {
var lines = []
for (let i = 1; i < 7; i++) {
var lastPoint = this.points[i - 1]
var nextPoint = this.points[i]
var x1 = lastPoint.x
var x2 = nextPoint.x
var y1 = lastPoint.y
var y2 = nextPoint.y
lines.push({
x: x1 + 4,
y: y1 + 2,
angle: this.getAngleBetweenPoints(x1, y1, x2, y2),
width: Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2)) - 2
})
}
return lines
},
totalMinutesListeningThisWeek() {
var _total = 0
Object.values(this.last7DaysOfListening).forEach((listeningObj) => (_total += listeningObj.minutesListening))
return _total
},
averageMinutesPerDay() {
return Math.round(this.totalMinutesListeningThisWeek / 7)
},
daysInARow() {
var count = 0
while (true) {
var _date = this.$addDaysToToday(count * -1)
var datestr = this.$formatJsDate(_date, 'yyyy-MM-dd')
if (!this.listeningStatsDays[datestr] || this.listeningStatsDays[datestr] === 0) {
return count
}
count++
if (count > 9999) {
console.error('Overflow protection')
return 0
}
}
},
listeningStatsDays() {
return this.listeningStats ? this.listeningStats.days || [] : []
}
},
methods: {
getAngleBetweenPoints(cx, cy, ex, ey) {
var dy = ey - cy
var dx = ex - cx
var theta = Math.atan2(dy, dx)
theta *= 180 / Math.PI // convert to degrees
return theta * -1
},
getMinutesListeningForDate(date) {
if (!this.listeningStats || !this.listeningStats.days) return 0
return Math.round((this.listeningStats.days[date] || 0) / 60)
}
},
mounted() {}
}
</script>
+8 -6
View File
@@ -1,12 +1,14 @@
<template> <template>
<div class="w-full bg-primary bg-opacity-40"> <div class="w-full bg-primary bg-opacity-40">
<div class="w-full h-14 flex items-center px-4 bg-primary"> <div class="w-full h-14 flex items-center px-4 bg-primary">
<p>Collection List</p> <p class="pr-4">Collection List</p>
<div class="w-6 h-6 bg-white bg-opacity-10 flex items-center justify-center rounded-full ml-2">
<p class="font-mono text-sm">{{ books.length }}</p> <div class="w-6 h-6 md:w-7 md:h-7 bg-white bg-opacity-10 rounded-full flex items-center justify-center">
<span class="text-xs md:text-sm font-mono leading-none">{{ books.length }}</span>
</div> </div>
<div class="flex-grow" /> <div class="flex-grow" />
<p v-if="totalDuration">{{ totalDurationPretty }}</p> <p v-if="totalDuration" class="text-sm text-gray-200">{{ totalDurationPretty }}</p>
</div> </div>
<template v-for="book in booksCopy"> <template v-for="book in booksCopy">
<tables-collection-book-table-row :key="book.id" :book="book" :collection-id="collectionId" class="item collection-book-item" @edit="editBook" /> <tables-collection-book-table-row :key="book.id" :book="book" :collection-id="collectionId" class="item collection-book-item" @edit="editBook" />
@@ -39,12 +41,12 @@ export default {
totalDuration() { totalDuration() {
var _total = 0 var _total = 0
this.books.forEach((book) => { this.books.forEach((book) => {
_total += book.duration _total += book.media.duration
}) })
return _total return _total
}, },
totalDurationPretty() { totalDurationPretty() {
return this.$elapsedPretty(this.totalDuration) return this.$elapsedPrettyExtended(this.totalDuration)
} }
}, },
methods: { methods: {
@@ -1,17 +1,17 @@
<template> <template>
<div class="w-full px-2 py-2 overflow-hidden relative"> <div class="w-full px-2 py-2 overflow-hidden relative">
<div v-if="book" class="flex w-full"> <nuxt-link v-if="book" :to="`/item/${book.id}`" class="flex w-full">
<div class="h-full relative" :style="{ width: bookWidth + 'px' }"> <div class="h-full relative" :style="{ width: bookWidth + 'px' }">
<covers-book-cover :library-item="book" :width="bookWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <covers-book-cover :library-item="book" :width="bookWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div> </div>
<div class="flex-grow book-table-content h-full px-2 flex items-center"> <div class="flex-grow book-table-content h-full px-2 flex items-center">
<div class="max-w-full"> <div class="max-w-full">
<nuxt-link :to="`/item/${book.id}`" class="truncate block text-sm">{{ bookTitle }}</nuxt-link> <p class="truncate block text-sm">{{ bookTitle }}</p>
<p class="truncate block text-gray-400 text-xs">{{ bookAuthor }}</p> <p class="truncate block text-gray-400 text-xs">{{ bookAuthor }}</p>
<p class="text-xxs text-gray-500">{{ bookDuration }}</p> <p class="text-xxs text-gray-500">{{ bookDuration }}</p>
</div> </div>
</div> </div>
</div> </nuxt-link>
</div> </div>
</template> </template>
+18 -4
View File
@@ -26,7 +26,7 @@
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" /> <ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
<div v-if="!isIos && userCanDownload"> <div v-if="userCanDownload">
<span v-if="isLocal" class="material-icons-outlined px-2 text-success text-lg">audio_file</span> <span v-if="isLocal" class="material-icons-outlined px-2 text-success text-lg">audio_file</span>
<span v-else-if="!localEpisode" class="material-icons mx-1 mt-2" :class="downloadItem ? 'animate-bounce text-warning text-opacity-75 text-xl' : 'text-gray-300 text-xl'" @click="downloadClick">{{ downloadItem ? 'downloading' : 'download' }}</span> <span v-else-if="!localEpisode" class="material-icons mx-1 mt-2" :class="downloadItem ? 'animate-bounce text-warning text-opacity-75 text-xl' : 'text-gray-300 text-xl'" @click="downloadClick">{{ downloadItem ? 'downloading' : 'download' }}</span>
<span v-else class="material-icons px-2 text-success text-xl">download_done</span> <span v-else class="material-icons px-2 text-success text-xl">download_done</span>
@@ -143,7 +143,12 @@ export default {
}, },
downloadClick() { downloadClick() {
if (this.downloadItem) return if (this.downloadItem) return
this.download() if (this.isIos) {
// no local folders on iOS
this.startDownload()
} else {
this.download()
}
}, },
async download(selectedLocalFolder = null) { async download(selectedLocalFolder = null) {
var localFolder = selectedLocalFolder var localFolder = selectedLocalFolder
@@ -183,7 +188,14 @@ export default {
} }
}, },
async startDownload(localFolder) { async startDownload(localFolder) {
var downloadRes = await AbsDownloader.downloadLibraryItem({ libraryItemId: this.libraryItemId, localFolderId: localFolder.id, episodeId: this.episode.id }) var payload = {
libraryItemId: this.libraryItemId,
episodeId: this.episode.id
}
if (localFolder) {
payload.localFolderId = localFolder.id
}
var downloadRes = await AbsDownloader.downloadLibraryItem(payload)
if (downloadRes && downloadRes.error) { if (downloadRes && downloadRes.error) {
var errorMsg = downloadRes.error || 'Unknown error' var errorMsg = downloadRes.error || 'Unknown error'
console.error('Download error', errorMsg) console.error('Download error', errorMsg)
@@ -199,7 +211,9 @@ export default {
this.$eventBus.$emit('play-item', { this.$eventBus.$emit('play-item', {
libraryItemId: this.localLibraryItemId, libraryItemId: this.localLibraryItemId,
episodeId: this.localEpisode.id episodeId: this.localEpisode.id,
serverLibraryItemId: this.libraryItemId,
serverEpisodeId: this.episode.id
}) })
} else { } else {
this.$eventBus.$emit('play-item', { this.$eventBus.$emit('play-item', {
+1 -1
View File
@@ -46,7 +46,7 @@ export default {
episodesCopy: [], episodesCopy: [],
showFiltersModal: false, showFiltersModal: false,
sortKey: 'publishedAt', sortKey: 'publishedAt',
sortDesc: false, sortDesc: true,
filterKey: 'incomplete', filterKey: 'incomplete',
episodeSortItems: [ episodeSortItems: [
{ {
+126 -10
View File
@@ -35,7 +35,30 @@
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; }; 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; }; A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; };
C4D0677528106D0C00B8F875 /* DataClasses.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D0677428106D0C00B8F875 /* DataClasses.swift */; }; E9D5504628AC1A3900C746DD /* LibraryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504528AC1A3900C746DD /* LibraryItem.swift */; };
E9D5504828AC1A7A00C746DD /* MediaType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504728AC1A7A00C746DD /* MediaType.swift */; };
E9D5504A28AC1AA600C746DD /* Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504928AC1AA600C746DD /* Metadata.swift */; };
E9D5504C28AC1AE000C746DD /* PodcastEpisode.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504B28AC1AE000C746DD /* PodcastEpisode.swift */; };
E9D5504E28AC1B0700C746DD /* AudioFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504D28AC1B0700C746DD /* AudioFile.swift */; };
E9D5505028AC1B3E00C746DD /* Author.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504F28AC1B3E00C746DD /* Author.swift */; };
E9D5505228AC1B5D00C746DD /* Chapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5505128AC1B5D00C746DD /* Chapter.swift */; };
E9D5505428AC1B7900C746DD /* AudioTrack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5505328AC1B7900C746DD /* AudioTrack.swift */; };
E9D5505628AC1BFA00C746DD /* FileMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5505528AC1BFA00C746DD /* FileMetadata.swift */; };
E9D5505828AC1C1A00C746DD /* Library.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5505728AC1C1A00C746DD /* Library.swift */; };
E9D5505A28AC1C4500C746DD /* Folder.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5505928AC1C4500C746DD /* Folder.swift */; };
E9D5505C28AC1C6200C746DD /* LibraryFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5505B28AC1C6200C746DD /* LibraryFile.swift */; };
E9D5505E28AC1C8500C746DD /* MediaProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5505D28AC1C8500C746DD /* MediaProgress.swift */; };
E9D5506028AC1CA900C746DD /* PlaybackMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5505F28AC1CA900C746DD /* PlaybackMetadata.swift */; };
E9D5506228AC1CC900C746DD /* PlayerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5506128AC1CC900C746DD /* PlayerState.swift */; };
E9D5506628AC1D7300C746DD /* LocalLibraryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5506528AC1D7300C746DD /* LocalLibraryItem.swift */; };
E9D5506828AC1DC300C746DD /* LocalPodcastEpisode.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5506728AC1DC300C746DD /* LocalPodcastEpisode.swift */; };
E9D5506A28AC1DF100C746DD /* LocalFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5506928AC1DF100C746DD /* LocalFile.swift */; };
E9D5506C28AC1E2100C746DD /* LocalMediaProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5506B28AC1E2100C746DD /* LocalMediaProgress.swift */; };
E9D5506F28AC1E8E00C746DD /* DownloadItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5506E28AC1E8E00C746DD /* DownloadItem.swift */; };
E9D5507128AC1EC700C746DD /* DownloadItemPart.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5507028AC1EC700C746DD /* DownloadItemPart.swift */; };
E9D5507328AC218300C746DD /* DaoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5507228AC218300C746DD /* DaoExtensions.swift */; };
E9D5507528AEF93100C746DD /* PlayerSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5507428AEF93100C746DD /* PlayerSettings.swift */; };
E9E985F828B02D9400957F23 /* PlayerProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9E985F728B02D9400957F23 /* PlayerProgress.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
@@ -71,7 +94,30 @@
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; }; 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; }; AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; }; AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
C4D0677428106D0C00B8F875 /* DataClasses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataClasses.swift; sourceTree = "<group>"; }; E9D5504528AC1A3900C746DD /* LibraryItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryItem.swift; sourceTree = "<group>"; };
E9D5504728AC1A7A00C746DD /* MediaType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaType.swift; sourceTree = "<group>"; };
E9D5504928AC1AA600C746DD /* Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Metadata.swift; sourceTree = "<group>"; };
E9D5504B28AC1AE000C746DD /* PodcastEpisode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastEpisode.swift; sourceTree = "<group>"; };
E9D5504D28AC1B0700C746DD /* AudioFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFile.swift; sourceTree = "<group>"; };
E9D5504F28AC1B3E00C746DD /* Author.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Author.swift; sourceTree = "<group>"; };
E9D5505128AC1B5D00C746DD /* Chapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Chapter.swift; sourceTree = "<group>"; };
E9D5505328AC1B7900C746DD /* AudioTrack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioTrack.swift; sourceTree = "<group>"; };
E9D5505528AC1BFA00C746DD /* FileMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileMetadata.swift; sourceTree = "<group>"; };
E9D5505728AC1C1A00C746DD /* Library.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Library.swift; sourceTree = "<group>"; };
E9D5505928AC1C4500C746DD /* Folder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Folder.swift; sourceTree = "<group>"; };
E9D5505B28AC1C6200C746DD /* LibraryFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryFile.swift; sourceTree = "<group>"; };
E9D5505D28AC1C8500C746DD /* MediaProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaProgress.swift; sourceTree = "<group>"; };
E9D5505F28AC1CA900C746DD /* PlaybackMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackMetadata.swift; sourceTree = "<group>"; };
E9D5506128AC1CC900C746DD /* PlayerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerState.swift; sourceTree = "<group>"; };
E9D5506528AC1D7300C746DD /* LocalLibraryItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalLibraryItem.swift; sourceTree = "<group>"; };
E9D5506728AC1DC300C746DD /* LocalPodcastEpisode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalPodcastEpisode.swift; sourceTree = "<group>"; };
E9D5506928AC1DF100C746DD /* LocalFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFile.swift; sourceTree = "<group>"; };
E9D5506B28AC1E2100C746DD /* LocalMediaProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalMediaProgress.swift; sourceTree = "<group>"; };
E9D5506E28AC1E8E00C746DD /* DownloadItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadItem.swift; sourceTree = "<group>"; };
E9D5507028AC1EC700C746DD /* DownloadItemPart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadItemPart.swift; sourceTree = "<group>"; };
E9D5507228AC218300C746DD /* DaoExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DaoExtensions.swift; sourceTree = "<group>"; };
E9D5507428AEF93100C746DD /* PlayerSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSettings.swift; sourceTree = "<group>"; };
E9E985F728B02D9400957F23 /* PlayerProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerProgress.swift; sourceTree = "<group>"; };
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; }; FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@@ -100,6 +146,7 @@
children = ( children = (
3A200C1427D64D7E00CBF02E /* AudioPlayer.swift */, 3A200C1427D64D7E00CBF02E /* AudioPlayer.swift */,
3ABF618E2804325C0070250E /* PlayerHandler.swift */, 3ABF618E2804325C0070250E /* PlayerHandler.swift */,
E9E985F728B02D9400957F23 /* PlayerProgress.swift */,
); );
path = player; path = player;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -134,9 +181,14 @@
children = ( children = (
3AD4FCE828043FD7006DB301 /* ServerConnectionConfig.swift */, 3AD4FCE828043FD7006DB301 /* ServerConnectionConfig.swift */,
3ABF580828059BAE005DFBE5 /* PlaybackSession.swift */, 3ABF580828059BAE005DFBE5 /* PlaybackSession.swift */,
C4D0677428106D0C00B8F875 /* DataClasses.swift */,
3A90295E280968E700E1D427 /* PlaybackReport.swift */, 3A90295E280968E700E1D427 /* PlaybackReport.swift */,
E9D5505F28AC1CA900C746DD /* PlaybackMetadata.swift */,
E9D5506128AC1CC900C746DD /* PlayerState.swift */,
4DF74911287105C600AC7814 /* DeviceSettings.swift */, 4DF74911287105C600AC7814 /* DeviceSettings.swift */,
E9D5507428AEF93100C746DD /* PlayerSettings.swift */,
E9D5506328AC1D3F00C746DD /* server */,
E9D5506428AC1D5800C746DD /* local */,
E9D5506D28AC1E7400C746DD /* download */,
); );
path = models; path = models;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -150,6 +202,7 @@
3AF1970B2806E2590096F747 /* ApiClient.swift */, 3AF1970B2806E2590096F747 /* ApiClient.swift */,
3AB34052280829BF0039308B /* Extensions.swift */, 3AB34052280829BF0039308B /* Extensions.swift */,
3AB34054280832720039308B /* PlayerEvents.swift */, 3AB34054280832720039308B /* PlayerEvents.swift */,
E9D5507228AC218300C746DD /* DaoExtensions.swift */,
); );
path = util; path = util;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -199,6 +252,46 @@
name = Pods; name = Pods;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
E9D5506328AC1D3F00C746DD /* server */ = {
isa = PBXGroup;
children = (
E9D5504528AC1A3900C746DD /* LibraryItem.swift */,
E9D5504728AC1A7A00C746DD /* MediaType.swift */,
E9D5504928AC1AA600C746DD /* Metadata.swift */,
E9D5504B28AC1AE000C746DD /* PodcastEpisode.swift */,
E9D5504D28AC1B0700C746DD /* AudioFile.swift */,
E9D5504F28AC1B3E00C746DD /* Author.swift */,
E9D5505128AC1B5D00C746DD /* Chapter.swift */,
E9D5505328AC1B7900C746DD /* AudioTrack.swift */,
E9D5505528AC1BFA00C746DD /* FileMetadata.swift */,
E9D5505728AC1C1A00C746DD /* Library.swift */,
E9D5505928AC1C4500C746DD /* Folder.swift */,
E9D5505B28AC1C6200C746DD /* LibraryFile.swift */,
E9D5505D28AC1C8500C746DD /* MediaProgress.swift */,
);
path = server;
sourceTree = "<group>";
};
E9D5506428AC1D5800C746DD /* local */ = {
isa = PBXGroup;
children = (
E9D5506528AC1D7300C746DD /* LocalLibraryItem.swift */,
E9D5506728AC1DC300C746DD /* LocalPodcastEpisode.swift */,
E9D5506928AC1DF100C746DD /* LocalFile.swift */,
E9D5506B28AC1E2100C746DD /* LocalMediaProgress.swift */,
);
path = local;
sourceTree = "<group>";
};
E9D5506D28AC1E7400C746DD /* download */ = {
isa = PBXGroup;
children = (
E9D5506E28AC1E8E00C746DD /* DownloadItem.swift */,
E9D5507028AC1EC700C746DD /* DownloadItemPart.swift */,
);
path = download;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@@ -312,28 +405,51 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
E9D5507328AC218300C746DD /* DaoExtensions.swift in Sources */,
E9D5506228AC1CC900C746DD /* PlayerState.swift in Sources */,
3AD4FCE728043E72006DB301 /* AbsDatabase.m in Sources */, 3AD4FCE728043E72006DB301 /* AbsDatabase.m in Sources */,
504EC3081FED79650016851F /* AppDelegate.swift in Sources */, 504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
3A90295F280968E700E1D427 /* PlaybackReport.swift in Sources */, 3A90295F280968E700E1D427 /* PlaybackReport.swift in Sources */,
E9D5505A28AC1C4500C746DD /* Folder.swift in Sources */,
3ABF580928059BAE005DFBE5 /* PlaybackSession.swift in Sources */, 3ABF580928059BAE005DFBE5 /* PlaybackSession.swift in Sources */,
E9D5506628AC1D7300C746DD /* LocalLibraryItem.swift in Sources */,
E9D5504628AC1A3900C746DD /* LibraryItem.swift in Sources */,
3ABF618F2804325C0070250E /* PlayerHandler.swift in Sources */, 3ABF618F2804325C0070250E /* PlayerHandler.swift in Sources */,
3AD4FCED28044E6C006DB301 /* Store.swift in Sources */, 3AD4FCED28044E6C006DB301 /* Store.swift in Sources */,
4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */, 4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */,
E9D5504C28AC1AE000C746DD /* PodcastEpisode.swift in Sources */,
E9D5506A28AC1DF100C746DD /* LocalFile.swift in Sources */,
3AF1970E2806E3CA0096F747 /* AbsAudioPlayer.swift in Sources */, 3AF1970E2806E3CA0096F747 /* AbsAudioPlayer.swift in Sources */,
E9D5506F28AC1E8E00C746DD /* DownloadItem.swift in Sources */,
3AD4FCE928043FD7006DB301 /* ServerConnectionConfig.swift in Sources */, 3AD4FCE928043FD7006DB301 /* ServerConnectionConfig.swift in Sources */,
E9E985F828B02D9400957F23 /* PlayerProgress.swift in Sources */,
E9D5505E28AC1C8500C746DD /* MediaProgress.swift in Sources */,
3A200C1527D64D7E00CBF02E /* AudioPlayer.swift in Sources */, 3A200C1527D64D7E00CBF02E /* AudioPlayer.swift in Sources */,
E9D5507128AC1EC700C746DD /* DownloadItemPart.swift in Sources */,
4D66B956282EE951008272D4 /* AbsFileSystem.m in Sources */, 4D66B956282EE951008272D4 /* AbsFileSystem.m in Sources */,
3AFCB5E827EA240D00ECCC05 /* NowPlayingInfo.swift in Sources */, 3AFCB5E827EA240D00ECCC05 /* NowPlayingInfo.swift in Sources */,
3AB34053280829BF0039308B /* Extensions.swift in Sources */, 3AB34053280829BF0039308B /* Extensions.swift in Sources */,
E9D5505828AC1C1A00C746DD /* Library.swift in Sources */,
3AD4FCEB280443DD006DB301 /* Database.swift in Sources */, 3AD4FCEB280443DD006DB301 /* Database.swift in Sources */,
3AD4FCE528043E50006DB301 /* AbsDatabase.swift in Sources */, 3AD4FCE528043E50006DB301 /* AbsDatabase.swift in Sources */,
4D66B952282EE822008272D4 /* AbsDownloader.m in Sources */, 4D66B952282EE822008272D4 /* AbsDownloader.m in Sources */,
E9D5506828AC1DC300C746DD /* LocalPodcastEpisode.swift in Sources */,
E9D5505228AC1B5D00C746DD /* Chapter.swift in Sources */,
E9D5506028AC1CA900C746DD /* PlaybackMetadata.swift in Sources */,
E9D5504828AC1A7A00C746DD /* MediaType.swift in Sources */,
E9D5504E28AC1B0700C746DD /* AudioFile.swift in Sources */,
E9D5505428AC1B7900C746DD /* AudioTrack.swift in Sources */,
E9D5505C28AC1C6200C746DD /* LibraryFile.swift in Sources */,
4DF74912287105C600AC7814 /* DeviceSettings.swift in Sources */, 4DF74912287105C600AC7814 /* DeviceSettings.swift in Sources */,
E9D5504A28AC1AA600C746DD /* Metadata.swift in Sources */,
3AF197102806E3DC0096F747 /* AbsAudioPlayer.m in Sources */, 3AF197102806E3DC0096F747 /* AbsAudioPlayer.m in Sources */,
E9D5507528AEF93100C746DD /* PlayerSettings.swift in Sources */,
E9D5505028AC1B3E00C746DD /* Author.swift in Sources */,
3AF1970C2806E2590096F747 /* ApiClient.swift in Sources */, 3AF1970C2806E2590096F747 /* ApiClient.swift in Sources */,
C4D0677528106D0C00B8F875 /* DataClasses.swift in Sources */,
4D66B954282EE87C008272D4 /* AbsDownloader.swift in Sources */, 4D66B954282EE87C008272D4 /* AbsDownloader.swift in Sources */,
E9D5505628AC1BFA00C746DD /* FileMetadata.swift in Sources */,
3AB34055280832720039308B /* PlayerEvents.swift in Sources */, 3AB34055280832720039308B /* PlayerEvents.swift in Sources */,
E9D5506C28AC1E2100C746DD /* LocalMediaProgress.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -479,12 +595,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = Icons; ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 12; CURRENT_PROJECT_VERSION = 14;
DEVELOPMENT_TEAM = 7UFJ7D8V6A; DEVELOPMENT_TEAM = 7UFJ7D8V6A;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 12.0; IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.9.55; MARKETING_VERSION = 0.9.57;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev; PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -503,12 +619,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = Icons; ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 12; CURRENT_PROJECT_VERSION = 14;
DEVELOPMENT_TEAM = 7UFJ7D8V6A; DEVELOPMENT_TEAM = 7UFJ7D8V6A;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 12.0; IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.9.55; MARKETING_VERSION = 0.9.57;
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app; PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
+13 -1
View File
@@ -11,7 +11,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
// Override point for customization after application launch. // Override point for customization after application launch.
let configuration = Realm.Configuration( let configuration = Realm.Configuration(
schemaVersion: 1, schemaVersion: 4,
migrationBlock: { migration, oldSchemaVersion in migrationBlock: { migration, oldSchemaVersion in
if (oldSchemaVersion < 1) { if (oldSchemaVersion < 1) {
NSLog("Realm schema version was \(oldSchemaVersion)") NSLog("Realm schema version was \(oldSchemaVersion)")
@@ -19,6 +19,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
newObject?["enableAltView"] = false newObject?["enableAltView"] = false
} }
} }
if (oldSchemaVersion < 4) {
NSLog("Realm schema version was \(oldSchemaVersion)... Reindexing server configs")
var indexCounter = 1
migration.enumerateObjects(ofType: ServerConnectionConfig.className()) { oldObject, newObject in
newObject?["index"] = indexCounter
indexCounter += 1
}
}
} }
) )
Realm.Configuration.defaultConfiguration = configuration Realm.Configuration.defaultConfiguration = configuration
@@ -34,18 +42,22 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
func applicationDidEnterBackground(_ application: UIApplication) { func applicationDidEnterBackground(_ application: UIApplication) {
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
NSLog("Audiobookself is now in the background")
} }
func applicationWillEnterForeground(_ application: UIApplication) { func applicationWillEnterForeground(_ application: UIApplication) {
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
NSLog("Audiobookself is now in the foreground")
} }
func applicationDidBecomeActive(_ application: UIApplication) { func applicationDidBecomeActive(_ application: UIApplication) {
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
NSLog("Audiobookself is now active")
} }
func applicationWillTerminate(_ application: UIApplication) { func applicationWillTerminate(_ application: UIApplication) {
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
NSLog("Audiobookself is terminating")
} }
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
+2
View File
@@ -9,6 +9,8 @@
#import <Capacitor/Capacitor.h> #import <Capacitor/Capacitor.h>
CAP_PLUGIN(AbsAudioPlayer, "AbsAudioPlayer", CAP_PLUGIN(AbsAudioPlayer, "AbsAudioPlayer",
CAP_PLUGIN_METHOD(onReady, CAPPluginReturnNone);
CAP_PLUGIN_METHOD(prepareLibraryItem, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(prepareLibraryItem, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(closePlayback, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(closePlayback, CAPPluginReturnPromise);
+111 -43
View File
@@ -7,11 +7,12 @@
import Foundation import Foundation
import Capacitor import Capacitor
import RealmSwift
@objc(AbsAudioPlayer) @objc(AbsAudioPlayer)
public class AbsAudioPlayer: CAPPlugin { public class AbsAudioPlayer: CAPPlugin {
private var initialPlayWhenReady = false private var initialPlayWhenReady = false
private var initialPlaybackRate:Float = 1 private var isUIReady = false
override public func load() { override public func load() {
NotificationCenter.default.addObserver(self, selector: #selector(sendMetadata), name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil) NotificationCenter.default.addObserver(self, selector: #selector(sendMetadata), name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil)
@@ -21,10 +22,43 @@ public class AbsAudioPlayer: CAPPlugin {
NotificationCenter.default.addObserver(self, selector: #selector(sendSleepTimerSet), name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil) NotificationCenter.default.addObserver(self, selector: #selector(sendSleepTimerSet), name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(sendSleepTimerEnded), name: NSNotification.Name(PlayerEvents.sleepEnded.rawValue), object: nil) NotificationCenter.default.addObserver(self, selector: #selector(sendSleepTimerEnded), name: NSNotification.Name(PlayerEvents.sleepEnded.rawValue), object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(onPlaybackFailed), name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil) NotificationCenter.default.addObserver(self, selector: #selector(onPlaybackFailed), name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(onLocalMediaProgressUpdate), name: NSNotification.Name(PlayerEvents.localProgress.rawValue), object: nil)
self.bridge?.webView?.allowsBackForwardNavigationGestures = true; self.bridge?.webView?.allowsBackForwardNavigationGestures = true;
} }
@objc func onReady(_ call: CAPPluginCall) {
Task { await self.restorePlaybackSession() }
}
func restorePlaybackSession() async {
// We don't need to restore if we have an active session
guard PlayerHandler.getPlaybackSession() == nil else { return }
do {
// Fetch the most recent active session
let activeSession = try await Realm().objects(PlaybackSession.self).where({
$0.isActiveSession == true && $0.serverConnectionConfigId == Store.serverConfig?.id
}).last?.freeze()
if let activeSession = activeSession {
await PlayerProgress.shared.syncFromServer()
try self.startPlaybackSession(activeSession, playWhenReady: false, playbackRate: PlayerSettings.main().playbackRate)
}
} catch {
NSLog("Failed to restore playback session")
debugPrint(error)
}
}
@objc func startPlaybackSession(_ session: PlaybackSession, playWhenReady: Bool, playbackRate: Float) throws {
guard let libraryItemId = session.libraryItemId else { throw PlayerError.libraryItemIdNotSpecified }
self.sendPrepareMetadataEvent(itemId: libraryItemId, playWhenReady: playWhenReady)
self.sendPlaybackSession(session: try session.asDictionary())
PlayerHandler.startPlayback(sessionId: session.id, playWhenReady: playWhenReady, playbackRate: playbackRate)
self.sendMetadata()
}
@objc func prepareLibraryItem(_ call: CAPPluginCall) { @objc func prepareLibraryItem(_ call: CAPPluginCall) {
let libraryItemId = call.getString("libraryItemId") let libraryItemId = call.getString("libraryItemId")
@@ -36,32 +70,42 @@ public class AbsAudioPlayer: CAPPlugin {
NSLog("provide library item id") NSLog("provide library item id")
return call.resolve() return call.resolve()
} }
if libraryItemId!.starts(with: "local") {
NSLog("local items are not implemnted")
return call.resolve()
}
initialPlayWhenReady = playWhenReady
initialPlaybackRate = playbackRate
PlayerHandler.stopPlayback() PlayerHandler.stopPlayback()
sendPrepareMetadataEvent(itemId: libraryItemId!, playWhenReady: playWhenReady) let isLocalItem = libraryItemId?.starts(with: "local_") ?? false
ApiClient.startPlaybackSession(libraryItemId: libraryItemId!, episodeId: episodeId, forceTranscode: false) { session in if (isLocalItem) {
let item = Database.shared.getLocalLibraryItem(localLibraryItemId: libraryItemId!)
let episode = item?.getPodcastEpisode(episodeId: episodeId)
guard let playbackSession = item?.getPlaybackSession(episode: episode) else {
NSLog("Failed to get local playback session")
return call.resolve([:])
}
do { do {
self.sendPlaybackSession(session: try session.asDictionary()) try playbackSession.save()
call.resolve(try session.asDictionary()) try self.startPlaybackSession(playbackSession, playWhenReady: playWhenReady, playbackRate: playbackRate)
call.resolve(try playbackSession.asDictionary())
} catch(let exception) { } catch(let exception) {
NSLog("failed to convert session to json") NSLog("Failed to start session")
debugPrint(exception) debugPrint(exception)
call.resolve([:]) call.resolve([:])
} }
} else { // Playing from the server
ApiClient.startPlaybackSession(libraryItemId: libraryItemId!, episodeId: episodeId, forceTranscode: false) { session in
PlayerHandler.startPlayback(session: session, playWhenReady: playWhenReady, playbackRate: playbackRate) do {
self.sendMetadata() try session.save()
try self.startPlaybackSession(session, playWhenReady: playWhenReady, playbackRate: playbackRate)
call.resolve(try session.asDictionary())
} catch(let exception) {
NSLog("Failed to start session")
debugPrint(exception)
call.resolve([:])
}
}
} }
} }
@objc func closePlayback(_ call: CAPPluginCall) { @objc func closePlayback(_ call: CAPPluginCall) {
NSLog("Close playback") NSLog("Close playback")
@@ -76,7 +120,12 @@ public class AbsAudioPlayer: CAPPlugin {
]) ])
} }
@objc func setPlaybackSpeed(_ call: CAPPluginCall) { @objc func setPlaybackSpeed(_ call: CAPPluginCall) {
PlayerHandler.setPlaybackSpeed(speed: call.getFloat("value", 1.0)) let playbackRate = call.getFloat("value", 1.0)
let settings = PlayerSettings.main()
try? settings.update {
settings.playbackRate = playbackRate
}
PlayerHandler.setPlaybackSpeed(speed: settings.playbackRate)
call.resolve() call.resolve()
} }
@@ -109,7 +158,9 @@ public class AbsAudioPlayer: CAPPlugin {
@objc func sendMetadata() { @objc func sendMetadata() {
self.notifyListeners("onPlayingUpdate", data: [ "value": !PlayerHandler.paused ]) self.notifyListeners("onPlayingUpdate", data: [ "value": !PlayerHandler.paused ])
self.notifyListeners("onMetadata", data: PlayerHandler.getMetdata()) if let metadata = PlayerHandler.getMetdata() {
self.notifyListeners("onMetadata", data: metadata)
}
} }
@objc func sendPlaybackClosedEvent() { @objc func sendPlaybackClosedEvent() {
self.notifyListeners("onPlaybackClosed", data: [ "value": true ]) self.notifyListeners("onPlaybackClosed", data: [ "value": true ])
@@ -117,43 +168,47 @@ public class AbsAudioPlayer: CAPPlugin {
@objc func decreaseSleepTime(_ call: CAPPluginCall) { @objc func decreaseSleepTime(_ call: CAPPluginCall) {
guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) } guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) }
guard let time = Int(timeString) else { return call.resolve([ "success": false ]) } guard let time = Double(timeString) else { return call.resolve([ "success": false ]) }
guard let currentSleepTime = PlayerHandler.remainingSleepTime else { return call.resolve([ "success": false ]) } guard let _ = PlayerHandler.remainingSleepTime else { return call.resolve([ "success": false ]) }
PlayerHandler.remainingSleepTime = currentSleepTime - (time / 1000) let seconds = time/1000
PlayerHandler.decreaseSleepTime(decreaseSeconds: seconds)
call.resolve() call.resolve()
} }
@objc func increaseSleepTime(_ call: CAPPluginCall) { @objc func increaseSleepTime(_ call: CAPPluginCall) {
guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) } guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) }
guard let time = Int(timeString) else { return call.resolve([ "success": false ]) } guard let time = Double(timeString) else { return call.resolve([ "success": false ]) }
guard let currentSleepTime = PlayerHandler.remainingSleepTime else { return call.resolve([ "success": false ]) } guard let _ = PlayerHandler.remainingSleepTime else { return call.resolve([ "success": false ]) }
PlayerHandler.remainingSleepTime = currentSleepTime + (time / 1000) let seconds = time/1000
PlayerHandler.increaseSleepTime(increaseSeconds: seconds)
call.resolve() call.resolve()
} }
@objc func setSleepTimer(_ call: CAPPluginCall) { @objc func setSleepTimer(_ call: CAPPluginCall) {
guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) } guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) }
guard let time = Int(timeString) else { return call.resolve([ "success": false ]) } guard let time = Int(timeString) else { return call.resolve([ "success": false ]) }
let isChapterTime = call.getBool("isChapterTime", false)
NSLog("chapter time: \(call.getBool("isChapterTime", false))") let seconds = time / 1000
if call.getBool("isChapterTime", false) { NSLog("chapter time: \(isChapterTime)")
let timeToPause = time / 1000 - Int(PlayerHandler.getCurrentTime() ?? 0) if isChapterTime {
if timeToPause < 0 { return call.resolve([ "success": false ]) } PlayerHandler.setChapterSleepTime(stopAt: Double(seconds))
NSLog("oof \(timeToPause)")
PlayerHandler.remainingSleepTime = timeToPause
return call.resolve([ "success": true ]) return call.resolve([ "success": true ])
} }
PlayerHandler.remainingSleepTime = time / 1000 PlayerHandler.setSleepTime(secondsUntilSleep: Double(seconds))
call.resolve([ "success": true ]) call.resolve([ "success": true ])
} }
@objc func cancelSleepTimer(_ call: CAPPluginCall) { @objc func cancelSleepTimer(_ call: CAPPluginCall) {
PlayerHandler.remainingSleepTime = nil PlayerHandler.cancelSleepTime()
PlayerHandler.sleepTimerChapterStopTime = nil
call.resolve() call.resolve()
} }
@objc func getSleepTimerTime(_ call: CAPPluginCall) { @objc func getSleepTimerTime(_ call: CAPPluginCall) {
call.resolve([ call.resolve([
"value": PlayerHandler.remainingSleepTime "value": PlayerHandler.remainingSleepTime
@@ -165,31 +220,39 @@ public class AbsAudioPlayer: CAPPlugin {
"value": PlayerHandler.getCurrentTime() "value": PlayerHandler.getCurrentTime()
]) ])
} }
@objc func sendSleepTimerSet() { @objc func sendSleepTimerSet() {
self.notifyListeners("onSleepTimerSet", data: [ self.notifyListeners("onSleepTimerSet", data: [
"value": PlayerHandler.remainingSleepTime "value": PlayerHandler.remainingSleepTime
]) ])
} }
@objc func onLocalMediaProgressUpdate() {
guard let localMediaProgressId = PlayerHandler.getPlaybackSession()?.localMediaProgressId else { return }
guard let localMediaProgress = Database.shared.getLocalMediaProgress(localMediaProgressId: localMediaProgressId) else { return }
guard let progressUpdate = try? localMediaProgress.asDictionary() else { return }
NSLog("Sending local progress back to the UI")
self.notifyListeners("onLocalMediaProgressUpdate", data: progressUpdate)
}
@objc func onPlaybackFailed() { @objc func onPlaybackFailed() {
if (PlayerHandler.getPlayMethod() == PlayMethod.directplay.rawValue) { if (PlayerHandler.getPlayMethod() == PlayMethod.directplay.rawValue) {
let playbackSession = PlayerHandler.getPlaybackSession() let session = PlayerHandler.getPlaybackSession()
let libraryItemId = playbackSession?.libraryItemId ?? "" let libraryItemId = session?.libraryItemId ?? ""
let episodeId = playbackSession?.episodeId ?? nil let episodeId = session?.episodeId ?? nil
NSLog("Forcing Transcode") NSLog("Forcing Transcode")
// If direct playing then fallback to transcode // If direct playing then fallback to transcode
ApiClient.startPlaybackSession(libraryItemId: libraryItemId, episodeId: episodeId, forceTranscode: true) { session in ApiClient.startPlaybackSession(libraryItemId: libraryItemId, episodeId: episodeId, forceTranscode: true) { session in
PlayerHandler.startPlayback(session: session, playWhenReady: self.initialPlayWhenReady, playbackRate: self.initialPlaybackRate)
do { do {
try session.save()
PlayerHandler.startPlayback(sessionId: session.id, playWhenReady: self.initialPlayWhenReady, playbackRate: PlayerSettings.main().playbackRate)
self.sendPlaybackSession(session: try session.asDictionary()) self.sendPlaybackSession(session: try session.asDictionary())
self.sendMetadata()
} catch(let exception) { } catch(let exception) {
NSLog("failed to convert session to json") NSLog("Failed to start transcoded session")
debugPrint(exception) debugPrint(exception)
} }
self.sendMetadata()
} }
} else { } else {
self.notifyListeners("onPlaybackFailed", data: [ self.notifyListeners("onPlaybackFailed", data: [
@@ -205,7 +268,12 @@ public class AbsAudioPlayer: CAPPlugin {
"playWhenReady": playWhenReady, "playWhenReady": playWhenReady,
]) ])
} }
@objc func sendPlaybackSession(session: [String: Any]) { @objc func sendPlaybackSession(session: [String: Any]) {
self.notifyListeners("onPlaybackSession", data: session) self.notifyListeners("onPlaybackSession", data: session)
} }
} }
enum PlayerError: String, Error {
case libraryItemIdNotSpecified = "No libraryItemId provided on session"
}
+4 -1
View File
@@ -20,6 +20,9 @@ CAP_PLUGIN(AbsDatabase, "AbsDatabase",
CAP_PLUGIN_METHOD(getLocalLibraryItemByLId, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(getLocalLibraryItemByLId, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(getLocalLibraryItemsInFolder, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(getLocalLibraryItemsInFolder, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(getAllLocalMediaProgress, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(getAllLocalMediaProgress, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(removeLocalMediaProgress, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(syncLocalMediaProgressWithServer, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(syncServerMediaProgressWithLocalMediaProgress, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(updateLocalMediaProgressFinished, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(updateDeviceSettings, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(updateDeviceSettings, CAPPluginReturnPromise);
) )
+147 -7
View File
@@ -8,6 +8,7 @@
import Foundation import Foundation
import Capacitor import Capacitor
import RealmSwift import RealmSwift
import SwiftUI
extension String { extension String {
@@ -35,13 +36,14 @@ public class AbsDatabase: CAPPlugin {
let token = call.getString("token", "") let token = call.getString("token", "")
let name = "\(address) (\(username))" let name = "\(address) (\(username))"
let config = ServerConnectionConfig()
if id == nil { if id == nil {
id = "\(address)@\(username)".toBase64() id = "\(address)@\(username)".toBase64()
} }
config.id = id! let config = ServerConnectionConfig()
config.id = id ?? ""
config.index = 0
config.name = name config.name = name
config.address = address config.address = address
config.userId = userId config.userId = userId
@@ -49,7 +51,8 @@ public class AbsDatabase: CAPPlugin {
config.token = token config.token = token
Store.serverConfig = config Store.serverConfig = config
call.resolve(convertServerConnectionConfigToJSON(config: config)) let savedConfig = Store.serverConfig // Fetch the latest value
call.resolve(convertServerConnectionConfigToJSON(config: savedConfig!))
} }
@objc func removeServerConnectionConfig(_ call: CAPPluginCall) { @objc func removeServerConnectionConfig(_ call: CAPPluginCall) {
let id = call.getString("serverConnectionConfigId", "") let id = call.getString("serverConnectionConfigId", "")
@@ -75,20 +78,157 @@ public class AbsDatabase: CAPPlugin {
} }
@objc func getLocalLibraryItems(_ call: CAPPluginCall) { @objc func getLocalLibraryItems(_ call: CAPPluginCall) {
call.resolve([ "value": [] ]) do {
let items = Database.shared.getLocalLibraryItems()
call.resolve([ "value": try items.asDictionaryArray()])
} catch(let exception) {
NSLog("error while readling local library items")
debugPrint(exception)
call.resolve()
}
} }
@objc func getLocalLibraryItem(_ call: CAPPluginCall) { @objc func getLocalLibraryItem(_ call: CAPPluginCall) {
call.resolve() do {
let item = Database.shared.getLocalLibraryItem(localLibraryItemId: call.getString("id") ?? "")
switch item {
case .some(let foundItem):
call.resolve(try foundItem.asDictionary())
default:
call.resolve()
}
} catch(let exception) {
NSLog("error while readling local library items")
debugPrint(exception)
call.resolve()
}
} }
@objc func getLocalLibraryItemByLId(_ call: CAPPluginCall) { @objc func getLocalLibraryItemByLId(_ call: CAPPluginCall) {
call.resolve() do {
let item = Database.shared.getLocalLibraryItem(byServerLibraryItemId: call.getString("libraryItemId") ?? "")
switch item {
case .some(let foundItem):
call.resolve(try foundItem.asDictionary())
default:
call.resolve()
}
} catch(let exception) {
NSLog("error while readling local library items")
debugPrint(exception)
call.resolve()
}
} }
@objc func getLocalLibraryItemsInFolder(_ call: CAPPluginCall) { @objc func getLocalLibraryItemsInFolder(_ call: CAPPluginCall) {
call.resolve([ "value": [] ]) call.resolve([ "value": [] ])
} }
@objc func getAllLocalMediaProgress(_ call: CAPPluginCall) { @objc func getAllLocalMediaProgress(_ call: CAPPluginCall) {
call.resolve([ "value": [] ]) do {
call.resolve([ "value": try Database.shared.getAllLocalMediaProgress().asDictionaryArray() ])
} catch {
NSLog("Error while loading local media progress")
debugPrint(error)
call.resolve(["value": []])
}
} }
@objc func removeLocalMediaProgress(_ call: CAPPluginCall) {
let localMediaProgressId = call.getString("localMediaProgressId")
guard let localMediaProgressId = localMediaProgressId else {
call.reject("localMediaProgressId not specificed")
return
}
try? Database.shared.removeLocalMediaProgress(localMediaProgressId: localMediaProgressId)
call.resolve()
}
@objc func syncLocalMediaProgressWithServer(_ call: CAPPluginCall) {
guard Store.serverConfig != nil else {
call.reject("syncLocalMediaProgressWithServer not connected to server")
return
}
ApiClient.syncMediaProgress { results in
do {
call.resolve(try results.asDictionary())
} catch {
call.reject("Failed to report synced media progress")
}
}
}
@objc func syncServerMediaProgressWithLocalMediaProgress(_ call: CAPPluginCall) {
let serverMediaProgress = call.getJson("mediaProgress", type: MediaProgress.self)
let localLibraryItemId = call.getString("localLibraryItemId")
let localEpisodeId = call.getString("localEpisodeId")
let localMediaProgressId = call.getString("localMediaProgressId")
do {
guard let serverMediaProgress = serverMediaProgress else {
return call.reject("serverMediaProgress not specified")
}
guard localLibraryItemId != nil || localMediaProgressId != nil else {
return call.reject("localLibraryItemId or localMediaProgressId must be specified")
}
let localMediaProgress = try LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localMediaProgressId, localLibraryItemId: localLibraryItemId, localEpisodeId: localEpisodeId)
guard let localMediaProgress = localMediaProgress else {
call.reject("Local media progress not found or created")
return
}
NSLog("syncServerMediaProgressWithLocalMediaProgress: Saving local media progress")
try localMediaProgress.updateFromServerMediaProgress(serverMediaProgress)
call.resolve(try localMediaProgress.asDictionary())
} catch {
call.reject("Failed to sync media progress")
debugPrint(error)
}
}
@objc func updateLocalMediaProgressFinished(_ call: CAPPluginCall) {
let localLibraryItemId = call.getString("localLibraryItemId")
let localEpisodeId = call.getString("localEpisodeId")
let localMediaProgressId = call.getString("localMediaProgressId")
let isFinished = call.getBool("isFinished", false)
NSLog("updateLocalMediaProgressFinished \(localMediaProgressId ?? "Unknown") | Is Finished: \(isFinished)")
do {
let localMediaProgress = try LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localMediaProgressId, localLibraryItemId: localLibraryItemId, localEpisodeId: localEpisodeId)
guard let localMediaProgress = localMediaProgress else {
call.resolve(["error": "Library Item not found"])
return
}
// Update finished status
try localMediaProgress.updateIsFinished(isFinished)
// Build API response
let progressDictionary = try? localMediaProgress.asDictionary()
var response: [String: Any] = ["local": true, "server": false, "localMediaProgress": progressDictionary ?? ""]
// Send update to the server if logged in
let hasLinkedServer = localMediaProgress.serverConnectionConfigId != nil
let loggedIntoServer = Store.serverConfig?.id == localMediaProgress.serverConnectionConfigId
if hasLinkedServer && loggedIntoServer {
response["server"] = true
let payload = ["isFinished": isFinished]
ApiClient.updateMediaProgress(libraryItemId: localMediaProgress.libraryItemId!, episodeId: localEpisodeId, payload: payload) {
call.resolve(response)
}
} else {
call.resolve(response)
}
} catch {
debugPrint(error)
call.resolve(["error": "Failed to mark as complete"])
return
}
}
@objc func updateDeviceSettings(_ call: CAPPluginCall) { @objc func updateDeviceSettings(_ call: CAPPluginCall) {
let disableAutoRewind = call.getBool("disableAutoRewind") ?? false let disableAutoRewind = call.getBool("disableAutoRewind") ?? false
let enableAltView = call.getBool("enableAltView") ?? false let enableAltView = call.getBool("enableAltView") ?? false
+372 -55
View File
@@ -7,65 +7,382 @@
import Foundation import Foundation
import Capacitor import Capacitor
import RealmSwift
@objc(AbsDownloader) @objc(AbsDownloader)
public class AbsDownloader: CAPPlugin { public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
@objc func downloadLibraryItem(_ call: CAPPluginCall) {
let libraryItemId = call.getString("libraryItemId") static private let downloadsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let episodeId = call.getString("episodeId")
private lazy var session: URLSession = {
NSLog("Download library item \(libraryItemId ?? "N/A") episode \(episodeId ?? "")") let queue = OperationQueue()
queue.maxConcurrentOperationCount = 5
ApiClient.getLibraryItemWithProgress(libraryItemId: libraryItemId!, episodeId: episodeId) { libraryItem in return URLSession(configuration: .default, delegate: self, delegateQueue: queue)
if (libraryItem == nil) { }()
NSLog("Library item not found") private let progressStatusQueue = DispatchQueue(label: "progress-status-queue", attributes: .concurrent)
call.resolve() private var downloadItemProgress = [String: DownloadItem]()
} else { private var monitoringProgressTimer: Timer?
NSLog("Got library item \(libraryItem!)")
// TODO: break out in seperate functions // MARK: - Progress handling
libraryItem!.media.tracks?.forEach { track in
NSLog("TRACK \(track.contentUrl!)") public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
// filename needs to be encoded otherwise would just use contentUrl handleDownloadTaskUpdate(downloadTask: downloadTask) { downloadItem, downloadItemPart in
let filename = track.metadata?.filename ?? "" let realm = try Realm()
let filenameEncoded = filename.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed) try realm.write {
let urlstr = "\(Store.serverConfig!.address)/s/item/\(libraryItemId!)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)" downloadItemPart.progress = 100
let url = URL(string: urlstr)! downloadItemPart.completed = true
}
let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let itemDirectory = documentsDirectory.appendingPathComponent("\(libraryItemId!)")
NSLog("ITEM DIR \(itemDirectory)")
// Create library item directory
do {
try FileManager.default.createDirectory(at: itemDirectory, withIntermediateDirectories: false)
} catch {
NSLog("Failed to CREATE LI DIRECTORY \(error)")
}
// Output filename
let trackFilename = itemDirectory.appendingPathComponent("\(filename)")
let downloadTask = URLSession.shared.downloadTask(with: url) { urlOrNil, responseOrNil, errorOrNil in
guard let fileURL = urlOrNil else { return }
do {
NSLog("Download TMP file URL \(fileURL)")
let imageData = try Data(contentsOf:fileURL)
try imageData.write(to: trackFilename)
NSLog("Download written to \(trackFilename)")
} catch {
NSLog("FILE ERROR: \(error)")
}
}
downloadTask.resume()
}
do {
call.resolve() // Move the downloaded file into place
guard let destinationUrl = downloadItemPart.destinationURL else {
throw LibraryItemDownloadError.downloadItemPartDestinationUrlNotDefined
}
try? FileManager.default.removeItem(at: destinationUrl)
try FileManager.default.moveItem(at: location, to: destinationUrl)
try realm.write {
downloadItemPart.moved = true
}
} catch {
try realm.write {
downloadItemPart.failed = true
}
throw error
} }
} }
} }
public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
handleDownloadTaskUpdate(downloadTask: task) { downloadItem, downloadItemPart in
if let error = error {
try Realm().write {
downloadItemPart.completed = true
downloadItemPart.failed = true
}
throw error
}
}
}
public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
handleDownloadTaskUpdate(downloadTask: downloadTask) { downloadItem, downloadItemPart in
// Calculate the download percentage
let percentDownloaded = (Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)) * 100
// Only update the progress if we received accurate progress data
if percentDownloaded >= 0.0 && percentDownloaded <= 100.0 {
try Realm().write {
downloadItemPart.progress = percentDownloaded
}
}
}
}
private func handleDownloadTaskUpdate(downloadTask: URLSessionTask, progressHandler: DownloadProgressHandler) {
do {
guard let downloadItemPartId = downloadTask.taskDescription else { throw LibraryItemDownloadError.noTaskDescription }
NSLog("Received download update for \(downloadItemPartId)")
// Find the download item
let downloadItem = Database.shared.getDownloadItem(downloadItemPartId: downloadItemPartId)
guard var downloadItem = downloadItem else { throw LibraryItemDownloadError.downloadItemNotFound }
// Find the download item part
let part = downloadItem.downloadItemParts.first(where: { $0.id == downloadItemPartId })
guard let part = part else { throw LibraryItemDownloadError.downloadItemPartNotFound }
// Call the progress handler
do {
try progressHandler(downloadItem, part)
} catch {
NSLog("Error while processing progress")
debugPrint(error)
}
// Update the progress
downloadItem = downloadItem.freeze()
self.progressStatusQueue.async(flags: .barrier) {
self.downloadItemProgress.updateValue(downloadItem, forKey: downloadItem.id!)
}
self.notifyDownloadProgress()
} catch {
NSLog("DownloadItemError")
debugPrint(error)
}
}
// We want to handle updating the UI in the background and throttled so we don't overload the UI with progress updates
private func notifyDownloadProgress() {
if self.monitoringProgressTimer?.isValid ?? false {
NSLog("Already monitoring progress, no need to start timer again")
} else {
DispatchQueue.runOnMainQueue {
self.monitoringProgressTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true, block: { t in
NSLog("Starting monitoring download progress...")
// Fetch active downloads in a thread-safe way
func fetchActiveDownloads() -> [String: DownloadItem]? {
self.progressStatusQueue.sync {
let activeDownloads = self.downloadItemProgress
if activeDownloads.isEmpty {
NSLog("Finishing monitoring download progress...")
t.invalidate()
}
return activeDownloads
}
}
// Remove a completed download item in a thread-safe way
func handleDoneDownloadItem(_ item: DownloadItem) {
self.progressStatusQueue.async(flags: .barrier) {
self.downloadItemProgress.removeValue(forKey: item.id!)
}
self.handleDownloadTaskCompleteFromDownloadItem(item)
if let item = Database.shared.getDownloadItem(downloadItemId: item.id!) {
try? item.delete()
}
}
// Emit status for active downloads
if let activeDownloads = fetchActiveDownloads() {
for item in activeDownloads.values {
try? self.notifyListeners("onItemDownloadUpdate", data: item.asDictionary())
if item.isDoneDownloading() { handleDoneDownloadItem(item) }
}
}
})
}
}
}
private func handleDownloadTaskCompleteFromDownloadItem(_ downloadItem: DownloadItem) {
var statusNotification = [String: Any]()
statusNotification["libraryItemId"] = downloadItem.id
if ( downloadItem.didDownloadSuccessfully() ) {
ApiClient.getLibraryItemWithProgress(libraryItemId: downloadItem.libraryItemId!, episodeId: downloadItem.episodeId) { libraryItem in
guard let libraryItem = libraryItem else { NSLog("LibraryItem not found"); return }
let localDirectory = libraryItem.id
var coverFile: String?
// Assemble the local library item
let files = downloadItem.downloadItemParts.enumerated().compactMap { _, part -> LocalFile? in
var mimeType = part.mimeType()
if part.filename == "cover.jpg" {
coverFile = part.destinationUri
mimeType = "image/jpg"
}
return LocalFile(libraryItem.id, part.filename!, mimeType!, part.destinationUri!, fileSize: Int(part.destinationURL!.fileSize))
}
var localLibraryItem = Database.shared.getLocalLibraryItem(byServerLibraryItemId: libraryItem.id)
if (localLibraryItem != nil && localLibraryItem!.isPodcast) {
try? Realm().write {
try? localLibraryItem?.addFiles(files, item: libraryItem)
}
} else {
localLibraryItem = LocalLibraryItem(libraryItem, localUrl: localDirectory, server: Store.serverConfig!, files: files, coverPath: coverFile)
try? Database.shared.saveLocalLibraryItem(localLibraryItem: localLibraryItem!)
}
statusNotification["localLibraryItem"] = try? localLibraryItem.asDictionary()
if let progress = libraryItem.userMediaProgress {
let episode = downloadItem.media?.episodes.first(where: { $0.id == downloadItem.episodeId })
let localMediaProgress = LocalMediaProgress(localLibraryItem: localLibraryItem!, episode: episode, progress: progress)
try? localMediaProgress.save()
statusNotification["localMediaProgress"] = try? localMediaProgress.asDictionary()
}
self.notifyListeners("onItemDownloadComplete", data: statusNotification)
}
} else {
self.notifyListeners("onItemDownloadComplete", data: statusNotification)
}
}
// MARK: - Capacitor functions
@objc func downloadLibraryItem(_ call: CAPPluginCall) {
let libraryItemId = call.getString("libraryItemId")
var episodeId = call.getString("episodeId")
if ( episodeId == "null" ) { episodeId = nil }
NSLog("Download library item \(libraryItemId ?? "N/A") / episode \(episodeId ?? "N/A")")
guard let libraryItemId = libraryItemId else { return call.resolve(["error": "libraryItemId not specified"]) }
ApiClient.getLibraryItemWithProgress(libraryItemId: libraryItemId, episodeId: episodeId) { libraryItem in
if let libraryItem = libraryItem {
NSLog("Got library item from server \(libraryItem.id)")
do {
if let episodeId = episodeId {
// Download a podcast episode
guard libraryItem.mediaType == "podcast" else { throw LibraryItemDownloadError.libraryItemNotPodcast }
let episode = libraryItem.media?.episodes.enumerated().first(where: { $1.id == episodeId })?.element
guard let episode = episode else { throw LibraryItemDownloadError.podcastEpisodeNotFound }
try self.startLibraryItemDownload(libraryItem, episode: episode)
} else {
// Download a book
try self.startLibraryItemDownload(libraryItem)
}
call.resolve()
} catch {
debugPrint(error)
call.resolve(["error": "Failed to download"])
}
} else {
call.resolve(["error": "Server request failed"])
}
}
}
private func startLibraryItemDownload(_ item: LibraryItem) throws {
try startLibraryItemDownload(item, episode: nil)
}
private func startLibraryItemDownload(_ item: LibraryItem, episode: PodcastEpisode?) throws {
let tracks = List<AudioTrack>()
var episodeId: String?
// Handle the different media type downloads
switch item.mediaType {
case "book":
guard item.media?.tracks.count ?? 0 > 0 else { throw LibraryItemDownloadError.noTracks }
item.media?.tracks.forEach { t in tracks.append(AudioTrack.detachCopy(of: t)!) }
case "podcast":
guard let episode = episode else { throw LibraryItemDownloadError.podcastEpisodeNotFound }
guard let podcastTrack = episode.audioTrack else { throw LibraryItemDownloadError.noTracks }
episodeId = episode.id
tracks.append(AudioTrack.detachCopy(of: podcastTrack)!)
default:
throw LibraryItemDownloadError.unknownMediaType
}
// Queue up everything for downloading
let downloadItem = DownloadItem(libraryItem: item, episodeId: episodeId, server: Store.serverConfig!)
var tasks = [DownloadItemPartTask]()
for (i, track) in tracks.enumerated() {
let task = try startLibraryItemTrackDownload(item: item, position: i, track: track, episode: episode)
downloadItem.downloadItemParts.append(task.part)
tasks.append(task)
}
// Also download the cover
if item.media?.coverPath != nil && !(item.media?.coverPath!.isEmpty ?? true) {
if let task = try? startLibraryItemCoverDownload(item: item) {
downloadItem.downloadItemParts.append(task.part)
tasks.append(task)
}
}
// Persist in the database before status start coming in
try Database.shared.saveDownloadItem(downloadItem)
// Start all the downloads
for task in tasks {
task.task.resume()
}
}
private func startLibraryItemTrackDownload(item: LibraryItem, position: Int, track: AudioTrack, episode: PodcastEpisode?) throws -> DownloadItemPartTask {
NSLog("TRACK \(track.contentUrl!)")
// If we don't name metadata, then we can't proceed
guard let filename = track.metadata?.filename else {
throw LibraryItemDownloadError.noMetadata
}
let serverUrl = urlForTrack(item: item, track: track)
let itemDirectory = try createLibraryItemFileDirectory(item: item)
let localUrl = "\(itemDirectory)/\(filename)"
let task = session.downloadTask(with: serverUrl)
let part = DownloadItemPart(filename: filename, destination: localUrl, itemTitle: track.title ?? "Unknown", serverPath: Store.serverConfig!.address, audioTrack: track, episode: episode)
// Store the id on the task so the download item can be pulled from the database later
task.taskDescription = part.id
return DownloadItemPartTask(part: part, task: task)
}
private func startLibraryItemCoverDownload(item: LibraryItem) throws -> DownloadItemPartTask {
let filename = "cover.jpg"
let serverPath = "/api/items/\(item.id)/cover"
let itemDirectory = try createLibraryItemFileDirectory(item: item)
let localUrl = "\(itemDirectory)/\(filename)"
let part = DownloadItemPart(filename: filename, destination: localUrl, itemTitle: "cover", serverPath: serverPath, audioTrack: nil, episode: nil)
let task = session.downloadTask(with: part.downloadURL!)
// Store the id on the task so the download item can be pulled from the database later
task.taskDescription = part.id
return DownloadItemPartTask(part: part, task: task)
}
private func urlForTrack(item: LibraryItem, track: AudioTrack) -> URL {
// filename needs to be encoded otherwise would just use contentUrl
let filenameEncoded = track.metadata?.filename.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed)
let urlstr = "\(Store.serverConfig!.address)/s/item/\(item.id)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)"
return URL(string: urlstr)!
}
private func createLibraryItemFileDirectory(item: LibraryItem) throws -> String {
let itemDirectory = item.id
NSLog("ITEM DIR \(itemDirectory)")
guard AbsDownloader.itemDownloadFolder(path: itemDirectory) != nil else {
NSLog("Failed to CREATE LI DIRECTORY \(itemDirectory)")
throw LibraryItemDownloadError.failedDirectory
}
return itemDirectory
}
static func itemDownloadFolder(path: String) -> URL? {
do {
var itemFolder = AbsDownloader.downloadsDirectory.appendingPathComponent(path)
if !FileManager.default.fileExists(atPath: itemFolder.path) {
try FileManager.default.createDirectory(at: itemFolder, withIntermediateDirectories: true)
}
// Make sure we don't backup download files to iCloud
var resourceValues = URLResourceValues()
resourceValues.isExcludedFromBackup = true
try itemFolder.setResourceValues(resourceValues)
return itemFolder
} catch {
NSLog("Failed to CREATE LI DIRECTORY \(error)")
return nil
}
}
}
// MARK: - Class structs
typealias DownloadProgressHandler = (_ downloadItem: DownloadItem, _ downloadItemPart: DownloadItemPart) throws -> Void
struct DownloadItemPartTask {
let part: DownloadItemPart
let task: URLSessionDownloadTask
}
enum LibraryItemDownloadError: String, Error {
case noTracks = "No tracks on library item"
case noMetadata = "No metadata for track, unable to download"
case libraryItemNotPodcast = "Library item is not a podcast but episode was requested"
case podcastEpisodeNotFound = "Invalid podcast episode not found"
case podcastOnlySupported = "Only podcasts are supported for this function"
case unknownMediaType = "Unknown media type"
case failedDirectory = "Failed to create directory"
case failedDownload = "Failed to download item"
case noTaskDescription = "No task description"
case downloadItemNotFound = "DownloadItem not found"
case downloadItemPartNotFound = "DownloadItemPart not found"
case downloadItemPartDestinationUrlNotDefined = "DownloadItemPart destination URL not defined"
case libraryItemNotFound = "LibraryItem not found for id"
} }
+54 -23
View File
@@ -13,79 +13,110 @@ public class AbsFileSystem: CAPPlugin {
@objc func selectFolder(_ call: CAPPluginCall) { @objc func selectFolder(_ call: CAPPluginCall) {
let mediaType = call.getString("mediaType") let mediaType = call.getString("mediaType")
// TODO: Implement
NSLog("Select Folder for media type \(mediaType ?? "UNSET")") NSLog("Select Folder for media type \(mediaType ?? "UNSET")")
call.resolve() call.unavailable("Not available on iOS")
} }
@objc func checkFolderPermission(_ call: CAPPluginCall) { @objc func checkFolderPermission(_ call: CAPPluginCall) {
let folderUrl = call.getString("folderUrl") let folderUrl = call.getString("folderUrl")
// TODO: Is this even necessary on iOS?
NSLog("checkFolderPermission for folder \(folderUrl ?? "UNSET")") NSLog("checkFolderPermission for folder \(folderUrl ?? "UNSET")")
call.resolve([ call.unavailable("Not available on iOS")
"value": true
])
} }
@objc func scanFolder(_ call: CAPPluginCall) { @objc func scanFolder(_ call: CAPPluginCall) {
let folderId = call.getString("folderId") let folderId = call.getString("folderId")
let forceAudioProbe = call.getBool("forceAudioProbe", false) let forceAudioProbe = call.getBool("forceAudioProbe", false)
// TODO: Implement
NSLog("scanFolder \(folderId ?? "UNSET") | Force Probe = \(forceAudioProbe)") NSLog("scanFolder \(folderId ?? "UNSET") | Force Probe = \(forceAudioProbe)")
call.resolve() call.unavailable("Not available on iOS")
} }
@objc func removeFolder(_ call: CAPPluginCall) { @objc func removeFolder(_ call: CAPPluginCall) {
let folderId = call.getString("folderId") let folderId = call.getString("folderId")
// TODO: Implement
NSLog("removeFolder \(folderId ?? "UNSET")") NSLog("removeFolder \(folderId ?? "UNSET")")
call.resolve() call.unavailable("Not available on iOS")
} }
@objc func removeLocalLibraryItem(_ call: CAPPluginCall) { @objc func removeLocalLibraryItem(_ call: CAPPluginCall) {
let localLibraryItemId = call.getString("localLibraryItemId") let localLibraryItemId = call.getString("localLibraryItemId")
// TODO: Implement
NSLog("removeLocalLibraryItem \(localLibraryItemId ?? "UNSET")") NSLog("removeLocalLibraryItem \(localLibraryItemId ?? "UNSET")")
call.resolve() call.unavailable("Not available on iOS")
} }
@objc func scanLocalLibraryItem(_ call: CAPPluginCall) { @objc func scanLocalLibraryItem(_ call: CAPPluginCall) {
let localLibraryItemId = call.getString("localLibraryItemId") let localLibraryItemId = call.getString("localLibraryItemId")
let forceAudioProbe = call.getBool("forceAudioProbe", false) let forceAudioProbe = call.getBool("forceAudioProbe", false)
// TODO: Implement
NSLog("scanLocalLibraryItem \(localLibraryItemId ?? "UNSET") | Force Probe = \(forceAudioProbe)") NSLog("scanLocalLibraryItem \(localLibraryItemId ?? "UNSET") | Force Probe = \(forceAudioProbe)")
call.resolve() call.unavailable("Not available on iOS")
} }
@objc func deleteItem(_ call: CAPPluginCall) { @objc func deleteItem(_ call: CAPPluginCall) {
let localLibraryItemId = call.getString("localLibraryItemId") let localLibraryItemId = call.getString("id")
let contentUrl = call.getString("contentUrl") let contentUrl = call.getString("contentUrl")
// TODO: Implement
NSLog("deleteItem \(localLibraryItemId ?? "UNSET") url \(contentUrl ?? "UNSET")") NSLog("deleteItem \(localLibraryItemId ?? "UNSET") url \(contentUrl ?? "UNSET")")
call.resolve() var success = false
do {
if let localLibraryItemId = localLibraryItemId, let item = Database.shared.getLocalLibraryItem(localLibraryItemId: localLibraryItemId) {
try FileManager.default.removeItem(at: item.contentDirectory!)
try item.delete()
success = true
}
} catch {
NSLog("Failed to delete \(error)")
success = false
}
call.resolve(["success": success])
} }
@objc func deleteTrackFromItem(_ call: CAPPluginCall) { @objc func deleteTrackFromItem(_ call: CAPPluginCall) {
let localLibraryItemId = call.getString("localLibraryItemId") let localLibraryItemId = call.getString("id")
let trackLocalFileId = call.getString("trackLocalFileId") let trackLocalFileId = call.getString("trackLocalFileId")
let contentUrl = call.getString("contentUrl")
// TODO: Implement NSLog("deleteTrackFromItem \(localLibraryItemId ?? "UNSET") track file \(trackLocalFileId ?? "UNSET")")
NSLog("deleteTrackFromItem \(localLibraryItemId ?? "UNSET") track file \(trackLocalFileId ?? "UNSET") url \(contentUrl ?? "UNSET")")
call.resolve() var success = false
if let localLibraryItemId = localLibraryItemId, let trackLocalFileId = trackLocalFileId, let item = Database.shared.getLocalLibraryItem(localLibraryItemId: localLibraryItemId) {
do {
try item.update {
do {
if let fileIndex = item.localFiles.firstIndex(where: { $0.id == trackLocalFileId }) {
try FileManager.default.removeItem(at: item.localFiles[fileIndex].contentPath)
item.realm?.delete(item.localFiles[fileIndex])
if item.isPodcast, let media = item.media {
if let episodeIndex = media.episodes.firstIndex(where: { $0.audioTrack?.localFileId == trackLocalFileId }) {
media.episodes.remove(at: episodeIndex)
}
item.media = media
}
call.resolve(try item.asDictionary())
success = true
}
} catch {
NSLog("Failed to delete \(error)")
success = false
}
}
} catch {
NSLog("Failed to delete \(error)")
success = false
}
}
if !success {
call.resolve(["success": success])
}
} }
} }
-141
View File
@@ -1,141 +0,0 @@
//
// DataClasses.swift
// App
//
// Created by Benonymity on 4/20/22.
//
import Foundation
import CoreMedia
struct LibraryItem: Codable {
var id: String
var ino:String
var libraryId: String
var folderId: String
var path: String
var relPath: String
var isFile: Bool
var mtimeMs: Int64
var ctimeMs: Int64
var birthtimeMs: Int64
var addedAt: Int64
var updatedAt: Int64
var lastScan: Int64?
var scanVersion: String?
var isMissing: Bool
var isInvalid: Bool
var mediaType: String
var media: MediaType
var libraryFiles: [LibraryFile]
var userMediaProgress:MediaProgress?
}
struct MediaType: Codable {
var libraryItemId: String?
var metadata: Metadata
var coverPath: String?
var tags: [String]?
var audioFiles: [AudioTrack]?
var chapters: [Chapter]?
var tracks: [AudioTrack]?
var size: Int64?
var duration: Double?
var episodes: [PodcastEpisode]?
var autoDownloadEpisodes: Bool?
}
struct Metadata: Codable {
var title: String
var subtitle: String?
var authors: [Author]?
var narrators: [String]?
var genres: [String]
var publishedYear: String?
var publishedDate: String?
var publisher: String?
var description: String?
var isbn: String?
var asin: String?
var language: String?
var explicit: Bool
var authorName: String?
var authorNameLF: String?
var narratorName: String?
var seriesName: String?
var feedUrl: String?
}
struct PodcastEpisode: Codable {
var id: String
var index: Int
var episode: String?
var episodeType: String?
var title: String
var subtitle: String?
var description: String?
var audioFile: AudioFile?
var audioTrack: AudioTrack?
var duration: Double
var size: Int64
// var serverEpisodeId: String?
}
struct AudioFile: Codable {
var index: Int
var ino: String
var metadata: FileMetadata
}
struct Author: Codable {
var id: String
var name: String
var coverPath: String?
}
struct Chapter: Codable {
var id: Int
var start: Double
var end: Double
var title: String?
}
struct AudioTrack: Codable {
var index: Int?
var startOffset: Double?
var duration: Double
var title: String?
var contentUrl: String?
var mimeType: String
var metadata: FileMetadata?
// var isLocal: Bool
// var localFileId: String?
// var audioProbeResult: AudioProbeResult? Needed for local playback
var serverIndex: Int?
}
struct FileMetadata: Codable {
var filename: String
var ext: String
var path: String
var relPath: String
}
struct Library: Codable {
var id: String
var name: String
var folders: [Folder]
var icon: String
var mediaType: String
}
struct Folder: Codable {
var id: String
var fullPath: String
}
struct LibraryFile: Codable {
var ino: String
var metadata: FileMetadata
}
struct MediaProgress:Codable {
var id:String
var libraryItemId:String
var episodeId:String?
var duration:Double
var progress:Double
var currentTime:Double
var isFinished:Bool
var lastUpdate:Int64
var startedAt:Int64
var finishedAt:Int64?
}
+11 -18
View File
@@ -9,28 +9,21 @@ import Foundation
import RealmSwift import RealmSwift
class DeviceSettings: Object { class DeviceSettings: Object {
@Persisted var disableAutoRewind: Bool @Persisted var disableAutoRewind: Bool = false
@Persisted var enableAltView: Bool @Persisted var enableAltView: Bool = false
@Persisted var jumpBackwardsTime: Int @Persisted var jumpBackwardsTime: Int = 10
@Persisted var jumpForwardTime: Int @Persisted var jumpForwardTime: Int = 10
} }
func getDefaultDeviceSettings() -> DeviceSettings { func getDefaultDeviceSettings() -> DeviceSettings {
let settings = DeviceSettings() return DeviceSettings()
settings.disableAutoRewind = false
settings.enableAltView = false
settings.jumpForwardTime = 10
settings.jumpBackwardsTime = 10
return settings
} }
func deviceSettingsToJSON(settings: DeviceSettings) -> Dictionary<String, Any> { func deviceSettingsToJSON(settings: DeviceSettings) -> Dictionary<String, Any> {
return Database.realmQueue.sync { return [
return [ "disableAutoRewind": settings.disableAutoRewind,
"disableAutoRewind": settings.disableAutoRewind, "enableAltView": settings.enableAltView,
"enableAltView": settings.enableAltView, "jumpBackwardsTime": settings.jumpBackwardsTime,
"jumpBackwardsTime": settings.jumpBackwardsTime, "jumpForwardTime": settings.jumpForwardTime
"jumpForwardTime": settings.jumpForwardTime ]
]
}
} }
@@ -0,0 +1,14 @@
//
// PlaybackMetadata.swift
// App
//
// Created by Ron Heft on 8/16/22.
//
import Foundation
class PlaybackMetadata: Codable {
var duration: Double = 0
var currentTime: Double = 0
var playerState: PlayerState = PlayerState.IDLE
}
+151 -22
View File
@@ -6,27 +6,156 @@
// //
import Foundation import Foundation
import RealmSwift
struct PlaybackSession: Decodable, Encodable { class PlaybackSession: Object, Codable, Deletable {
var id: String @Persisted(primaryKey: true) var id: String = ""
var userId: String? @Persisted var userId: String?
var libraryItemId: String? @Persisted var libraryItemId: String?
var episodeId: String? @Persisted var episodeId: String?
var mediaType: String @Persisted var mediaType: String = ""
// var mediaMetadata: MediaTypeMetadata - It is not implemented in android? @Persisted var mediaMetadata: Metadata?
var chapters: [Chapter] @Persisted var chapters = List<Chapter>()
var displayTitle: String? @Persisted var displayTitle: String?
var displayAuthor: String? @Persisted var displayAuthor: String?
var coverPath: String? @Persisted var coverPath: String?
var duration: Double @Persisted var duration: Double = 0
var playMethod: Int @Persisted var playMethod: Int = PlayMethod.directplay.rawValue
var startedAt: Double? @Persisted var startedAt: Double?
var updatedAt: Double? @Persisted var updatedAt: Double?
var timeListening: Double @Persisted var timeListening: Double = 0
var audioTracks: [AudioTrack] @Persisted var audioTracks = List<AudioTrack>()
var currentTime: Double @Persisted var currentTime: Double = 0
var libraryItem: LibraryItem @Persisted var libraryItem: LibraryItem?
// var localLibraryItem: LocalLibraryItem? @Persisted var localLibraryItem: LocalLibraryItem?
var serverConnectionConfigId: String? @Persisted var serverConnectionConfigId: String?
var serverAddress: String? @Persisted var serverAddress: String?
@Persisted var isActiveSession = true
@Persisted var serverUpdatedAt: Double = 0
var isLocal: Bool { self.localLibraryItem != nil }
var mediaPlayer: String { "AVPlayer" }
var deviceInfo: [String: String?]? {
var systemInfo = utsname()
uname(&systemInfo)
let modelCode = withUnsafePointer(to: &systemInfo.machine) {
$0.withMemoryRebound(to: CChar.self, capacity: 1) {
ptr in String.init(validatingUTF8: ptr)
}
}
return [
"manufacturer": "Apple",
"model": modelCode,
"clientVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
]
}
var localMediaProgressId: String? {
if let localLibraryItem = localLibraryItem, let episodeId = episodeId {
return "\(localLibraryItem.id)-\(episodeId)"
} else if let localLibraryItem = localLibraryItem {
return localLibraryItem.id
} else {
return nil
}
}
var totalDuration: Double {
var total = 0.0
self.audioTracks.forEach { total += $0.duration }
return total
}
var progress: Double { self.currentTime / self.totalDuration }
internal init(id: String, userId: String? = nil, libraryItemId: String? = nil, episodeId: String? = nil, mediaType: String, mediaMetadata: Metadata?, chapters: List<Chapter> = List<Chapter>(), displayTitle: String? = nil, displayAuthor: String? = nil, coverPath: String? = nil, duration: Double, playMethod: Int, startedAt: Double? = nil, updatedAt: Double? = nil, timeListening: Double, audioTracks: List<AudioTrack> = List<AudioTrack>(), currentTime: Double, libraryItem: LibraryItem? = nil, localLibraryItem: LocalLibraryItem? = nil, serverConnectionConfigId: String? = nil, serverAddress: String? = nil) {
self.id = id
self.userId = userId
self.libraryItemId = libraryItemId
self.episodeId = episodeId
self.mediaType = mediaType
self.mediaMetadata = mediaMetadata
self.chapters = chapters
self.displayTitle = displayTitle
self.displayAuthor = displayAuthor
self.coverPath = coverPath
self.duration = duration
self.playMethod = playMethod
self.startedAt = startedAt
self.updatedAt = updatedAt
self.timeListening = timeListening
self.audioTracks = audioTracks
self.currentTime = currentTime
self.libraryItem = libraryItem
self.localLibraryItem = localLibraryItem
self.serverConnectionConfigId = serverConnectionConfigId
self.serverAddress = serverAddress
self.isActiveSession = true
}
private enum CodingKeys : String, CodingKey {
case id, userId, libraryItemId, episodeId, mediaType, mediaMetadata, chapters, displayTitle, displayAuthor, coverPath, duration, playMethod, mediaPlayer, deviceInfo, startedAt, updatedAt, timeListening, audioTracks, currentTime, libraryItem, localLibraryItem, serverConnectionConfigId, serverAddress, isLocal, localMediaProgressId
}
override init() {}
required init(from decoder: Decoder) throws {
super.init()
let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decode(String.self, forKey: .id)
userId = try values.decodeIfPresent(String.self, forKey: .userId)
libraryItemId = try values.decodeIfPresent(String.self, forKey: .libraryItemId)
episodeId = try values.decodeIfPresent(String.self, forKey: .episodeId)
mediaType = try values.decode(String.self, forKey: .mediaType)
mediaMetadata = try values.decodeIfPresent(Metadata.self, forKey: .mediaMetadata)
if let chapterList = try values.decodeIfPresent([Chapter].self, forKey: .chapters) {
chapters.append(objectsIn: chapterList)
}
displayTitle = try values.decodeIfPresent(String.self, forKey: .displayTitle)
displayAuthor = try values.decodeIfPresent(String.self, forKey: .displayAuthor)
coverPath = try values.decodeIfPresent(String.self, forKey: .coverPath)
duration = try values.decode(Double.self, forKey: .duration)
playMethod = try values.decode(Int.self, forKey: .playMethod)
startedAt = try values.decodeIfPresent(Double.self, forKey: .startedAt)
updatedAt = try values.decodeIfPresent(Double.self, forKey: .updatedAt)
timeListening = try values.decode(Double.self, forKey: .timeListening)
if let trackList = try values.decodeIfPresent([AudioTrack].self, forKey: .audioTracks) {
audioTracks.append(objectsIn: trackList)
}
currentTime = try values.decode(Double.self, forKey: .currentTime)
libraryItem = try values.decodeIfPresent(LibraryItem.self, forKey: .libraryItem)
localLibraryItem = try values.decodeIfPresent(LocalLibraryItem.self, forKey: .localLibraryItem)
serverConnectionConfigId = try values.decodeIfPresent(String.self, forKey: .serverConnectionConfigId)
serverAddress = try values.decodeIfPresent(String.self, forKey: .serverAddress)
isActiveSession = true
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(userId, forKey: .userId)
try container.encode(libraryItemId, forKey: .libraryItemId)
try container.encode(episodeId, forKey: .episodeId)
try container.encode(mediaType, forKey: .mediaType)
try container.encode(mediaMetadata, forKey: .mediaMetadata)
try container.encode(Array(chapters), forKey: .chapters)
try container.encode(displayTitle, forKey: .displayTitle)
try container.encode(displayAuthor, forKey: .displayAuthor)
try container.encode(coverPath, forKey: .coverPath)
try container.encode(duration, forKey: .duration)
try container.encode(playMethod, forKey: .playMethod)
try container.encode(mediaPlayer, forKey: .mediaPlayer)
try container.encode(deviceInfo, forKey: .deviceInfo)
try container.encode(startedAt, forKey: .startedAt)
try container.encode(updatedAt, forKey: .updatedAt)
try container.encode(timeListening, forKey: .timeListening)
try container.encode(Array(audioTracks), forKey: .audioTracks)
try container.encode(currentTime, forKey: .currentTime)
try container.encode(libraryItem, forKey: .libraryItem)
try container.encode(localLibraryItem, forKey: .localLibraryItem)
try container.encode(serverConnectionConfigId, forKey: .serverConnectionConfigId)
try container.encode(serverAddress, forKey: .serverAddress)
try container.encode(isLocal, forKey: .isLocal)
try container.encode(localMediaProgressId, forKey: .localMediaProgressId)
}
} }
@@ -0,0 +1,30 @@
//
// PlayerSettings.swift
// App
//
// Created by Ron Heft on 8/18/22.
//
import Foundation
import RealmSwift
class PlayerSettings: Object {
// The webapp has a persisted setting for playback speed, but it's not always available to the native code
// Lets track it natively as well, so we never have a situation where the UI and native player are out of sync
@Persisted var playbackRate: Float = 1.0
// Singleton pattern for Realm objects
static func main() -> PlayerSettings {
let realm = try! Realm()
if let settings = realm.objects(PlayerSettings.self).last {
return settings
}
let settings = PlayerSettings()
try! realm.write {
realm.add(settings)
}
return settings
}
}
+15
View File
@@ -0,0 +1,15 @@
//
// PlayerState.swift
// App
//
// Created by Ron Heft on 8/16/22.
//
import Foundation
enum PlayerState: Codable {
case IDLE
case BUFFERING
case READY
case ENDED
}
@@ -9,30 +9,28 @@ import Foundation
import RealmSwift import RealmSwift
class ServerConnectionConfig: Object { class ServerConnectionConfig: Object {
@Persisted(primaryKey: true) var id: String @Persisted(primaryKey: true) var id: String = UUID().uuidString
@Persisted(indexed: true) var index: Int @Persisted(indexed: true) var index: Int = 1
@Persisted var name: String @Persisted var name: String = ""
@Persisted var address: String @Persisted var address: String = ""
@Persisted var userId: String @Persisted var userId: String = ""
@Persisted var username: String @Persisted var username: String = ""
@Persisted var token: String @Persisted var token: String = ""
} }
class ServerConnectionConfigActiveIndex: Object { class ServerConnectionConfigActiveIndex: Object {
// This could overflow, but you really would have to try // This could overflow, but you really would have to try
@Persisted var index: Int? @Persisted(primaryKey: true) var index: Int?
} }
func convertServerConnectionConfigToJSON(config: ServerConnectionConfig) -> Dictionary<String, Any> { func convertServerConnectionConfigToJSON(config: ServerConnectionConfig) -> Dictionary<String, Any> {
return Database.realmQueue.sync { return [
return [ "id": config.id,
"id": config.id, "name": config.name,
"name": config.name, "index": config.index,
"index": config.index, "address": config.address,
"address": config.address, "userId": config.userId,
"userId": config.userId, "username": config.username,
"username": config.username, "token": config.token,
"token": config.token, ]
]
}
} }
@@ -0,0 +1,97 @@
//
// DownloadItem.swift
// App
//
// Created by Ron Heft on 8/16/22.
//
import Foundation
import RealmSwift
class DownloadItem: Object, Codable {
@Persisted(primaryKey: true) var id: String?
@Persisted(indexed: true) var libraryItemId: String?
@Persisted var episodeId: String?
@Persisted var userMediaProgress: MediaProgress?
@Persisted var serverConnectionConfigId: String?
@Persisted var serverAddress: String?
@Persisted var serverUserId: String?
@Persisted var mediaType: String?
@Persisted var itemTitle: String?
@Persisted var media: MediaType?
@Persisted var downloadItemParts = List<DownloadItemPart>()
private enum CodingKeys : String, CodingKey {
case id, libraryItemId, episodeId, serverConnectionConfigId, serverAddress, serverUserId, mediaType, itemTitle, downloadItemParts
}
override init() {
super.init()
}
required init(from decoder: Decoder) throws {
super.init()
let values = try decoder.container(keyedBy: CodingKeys.self)
id = try? values.decode(String.self, forKey: .id)
libraryItemId = try? values.decode(String.self, forKey: .libraryItemId)
episodeId = try? values.decode(String.self, forKey: .episodeId)
serverConnectionConfigId = try? values.decode(String.self, forKey: .serverConnectionConfigId)
serverAddress = try? values.decode(String.self, forKey: .serverAddress)
serverUserId = try? values.decode(String.self, forKey: .serverUserId)
mediaType = try? values.decode(String.self, forKey: .mediaType)
itemTitle = try? values.decode(String.self, forKey: .itemTitle)
if let parts = try? values.decode([DownloadItemPart].self, forKey: .downloadItemParts) {
downloadItemParts.append(objectsIn: parts)
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(libraryItemId, forKey: .libraryItemId)
try container.encode(episodeId, forKey: .episodeId)
try container.encode(serverConnectionConfigId, forKey: .serverConnectionConfigId)
try container.encode(serverAddress, forKey: .serverAddress)
try container.encode(serverUserId, forKey: .serverUserId)
try container.encode(mediaType, forKey: .mediaType)
try container.encode(itemTitle, forKey: .itemTitle)
try container.encode(Array(downloadItemParts), forKey: .downloadItemParts)
}
}
extension DownloadItem {
convenience init(libraryItem: LibraryItem, episodeId: String?, server: ServerConnectionConfig) {
self.init()
self.id = libraryItem.id
self.libraryItemId = libraryItem.id
self.userMediaProgress = libraryItem.userMediaProgress
self.serverConnectionConfigId = server.id
self.serverAddress = server.address
self.serverUserId = server.userId
self.mediaType = libraryItem.mediaType
self.itemTitle = libraryItem.media?.metadata?.title
self.media = libraryItem.media
if let episodeId = episodeId {
self.id! += "-\(episodeId)"
self.episodeId = episodeId
}
}
func isDoneDownloading() -> Bool {
self.downloadItemParts.allSatisfy({ $0.completed })
}
func didDownloadSuccessfully() -> Bool {
self.downloadItemParts.allSatisfy({ $0.failed == false })
}
func delete() throws {
try self.realm?.write {
self.realm?.delete(self.downloadItemParts)
self.realm?.delete(self)
}
}
}
@@ -0,0 +1,95 @@
//
// DownloadItemPart.swift
// App
//
// Created by Ron Heft on 8/16/22.
//
import Foundation
import RealmSwift
class DownloadItemPart: Object, Codable {
@Persisted(primaryKey: true) var id = ""
@Persisted var filename: String?
@Persisted var itemTitle: String?
@Persisted var serverPath: String?
@Persisted var audioTrack: AudioTrack?
@Persisted var episode: PodcastEpisode?
@Persisted var completed: Bool = false
@Persisted var moved: Bool = false
@Persisted var failed: Bool = false
@Persisted var uri: String?
@Persisted var destinationUri: String?
@Persisted var progress: Double = 0
private enum CodingKeys : String, CodingKey {
case id, filename, itemTitle, completed, moved, failed, progress
}
override init() {
super.init()
}
required init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decode(String.self, forKey: .id)
filename = try? values.decode(String.self, forKey: .filename)
itemTitle = try? values.decode(String.self, forKey: .itemTitle)
completed = try values.decode(Bool.self, forKey: .completed)
moved = try values.decode(Bool.self, forKey: .moved)
failed = try values.decode(Bool.self, forKey: .failed)
progress = try values.decode(Double.self, forKey: .progress)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(filename, forKey: .filename)
try container.encode(itemTitle, forKey: .itemTitle)
try container.encode(completed, forKey: .completed)
try container.encode(moved, forKey: .moved)
try container.encode(failed, forKey: .failed)
try container.encode(progress, forKey: .progress)
}
}
extension DownloadItemPart {
convenience init(filename: String, destination: String, itemTitle: String, serverPath: String, audioTrack: AudioTrack?, episode: PodcastEpisode?) {
self.init()
self.id = destination.toBase64()
self.filename = filename
self.itemTitle = itemTitle
self.serverPath = serverPath
self.audioTrack = AudioTrack.detachCopy(of: audioTrack)
self.episode = PodcastEpisode.detachCopy(of: episode)
let config = Store.serverConfig!
var downloadUrl = "\(config.address)\(serverPath)?token=\(config.token)"
if (serverPath.hasSuffix("/cover")) {
downloadUrl += "&format=jpeg" // For cover images force to jpeg
}
self.uri = downloadUrl
self.destinationUri = destination
}
var downloadURL: URL? {
if let uri = self.uri {
return URL(string: uri)
} else {
return nil
}
}
var destinationURL: URL? {
if let destinationUri = self.destinationUri {
return AbsDownloader.itemDownloadFolder(path: destinationUri)
} else {
return nil
}
}
func mimeType() -> String? {
audioTrack?.mimeType ?? episode?.audioTrack?.mimeType
}
}
@@ -0,0 +1,73 @@
//
// LocalFile.swift
// App
//
// Created by Ron Heft on 8/16/22.
//
import Foundation
import RealmSwift
class LocalFile: Object, Codable {
@Persisted(primaryKey: true) var id: String = UUID().uuidString
@Persisted var filename: String?
@Persisted var _contentUrl: String = ""
@Persisted var mimeType: String?
@Persisted var size: Int = 0
var contentUrl: String { AbsDownloader.itemDownloadFolder(path: _contentUrl)!.absoluteString }
var contentPath: URL { AbsDownloader.itemDownloadFolder(path: _contentUrl)! }
var basePath: String? { self.filename }
private enum CodingKeys : String, CodingKey {
case id, filename, contentUrl, mimeType, size, basePath
}
override init() {
super.init()
}
required init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decode(String.self, forKey: .id)
filename = try? values.decode(String.self, forKey: .filename)
mimeType = try? values.decode(String.self, forKey: .mimeType)
size = try values.decode(Int.self, forKey: .size)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(filename, forKey: .filename)
try container.encode(contentUrl, forKey: .contentUrl)
try container.encode(mimeType, forKey: .mimeType)
try container.encode(size, forKey: .size)
try container.encode(basePath, forKey: .basePath)
}
}
extension LocalFile {
convenience init(_ libraryItemId: String, _ filename: String, _ mimeType: String, _ localUrl: String, fileSize: Int) {
self.init()
self.id = "\(libraryItemId)_\(filename.toBase64())"
self.filename = filename
self.mimeType = mimeType
self._contentUrl = localUrl
self.size = fileSize
}
var absolutePath: String {
return AbsDownloader.itemDownloadFolder(path: self._contentUrl)?.absoluteString ?? ""
}
func isAudioFile() -> Bool {
switch self.mimeType {
case "application/octet-stream",
"video/mp4":
return true
default:
return self.mimeType?.starts(with: "audio") ?? false
}
}
}
@@ -0,0 +1,207 @@
//
// LocalLibraryItem.swift
// App
//
// Created by Ron Heft on 8/16/22.
//
import Foundation
import RealmSwift
class LocalLibraryItem: Object, Codable {
@Persisted(primaryKey: true) var id: String = "local_\(UUID().uuidString)"
@Persisted var basePath: String = ""
@Persisted var _contentUrl: String?
@Persisted var isInvalid: Bool = false
@Persisted var mediaType: String = ""
@Persisted var media: MediaType?
@Persisted var localFiles = List<LocalFile>()
@Persisted var _coverContentUrl: String?
@Persisted var isLocal: Bool = true
@Persisted var serverConnectionConfigId: String?
@Persisted var serverAddress: String?
@Persisted var serverUserId: String?
@Persisted(indexed: true) var libraryItemId: String?
var contentUrl: String? {
if let path = _contentUrl {
return AbsDownloader.itemDownloadFolder(path: path)!.absoluteString
} else {
return nil
}
}
var contentDirectory: URL? {
if let path = _contentUrl {
return AbsDownloader.itemDownloadFolder(path: path)
} else {
return nil
}
}
var coverContentUrl: String? {
if let path = self._coverContentUrl {
return AbsDownloader.itemDownloadFolder(path: path)!.absoluteString
} else {
return nil
}
}
var isBook: Bool { self.mediaType == "book" }
var isPodcast: Bool { self.mediaType == "podcast" }
private enum CodingKeys : String, CodingKey {
case id, basePath, contentUrl, isInvalid, mediaType, media, localFiles, coverContentUrl, isLocal, serverConnectionConfigId, serverAddress, serverUserId, libraryItemId
}
override init() {
super.init()
}
required init(from decoder: Decoder) throws {
super.init()
let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decode(String.self, forKey: .id)
basePath = try values.decode(String.self, forKey: .basePath)
isInvalid = try values.decode(Bool.self, forKey: .isInvalid)
mediaType = try values.decode(String.self, forKey: .mediaType)
media = try? values.decode(MediaType.self, forKey: .media)
if let files = try? values.decode([LocalFile].self, forKey: .localFiles) {
localFiles.append(objectsIn: files)
}
isLocal = try values.decode(Bool.self, forKey: .isLocal)
serverConnectionConfigId = try? values.decode(String.self, forKey: .serverConnectionConfigId)
serverAddress = try? values.decode(String.self, forKey: .serverAddress)
serverUserId = try? values.decode(String.self, forKey: .serverUserId)
libraryItemId = try? values.decode(String.self, forKey: .libraryItemId)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(basePath, forKey: .basePath)
try container.encode(contentUrl, forKey: .contentUrl)
try container.encode(isInvalid, forKey: .isInvalid)
try container.encode(mediaType, forKey: .mediaType)
try container.encode(media, forKey: .media)
try container.encode(Array(localFiles), forKey: .localFiles)
try container.encode(coverContentUrl, forKey: .coverContentUrl)
try container.encode(isLocal, forKey: .isLocal)
try container.encode(serverConnectionConfigId, forKey: .serverConnectionConfigId)
try container.encode(serverAddress, forKey: .serverAddress)
try container.encode(serverUserId, forKey: .serverUserId)
try container.encode(libraryItemId, forKey: .libraryItemId)
}
}
extension LocalLibraryItem {
convenience init(_ item: LibraryItem, localUrl: String, server: ServerConnectionConfig, files: [LocalFile], coverPath: String?) {
self.init()
self._contentUrl = localUrl
self.mediaType = item.mediaType
self.localFiles.append(objectsIn: files)
self._coverContentUrl = coverPath
self.libraryItemId = item.id
self.serverConnectionConfigId = server.id
self.serverAddress = server.address
self.serverUserId = server.userId
// Link the audio tracks and files
linkLocalFiles(self.localFiles, fromMedia: item.media)
}
func addFiles(_ files: [LocalFile], item: LibraryItem) throws {
guard self.isPodcast else { throw LibraryItemDownloadError.podcastOnlySupported }
self.localFiles.append(objectsIn: files.filter({ $0.isAudioFile() }))
linkLocalFiles(self.localFiles, fromMedia: item.media)
}
private func linkLocalFiles(_ files: List<LocalFile>, fromMedia: MediaType?) {
guard let fromMedia = MediaType.detachCopy(of: fromMedia) else { return }
let fileMap = files.map { ($0.filename ?? "", $0.id) }
let fileIdByFilename = Dictionary(fileMap, uniquingKeysWith: { (_, last) in last })
if ( self.isBook ) {
for i in fromMedia.tracks.indices {
_ = fromMedia.tracks[i].setLocalInfo(filenameIdMap: fileIdByFilename, serverIndex: i)
}
} else if ( self.isPodcast ) {
let episodes = List<PodcastEpisode>()
for episode in fromMedia.episodes {
// Filter out episodes not downloaded
let episodeIsDownloaded = episode.audioTrack?.setLocalInfo(filenameIdMap: fileIdByFilename, serverIndex: 0) ?? false
if episodeIsDownloaded {
episodes.append(episode)
}
}
fromMedia.episodes = episodes
}
self.media = fromMedia
}
func getDuration() -> Double {
var total = 0.0
self.media?.tracks.enumerated().forEach { _, track in total += track.duration }
return total
}
func getPodcastEpisode(episodeId: String?) -> PodcastEpisode? {
guard self.isPodcast else { return nil }
guard let episodes = self.media?.episodes else { return nil }
return episodes.first(where: { $0.id == episodeId })
}
func getPlaybackSession(episode: PodcastEpisode?) -> PlaybackSession {
let localEpisodeId = episode?.id
let sessionId = "play_local_\(UUID().uuidString)"
// Get current progress from local media
let mediaProgressId = (localEpisodeId != nil) ? "\(self.id)-\(localEpisodeId!)" : self.id
let mediaProgress = Database.shared.getLocalMediaProgress(localMediaProgressId: mediaProgressId)
let mediaMetadata = Metadata.detachCopy(of: self.media?.metadata)
let chapters = List<Chapter>()
self.media?.chapters.forEach { chapter in chapters.append(Chapter.detachCopy(of: chapter)!) }
let authorName = mediaMetadata?.authorDisplayName
let audioTracks = List<AudioTrack>()
if let episode = episode, let track = episode.audioTrack {
audioTracks.append(AudioTrack.detachCopy(of: track)!)
} else if let tracks = self.media?.tracks {
tracks.forEach { t in audioTracks.append(AudioTrack.detachCopy(of: t)!) }
}
let dateNow = Date().timeIntervalSince1970 * 1000
return PlaybackSession(
id: sessionId,
userId: self.serverUserId,
libraryItemId: self.libraryItemId,
episodeId: episode?.serverEpisodeId,
mediaType: self.mediaType,
mediaMetadata: mediaMetadata,
chapters: chapters,
displayTitle: mediaMetadata?.title,
displayAuthor: authorName,
coverPath: self.coverContentUrl,
duration: self.getDuration(),
playMethod: PlayMethod.local.rawValue,
startedAt: dateNow,
updatedAt: dateNow,
timeListening: 0.0,
audioTracks: audioTracks,
currentTime: mediaProgress?.currentTime ?? 0.0,
libraryItem: nil,
localLibraryItem: self,
serverConnectionConfigId: self.serverConnectionConfigId,
serverAddress: self.serverAddress
)
}
func delete() throws {
try self.realm?.write {
self.realm?.delete(self.localFiles)
self.realm?.delete(self)
}
}
}
@@ -0,0 +1,182 @@
//
// LocalMediaProgress.swift
// App
//
// Created by Ron Heft on 8/16/22.
//
import Foundation
import RealmSwift
class LocalMediaProgress: Object, Codable {
@Persisted(primaryKey: true) var id: String = ""
@Persisted(indexed: true) var localLibraryItemId: String = ""
@Persisted(indexed: true) var localEpisodeId: String?
@Persisted var duration: Double = 0
@Persisted var progress: Double = 0
@Persisted var currentTime: Double = 0
@Persisted var isFinished: Bool = false
@Persisted var lastUpdate: Double = 0
@Persisted var startedAt: Double = 0
@Persisted var finishedAt: Double?
// For local lib items from server to support server sync
@Persisted var serverConnectionConfigId: String?
@Persisted var serverAddress: String?
@Persisted var serverUserId: String?
@Persisted(indexed: true) var libraryItemId: String?
@Persisted(indexed: true) var episodeId: String?
var progressPercent: Int { Int(self.progress * 100) }
private enum CodingKeys : String, CodingKey {
case id, localLibraryItemId, localEpisodeId, duration, progress, currentTime, isFinished, lastUpdate, startedAt, finishedAt, serverConnectionConfigId, serverAddress, serverUserId, libraryItemId, episodeId
}
override init() {
super.init()
}
required init(from decoder: Decoder) throws {
super.init()
let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decode(String.self, forKey: .id)
localLibraryItemId = try values.decode(String.self, forKey: .localLibraryItemId)
localEpisodeId = try values.decodeIfPresent(String.self, forKey: .localEpisodeId)
duration = try values.decode(Double.self, forKey: .duration)
progress = try values.decode(Double.self, forKey: .progress)
currentTime = try values.decode(Double.self, forKey: .currentTime)
isFinished = try values.decode(Bool.self, forKey: .isFinished)
lastUpdate = try values.decode(Double.self, forKey: .lastUpdate)
startedAt = try values.decode(Double.self, forKey: .startedAt)
finishedAt = try values.decodeIfPresent(Double.self, forKey: .finishedAt)
serverConnectionConfigId = try values.decodeIfPresent(String.self, forKey: .serverConnectionConfigId)
serverAddress = try values.decodeIfPresent(String.self, forKey: .serverAddress)
serverUserId = try values.decodeIfPresent(String.self, forKey: .serverUserId)
libraryItemId = try values.decodeIfPresent(String.self, forKey: .libraryItemId)
episodeId = try values.decodeIfPresent(String.self, forKey: .episodeId)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(localLibraryItemId, forKey: .localLibraryItemId)
try container.encode(localEpisodeId, forKey: .localEpisodeId)
try container.encode(duration, forKey: .duration)
if progress.isNaN == false {
try container.encode(progress, forKey: .progress)
}
if currentTime.isNaN == false {
try container.encode(currentTime, forKey: .currentTime)
}
try container.encode(isFinished, forKey: .isFinished)
try container.encode(lastUpdate, forKey: .lastUpdate)
try container.encode(startedAt, forKey: .startedAt)
try container.encode(finishedAt, forKey: .finishedAt)
try container.encode(serverConnectionConfigId, forKey: .serverConnectionConfigId)
try container.encode(serverAddress, forKey: .serverAddress)
try container.encode(serverUserId, forKey: .serverUserId)
try container.encode(libraryItemId, forKey: .libraryItemId)
try container.encode(episodeId, forKey: .episodeId)
}
}
extension LocalMediaProgress {
convenience init(localLibraryItem: LocalLibraryItem, episode: PodcastEpisode?) {
self.init()
self.id = localLibraryItem.id
self.localLibraryItemId = localLibraryItem.id
self.libraryItemId = localLibraryItem.libraryItemId
self.serverAddress = localLibraryItem.serverAddress
self.serverUserId = localLibraryItem.serverUserId
self.serverConnectionConfigId = localLibraryItem.serverConnectionConfigId
self.duration = localLibraryItem.getDuration()
self.progress = 0.0
self.currentTime = 0.0
self.isFinished = false
self.lastUpdate = Date().timeIntervalSince1970 * 1000
self.startedAt = 0
self.finishedAt = nil
if let episode = episode {
self.id += "-\(episode.id)"
self.episodeId = episode.id
self.duration = episode.duration ?? 0.0
}
}
convenience init(localLibraryItem: LocalLibraryItem, episode: PodcastEpisode?, progress: MediaProgress) {
self.init(localLibraryItem: localLibraryItem, episode: episode)
self.duration = progress.duration
self.progress = progress.progress
self.currentTime = progress.currentTime
self.isFinished = progress.isFinished
self.lastUpdate = progress.lastUpdate
self.startedAt = progress.startedAt
self.finishedAt = progress.finishedAt
}
func updateIsFinished(_ finished: Bool) throws {
try self.realm?.write {
if self.isFinished != finished {
self.progress = finished ? 1.0 : 0.0
}
if self.startedAt == 0 && finished {
self.startedAt = Date().timeIntervalSince1970 * 1000
}
self.isFinished = finished
self.lastUpdate = Date().timeIntervalSince1970 * 1000
self.finishedAt = finished ? lastUpdate : nil
}
}
func updateFromPlaybackSession(_ playbackSession: PlaybackSession) throws {
try self.realm?.write {
self.currentTime = playbackSession.currentTime
self.progress = playbackSession.progress
self.lastUpdate = Date().timeIntervalSince1970 * 1000
self.isFinished = playbackSession.progress >= 100.0
self.finishedAt = self.isFinished ? self.lastUpdate : nil
}
}
func updateFromServerMediaProgress(_ serverMediaProgress: MediaProgress) throws {
try self.realm?.write {
self.isFinished = serverMediaProgress.isFinished
self.progress = serverMediaProgress.progress
self.currentTime = serverMediaProgress.currentTime
self.duration = serverMediaProgress.duration
self.lastUpdate = serverMediaProgress.lastUpdate
self.finishedAt = serverMediaProgress.finishedAt
self.startedAt = serverMediaProgress.startedAt
}
}
static func fetchOrCreateLocalMediaProgress(localMediaProgressId: String?, localLibraryItemId: String?, localEpisodeId: String?) throws -> LocalMediaProgress? {
let realm = try Realm()
return try realm.write { () -> LocalMediaProgress? in
if let localMediaProgressId = localMediaProgressId {
// Check if it existing in the database, if not, we need to create it
if let progress = Database.shared.getLocalMediaProgress(localMediaProgressId: localMediaProgressId) {
return progress
}
}
if let localLibraryItemId = localLibraryItemId {
guard let localLibraryItem = Database.shared.getLocalLibraryItem(localLibraryItemId: localLibraryItemId) else { return nil }
let episode = localLibraryItem.getPodcastEpisode(episodeId: localEpisodeId)
let progress = LocalMediaProgress(localLibraryItem: localLibraryItem, episode: episode)
realm.add(progress)
return progress
} else {
return nil
}
}
}
}
@@ -0,0 +1,46 @@
//
// LocalPodcastEpisode.swift
// App
//
// Created by Ron Heft on 8/16/22.
//
import Foundation
import RealmSwift
class LocalPodcastEpisode: Object, Codable {
@Persisted(primaryKey: true) var id: String = UUID().uuidString
@Persisted var index: Int = 0
@Persisted var episode: String?
@Persisted var episodeType: String?
@Persisted var title: String = "Unknown"
@Persisted var subtitle: String?
@Persisted var desc: String?
@Persisted var audioFile: AudioFile?
@Persisted var audioTrack: AudioTrack?
@Persisted var duration: Double = 0
@Persisted var size: Int = 0
@Persisted(indexed: true) var serverEpisodeId: String?
private enum CodingKeys : String, CodingKey {
case id
}
override init() {
super.init()
}
required init(from decoder: Decoder) throws {
super.init()
let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decode(String.self, forKey: .id)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
}
}
@@ -0,0 +1,38 @@
//
// AudioFile.swift
// App
//
// Created by Ron Heft on 8/16/22.
//
import Foundation
import RealmSwift
class AudioFile: EmbeddedObject, Codable {
@Persisted var index: Int?
@Persisted var ino: String = ""
@Persisted var metadata: FileMetadata?
private enum CodingKeys : String, CodingKey {
case index, ino, metadata
}
override init() {
super.init()
}
required init(from decoder: Decoder) throws {
super.init()
let values = try decoder.container(keyedBy: CodingKeys.self)
index = try? values.decode(Int.self, forKey: .index)
ino = try values.decode(String.self, forKey: .ino)
metadata = try? values.decode(FileMetadata.self, forKey: .metadata)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(index, forKey: .index)
try container.encode(ino, forKey: .ino)
try container.encode(metadata, forKey: .metadata)
}
}
@@ -0,0 +1,72 @@
//
// AudioTrack.swift
// App
//
// Created by Ron Heft on 8/16/22.
//
import Foundation
import RealmSwift
class AudioTrack: EmbeddedObject, Codable {
@Persisted var index: Int?
@Persisted var startOffset: Double?
@Persisted var duration: Double = 0
@Persisted var title: String?
@Persisted var contentUrl: String?
@Persisted var mimeType: String = ""
@Persisted var metadata: FileMetadata?
@Persisted var localFileId: String?
@Persisted var serverIndex: Int?
private enum CodingKeys : String, CodingKey {
case index, startOffset, duration, title, contentUrl, mimeType, metadata, localFileId, serverIndex
}
override init() {
super.init()
}
required init(from decoder: Decoder) throws {
super.init()
let values = try decoder.container(keyedBy: CodingKeys.self)
index = try? values.decode(Int.self, forKey: .index)
startOffset = try? values.decode(Double.self, forKey: .startOffset)
duration = try values.decode(Double.self, forKey: .duration)
title = try? values.decode(String.self, forKey: .title)
contentUrl = try? values.decode(String.self, forKey: .contentUrl)
mimeType = try values.decode(String.self, forKey: .mimeType)
metadata = try? values.decode(FileMetadata.self, forKey: .metadata)
localFileId = try? values.decodeIfPresent(String.self, forKey: .localFileId)
serverIndex = try? values.decode(Int.self, forKey: .serverIndex)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(index, forKey: .index)
try container.encode(startOffset, forKey: .startOffset)
try container.encode(duration, forKey: .duration)
try container.encode(title, forKey: .title)
try container.encode(contentUrl, forKey: .contentUrl)
try container.encode(mimeType, forKey: .mimeType)
try container.encode(metadata, forKey: .metadata)
try container.encode(localFileId, forKey: .localFileId)
try container.encode(serverIndex, forKey: .serverIndex)
}
}
extension AudioTrack {
func setLocalInfo(filenameIdMap: [String: String], serverIndex: Int) -> Bool {
if let localFileId = filenameIdMap[self.metadata?.filename ?? ""] {
self.localFileId = localFileId
self.serverIndex = serverIndex
return true
}
return false
}
func getLocalFile() -> LocalFile? {
guard let localFileId = self.localFileId else { return nil }
return Database.shared.getLocalFile(localFileId: localFileId)
}
}
+38
View File
@@ -0,0 +1,38 @@
//
// Author.swift
// App
//
// Created by Ron Heft on 8/16/22.
//
import Foundation
import RealmSwift
class Author: EmbeddedObject, Codable {
@Persisted var id: String = ""
@Persisted var name: String = "Unknown"
@Persisted var coverPath: String?
private enum CodingKeys : String, CodingKey {
case id, name, coverPath
}
override init() {
super.init()
}
required init(from decoder: Decoder) throws {
super.init()
let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decode(String.self, forKey: .id)
name = try values.decode(String.self, forKey: .name)
coverPath = try? values.decode(String.self, forKey: .coverPath)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(name, forKey: .name)
try container.encode(coverPath, forKey: .coverPath)
}
}
@@ -0,0 +1,41 @@
//
// Chapter.swift
// App
//
// Created by Ron Heft on 8/16/22.
//
import Foundation
import RealmSwift
class Chapter: EmbeddedObject, Codable {
@Persisted var id: Int = 0
@Persisted var start: Double = 0
@Persisted var end: Double = 0
@Persisted var title: String?
private enum CodingKeys : String, CodingKey {
case id, start, end, title
}
override init() {
super.init()
}
required init(from decoder: Decoder) throws {
super.init()
let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decode(Int.self, forKey: .id)
start = try values.decode(Double.self, forKey: .start)
end = try values.decode(Double.self, forKey: .end)
title = try? values.decode(String.self, forKey: .title)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(start, forKey: .start)
try container.encode(end, forKey: .end)
try container.encode(title, forKey: .title)
}
}
@@ -0,0 +1,41 @@
//
// FileMetadata.swift
// App
//
// Created by Ron Heft on 8/16/22.
//
import Foundation
import RealmSwift
class FileMetadata: EmbeddedObject, Codable {
@Persisted var filename: String = ""
@Persisted var ext: String = ""
@Persisted var path: String = ""
@Persisted var relPath: String = ""
private enum CodingKeys : String, CodingKey {
case filename, ext, path, relPath
}
override init() {
super.init()
}
required init(from decoder: Decoder) throws {
super.init()
let values = try decoder.container(keyedBy: CodingKeys.self)
filename = try values.decode(String.self, forKey: .filename)
ext = try values.decode(String.self, forKey: .ext)
path = try values.decode(String.self, forKey: .path)
relPath = try values.decode(String.self, forKey: .relPath)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(filename, forKey: .filename)
try container.encode(ext, forKey: .ext)
try container.encode(path, forKey: .path)
try container.encode(relPath, forKey: .relPath)
}
}
+35
View File
@@ -0,0 +1,35 @@
//
// Folder.swift
// App
//
// Created by Ron Heft on 8/16/22.
//
import Foundation
import RealmSwift
class Folder: EmbeddedObject, Codable {
@Persisted var id: String = ""
@Persisted var fullPath: String = ""
private enum CodingKeys : String, CodingKey {
case id, fullPath
}
override init() {
super.init()
}
required init(from decoder: Decoder) throws {
super.init()
let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decode(String.self, forKey: .id)
fullPath = try values.decode(String.self, forKey: .fullPath)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(fullPath, forKey: .fullPath)
}
}
@@ -0,0 +1,46 @@
//
// Library.swift
// App
//
// Created by Ron Heft on 8/16/22.
//
import Foundation
import RealmSwift
class Library: EmbeddedObject, Codable {
@Persisted var id: String = ""
@Persisted var name: String = "Unknown"
@Persisted var folders = List<Folder>()
@Persisted var icon: String = ""
@Persisted var mediaType: String = ""
private enum CodingKeys : String, CodingKey {
case id, name, folders, icon, mediaType
}
override init() {
super.init()
}
required init(from decoder: Decoder) throws {
super.init()
let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decode(String.self, forKey: .id)
name = try values.decode(String.self, forKey: .name)
if let folderList = try? values.decode([Folder].self, forKey: .folders) {
folders.append(objectsIn: folderList)
}
icon = try values.decode(String.self, forKey: .icon)
mediaType = try values.decode(String.self, forKey: .mediaType)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(name, forKey: .name)
try container.encode(folders, forKey: .folders)
try container.encode(icon, forKey: .icon)
try container.encode(mediaType, forKey: .mediaType)
}
}
@@ -0,0 +1,35 @@
//
// LibraryFile.swift
// App
//
// Created by Ron Heft on 8/16/22.
//
import Foundation
import RealmSwift
class LibraryFile: EmbeddedObject, Codable {
@Persisted var ino: String = ""
@Persisted var metadata: FileMetadata?
private enum CodingKeys : String, CodingKey {
case ino, metadata
}
override init() {
super.init()
}
required init(from decoder: Decoder) throws {
super.init()
let values = try decoder.container(keyedBy: CodingKeys.self)
ino = try values.decode(String.self, forKey: .ino)
metadata = try values.decode(FileMetadata.self, forKey: .metadata)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(ino, forKey: .ino)
try container.encode(metadata, forKey: .metadata)
}
}
@@ -0,0 +1,92 @@
//
// LibraryItem.swift
// App
//
// Created by Ron Heft on 8/16/22.
//
import Foundation
import RealmSwift
class LibraryItem: Object, Codable {
@Persisted var id: String = ""
@Persisted var ino: String = ""
@Persisted var libraryId: String = ""
@Persisted var folderId: String = ""
@Persisted var path: String = ""
@Persisted var relPath: String = ""
@Persisted var isFile: Bool = true
@Persisted var mtimeMs: Int = 0
@Persisted var ctimeMs: Int = 0
@Persisted var birthtimeMs: Int = 0
@Persisted var addedAt: Int = 0
@Persisted var updatedAt: Int = 0
@Persisted var lastScan: Int?
@Persisted var scanVersion: String?
@Persisted var isMissing: Bool = false
@Persisted var isInvalid: Bool = false
@Persisted var mediaType: String = ""
@Persisted var media: MediaType?
@Persisted var libraryFiles = List<LibraryFile>()
@Persisted var userMediaProgress: MediaProgress?
private enum CodingKeys : String, CodingKey {
case id, ino, libraryId, folderId, path, relPath, isFile, mtimeMs, ctimeMs, birthtimeMs, addedAt, updatedAt, lastScan, scanVersion, isMissing, isInvalid, mediaType, media, libraryFiles, userMediaProgress
}
override init() {
super.init()
}
required init(from decoder: Decoder) throws {
super.init()
let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decode(String.self, forKey: .id)
ino = try values.decode(String.self, forKey: .ino)
libraryId = try values.decode(String.self, forKey: .libraryId)
folderId = try values.decode(String.self, forKey: .folderId)
path = try values.decode(String.self, forKey: .path)
relPath = try values.decode(String.self, forKey: .relPath)
isFile = try values.decode(Bool.self, forKey: .isFile)
mtimeMs = try values.decode(Int.self, forKey: .mtimeMs)
ctimeMs = try values.decode(Int.self, forKey: .ctimeMs)
birthtimeMs = try values.decode(Int.self, forKey: .birthtimeMs)
addedAt = try values.decode(Int.self, forKey: .addedAt)
updatedAt = try values.decode(Int.self, forKey: .updatedAt)
lastScan = try? values.decode(Int.self, forKey: .lastScan)
scanVersion = try? values.decode(String.self, forKey: .scanVersion)
isMissing = try values.decode(Bool.self, forKey: .isMissing)
isInvalid = try values.decode(Bool.self, forKey: .isInvalid)
mediaType = try values.decode(String.self, forKey: .mediaType)
media = try? values.decode(MediaType.self, forKey: .media)
if let files = try? values.decode([LibraryFile].self, forKey: .libraryFiles) {
libraryFiles.append(objectsIn: files)
}
userMediaProgress = try? values.decode(MediaProgress.self, forKey: .userMediaProgress)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(ino, forKey: .ino)
try container.encode(libraryId, forKey: .libraryId)
try container.encode(folderId, forKey: .folderId)
try container.encode(path, forKey: .path)
try container.encode(relPath, forKey: .relPath)
try container.encode(isFile, forKey: .isFile)
try container.encode(mtimeMs, forKey: .mtimeMs)
try container.encode(ctimeMs, forKey: .ctimeMs)
try container.encode(birthtimeMs, forKey: .birthtimeMs)
try container.encode(addedAt, forKey: .addedAt)
try container.encode(updatedAt, forKey: .updatedAt)
try container.encode(lastScan, forKey: .lastScan)
try container.encode(scanVersion, forKey: .scanVersion)
try container.encode(isMissing, forKey: .isMissing)
try container.encode(isInvalid, forKey: .isInvalid)
try container.encode(mediaType, forKey: .mediaType)
try container.encode(media, forKey: .media)
try container.encode(Array(libraryFiles), forKey: .libraryFiles)
try container.encode(userMediaProgress, forKey: .userMediaProgress)
}
}
@@ -0,0 +1,58 @@
//
// MediaProgress.swift
// App
//
// Created by Ron Heft on 8/16/22.
//
import Foundation
import RealmSwift
class MediaProgress: EmbeddedObject, Codable {
@Persisted var id: String = ""
@Persisted var libraryItemId: String = ""
@Persisted var episodeId: String?
@Persisted var duration: Double = 0
@Persisted var progress: Double = 0
@Persisted var currentTime: Double = 0
@Persisted var isFinished: Bool = false
@Persisted var lastUpdate: Double = 0
@Persisted var startedAt: Double = 0
@Persisted var finishedAt: Double?
private enum CodingKeys : String, CodingKey {
case id, libraryItemId, episodeId, duration, progress, currentTime, isFinished, lastUpdate, startedAt, finishedAt
}
override init() {
super.init()
}
required init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decode(String.self, forKey: .id)
libraryItemId = try values.decode(String.self, forKey: .libraryItemId)
episodeId = try? values.decode(String.self, forKey: .episodeId)
duration = try values.doubleOrStringDecoder(key: .duration)
progress = try values.doubleOrStringDecoder(key: .progress)
currentTime = try values.doubleOrStringDecoder(key: .currentTime)
isFinished = try values.decode(Bool.self, forKey: .isFinished)
lastUpdate = try values.doubleOrStringDecoder(key: .lastUpdate)
startedAt = try values.doubleOrStringDecoder(key: .startedAt)
finishedAt = try? values.doubleOrStringDecoder(key: .finishedAt)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(libraryItemId, forKey: .libraryItemId)
try container.encode(episodeId, forKey: .episodeId)
try container.encode(duration, forKey: .duration)
try container.encode(progress, forKey: .progress)
try container.encode(currentTime, forKey: .currentTime)
try container.encode(isFinished, forKey: .isFinished)
try container.encode(lastUpdate, forKey: .lastUpdate)
try container.encode(startedAt, forKey: .startedAt)
try container.encode(finishedAt, forKey: .finishedAt)
}
}
@@ -0,0 +1,72 @@
//
// MediaType.swift
// App
//
// Created by Ron Heft on 8/16/22.
//
import Foundation
import RealmSwift
class MediaType: EmbeddedObject, Codable {
@Persisted var libraryItemId: String?
@Persisted var metadata: Metadata?
@Persisted var coverPath: String?
@Persisted var tags = List<String>()
@Persisted var audioFiles = List<AudioFile>()
@Persisted var chapters = List<Chapter>()
@Persisted var tracks = List<AudioTrack>()
@Persisted var size: Int?
@Persisted var duration: Double?
@Persisted var episodes = List<PodcastEpisode>()
@Persisted var autoDownloadEpisodes: Bool?
private enum CodingKeys : String, CodingKey {
case libraryItemId, metadata, coverPath, tags, audioFiles, chapters, tracks, size, duration, episodes, autoDownloadEpisodes
}
override init() {
super.init()
}
required init(from decoder: Decoder) throws {
super.init()
let values = try decoder.container(keyedBy: CodingKeys.self)
libraryItemId = try? values.decode(String.self, forKey: .libraryItemId)
metadata = try? values.decode(Metadata.self, forKey: .metadata)
coverPath = try? values.decode(String.self, forKey: .coverPath)
if let tagList = try? values.decode([String].self, forKey: .tags) {
tags.append(objectsIn: tagList)
}
if let fileList = try? values.decode([AudioFile].self, forKey: .audioFiles) {
audioFiles.append(objectsIn: fileList)
}
if let chapterList = try? values.decode([Chapter].self, forKey: .chapters) {
chapters.append(objectsIn: chapterList)
}
if let trackList = try? values.decode([AudioTrack].self, forKey: .tracks) {
tracks.append(objectsIn: trackList)
}
size = try? values.decode(Int.self, forKey: .size)
duration = try? values.decode(Double.self, forKey: .duration)
if let episodeList = try? values.decode([PodcastEpisode].self, forKey: .episodes) {
episodes.append(objectsIn: episodeList)
}
autoDownloadEpisodes = try? values.decode(Bool.self, forKey: .autoDownloadEpisodes)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(libraryItemId, forKey: .libraryItemId)
try container.encode(metadata, forKey: .metadata)
try container.encode(coverPath, forKey: .coverPath)
try container.encode(Array(tags), forKey: .tags)
try container.encode(Array(audioFiles), forKey: .audioFiles)
try container.encode(Array(chapters), forKey: .chapters)
try container.encode(Array(tracks), forKey: .tracks)
try container.encode(size, forKey: .size)
try container.encode(duration, forKey: .duration)
try container.encode(Array(episodes), forKey: .episodes)
try container.encode(autoDownloadEpisodes, forKey: .autoDownloadEpisodes)
}
}
+108
View File
@@ -0,0 +1,108 @@
//
// Metadata.swift
// App
//
// Created by Ron Heft on 8/16/22.
//
import Foundation
import RealmSwift
class Metadata: EmbeddedObject, Codable {
@Persisted var title: String = "Unknown"
@Persisted var subtitle: String?
@Persisted var authors = List<Author>()
@Persisted var narrators = List<String>()
@Persisted var genres = List<String>()
@Persisted var publishedYear: String?
@Persisted var publishedDate: String?
@Persisted var publisher: String?
@Persisted var desc: String?
@Persisted var isbn: String?
@Persisted var asin: String?
@Persisted var language: String?
@Persisted var explicit: Bool = false
@Persisted var authorName: String?
@Persisted var authorNameLF: String?
@Persisted var narratorName: String?
@Persisted var seriesName: String?
@Persisted var feedUrl: String?
var authorDisplayName: String { self.authorName ?? "Unknown" }
private enum CodingKeys : String, CodingKey {
case title,
subtitle,
authors,
narrators,
genres,
publishedYear,
publishedDate,
publisher,
desc = "description", // Fixes a collision with the base Swift object's field "description"
isbn,
asin,
language,
explicit,
authorName,
authorNameLF,
narratorName,
seriesName,
feedUrl
}
override init() {
super.init()
}
required init(from decoder: Decoder) throws {
super.init()
let values = try decoder.container(keyedBy: CodingKeys.self)
title = try values.decode(String.self, forKey: .title)
subtitle = try? values.decode(String.self, forKey: .subtitle)
if let authorList = try? values.decode([Author].self, forKey: .authors) {
authors.append(objectsIn: authorList)
}
if let narratorList = try? values.decode([String].self, forKey: .narrators) {
narrators.append(objectsIn: narratorList)
}
if let genreList = try? values.decode([String].self, forKey: .genres) {
genres.append(objectsIn: genreList)
}
publishedYear = try? values.decode(String.self, forKey: .publishedYear)
publishedDate = try? values.decode(String.self, forKey: .publishedDate)
publisher = try? values.decode(String.self, forKey: .publisher)
desc = try? values.decode(String.self, forKey: .desc)
isbn = try? values.decode(String.self, forKey: .isbn)
asin = try? values.decode(String.self, forKey: .asin)
language = try? values.decode(String.self, forKey: .language)
explicit = try values.decode(Bool.self, forKey: .explicit)
authorName = try? values.decode(String.self, forKey: .authorName)
authorNameLF = try? values.decode(String.self, forKey: .authorNameLF)
narratorName = try? values.decode(String.self, forKey: .narratorName)
seriesName = try? values.decode(String.self, forKey: .seriesName)
feedUrl = try? values.decode(String.self, forKey: .feedUrl)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(title, forKey: .title)
try container.encode(subtitle, forKey: .subtitle)
try container.encode(Array(authors), forKey: .authors)
try container.encode(Array(narrators), forKey: .narrators)
try container.encode(Array(genres), forKey: .genres)
try container.encode(publishedYear, forKey: .publishedYear)
try container.encode(publishedDate, forKey: .publishedDate)
try container.encode(publisher, forKey: .publisher)
try container.encode(desc, forKey: .desc)
try container.encode(isbn, forKey: .isbn)
try container.encode(asin, forKey: .asin)
try container.encode(language, forKey: .language)
try container.encode(explicit, forKey: .explicit)
try container.encode(authorName, forKey: .authorName)
try container.encode(authorNameLF, forKey: .authorNameLF)
try container.encode(narratorName, forKey: .narratorName)
try container.encode(seriesName, forKey: .seriesName)
try container.encode(feedUrl, forKey: .feedUrl)
}
}
@@ -0,0 +1,72 @@
//
// PodcastEpisode.swift
// App
//
// Created by Ron Heft on 8/16/22.
//
import Foundation
import RealmSwift
class PodcastEpisode: EmbeddedObject, Codable {
@Persisted var id: String = ""
@Persisted var index: Int?
@Persisted var episode: String?
@Persisted var episodeType: String?
@Persisted var title: String = "Unknown"
@Persisted var subtitle: String?
@Persisted var desc: String?
@Persisted var audioFile: AudioFile?
@Persisted var audioTrack: AudioTrack?
@Persisted var duration: Double?
@Persisted var size: Int?
var serverEpisodeId: String { self.id }
private enum CodingKeys : String, CodingKey {
case id,
index,
episode,
episodeType,
title,
subtitle,
desc = "description", // Fixes a collision with the base Swift object's field "description"
audioFile,
audioTrack,
duration,
size,
serverEpisodeId
}
override init() {}
required init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decode(String.self, forKey: .id)
index = try? values.decode(Int.self, forKey: .index)
episode = try? values.decode(String.self, forKey: .episode)
episodeType = try? values.decode(String.self, forKey: .episodeType)
title = try values.decode(String.self, forKey: .title)
subtitle = try? values.decode(String.self, forKey: .subtitle)
desc = try? values.decode(String.self, forKey: .desc)
audioFile = try? values.decode(AudioFile.self, forKey: .audioFile)
audioTrack = try? values.decode(AudioTrack.self, forKey: .audioTrack)
duration = try? values.decode(Double.self, forKey: .duration)
size = try? values.decode(Int.self, forKey: .size)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(index, forKey: .index)
try container.encode(episode, forKey: .episode)
try container.encode(episodeType, forKey: .episodeType)
try container.encode(title, forKey: .title)
try container.encode(subtitle, forKey: .subtitle)
try container.encode(desc, forKey: .desc)
try container.encode(audioFile, forKey: .audioFile)
try container.encode(audioTrack, forKey: .audioTrack)
try container.encode(duration, forKey: .duration)
try container.encode(size, forKey: .size)
try container.encode(serverEpisodeId, forKey: .serverEpisodeId)
}
}
+272 -33
View File
@@ -18,12 +18,13 @@ enum PlayMethod:Int {
} }
class AudioPlayer: NSObject { class AudioPlayer: NSObject {
private let queue = DispatchQueue(label: "ABSAudioPlayerQueue")
// enums and @objc are not compatible // enums and @objc are not compatible
@objc dynamic var status: Int @objc dynamic var status: Int
@objc dynamic var rate: Float @objc dynamic var rate: Float
private var tmpRate: Float = 1.0 private var tmpRate: Float = 1.0
private var lastPlayTime: Double = 0.0
private var playerContext = 0 private var playerContext = 0
private var playerItemContext = 0 private var playerItemContext = 0
@@ -32,21 +33,27 @@ class AudioPlayer: NSObject {
private var initialPlaybackRate: Float private var initialPlaybackRate: Float
private var audioPlayer: AVQueuePlayer private var audioPlayer: AVQueuePlayer
private var playbackSession: PlaybackSession private var sessionId: String
private var timeObserverToken: Any?
private var queueObserver:NSKeyValueObservation? private var queueObserver:NSKeyValueObservation?
private var queueItemStatusObserver:NSKeyValueObservation? private var queueItemStatusObserver:NSKeyValueObservation?
private var sleepTimeStopAt: Double?
private var sleepTimeToken: Any?
private var currentTrackIndex = 0 private var currentTrackIndex = 0
private var allPlayerItems:[AVPlayerItem] = [] private var allPlayerItems:[AVPlayerItem] = []
private var pausedTimer: Timer?
// MARK: - Constructor // MARK: - Constructor
init(playbackSession: PlaybackSession, playWhenReady: Bool = false, playbackRate: Float = 1) { init(sessionId: String, playWhenReady: Bool = false, playbackRate: Float = 1) {
self.playWhenReady = playWhenReady self.playWhenReady = playWhenReady
self.initialPlaybackRate = playbackRate self.initialPlaybackRate = playbackRate
self.audioPlayer = AVQueuePlayer() self.audioPlayer = AVQueuePlayer()
self.audioPlayer.automaticallyWaitsToMinimizeStalling = false self.audioPlayer.automaticallyWaitsToMinimizeStalling = false
self.playbackSession = playbackSession self.sessionId = sessionId
self.status = -1 self.status = -1
self.rate = 0.0 self.rate = 0.0
self.tmpRate = playbackRate self.tmpRate = playbackRate
@@ -56,6 +63,8 @@ class AudioPlayer: NSObject {
initAudioSession() initAudioSession()
setupRemoteTransportControls() setupRemoteTransportControls()
let playbackSession = Database.shared.getPlaybackSession(id: self.sessionId)!
// Listen to player events // Listen to player events
self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.rate), options: .new, context: &playerContext) self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.rate), options: .new, context: &playerContext)
self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem), options: .new, context: &playerContext) self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem), options: .new, context: &playerContext)
@@ -75,12 +84,16 @@ class AudioPlayer: NSObject {
self.audioPlayer.insert(item, after:self.audioPlayer.items().last) self.audioPlayer.insert(item, after:self.audioPlayer.items().last)
} }
setupTimeObserver()
setupQueueObserver() setupQueueObserver()
setupQueueItemStatusObserver() setupQueueItemStatusObserver()
NSLog("Audioplayer ready") NSLog("Audioplayer ready")
} }
deinit { deinit {
self.stopPausedTimer()
self.removeSleepTimer()
self.removeTimeObserver()
self.queueObserver?.invalidate() self.queueObserver?.invalidate()
self.queueItemStatusObserver?.invalidate() self.queueItemStatusObserver?.invalidate()
destroy() destroy()
@@ -105,7 +118,12 @@ class AudioPlayer: NSObject {
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.closed.rawValue), object: nil) NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.closed.rawValue), object: nil)
} }
func isInitialized() -> Bool {
return self.status != -1
}
func getItemIndexForTime(time:Double) -> Int { func getItemIndexForTime(time:Double) -> Int {
let playbackSession = Database.shared.getPlaybackSession(id: self.sessionId)!
for index in 0..<self.allPlayerItems.count { for index in 0..<self.allPlayerItems.count {
let startOffset = playbackSession.audioTracks[index].startOffset ?? 0.0 let startOffset = playbackSession.audioTracks[index].startOffset ?? 0.0
let duration = playbackSession.audioTracks[index].duration let duration = playbackSession.audioTracks[index].duration
@@ -117,6 +135,36 @@ class AudioPlayer: NSObject {
return 0 return 0
} }
private func setupTimeObserver() {
// Time observer should be configured on the main queue
DispatchQueue.runOnMainQueue {
self.removeTimeObserver()
let timeScale = CMTimeScale(NSEC_PER_SEC)
// Rate will be different depending on playback speed, aim for 2 observations/sec
let seconds = 0.5 * (self.rate > 0 ? self.rate : 1.0)
let time = CMTime(seconds: Double(seconds), preferredTimescale: timeScale)
self.timeObserverToken = self.audioPlayer.addPeriodicTimeObserver(forInterval: time, queue: self.queue) { [weak self] time in
Task {
// Let the player update the current playback positions
await PlayerProgress.shared.syncFromPlayer(currentTime: time.seconds, includesPlayProgress: true, isStopping: false)
}
// Update the sleep time, if set
if self?.sleepTimeStopAt != nil {
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil)
}
}
}
}
private func removeTimeObserver() {
if let timeObserverToken = timeObserverToken {
self.audioPlayer.removeTimeObserver(timeObserverToken)
self.timeObserverToken = nil
}
}
func setupQueueObserver() { func setupQueueObserver() {
self.queueObserver = self.audioPlayer.observe(\.currentItem, options: [.new]) {_,_ in self.queueObserver = self.audioPlayer.observe(\.currentItem, options: [.new]) {_,_ in
let prevTrackIndex = self.currentTrackIndex let prevTrackIndex = self.currentTrackIndex
@@ -132,44 +180,70 @@ class AudioPlayer: NSObject {
func setupQueueItemStatusObserver() { func setupQueueItemStatusObserver() {
self.queueItemStatusObserver?.invalidate() self.queueItemStatusObserver?.invalidate()
self.queueItemStatusObserver = self.audioPlayer.currentItem?.observe(\.status, options: [.new, .old], changeHandler: { (playerItem, change) in self.queueItemStatusObserver = self.audioPlayer.currentItem?.observe(\.status, options: [.new, .old], changeHandler: { (playerItem, change) in
let playbackSession = Database.shared.getPlaybackSession(id: self.sessionId)!
if (playerItem.status == .readyToPlay) { if (playerItem.status == .readyToPlay) {
NSLog("queueStatusObserver: Current Item Ready to play. PlayWhenReady: \(self.playWhenReady)") NSLog("queueStatusObserver: Current Item Ready to play. PlayWhenReady: \(self.playWhenReady)")
self.updateNowPlaying() self.updateNowPlaying()
// Seek the player before initializing, so a currentTime of 0 does not appear in MediaProgress / session
let firstReady = self.status < 0 let firstReady = self.status < 0
if firstReady || self.playWhenReady {
self.seek(playbackSession.currentTime, from: "queueItemStatusObserver")
}
// Mark the player as ready
self.status = 0 self.status = 0
// Start the player, if requested
if self.playWhenReady { if self.playWhenReady {
self.seek(self.playbackSession.currentTime, from: "queueItemStatusObserver")
self.playWhenReady = false self.playWhenReady = false
self.play() self.play()
} else if (firstReady) { // Only seek on first readyToPlay
self.seek(self.playbackSession.currentTime, from: "queueItemStatusObserver")
} }
} else if (playerItem.status == .failed) { } else if (playerItem.status == .failed) {
NSLog("queueStatusObserver: FAILED \(playerItem.error?.localizedDescription ?? "")") NSLog("queueStatusObserver: FAILED \(playerItem.error?.localizedDescription ?? "")")
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil) NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
} }
}) })
} }
private func startPausedTimer() {
guard self.pausedTimer == nil else { return }
self.queue.async {
self.pausedTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { timer in
NSLog("PAUSE TIMER: Syncing from server")
Task { await PlayerProgress.shared.syncFromServer() }
}
}
}
private func stopPausedTimer() {
self.pausedTimer?.invalidate()
self.pausedTimer = nil
}
// MARK: - Methods // MARK: - Methods
public func play(allowSeekBack: Bool = false) { public func play(allowSeekBack: Bool = false) {
if allowSeekBack { guard self.isInitialized() else { return }
let diffrence = Date.timeIntervalSinceReferenceDate - lastPlayTime
// Capture remaining sleep time before changing the track position
let sleepSecondsRemaining = PlayerHandler.remainingSleepTime
if allowSeekBack, let session = Database.shared.getPlaybackSession(id: self.sessionId) {
let lastPlayed = (session.updatedAt ?? 0)/1000
let difference = Date.timeIntervalSinceReferenceDate - lastPlayed
var time: Int? var time: Int?
if lastPlayTime == 0 { if lastPlayed == 0 {
time = 5 time = 5
} else if diffrence < 6 { } else if difference < 6 {
time = 2 time = 2
} else if diffrence < 12 { } else if difference < 12 {
time = 10 time = 10
} else if diffrence < 30 { } else if difference < 30 {
time = 15 time = 15
} else if diffrence < 180 { } else if difference < 180 {
time = 20 time = 20
} else if diffrence < 3600 { } else if difference < 3600 {
time = 25 time = 25
} else { } else {
time = 29 time = 29
@@ -179,23 +253,41 @@ class AudioPlayer: NSObject {
seek(getCurrentTime() - Double(time!), from: "play") seek(getCurrentTime() - Double(time!), from: "play")
} }
} }
lastPlayTime = Date.timeIntervalSinceReferenceDate
self.stopPausedTimer()
Task {
let isPlaying = self.status > 0
await PlayerProgress.shared.syncFromPlayer(currentTime: self.getCurrentTime(), includesPlayProgress: isPlaying, isStopping: false)
}
self.audioPlayer.play() self.audioPlayer.play()
self.status = 1 self.status = 1
self.rate = self.tmpRate self.rate = self.tmpRate
self.audioPlayer.rate = self.tmpRate self.audioPlayer.rate = self.tmpRate
// If we have an active sleep timer, reschedule based on rate
self.rescheduleSleepTimerAtTime(time: self.getCurrentTime(), secondsRemaining: sleepSecondsRemaining)
updateNowPlaying() updateNowPlaying()
} }
public func pause() { public func pause() {
guard self.isInitialized() else { return }
self.audioPlayer.pause() self.audioPlayer.pause()
Task {
let wasPlaying = self.status > 0
await PlayerProgress.shared.syncFromPlayer(currentTime: self.getCurrentTime(), includesPlayProgress: wasPlaying, isStopping: true)
}
self.status = 0 self.status = 0
self.rate = 0.0 self.rate = 0.0
updateNowPlaying() updateNowPlaying()
lastPlayTime = Date.timeIntervalSinceReferenceDate
self.startPausedTimer()
} }
public func seek(_ to: Double, from: String) { public func seek(_ to: Double, from: String) {
@@ -205,11 +297,15 @@ class AudioPlayer: NSObject {
NSLog("Seek to \(to) from \(from)") NSLog("Seek to \(to) from \(from)")
let currentTrack = self.playbackSession.audioTracks[self.currentTrackIndex] let playbackSession = Database.shared.getPlaybackSession(id: self.sessionId)!
let currentTrack = playbackSession.audioTracks[self.currentTrackIndex]
let ctso = currentTrack.startOffset ?? 0.0 let ctso = currentTrack.startOffset ?? 0.0
let trackEnd = ctso + currentTrack.duration let trackEnd = ctso + currentTrack.duration
NSLog("Seek current track END = \(trackEnd)") NSLog("Seek current track END = \(trackEnd)")
// Capture remaining sleep time before changing the track position
let sleepSecondsRemaining = PlayerHandler.remainingSleepTime
let indexOfSeek = getItemIndexForTime(time: to) let indexOfSeek = getItemIndexForTime(time: to)
NSLog("Seek to index \(indexOfSeek) | Current index \(self.currentTrackIndex)") NSLog("Seek to index \(indexOfSeek) | Current index \(self.currentTrackIndex)")
@@ -218,7 +314,9 @@ class AudioPlayer: NSObject {
if (self.currentTrackIndex != indexOfSeek) { if (self.currentTrackIndex != indexOfSeek) {
self.currentTrackIndex = indexOfSeek self.currentTrackIndex = indexOfSeek
self.playbackSession.currentTime = to try? playbackSession.update {
playbackSession.currentTime = to
}
self.playWhenReady = continuePlaying // Only playWhenReady if already playing self.playWhenReady = continuePlaying // Only playWhenReady if already playing
self.status = -1 self.status = -1
@@ -232,53 +330,182 @@ class AudioPlayer: NSObject {
setupQueueItemStatusObserver() setupQueueItemStatusObserver()
} else { } else {
NSLog("Seeking in current item \(to)") NSLog("Seeking in current item \(to)")
let currentTrackStartOffset = self.playbackSession.audioTracks[self.currentTrackIndex].startOffset ?? 0.0 let currentTrackStartOffset = playbackSession.audioTracks[self.currentTrackIndex].startOffset ?? 0.0
let seekTime = to - currentTrackStartOffset let seekTime = to - currentTrackStartOffset
self.audioPlayer.seek(to: CMTime(seconds: seekTime, preferredTimescale: 1000)) { completed in self.audioPlayer.seek(to: CMTime(seconds: seekTime, preferredTimescale: 1000)) { [weak self] completed in
if !completed { if !completed {
NSLog("WARNING: seeking not completed (to \(seekTime)") NSLog("WARNING: seeking not completed (to \(seekTime)")
} }
if continuePlaying { if continuePlaying {
self.play() self?.play()
}
self?.updateNowPlaying()
// If we have an active sleep timer, reschedule based on seek, since seek is fuzzy
// This needs to occur after play() to capture the correct playback rate
if let currentTime = self?.getCurrentTime() {
self?.rescheduleSleepTimerAtTime(time: currentTime, secondsRemaining: sleepSecondsRemaining)
} }
self.updateNowPlaying()
} }
} }
} }
public func setPlaybackRate(_ rate: Float, observed: Bool = false) { public func setPlaybackRate(_ rate: Float, observed: Bool = false) {
// Capture remaining sleep time before changing the rate
let sleepSecondsRemaining = PlayerHandler.remainingSleepTime
let playbackSpeedChanged = rate > 0.0 && rate != self.tmpRate && !(observed && rate == 1)
if self.audioPlayer.rate != rate { if self.audioPlayer.rate != rate {
NSLog("setPlaybakRate rate changed from \(self.audioPlayer.rate) to \(rate)") NSLog("setPlaybakRate rate changed from \(self.audioPlayer.rate) to \(rate)")
self.audioPlayer.rate = rate self.audioPlayer.rate = rate
} }
if rate > 0.0 && !(observed && rate == 1) {
self.tmpRate = rate
}
self.rate = rate self.rate = rate
self.updateNowPlaying() self.updateNowPlaying()
if playbackSpeedChanged {
self.tmpRate = rate
// If we have an active sleep timer, reschedule based on rate
self.rescheduleSleepTimerAtTime(time: self.getCurrentTime(), secondsRemaining: sleepSecondsRemaining)
// Setup the time observer again at the new rate
self.setupTimeObserver()
}
}
public func getSleepStopAt() -> Double? {
return self.sleepTimeStopAt
}
// Let iOS handle the sleep timer logic by letting us know when it's time to stop
public func setSleepTime(stopAt: Double, scaleBasedOnSpeed: Bool = false) {
NSLog("SLEEP TIMER: Scheduling for \(stopAt)")
// Reset any previous sleep timer
self.removeSleepTimer()
let currentTime = getCurrentTime()
// Mark the time to stop playing
if scaleBasedOnSpeed {
// Consider paused as playing at 1x
let rate = Double(self.rate > 0 ? self.rate : 1)
// Calculate the scaled time to stop at
let timeUntilSleep = (stopAt - currentTime) * rate
self.sleepTimeStopAt = currentTime + timeUntilSleep
NSLog("SLEEP TIMER: Adjusted based on playback speed of \(rate) to \(self.sleepTimeStopAt!)")
} else {
self.sleepTimeStopAt = stopAt
}
guard let sleepTimeStopAt = self.sleepTimeStopAt else { return }
let sleepTime = CMTime(seconds: sleepTimeStopAt, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
// Schedule the observation time
var times = [NSValue]()
times.append(NSValue(time: sleepTime))
sleepTimeToken = self.audioPlayer.addBoundaryTimeObserver(forTimes: times, queue: queue) { [weak self] in
NSLog("SLEEP TIMER: Pausing audio")
self?.pause()
PlayerHandler.sleepTimerChapterStopTime = nil
self?.removeSleepTimer()
}
// Update the UI
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil)
}
private func rescheduleSleepTimerAtTime(time: Double, secondsRemaining: Int?) {
// Not a chapter sleep timer
let hadToCancelChapterSleepTimer = decideIfChapterSleepTimerNeedsToBeCanceled(time: time)
guard !hadToCancelChapterSleepTimer else { return }
guard PlayerHandler.sleepTimerChapterStopTime == nil else { return }
// Verify sleep timer is set
guard self.sleepTimeToken != nil else { return }
// Update the sleep timer
if let secondsRemaining = secondsRemaining {
let newSleepTimerPosition = time + Double(secondsRemaining)
self.setSleepTime(stopAt: newSleepTimerPosition, scaleBasedOnSpeed: true)
}
}
private func decideIfChapterSleepTimerNeedsToBeCanceled(time: Double) -> Bool {
if let chapterSleepTime = PlayerHandler.sleepTimerChapterStopTime {
let sleepIsBeforeCurrentTime = Double(chapterSleepTime) <= time
if sleepIsBeforeCurrentTime {
PlayerHandler.sleepTimerChapterStopTime = nil
self.removeSleepTimer()
return true
}
}
return false
}
public func increaseSleepTime(extraTimeInSeconds: Double) {
if let sleepTime = PlayerHandler.remainingSleepTime {
let currentTime = getCurrentTime()
let newSleepTimerPosition = currentTime + Double(sleepTime) + extraTimeInSeconds
if newSleepTimerPosition > currentTime {
self.setSleepTime(stopAt: newSleepTimerPosition, scaleBasedOnSpeed: true)
}
}
}
public func decreaseSleepTime(removeTimeInSeconds: Double) {
if let sleepTime = PlayerHandler.remainingSleepTime {
let currentTime = getCurrentTime()
let newSleepTimerPosition = currentTime + Double(sleepTime) - removeTimeInSeconds
guard newSleepTimerPosition > currentTime else { return }
if newSleepTimerPosition > currentTime {
self.setSleepTime(stopAt: newSleepTimerPosition, scaleBasedOnSpeed: true)
}
}
}
public func removeSleepTimer() {
self.sleepTimeStopAt = nil
if let token = sleepTimeToken {
self.audioPlayer.removeTimeObserver(token)
sleepTimeToken = nil
}
// Update the UI
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepEnded.rawValue), object: self)
} }
public func getCurrentTime() -> Double { public func getCurrentTime() -> Double {
let playbackSession = Database.shared.getPlaybackSession(id: self.sessionId)!
let currentTrackTime = self.audioPlayer.currentTime().seconds let currentTrackTime = self.audioPlayer.currentTime().seconds
let audioTrack = playbackSession.audioTracks[currentTrackIndex] let audioTrack = playbackSession.audioTracks[currentTrackIndex]
let startOffset = audioTrack.startOffset ?? 0.0 let startOffset = audioTrack.startOffset ?? 0.0
return startOffset + currentTrackTime return startOffset + currentTrackTime
} }
public func getPlayMethod() -> Int { public func getPlayMethod() -> Int {
return self.playbackSession.playMethod let playbackSession = Database.shared.getPlaybackSession(id: self.sessionId)!
return playbackSession.playMethod
} }
public func getPlaybackSession() -> PlaybackSession { public func getPlaybackSessionId() -> String {
return self.playbackSession return self.sessionId
} }
public func getDuration() -> Double { public func getDuration() -> Double {
let playbackSession = Database.shared.getPlaybackSession(id: self.sessionId)!
return playbackSession.duration return playbackSession.duration
} }
// MARK: - Private // MARK: - Private
private func createAsset(itemId:String, track:AudioTrack) -> AVAsset { private func createAsset(itemId:String, track:AudioTrack) -> AVAsset {
let playbackSession = Database.shared.getPlaybackSession(id: self.sessionId)!
if (playbackSession.playMethod == PlayMethod.directplay.rawValue) { if (playbackSession.playMethod == PlayMethod.directplay.rawValue) {
// The only reason this is separate is because the filename needs to be encoded // The only reason this is separate is because the filename needs to be encoded
let filename = track.metadata?.filename ?? "" let filename = track.metadata?.filename ?? ""
@@ -286,6 +513,17 @@ class AudioPlayer: NSObject {
let urlstr = "\(Store.serverConfig!.address)/s/item/\(itemId)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)" let urlstr = "\(Store.serverConfig!.address)/s/item/\(itemId)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)"
let url = URL(string: urlstr)! let url = URL(string: urlstr)!
return AVURLAsset(url: url) return AVURLAsset(url: url)
} else if (playbackSession.playMethod == PlayMethod.local.rawValue) {
guard let localFile = track.getLocalFile() else {
// Worst case we can stream the file
NSLog("Unable to play local file. Resulting to streaming \(track.localFileId ?? "Unknown")")
let filename = track.metadata?.filename ?? ""
let filenameEncoded = filename.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed)
let urlstr = "\(Store.serverConfig!.address)/s/item/\(itemId)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)"
let url = URL(string: urlstr)!
return AVURLAsset(url: url)
}
return AVURLAsset(url: localFile.contentPath)
} else { // HLS Transcode } else { // HLS Transcode
let headers: [String: String] = [ let headers: [String: String] = [
"Authorization": "Bearer \(Store.serverConfig!.token)" "Authorization": "Bearer \(Store.serverConfig!.token)"
@@ -296,7 +534,7 @@ class AudioPlayer: NSObject {
private func initAudioSession() { private func initAudioSession() {
do { do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio, options: [.allowAirPlay]) try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio)
try AVAudioSession.sharedInstance().setActive(true) try AVAudioSession.sharedInstance().setActive(true)
} catch { } catch {
NSLog("Failed to set AVAudioSession category") NSLog("Failed to set AVAudioSession category")
@@ -310,6 +548,7 @@ class AudioPlayer: NSObject {
UIApplication.shared.beginReceivingRemoteControlEvents() UIApplication.shared.beginReceivingRemoteControlEvents()
} }
let commandCenter = MPRemoteCommandCenter.shared() let commandCenter = MPRemoteCommandCenter.shared()
let deviceSettings = Database.shared.getDeviceSettings()
commandCenter.playCommand.isEnabled = true commandCenter.playCommand.isEnabled = true
commandCenter.playCommand.addTarget { [unowned self] event in commandCenter.playCommand.addTarget { [unowned self] event in
@@ -323,7 +562,7 @@ class AudioPlayer: NSObject {
} }
commandCenter.skipForwardCommand.isEnabled = true commandCenter.skipForwardCommand.isEnabled = true
commandCenter.skipForwardCommand.preferredIntervals = [30] commandCenter.skipForwardCommand.preferredIntervals = [NSNumber(value: deviceSettings.jumpForwardTime)]
commandCenter.skipForwardCommand.addTarget { [unowned self] event in commandCenter.skipForwardCommand.addTarget { [unowned self] event in
guard let command = event.command as? MPSkipIntervalCommand else { guard let command = event.command as? MPSkipIntervalCommand else {
return .noSuchContent return .noSuchContent
@@ -333,7 +572,7 @@ class AudioPlayer: NSObject {
return .success return .success
} }
commandCenter.skipBackwardCommand.isEnabled = true commandCenter.skipBackwardCommand.isEnabled = true
commandCenter.skipBackwardCommand.preferredIntervals = [30] commandCenter.skipBackwardCommand.preferredIntervals = [NSNumber(value: deviceSettings.jumpBackwardsTime)]
commandCenter.skipBackwardCommand.addTarget { [unowned self] event in commandCenter.skipBackwardCommand.addTarget { [unowned self] event in
guard let command = event.command as? MPSkipIntervalCommand else { guard let command = event.command as? MPSkipIntervalCommand else {
return .noSuchContent return .noSuchContent
+118 -105
View File
@@ -6,41 +6,48 @@
// //
import Foundation import Foundation
import RealmSwift
class PlayerHandler { class PlayerHandler {
private static var player: AudioPlayer? private static var player: AudioPlayer?
private static var session: PlaybackSession?
private static var timer: Timer?
private static var lastSyncTime:Double = 0.0
private static var _remainingSleepTime: Int? = nil public static var sleepTimerChapterStopTime: Int? = nil
public static var remainingSleepTime: Int? {
get { public static func startPlayback(sessionId: String, playWhenReady: Bool, playbackRate: Float) {
return _remainingSleepTime guard let session = Database.shared.getPlaybackSession(id: sessionId) else { return }
}
set(time) { // Clean up the existing player
if time != nil && time! < 0 { if player != nil {
_remainingSleepTime = nil player?.destroy()
} else { player = nil
_remainingSleepTime = time
}
if _remainingSleepTime == nil {
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepEnded.rawValue), object: _remainingSleepTime)
} else {
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: _remainingSleepTime)
}
} }
// Cleanup and sync old sessions
cleanupOldSessions(currentSessionId: sessionId)
Task { await PlayerProgress.shared.syncToServer() }
// Set now playing info
NowPlayingInfo.shared.setSessionMetadata(metadata: NowPlayingMetadata(id: session.id, itemId: session.libraryItemId!, artworkUrl: session.coverPath, title: session.displayTitle ?? "Unknown title", author: session.displayAuthor, series: nil))
// Create the audio player
player = AudioPlayer(sessionId: sessionId, playWhenReady: playWhenReady, playbackRate: playbackRate)
}
public static func stopPlayback() {
// Pause playback first, so we can sync our current progress
player?.pause()
player?.destroy()
player = nil
cleanupOldSessions(currentSessionId: nil)
NowPlayingInfo.shared.reset()
} }
private static var listeningTimePassedSinceLastSync: Double = 0.0
private static var lastSyncReport: PlaybackReport?
public static var paused: Bool { public static var paused: Bool {
get { get {
guard let player = player else { guard let player = player else { return true }
return true
}
return player.rate == 0.0 return player.rate == 0.0
} }
set(paused) { set(paused) {
@@ -52,121 +59,127 @@ class PlayerHandler {
} }
} }
public static func startPlayback(session: PlaybackSession, playWhenReady: Bool, playbackRate: Float) { public static var remainingSleepTime: Int? {
if player != nil { get {
player?.destroy() guard let player = player else { return nil }
player = nil
} // Return the player time until sleep
var timeUntilSleep: Double? = nil
NowPlayingInfo.shared.setSessionMetadata(metadata: NowPlayingMetadata(id: session.id, itemId: session.libraryItemId!, artworkUrl: session.coverPath, title: session.displayTitle ?? "Unknown title", author: session.displayAuthor, series: nil)) if let sleepTimerChapterStopTime = sleepTimerChapterStopTime {
timeUntilSleep = Double(sleepTimerChapterStopTime) - player.getCurrentTime()
self.session = session } else if let stopAt = player.getSleepStopAt() {
player = AudioPlayer(playbackSession: session, playWhenReady: playWhenReady, playbackRate: playbackRate) timeUntilSleep = stopAt - player.getCurrentTime()
}
DispatchQueue.runOnMainQueue {
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in // Scale the time until sleep based on the playback rate
self.tick() if let timeUntilSleep = timeUntilSleep {
// Consider paused as playing at 1x
let rate = Double(player.rate > 0 ? player.rate : 1)
let timeUntilSleepScaled = timeUntilSleep / rate
guard timeUntilSleepScaled.isNaN == false else { return nil }
return Int(timeUntilSleepScaled.rounded())
} else {
return nil
} }
} }
} }
public static func stopPlayback() {
player?.destroy()
player = nil
timer?.invalidate()
timer = nil
NowPlayingInfo.shared.reset()
}
public static func getCurrentTime() -> Double? { public static func getCurrentTime() -> Double? {
self.player?.getCurrentTime() self.player?.getCurrentTime()
} }
public static func setPlaybackSpeed(speed: Float) { public static func setPlaybackSpeed(speed: Float) {
self.player?.setPlaybackRate(speed) self.player?.setPlaybackRate(speed)
} }
public static func setSleepTime(secondsUntilSleep: Double) {
guard let player = player else { return }
let stopAt = secondsUntilSleep + player.getCurrentTime()
player.setSleepTime(stopAt: stopAt, scaleBasedOnSpeed: true)
}
public static func setChapterSleepTime(stopAt: Double) {
guard let player = player else { return }
self.sleepTimerChapterStopTime = Int(stopAt)
player.setSleepTime(stopAt: stopAt, scaleBasedOnSpeed: false)
}
public static func increaseSleepTime(increaseSeconds: Double) {
self.sleepTimerChapterStopTime = nil
self.player?.increaseSleepTime(extraTimeInSeconds: increaseSeconds)
}
public static func decreaseSleepTime(decreaseSeconds: Double) {
self.sleepTimerChapterStopTime = nil
self.player?.decreaseSleepTime(removeTimeInSeconds: decreaseSeconds)
}
public static func cancelSleepTime() {
PlayerHandler.sleepTimerChapterStopTime = nil
self.player?.removeSleepTimer()
}
public static func getPlayMethod() -> Int? { public static func getPlayMethod() -> Int? {
self.player?.getPlayMethod() self.player?.getPlayMethod()
} }
public static func getPlaybackSession() -> PlaybackSession? { public static func getPlaybackSession() -> PlaybackSession? {
self.player?.getPlaybackSession() guard let player = player else { return nil }
guard player.isInitialized() else { return nil }
return Database.shared.getPlaybackSession(id: player.getPlaybackSessionId())
} }
public static func seekForward(amount: Double) { public static func seekForward(amount: Double) {
guard let player = player else { guard let player = player else { return }
return
}
let destinationTime = player.getCurrentTime() + amount let destinationTime = player.getCurrentTime() + amount
player.seek(destinationTime, from: "handler") player.seek(destinationTime, from: "handler")
} }
public static func seekBackward(amount: Double) { public static func seekBackward(amount: Double) {
guard let player = player else { guard let player = player else { return }
return
}
let destinationTime = player.getCurrentTime() - amount let destinationTime = player.getCurrentTime() - amount
player.seek(destinationTime, from: "handler") player.seek(destinationTime, from: "handler")
} }
public static func seek(amount: Double) { public static func seek(amount: Double) {
player?.seek(amount, from: "handler") player?.seek(amount, from: "handler")
} }
public static func getMetdata() -> [String: Any] {
DispatchQueue.main.async { public static func getMetdata() -> [String: Any]? {
syncProgress() guard let player = player else { return nil }
} guard player.isInitialized() else { return nil }
return [ return [
"duration": player?.getDuration() ?? 0, "duration": player.getDuration(),
"currentTime": player?.getCurrentTime() ?? 0, "currentTime": player.getCurrentTime(),
"playerState": !paused, "playerState": !paused,
"currentRate": player?.rate ?? 0, "currentRate": player.rate,
] ]
} }
private static func tick() { // MARK: - Helper logic
if !paused {
listeningTimePassedSinceLastSync += 1 private static func cleanupOldSessions(currentSessionId: String?) {
} do {
let realm = try Realm()
if listeningTimePassedSinceLastSync >= 5 { let oldSessions = realm.objects(PlaybackSession.self) .where({
syncProgress() $0.isActiveSession == true && $0.serverConnectionConfigId == Store.serverConfig?.id
} })
try realm.write {
if remainingSleepTime != nil { for s in oldSessions {
if remainingSleepTime! == 0 { if s.id != currentSessionId {
paused = true s.isActiveSession = false
}
}
} }
remainingSleepTime! -= 1 } catch {
debugPrint("Failed to cleanup sessions")
debugPrint(error)
} }
} }
public static func syncProgress() {
if session == nil { return }
guard let player = player else { return }
let playerCurrentTime = player.getCurrentTime()
if (lastSyncReport != nil && lastSyncReport?.currentTime == playerCurrentTime) {
// No need to syncProgress
return
}
// Prevent multiple sync requests
let timeSinceLastSync = Date().timeIntervalSince1970 - lastSyncTime
if (lastSyncTime > 0 && timeSinceLastSync < 1) {
NSLog("syncProgress last sync time was < 1 second so not syncing")
return
}
lastSyncTime = Date().timeIntervalSince1970 // seconds
let report = PlaybackReport(currentTime: playerCurrentTime, duration: player.getDuration(), timeListened: listeningTimePassedSinceLastSync)
session!.currentTime = playerCurrentTime
listeningTimePassedSinceLastSync = 0
lastSyncReport = report
// TODO: check if online
NSLog("sending playback report")
ApiClient.reportPlaybackProgress(report: report, sessionId: session!.id)
}
} }
+206
View File
@@ -0,0 +1,206 @@
//
// PlayerProgressSync.swift
// App
//
// Created by Ron Heft on 8/19/22.
//
import Foundation
import UIKit
import RealmSwift
class PlayerProgress {
public static let shared = PlayerProgress()
private static let TIME_BETWEEN_SESSION_SYNC_IN_SECONDS = 10.0
private init() {}
// MARK: - SYNC HOOKS
public func syncFromPlayer(currentTime: Double, includesPlayProgress: Bool, isStopping: Bool) async {
let backgroundToken = await UIApplication.shared.beginBackgroundTask(withName: "ABS:syncFromPlayer")
do {
let session = try updateLocalSessionFromPlayer(currentTime: currentTime, includesPlayProgress: includesPlayProgress)
try updateLocalMediaProgressFromLocalSession()
if let session = session {
try await updateServerSessionFromLocalSession(session, rateLimitSync: !isStopping)
}
} catch {
debugPrint("Failed to syncFromPlayer")
debugPrint(error)
}
await UIApplication.shared.endBackgroundTask(backgroundToken)
}
public func syncToServer() async {
let backgroundToken = await UIApplication.shared.beginBackgroundTask(withName: "ABS:syncToServer")
do {
try await updateAllServerSessionFromLocalSession()
} catch {
debugPrint("Failed to syncToServer")
debugPrint(error)
}
await UIApplication.shared.endBackgroundTask(backgroundToken)
}
public func syncFromServer() async {
let backgroundToken = await UIApplication.shared.beginBackgroundTask(withName: "ABS:syncFromServer")
do {
try await updateLocalSessionFromServerMediaProgress()
} catch {
debugPrint("Failed to syncFromServer")
debugPrint(error)
}
await UIApplication.shared.endBackgroundTask(backgroundToken)
}
// MARK: - SYNC LOGIC
private func updateLocalSessionFromPlayer(currentTime: Double, includesPlayProgress: Bool) throws -> PlaybackSession? {
guard let session = PlayerHandler.getPlaybackSession() else { return nil }
guard !currentTime.isNaN else { return nil } // Prevent bad data on player stop
try session.update {
session.realm?.refresh()
let nowInSeconds = Date().timeIntervalSince1970
let nowInMilliseconds = nowInSeconds * 1000
let lastUpdateInMilliseconds = session.updatedAt ?? nowInMilliseconds
let lastUpdateInSeconds = lastUpdateInMilliseconds / 1000
let secondsSinceLastUpdate = nowInSeconds - lastUpdateInSeconds
session.currentTime = currentTime
session.updatedAt = nowInMilliseconds
if includesPlayProgress {
session.timeListening += secondsSinceLastUpdate
}
}
return session.freeze()
}
private func updateLocalMediaProgressFromLocalSession() throws {
guard let session = PlayerHandler.getPlaybackSession() else { return }
guard session.isLocal else { return }
let localMediaProgress = try LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: session.localMediaProgressId, localLibraryItemId: session.localLibraryItem?.id, localEpisodeId: session.episodeId)
guard let localMediaProgress = localMediaProgress else {
// Local media progress should have been created
// If we're here, it means a library id is invalid
return
}
try localMediaProgress.updateFromPlaybackSession(session)
NSLog("Local progress saved to the database")
// Send the local progress back to front-end
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.localProgress.rawValue), object: nil)
}
private func updateAllServerSessionFromLocalSession() async throws {
try await withThrowingTaskGroup(of: Void.self) { [self] group in
for session in try await Realm().objects(PlaybackSession.self).where({ $0.serverConnectionConfigId == Store.serverConfig?.id }) {
let session = session.freeze()
group.addTask {
try await self.updateServerSessionFromLocalSession(session)
}
}
try await group.waitForAll()
}
}
private func updateServerSessionFromLocalSession(_ session: PlaybackSession, rateLimitSync: Bool = false) async throws {
var safeToSync = true
guard var session = session.thaw() else { return }
// We need to update and check the server time in a transaction for thread-safety
try session.update {
session.realm?.refresh()
let nowInMilliseconds = Date().timeIntervalSince1970 * 1000
let lastUpdateInMilliseconds = session.serverUpdatedAt
// If required, rate limit requests based on session last update
if rateLimitSync {
let timeSinceLastSync = nowInMilliseconds - lastUpdateInMilliseconds
let timeBetweenSessionSync = PlayerProgress.TIME_BETWEEN_SESSION_SYNC_IN_SECONDS * 1000
safeToSync = timeSinceLastSync > timeBetweenSessionSync
if !safeToSync {
return // This only exits the update block
}
}
session.serverUpdatedAt = nowInMilliseconds
}
session = session.freeze()
guard safeToSync else { return }
NSLog("Sending sessionId(\(session.id)) to server with currentTime(\(session.currentTime))")
var success = false
if session.isLocal {
success = await ApiClient.reportLocalPlaybackProgress(session)
} else {
let playbackReport = PlaybackReport(currentTime: session.currentTime, duration: session.duration, timeListened: session.timeListening)
success = await ApiClient.reportPlaybackProgress(report: playbackReport, sessionId: session.id)
}
// Remove old sessions after they synced with the server
if success && !session.isActiveSession {
if let session = session.thaw() {
try session.delete()
}
}
}
private func updateLocalSessionFromServerMediaProgress() async throws {
NSLog("updateLocalSessionFromServerMediaProgress: Checking if local media progress was updated on server")
guard let session = try await Realm().objects(PlaybackSession.self).last(where: {
$0.isActiveSession == true && $0.serverConnectionConfigId == Store.serverConfig?.id
})?.freeze() else {
NSLog("updateLocalSessionFromServerMediaProgress: Failed to get session")
return
}
// Fetch the current progress
let progress = await ApiClient.getMediaProgress(libraryItemId: session.libraryItemId!, episodeId: session.episodeId)
guard let progress = progress else {
NSLog("updateLocalSessionFromServerMediaProgress: No progress object")
return
}
// Determine which session is newer
let serverLastUpdate = progress.lastUpdate
guard let localLastUpdate = session.updatedAt else {
NSLog("updateLocalSessionFromServerMediaProgress: No local session updatedAt")
return
}
let serverCurrentTime = progress.currentTime
let localCurrentTime = session.currentTime
let serverIsNewerThanLocal = serverLastUpdate > localLastUpdate
let currentTimeIsDifferent = serverCurrentTime != localCurrentTime
// Update the session, if needed
if serverIsNewerThanLocal && currentTimeIsDifferent {
NSLog("updateLocalSessionFromServerMediaProgress: Server has newer time than local serverLastUpdate=\(serverLastUpdate) localLastUpdate=\(localLastUpdate)")
guard let session = session.thaw() else { return }
try session.update {
session.currentTime = serverCurrentTime
session.updatedAt = serverLastUpdate
}
NSLog("updateLocalSessionFromServerMediaProgress: Updated session currentTime newCurrentTime=\(serverCurrentTime) previousCurrentTime=\(localCurrentTime)")
PlayerHandler.seek(amount: session.currentTime)
} else {
NSLog("updateLocalSessionFromServerMediaProgress: Local session does not need updating; local has latest progress")
}
}
}
+152 -6
View File
@@ -37,7 +37,37 @@ class ApiClient {
} }
} }
} }
public static func postResource(endpoint: String, parameters: [String: String], callback: ((_ success: Bool) -> Void)?) {
public static func postResource<T: Encodable, U: Decodable>(endpoint: String, parameters: T, decodable: U.Type = U.self, callback: ((_ param: U) -> Void)?) {
if (Store.serverConfig == nil) {
NSLog("Server config not set")
return
}
let headers: HTTPHeaders = [
"Authorization": "Bearer \(Store.serverConfig!.token)"
]
AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, headers: headers).responseDecodable(of: decodable) { response in
switch response.result {
case .success(let obj):
callback?(obj)
case .failure(let error):
NSLog("api request to \(endpoint) failed")
print(error)
}
}
}
public static func postResource<T:Encodable>(endpoint: String, parameters: T) async -> Bool {
return await withCheckedContinuation { continuation in
postResource(endpoint: endpoint, parameters: parameters) { success in
continuation.resume(returning: success)
}
}
}
public static func postResource<T:Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) {
if (Store.serverConfig == nil) { if (Store.serverConfig == nil) {
NSLog("Server config not set") NSLog("Server config not set")
callback?(false) callback?(false)
@@ -50,7 +80,7 @@ class ApiClient {
AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, headers: headers).response { response in AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, headers: headers).response { response in
switch response.result { switch response.result {
case .success(let _): case .success(_):
callback?(true) callback?(true)
case .failure(let error): case .failure(let error):
NSLog("api request to \(endpoint) failed") NSLog("api request to \(endpoint) failed")
@@ -60,6 +90,38 @@ class ApiClient {
} }
} }
} }
public static func patchResource<T: Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) {
if (Store.serverConfig == nil) {
NSLog("Server config not set")
callback?(false)
return
}
let headers: HTTPHeaders = [
"Authorization": "Bearer \(Store.serverConfig!.token)"
]
AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .patch, parameters: parameters, encoder: JSONParameterEncoder.default, headers: headers).response { response in
switch response.result {
case .success(_):
callback?(true)
case .failure(let error):
NSLog("api request to \(endpoint) failed")
print(error)
callback?(false)
}
}
}
public static func getResource<T: Decodable>(endpoint: String, decodable: T.Type = T.self) async -> T? {
return await withCheckedContinuation { continuation in
getResource(endpoint: endpoint, decodable: decodable) { result in
continuation.resume(returning: result)
}
}
}
public static func getResource<T: Decodable>(endpoint: String, decodable: T.Type = T.self, callback: ((_ param: T?) -> Void)?) { public static func getResource<T: Decodable>(endpoint: String, decodable: T.Type = T.self, callback: ((_ param: T?) -> Void)?) {
if (Store.serverConfig == nil) { if (Store.serverConfig == nil) {
NSLog("Server config not set") NSLog("Server config not set")
@@ -96,7 +158,7 @@ class ApiClient {
} }
} }
ApiClient.postResource(endpoint: endpoint, parameters: [ let parameters: [String: Any] = [
"forceDirectPlay": !forceTranscode ? "1" : "", "forceDirectPlay": !forceTranscode ? "1" : "",
"forceTranscode": forceTranscode ? "1" : "", "forceTranscode": forceTranscode ? "1" : "",
"mediaPlayer": "AVPlayer", "mediaPlayer": "AVPlayer",
@@ -105,7 +167,8 @@ class ApiClient {
"model": modelCode, "model": modelCode,
"clientVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String "clientVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
] ]
], decodable: PlaybackSession.self) { obj in ]
ApiClient.postResource(endpoint: endpoint, parameters: parameters, decodable: PlaybackSession.self) { obj in
var session = obj var session = obj
session.serverConnectionConfigId = Store.serverConfig!.id session.serverConnectionConfigId = Store.serverConfig!.id
@@ -114,9 +177,60 @@ class ApiClient {
callback(session) callback(session)
} }
} }
public static func reportPlaybackProgress(report: PlaybackReport, sessionId: String) {
try? postResource(endpoint: "api/session/\(sessionId)/sync", parameters: report.asDictionary().mapValues({ value in "\(value)" }), callback: nil) public static func reportPlaybackProgress(report: PlaybackReport, sessionId: String) async -> Bool {
return await postResource(endpoint: "api/session/\(sessionId)/sync", parameters: report)
} }
public static func reportLocalPlaybackProgress(_ session: PlaybackSession) async -> Bool {
return await postResource(endpoint: "api/session/local", parameters: session)
}
public static func syncMediaProgress(callback: @escaping (_ results: LocalMediaProgressSyncResultsPayload) -> Void) {
let localMediaProgressList = Database.shared.getAllLocalMediaProgress().filter {
$0.serverConnectionConfigId == Store.serverConfig?.id
}.map { $0.freeze() }
if ( !localMediaProgressList.isEmpty ) {
let payload = LocalMediaProgressSyncPayload(localMediaProgress: localMediaProgressList)
NSLog("Sending sync local progress request with \(localMediaProgressList.count) progress items")
postResource(endpoint: "api/me/sync-local-progress", parameters: payload, decodable: MediaProgressSyncResponsePayload.self) { response in
let resultsPayload = LocalMediaProgressSyncResultsPayload(numLocalMediaProgressForServer: localMediaProgressList.count, numServerProgressUpdates: response.numServerProgressUpdates, numLocalProgressUpdates: response.localProgressUpdates?.count)
NSLog("Media Progress Sync | \(String(describing: try? resultsPayload.asDictionary()))")
if let updates = response.localProgressUpdates {
for update in updates {
do {
try update.save()
} catch {
debugPrint("Failed to update local media progress")
debugPrint(error)
}
}
}
callback(resultsPayload)
}
} else {
NSLog("No local media progress to sync")
callback(LocalMediaProgressSyncResultsPayload(numLocalMediaProgressForServer: 0, numServerProgressUpdates: 0, numLocalProgressUpdates: 0))
}
}
public static func updateMediaProgress<T:Encodable>(libraryItemId: String, episodeId: String?, payload: T, callback: @escaping () -> Void) {
NSLog("updateMediaProgress \(libraryItemId) \(episodeId ?? "NIL") \(payload)")
let endpoint = episodeId?.isEmpty ?? true ? "api/me/progress/\(libraryItemId)" : "api/me/progress/\(libraryItemId)/\(episodeId ?? "")"
patchResource(endpoint: endpoint, parameters: payload) { success in
callback()
}
}
public static func getMediaProgress(libraryItemId: String, episodeId: String?) async -> MediaProgress? {
NSLog("getMediaProgress \(libraryItemId) \(episodeId ?? "NIL")")
let endpoint = episodeId?.isEmpty ?? true ? "api/me/progress/\(libraryItemId)" : "api/me/progress/\(libraryItemId)/\(episodeId ?? "")"
return await getResource(endpoint: endpoint, decodable: MediaProgress.self)
}
public static func getLibraryItemWithProgress(libraryItemId:String, episodeId:String?, callback: @escaping (_ param: LibraryItem?) -> Void) { public static func getLibraryItemWithProgress(libraryItemId:String, episodeId:String?, callback: @escaping (_ param: LibraryItem?) -> Void) {
var endpoint = "api/items/\(libraryItemId)?expanded=1&include=progress" var endpoint = "api/items/\(libraryItemId)?expanded=1&include=progress"
if episodeId != nil { if episodeId != nil {
@@ -128,3 +242,35 @@ class ApiClient {
} }
} }
} }
struct LocalMediaProgressSyncPayload: Codable {
var localMediaProgress: [LocalMediaProgress]
}
struct MediaProgressSyncResponsePayload: Decodable {
var numServerProgressUpdates: Int?
var localProgressUpdates: [LocalMediaProgress]?
private enum CodingKeys : String, CodingKey {
case numServerProgressUpdates, localProgressUpdates
}
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
numServerProgressUpdates = try? values.intOrStringDecoder(key: .numServerProgressUpdates)
localProgressUpdates = try? values.decode([LocalMediaProgress].self, forKey: .localProgressUpdates)
}
}
struct LocalMediaProgressSyncResultsPayload: Codable {
var numLocalMediaProgressForServer: Int?
var numServerProgressUpdates: Int?
var numLocalProgressUpdates: Int?
}
struct Connectivity {
static private let sharedInstance = NetworkReachabilityManager()!
static var isConnectedToInternet:Bool {
return self.sharedInstance.isReachable
}
}
+45
View File
@@ -0,0 +1,45 @@
//
// DaoExtensions.swift
// App
//
// Created by Ron Heft on 8/16/22.
//
import Foundation
import RealmSwift
extension Object {
func save() throws {
let realm = try Realm()
try realm.write {
realm.add(self, update: .modified)
}
}
func update(handler: () -> Void) throws {
try self.realm?.write {
handler()
}
}
}
extension EmbeddedObject {
// Required to disassociate from Realm when copying into local objects
static func detachCopy<T:Codable>(of object: T?) -> T? {
guard let object = object else { return nil }
let json = try! JSONEncoder().encode(object)
return try! JSONDecoder().decode(T.self, from: json)
}
}
protocol Deletable {
func delete() throws
}
extension Deletable where Self: Object {
func delete() throws {
try self.realm?.write {
self.realm?.delete(self)
}
}
}
+186 -86
View File
@@ -9,30 +9,35 @@ import Foundation
import RealmSwift import RealmSwift
class Database { class Database {
// All DB releated actions must be executed on "realm-queue"
public static let realmQueue: DispatchQueue = DispatchQueue(label: "realm-queue")
public static var shared = { public static var shared = {
realmQueue.sync { return Database()
return Database()
}
}() }()
private var instance: Realm private init() {}
private init() {
self.instance = try! Realm(queue: Database.realmQueue)
}
public func setServerConnectionConfig(config: ServerConnectionConfig) { public func setServerConnectionConfig(config: ServerConnectionConfig) {
var refrence: ThreadSafeReference<ServerConnectionConfig>? let config = config
if config.realm != nil { let realm = try! Realm()
refrence = ThreadSafeReference(to: config) let existing: ServerConnectionConfig? = realm.object(ofType: ServerConnectionConfig.self, forPrimaryKey: config.id)
}
Database.realmQueue.sync { if let existing = existing {
let existing: ServerConnectionConfig? = instance.object(ofType: ServerConnectionConfig.self, forPrimaryKey: config.id) do {
try existing.update {
existing.name = config.name
existing.address = config.address
existing.userId = config.userId
existing.username = config.username
existing.token = config.token
}
} catch {
NSLog("failed to update server config")
debugPrint(error)
}
setLastActiveConfigIndex(index: existing.index)
} else {
if config.index == 0 { if config.index == 0 {
let lastConfig: ServerConnectionConfig? = instance.objects(ServerConnectionConfig.self).last let lastConfig: ServerConnectionConfig? = realm.objects(ServerConnectionConfig.self).last
if lastConfig != nil { if lastConfig != nil {
config.index = lastConfig!.index + 1 config.index = lastConfig!.index + 1
@@ -42,19 +47,8 @@ class Database {
} }
do { do {
try instance.write { try realm.write {
if existing != nil { realm.add(config)
instance.delete(existing!)
}
if refrence == nil {
instance.add(config)
} else {
guard let resolved = instance.resolve(refrence!) else {
throw "unable to resolve refrence"
}
instance.add(resolved);
}
} }
} catch(let exception) { } catch(let exception) {
NSLog("failed to save server config") NSLog("failed to save server config")
@@ -64,88 +58,194 @@ class Database {
setLastActiveConfigIndex(index: config.index) setLastActiveConfigIndex(index: config.index)
} }
} }
public func deleteServerConnectionConfig(id: String) { public func deleteServerConnectionConfig(id: String) {
Database.realmQueue.sync { let realm = try! Realm()
let config = instance.object(ofType: ServerConnectionConfig.self, forPrimaryKey: id) let config = realm.object(ofType: ServerConnectionConfig.self, forPrimaryKey: id)
do {
try instance.write {
if config != nil {
instance.delete(config!)
}
}
} catch(let exception) {
NSLog("failed to delete server config")
debugPrint(exception)
}
}
}
public func getServerConnectionConfigs() -> [ServerConnectionConfig] {
var refrences: [ThreadSafeReference<ServerConnectionConfig>] = []
Database.realmQueue.sync {
let configs = instance.objects(ServerConnectionConfig.self)
refrences = configs.map { config in
return ThreadSafeReference(to: config)
}
}
do { do {
let realm = try Realm() try realm.write {
if config != nil {
return refrences.map { refrence in realm.delete(config!)
return realm.resolve(refrence)! }
} }
} catch(let exception) { } catch(let exception) {
NSLog("error while readling configs") NSLog("failed to delete server config")
debugPrint(exception) debugPrint(exception)
return []
} }
} }
public func setLastActiveConfigIndexToNil() { public func getServerConnectionConfigs() -> [ServerConnectionConfig] {
Database.realmQueue.sync { let realm = try! Realm()
setLastActiveConfigIndex(index: nil) return Array(realm.objects(ServerConnectionConfig.self))
}
} }
public func setLastActiveConfigIndex(index: Int?) {
let existing = instance.objects(ServerConnectionConfigActiveIndex.self) public func setLastActiveConfigIndexToNil() {
let obj = ServerConnectionConfigActiveIndex() setLastActiveConfigIndex(index: nil)
obj.index = index }
private func setLastActiveConfigIndex(index: Int?) {
let realm = try! Realm()
do { do {
try instance.write { try realm.write {
instance.delete(existing) let existing = realm.objects(ServerConnectionConfigActiveIndex.self).last
instance.add(obj)
if ( existing?.index != index ) {
if let existing = existing {
realm.delete(existing)
}
let activeConfig = ServerConnectionConfigActiveIndex()
activeConfig.index = index
realm.add(activeConfig)
}
} }
} catch(let exception) { } catch(let exception) {
NSLog("failed to save server config active index") NSLog("failed to save server config active index")
debugPrint(exception) debugPrint(exception)
} }
} }
public func getLastActiveConfigIndex() -> Int? { public func getLastActiveConfigIndex() -> Int? {
return Database.realmQueue.sync { let realm = try! Realm()
return instance.objects(ServerConnectionConfigActiveIndex.self).first?.index ?? nil return realm.objects(ServerConnectionConfigActiveIndex.self).first?.index ?? nil
}
} }
public func setDeviceSettings(deviceSettings: DeviceSettings) { public func setDeviceSettings(deviceSettings: DeviceSettings) {
Database.realmQueue.sync { let realm = try! Realm()
let existing = instance.objects(DeviceSettings.self) let existing = realm.objects(DeviceSettings.self)
do { do {
try instance.write { try realm.write {
instance.delete(existing) realm.delete(existing)
instance.add(deviceSettings) realm.add(deviceSettings)
}
} catch(let exception) {
NSLog("failed to save device settings")
debugPrint(exception)
} }
} catch {
NSLog("failed to save device settings")
} }
} }
public func getLocalLibraryItems(mediaType: MediaType? = nil) -> [LocalLibraryItem] {
do {
let realm = try Realm()
return Array(realm.objects(LocalLibraryItem.self))
} catch {
debugPrint(error)
return []
}
}
public func getLocalLibraryItem(byServerLibraryItemId: String) -> LocalLibraryItem? {
do {
let realm = try Realm()
return realm.objects(LocalLibraryItem.self).first(where: { $0.libraryItemId == byServerLibraryItemId })
} catch {
debugPrint(error)
return nil
}
}
public func getLocalLibraryItem(localLibraryItemId: String) -> LocalLibraryItem? {
do {
let realm = try Realm()
return realm.object(ofType: LocalLibraryItem.self, forPrimaryKey: localLibraryItemId)
} catch {
debugPrint(error)
return nil
}
}
public func saveLocalLibraryItem(localLibraryItem: LocalLibraryItem) throws {
let realm = try Realm()
try realm.write { realm.add(localLibraryItem, update: .modified) }
}
public func getLocalFile(localFileId: String) -> LocalFile? {
do {
let realm = try Realm()
return realm.object(ofType: LocalFile.self, forPrimaryKey: localFileId)
} catch {
debugPrint(error)
return nil
}
}
public func getDownloadItem(downloadItemId: String) -> DownloadItem? {
do {
let realm = try Realm()
return realm.object(ofType: DownloadItem.self, forPrimaryKey: downloadItemId)
} catch {
debugPrint(error)
return nil
}
}
public func getDownloadItem(libraryItemId: String) -> DownloadItem? {
do {
let realm = try Realm()
return realm.objects(DownloadItem.self).filter("libraryItemId == %@", libraryItemId).first
} catch {
debugPrint(error)
return nil
}
}
public func getDownloadItem(downloadItemPartId: String) -> DownloadItem? {
do {
let realm = try Realm()
return realm.objects(DownloadItem.self).filter("SUBQUERY(downloadItemParts, $part, $part.id == %@) .@count > 0", downloadItemPartId).first
} catch {
debugPrint(error)
return nil
}
}
public func saveDownloadItem(_ downloadItem: DownloadItem) throws {
let realm = try Realm()
return try realm.write { realm.add(downloadItem, update: .modified) }
}
public func getDeviceSettings() -> DeviceSettings { public func getDeviceSettings() -> DeviceSettings {
return Database.realmQueue.sync { let realm = try! Realm()
return instance.objects(DeviceSettings.self).first ?? getDefaultDeviceSettings() return realm.objects(DeviceSettings.self).first ?? getDefaultDeviceSettings()
}
public func getAllLocalMediaProgress() -> [LocalMediaProgress] {
do {
let realm = try Realm()
return Array(realm.objects(LocalMediaProgress.self))
} catch {
debugPrint(error)
return []
}
}
// For books this will just be the localLibraryItemId for podcast episodes this will be "{localLibraryItemId}-{episodeId}"
public func getLocalMediaProgress(localMediaProgressId: String) -> LocalMediaProgress? {
do {
let realm = try Realm()
return realm.object(ofType: LocalMediaProgress.self, forPrimaryKey: localMediaProgressId)
} catch {
debugPrint(error)
return nil
}
}
public func removeLocalMediaProgress(localMediaProgressId: String) throws {
let realm = try Realm()
try realm.write {
let progress = realm.object(ofType: LocalMediaProgress.self, forPrimaryKey: localMediaProgressId)
realm.delete(progress!)
}
}
public func getPlaybackSession(id: String) -> PlaybackSession? {
do {
let realm = try Realm()
return realm.object(ofType: PlaybackSession.self, forPrimaryKey: id)
} catch {
debugPrint(error)
return nil
} }
} }
} }
+77 -6
View File
@@ -6,18 +6,66 @@
// //
import Foundation import Foundation
import RealmSwift
import Capacitor
import CoreMedia
extension String: Error {} extension String: Error {}
typealias Dictionaryable = Encodable
extension Encodable { extension Encodable {
func asDictionary() throws -> [String: Any] { func asDictionary() throws -> [String: Any] {
let data = try JSONEncoder().encode(self) let data = try JSONEncoder().encode(self)
guard let dictionary = try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any] else { guard let dictionary = try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any] else {
throw NSError() throw NSError()
}
return dictionary
} }
return dictionary
}
} }
extension Collection where Iterator.Element: Encodable {
func asDictionaryArray() throws -> [[String: Any]] {
return try self.enumerated().map() {
i, element -> [String: Any] in try element.asDictionary()
}
}
}
extension KeyedDecodingContainer {
func doubleOrStringDecoder(key: KeyedDecodingContainer<K>.Key) throws -> Double {
do {
return try decode(Double.self, forKey: key)
} catch {
let stringValue = try decode(String.self, forKey: key)
return Double(stringValue) ?? 0.0
}
}
func intOrStringDecoder(key: KeyedDecodingContainer<K>.Key) throws -> Int {
do {
return try decode(Int.self, forKey: key)
} catch {
let stringValue = try decode(String.self, forKey: key)
return Int(stringValue) ?? 0
}
}
}
extension CAPPluginCall {
func getJson<T: Decodable>(_ key: String, type: T.Type) -> T? {
guard let value = getObject(key) else { return nil }
do {
let json = try JSONSerialization.data(withJSONObject: value)
return try JSONDecoder().decode(type, from: json)
} catch {
NSLog("Failed to get json for \(key)")
debugPrint(error)
return nil
}
}
}
extension DispatchQueue { extension DispatchQueue {
static func runOnMainQueue(callback: @escaping (() -> Void)) { static func runOnMainQueue(callback: @escaping (() -> Void)) {
if Thread.isMainThread { if Thread.isMainThread {
@@ -29,3 +77,26 @@ extension DispatchQueue {
} }
} }
} }
extension URL {
var attributes: [FileAttributeKey : Any]? {
do {
return try FileManager.default.attributesOfItem(atPath: path)
} catch let error as NSError {
print("FileAttribute error: \(error)")
}
return nil
}
var fileSize: Int64 {
return attributes?[.size] as? Int64 ?? Int64(0)
}
var fileSizeString: String {
return ByteCountFormatter.string(fromByteCount: Int64(fileSize), countStyle: .file)
}
var creationDate: Date? {
return attributes?[.creationDate] as? Date
}
}
+37 -16
View File
@@ -15,6 +15,11 @@ struct NowPlayingMetadata {
var title: String var title: String
var author: String? var author: String?
var series: String? var series: String?
var coverUrl: URL? {
guard let config = Store.serverConfig else { return nil }
guard let url = URL(string: "\(config.address)/api/items/\(itemId)/cover?token=\(config.token)") else { return nil }
return url
}
} }
class NowPlayingInfo { class NowPlayingInfo {
@@ -30,28 +35,43 @@ class NowPlayingInfo {
public func setSessionMetadata(metadata: NowPlayingMetadata) { public func setSessionMetadata(metadata: NowPlayingMetadata) {
setMetadata(artwork: nil, metadata: metadata) setMetadata(artwork: nil, metadata: metadata)
guard let url = URL(string: "\(Store.serverConfig!.address)/api/items/\(metadata.itemId)/cover?token=\(Store.serverConfig!.token)") else { let isLocalItem = metadata.itemId.starts(with: "local_")
return if isLocalItem {
} guard let artworkUrl = metadata.artworkUrl else { return }
ApiClient.getData(from: url) { [self] image in let coverImage = UIImage(contentsOfFile: artworkUrl)
guard let downloadedImage = image else { guard let coverImage = coverImage else { return }
return let artwork = MPMediaItemArtwork(boundsSize: coverImage.size) { _ -> UIImage in
return coverImage
} }
let artwork = MPMediaItemArtwork.init(boundsSize: downloadedImage.size, requestHandler: { _ -> UIImage in
return downloadedImage
})
self.setMetadata(artwork: artwork, metadata: metadata) self.setMetadata(artwork: artwork, metadata: metadata)
} else {
guard let url = metadata.coverUrl else { return }
ApiClient.getData(from: url) { [self] image in
guard let downloadedImage = image else {
return
}
let artwork = MPMediaItemArtwork.init(boundsSize: downloadedImage.size, requestHandler: { _ -> UIImage in
return downloadedImage
})
self.setMetadata(artwork: artwork, metadata: metadata)
}
} }
} }
public func update(duration: Double, currentTime: Double, rate: Float) { public func update(duration: Double, currentTime: Double, rate: Float) {
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration // Update on the main to prevent access collisions
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime DispatchQueue.main.async { [weak self] in
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = rate if let self = self {
nowPlayingInfo[MPNowPlayingInfoPropertyDefaultPlaybackRate] = 1.0 self.nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration
self.nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo self.nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = rate
self.nowPlayingInfo[MPNowPlayingInfoPropertyDefaultPlaybackRate] = 1.0
MPNowPlayingInfoCenter.default().nowPlayingInfo = self.nowPlayingInfo
}
}
} }
public func reset() { public func reset() {
nowPlayingInfo = [:] nowPlayingInfo = [:]
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
@@ -76,6 +96,7 @@ class NowPlayingInfo {
nowPlayingInfo[MPMediaItemPropertyArtist] = metadata!.author ?? "unknown" nowPlayingInfo[MPMediaItemPropertyArtist] = metadata!.author ?? "unknown"
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = metadata!.series nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = metadata!.series
} }
private func shouldFetchCover(id: String) -> Bool { private func shouldFetchCover(id: String) -> Bool {
nowPlayingInfo[MPNowPlayingInfoPropertyExternalContentIdentifier] as? String != id || nowPlayingInfo[MPMediaItemPropertyArtwork] == nil nowPlayingInfo[MPNowPlayingInfoPropertyExternalContentIdentifier] as? String != id || nowPlayingInfo[MPMediaItemPropertyArtwork] == nil
} }
+1
View File
@@ -13,4 +13,5 @@ enum PlayerEvents: String {
case sleepSet = "com.audiobookshelf.app.player.sleep.set" case sleepSet = "com.audiobookshelf.app.player.sleep.set"
case sleepEnded = "com.audiobookshelf.app.player.sleep.ended" case sleepEnded = "com.audiobookshelf.app.player.sleep.ended"
case failed = "com.audiobookshelf.app.player.failed" case failed = "com.audiobookshelf.app.player.failed"
case localProgress = "com.audiobookshelf.app.player.localProgress"
} }
+9 -6
View File
@@ -9,10 +9,17 @@ import Foundation
import RealmSwift import RealmSwift
class Store { class Store {
@ThreadSafe private static var _serverConfig: ServerConnectionConfig?
public static var serverConfig: ServerConnectionConfig? { public static var serverConfig: ServerConnectionConfig? {
get { get {
return _serverConfig do {
// Fetch each time, as holding onto a live or frozen realm object is bad
let index = Database.shared.getLastActiveConfigIndex()
let realm = try Realm()
return realm.objects(ServerConnectionConfig.self).first(where: { $0.index == index })
} catch {
debugPrint(error)
return nil
}
} }
set(updated) { set(updated) {
if updated != nil { if updated != nil {
@@ -20,10 +27,6 @@ class Store {
} else { } else {
Database.shared.setLastActiveConfigIndexToNil() Database.shared.setLastActiveConfigIndexToNil()
} }
Database.realmQueue.sync {
_serverConfig = updated
}
} }
} }
} }
+3
View File
@@ -279,6 +279,9 @@ export default {
this.loadSavedSettings() this.loadSavedSettings()
this.hasMounted = true this.hasMounted = true
console.log('[default] fully initialized')
this.$eventBus.$emit('abs-ui-ready')
} }
}, },
beforeDestroy() { beforeDestroy() {
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf-app", "name": "audiobookshelf-app",
"version": "0.9.55-beta", "version": "0.9.57-beta",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf-app", "name": "audiobookshelf-app",
"version": "0.9.55-beta", "version": "0.9.57-beta",
"dependencies": { "dependencies": {
"@capacitor/android": "^3.4.3", "@capacitor/android": "^3.4.3",
"@capacitor/app": "^1.1.1", "@capacitor/app": "^1.1.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-app", "name": "audiobookshelf-app",
"version": "0.9.55-beta", "version": "0.9.57-beta",
"author": "advplyr", "author": "advplyr",
"scripts": { "scripts": {
"dev": "nuxt --hostname 0.0.0.0 --port 1337", "dev": "nuxt --hostname 0.0.0.0 --port 1337",

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