From fc946702c1dbe449b6e2d71169244b3e671e2846 Mon Sep 17 00:00:00 2001 From: FabioMich66 Date: Sat, 3 Oct 2026 13:38:18 +0200 Subject: [PATCH] first commit --- .editorconfig | 12 + .github/ISSUE_TEMPLATE/bug_report.yml | 98 + .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/feature_request.yml | 57 + .github/pull_request_template.md | 41 + .github/testing-page-template.html | 81 + .github/workflows/build-apk.yml | 53 + .github/workflows/build-ios.yml | 68 + .github/workflows/close-issues-on-release.yml | 20 + .github/workflows/close_blank_issues.yaml | 42 + .github/workflows/deploy-apk.yml | 82 + .github/workflows/i18n-check.yml | 28 + .github/workflows/translate-credits.yml | 25 + .gitignore | 93 + .prettierrc | 17 + .vscode/extensions.json | 7 + .vscode/settings.json | 27 + INSTALL.md | 29 + LICENSE | 674 + android/.gitignore | 96 + android/.vscode/settings.json | 6 + android/app/.gitignore | 2 + android/app/build.gradle | 133 + android/app/capacitor.build.gradle | 29 + android/app/proguard-rules.pro | 21 + .../myapp/ExampleInstrumentedTest.java | 26 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 6 + .../src/debug/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 5879 bytes .../mipmap-hdpi/ic_launcher_background.png | Bin 0 -> 879 bytes .../mipmap-hdpi/ic_launcher_foreground.png | Bin 0 -> 1931 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 5879 bytes .../src/debug/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 3747 bytes .../mipmap-mdpi/ic_launcher_background.png | Bin 0 -> 685 bytes .../mipmap-mdpi/ic_launcher_foreground.png | Bin 0 -> 1351 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 3747 bytes .../debug/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 8193 bytes .../mipmap-xhdpi/ic_launcher_background.png | Bin 0 -> 1038 bytes .../mipmap-xhdpi/ic_launcher_foreground.png | Bin 0 -> 2632 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 8193 bytes .../debug/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 11890 bytes .../mipmap-xxhdpi/ic_launcher_background.png | Bin 0 -> 1487 bytes .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 0 -> 3978 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 11890 bytes .../debug/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 16479 bytes .../mipmap-xxxhdpi/ic_launcher_background.png | Bin 0 -> 1815 bytes .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 0 -> 5522 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 16479 bytes android/app/src/debug/res/values/strings.xml | 14 + android/app/src/main/AndroidManifest.xml | 120 + .../app/src/main/assets/capacitor.config.json | 17 + .../src/main/assets/capacitor.plugins.json | 46 + .../app/src/main/ic_launcher-playstore.png | Bin 0 -> 20340 bytes .../audiobookshelf/app/CastOptionsProvider.kt | 30 + .../com/audiobookshelf/app/MainActivity.kt | 185 + .../audiobookshelf/app/MediaPlayerWidget.kt | 103 + .../com/audiobookshelf/app/data/AudioTrack.kt | 38 + .../app/data/CollapsedSeries.kt | 36 + .../audiobookshelf/app/data/DataClasses.kt | 514 + .../audiobookshelf/app/data/DeviceClasses.kt | 254 + .../com/audiobookshelf/app/data/EBookFile.kt | 13 + .../audiobookshelf/app/data/ItemInProgress.kt | 32 + .../app/data/LibraryAuthorItem.kt | 63 + .../app/data/LibraryCategory.kt | 12 + .../app/data/LibraryCollection.kt | 40 + .../audiobookshelf/app/data/LibraryItem.kt | 178 + .../app/data/LibrarySeriesItem.kt | 60 + .../app/data/LocalLibraryItem.kt | 197 + .../audiobookshelf/app/data/LocalMediaItem.kt | 67 + .../app/data/LocalMediaProgress.kt | 86 + .../audiobookshelf/app/data/MediaItemEvent.kt | 15 + .../app/data/MediaItemHistory.kt | 17 + .../audiobookshelf/app/data/MediaProgress.kt | 24 + .../app/data/PlaybackMetadata.kt | 11 + .../app/data/PlaybackSession.kt | 449 + .../app/device/DeviceManager.kt | 228 + .../app/device/FolderScanner.kt | 312 + .../audiobookshelf/app/managers/DbManager.kt | 325 + .../app/managers/DownloadItemManager.kt | 794 + .../app/managers/IncompleteDownloadCleanup.kt | 89 + .../app/managers/InternalDownloadManager.kt | 203 + .../app/managers/SecureStorage.kt | 124 + .../app/managers/SleepTimerManager.kt | 544 + .../app/media/MediaEventManager.kt | 132 + .../audiobookshelf/app/media/MediaManager.kt | 1028 + .../app/media/MediaProgressSyncer.kt | 360 + .../com/audiobookshelf/app/media/icons.kt | 55 + .../audiobookshelf/app/models/DownloadItem.kt | 50 + .../app/models/DownloadItemPart.kt | 88 + .../com/audiobookshelf/app/models/User.kt | 11 + .../app/player/AbMediaDescriptionAdapter.kt | 72 + .../audiobookshelf/app/player/BrowseTree.kt | 87 + .../audiobookshelf/app/player/CastManager.kt | 381 + .../audiobookshelf/app/player/CastPlayer.kt | 1028 + .../audiobookshelf/app/player/CastTimeline.kt | 162 + .../app/player/CastTimelineTracker.kt | 109 + .../app/player/CastTrackSelection.kt | 79 + .../app/player/CoverImageLoader.kt | 36 + .../app/player/MediaSessionCallback.kt | 286 + .../player/MediaSessionPlaybackPreparer.kt | 93 + .../app/player/PlayerConstants.kt | 21 + .../app/player/PlayerListener.kt | 174 + .../app/player/PlayerNotificationListener.kt | 56 + .../app/player/PlayerNotificationService.kt | 2221 ++ .../app/player/ShakeDetector.kt | 70 + .../app/plugins/AbsAudioPlayer.kt | 461 + .../audiobookshelf/app/plugins/AbsDatabase.kt | 591 + .../app/plugins/AbsDownloader.kt | 272 + .../app/plugins/AbsFileSystem.kt | 328 + .../audiobookshelf/app/plugins/AbsLogger.kt | 80 + .../audiobookshelf/app/server/ApiHandler.kt | 840 + .../app/services/DownloadService.kt | 102 + .../app/services/DownloadServiceHost.kt | 213 + .../exo_icon_downloaddone.xml | 18 + .../exo_icon_localaudio.xml | 15 + .../drawable-anydpi/exo_icon_fastforward.xml | 15 + .../res/drawable-anydpi/exo_icon_rewind.xml | 15 + .../drawable-anydpi/ic_play_speed_0_5x.xml | 25 + .../drawable-anydpi/ic_play_speed_1_0x.xml | 27 + .../drawable-anydpi/ic_play_speed_1_2x.xml | 27 + .../drawable-anydpi/ic_play_speed_1_5x.xml | 27 + .../drawable-anydpi/ic_play_speed_2_0x.xml | 25 + .../drawable-anydpi/ic_play_speed_3_0x.xml | 25 + .../drawable-anydpi/icon_library_folder.xml | 15 + .../res/drawable-anydpi/md_clock_outline.xml | 1 + .../main/res/drawable-anydpi/skip_next_24.xml | 15 + .../res/drawable-anydpi/skip_previous_24.xml | 15 + .../drawable-hdpi/exo_icon_downloaddone.png | Bin 0 -> 215 bytes .../drawable-hdpi/exo_icon_fastforward.png | Bin 0 -> 462 bytes .../res/drawable-hdpi/exo_icon_localaudio.png | Bin 0 -> 250 bytes .../res/drawable-hdpi/exo_icon_rewind.png | Bin 0 -> 444 bytes .../res/drawable-hdpi/ic_play_speed_0_5x.png | Bin 0 -> 500 bytes .../res/drawable-hdpi/ic_play_speed_1_0x.png | Bin 0 -> 447 bytes .../res/drawable-hdpi/ic_play_speed_1_2x.png | Bin 0 -> 450 bytes .../res/drawable-hdpi/ic_play_speed_1_5x.png | Bin 0 -> 465 bytes .../res/drawable-hdpi/ic_play_speed_2_0x.png | Bin 0 -> 512 bytes .../res/drawable-hdpi/ic_play_speed_3_0x.png | Bin 0 -> 496 bytes .../res/drawable-hdpi/icon_library_folder.png | Bin 0 -> 216 bytes .../res/drawable-hdpi/md_account_outline.png | Bin 0 -> 560 bytes .../res/drawable-hdpi/md_clock_outline.png | Bin 0 -> 798 bytes .../drawable-mdpi/exo_icon_downloaddone.png | Bin 0 -> 176 bytes .../drawable-mdpi/exo_icon_fastforward.png | Bin 0 -> 335 bytes .../res/drawable-mdpi/exo_icon_localaudio.png | Bin 0 -> 186 bytes .../res/drawable-mdpi/exo_icon_rewind.png | Bin 0 -> 329 bytes .../res/drawable-mdpi/ic_play_speed_0_5x.png | Bin 0 -> 323 bytes .../res/drawable-mdpi/ic_play_speed_1_0x.png | Bin 0 -> 311 bytes .../res/drawable-mdpi/ic_play_speed_1_2x.png | Bin 0 -> 303 bytes .../res/drawable-mdpi/ic_play_speed_1_5x.png | Bin 0 -> 315 bytes .../res/drawable-mdpi/ic_play_speed_2_0x.png | Bin 0 -> 338 bytes .../res/drawable-mdpi/ic_play_speed_3_0x.png | Bin 0 -> 346 bytes .../res/drawable-mdpi/icon_library_folder.png | Bin 0 -> 164 bytes .../res/drawable-mdpi/md_account_outline.png | Bin 0 -> 387 bytes .../res/drawable-mdpi/md_clock_outline.png | Bin 0 -> 532 bytes .../media_player_widget_preview.png | Bin 0 -> 91215 bytes .../drawable-v21/app_widget_background.xml | 11 + .../app_widget_inner_view_background.xml | 11 + .../drawable-xhdpi/exo_icon_downloaddone.png | Bin 0 -> 355 bytes .../drawable-xhdpi/exo_icon_fastforward.png | Bin 0 -> 591 bytes .../drawable-xhdpi/exo_icon_localaudio.png | Bin 0 -> 289 bytes .../res/drawable-xhdpi/exo_icon_rewind.png | Bin 0 -> 598 bytes .../res/drawable-xhdpi/ic_play_speed_0_5x.png | Bin 0 -> 664 bytes .../res/drawable-xhdpi/ic_play_speed_1_0x.png | Bin 0 -> 608 bytes .../res/drawable-xhdpi/ic_play_speed_1_2x.png | Bin 0 -> 583 bytes .../res/drawable-xhdpi/ic_play_speed_1_5x.png | Bin 0 -> 599 bytes .../res/drawable-xhdpi/ic_play_speed_2_0x.png | Bin 0 -> 684 bytes .../res/drawable-xhdpi/ic_play_speed_3_0x.png | Bin 0 -> 712 bytes .../drawable-xhdpi/icon_library_folder.png | Bin 0 -> 254 bytes .../res/drawable-xhdpi/md_account_outline.png | Bin 0 -> 707 bytes .../res/drawable-xhdpi/md_clock_outline.png | Bin 0 -> 1063 bytes .../drawable-xxhdpi/exo_icon_downloaddone.png | Bin 0 -> 448 bytes .../drawable-xxhdpi/exo_icon_fastforward.png | Bin 0 -> 869 bytes .../drawable-xxhdpi/exo_icon_localaudio.png | Bin 0 -> 477 bytes .../res/drawable-xxhdpi/exo_icon_rewind.png | Bin 0 -> 843 bytes .../drawable-xxhdpi/ic_play_speed_0_5x.png | Bin 0 -> 1013 bytes .../drawable-xxhdpi/ic_play_speed_1_0x.png | Bin 0 -> 895 bytes .../drawable-xxhdpi/ic_play_speed_1_2x.png | Bin 0 -> 866 bytes .../drawable-xxhdpi/ic_play_speed_1_5x.png | Bin 0 -> 892 bytes .../drawable-xxhdpi/ic_play_speed_2_0x.png | Bin 0 -> 1011 bytes .../drawable-xxhdpi/ic_play_speed_3_0x.png | Bin 0 -> 1049 bytes .../drawable-xxhdpi/icon_library_folder.png | Bin 0 -> 466 bytes .../drawable-xxhdpi/md_account_outline.png | Bin 0 -> 1063 bytes .../res/drawable-xxhdpi/md_clock_outline.png | Bin 0 -> 1562 bytes .../main/res/drawable/abs_audiobookshelf.xml | 9 + .../app/src/main/res/drawable/abs_authors.xml | 9 + .../app/src/main/res/drawable/abs_book_1.xml | 9 + .../app/src/main/res/drawable/abs_books_1.xml | 9 + .../app/src/main/res/drawable/abs_books_2.xml | 9 + .../app/src/main/res/drawable/abs_columns.xml | 9 + .../src/main/res/drawable/abs_database.xml | 9 + .../main/res/drawable/abs_file_picture.xml | 9 + .../src/main/res/drawable/abs_headphones.xml | 9 + .../app/src/main/res/drawable/abs_heart.xml | 9 + .../main/res/drawable/abs_microphone_1.xml | 9 + .../main/res/drawable/abs_microphone_2.xml | 9 + .../main/res/drawable/abs_microphone_3.xml | 9 + .../app/src/main/res/drawable/abs_music.xml | 9 + .../app/src/main/res/drawable/abs_podcast.xml | 9 + .../app/src/main/res/drawable/abs_radio.xml | 9 + .../app/src/main/res/drawable/abs_rocket.xml | 9 + android/app/src/main/res/drawable/abs_rss.xml | 9 + .../app/src/main/res/drawable/abs_star.xml | 9 + .../res/drawable/exo_icon_fastforward.xml | 5 + .../src/main/res/drawable/exo_icon_rewind.xml | 5 + android/app/src/main/res/drawable/icon.png | Bin 0 -> 10705 bytes android/app/src/main/res/drawable/icon_32.png | Bin 0 -> 2880 bytes .../src/main/res/drawable/icon_monochrome.xml | 9 + .../main/res/drawable/md_account_outline.xml | 1 + .../res/drawable/md_book_multiple_outline.xml | 1 + .../md_book_open_blank_variant_outline.xml | 1 + .../main/res/drawable/md_clock_outline.xml | 1 + .../src/main/res/drawable/md_telescope.xml | 1 + .../src/main/res/drawable/skip_next_24.xml | 10 + .../main/res/drawable/skip_previous_24.xml | 10 + .../app/src/main/res/layout/activity_main.xml | 13 + .../main/res/layout/media_player_widget.xml | 108 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 6 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 5219 bytes .../mipmap-hdpi/ic_launcher_background.png | Bin 0 -> 803 bytes .../mipmap-hdpi/ic_launcher_foreground.png | Bin 0 -> 1831 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 5219 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 3274 bytes .../mipmap-mdpi/ic_launcher_background.png | Bin 0 -> 564 bytes .../mipmap-mdpi/ic_launcher_foreground.png | Bin 0 -> 1198 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 3274 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 7383 bytes .../mipmap-xhdpi/ic_launcher_background.png | Bin 0 -> 1011 bytes .../mipmap-xhdpi/ic_launcher_foreground.png | Bin 0 -> 2578 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 7383 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 11171 bytes .../mipmap-xxhdpi/ic_launcher_background.png | Bin 0 -> 1397 bytes .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 0 -> 4092 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 11171 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 15412 bytes .../mipmap-xxxhdpi/ic_launcher_background.png | Bin 0 -> 1966 bytes .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 0 -> 5967 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 15412 bytes android/app/src/main/res/raw/bell.mp3 | Bin 0 -> 126118 bytes .../src/main/res/values-night-v31/themes.xml | 10 + .../app/src/main/res/values-v21/styles.xml | 32 + .../app/src/main/res/values-v31/styles.xml | 16 + .../app/src/main/res/values-v31/themes.xml | 11 + android/app/src/main/res/values/attrs.xml | 9 + android/app/src/main/res/values/colors.xml | 7 + android/app/src/main/res/values/dimens.xml | 10 + android/app/src/main/res/values/strings.xml | 14 + android/app/src/main/res/values/styles.xml | 42 + android/app/src/main/res/values/themes.xml | 17 + .../src/main/res/xml/automotive_app_desc.xml | 5 + android/app/src/main/res/xml/config.xml | 10 + android/app/src/main/res/xml/file_paths.xml | 4 + .../main/res/xml/media_player_widget_info.xml | 14 + .../main/res/xml/network_security_config.xml | 9 + .../res/xml/samsung_cover_widget_info.xml | 4 + .../getcapacitor/myapp/ExampleUnitTest.java | 18 + android/build.gradle | 33 + android/capacitor.settings.gradle | 36 + android/gradle.properties | 36 + android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 61608 bytes .../gradle/wrapper/gradle-wrapper.properties | 6 + android/gradlew | 244 + android/gradlew.bat | 92 + android/settings.gradle | 9 + android/variables.gradle | 24 + assets/WrappingMarquee.js | 119 + assets/absicons.css | 121 + assets/app.css | 171 + assets/defaultStyles.css | 67 + assets/ebooks/basic.js | 506 + assets/ebooks/htmlParser.js | 248 + assets/ebooks/mobi.js | 450 + assets/fonts.css | 296 + assets/tailwind.css | 66 + assets/transitions.css | 51 + capacitor.config.json | 17 + components/app/Appbar.vue | 162 + components/app/AudioPlayer.vue | 1239 ++ components/app/AudioPlayerContainer.vue | 474 + components/app/SideDrawer.vue | 221 + components/bookshelf/LazyBookshelf.vue | 562 + components/bookshelf/Shelf.vue | 81 + components/cards/AuthorCard.vue | 61 + components/cards/AuthorSearchCard.vue | 40 + components/cards/EpisodeSearchCard.vue | 69 + components/cards/ItemSearchCard.vue | 67 + components/cards/LazyBookCard.vue | 571 + components/cards/LazyCollectionCard.vue | 80 + components/cards/LazyListBookCard.vue | 416 + components/cards/LazyPlaylistCard.vue | 86 + components/cards/LazySeriesCard.vue | 123 + components/cards/NarratorSearchCard.vue | 34 + components/cards/SeriesSearchCard.vue | 45 + components/cards/TagSearchCard.vue | 34 + components/connection/ServerConnectForm.vue | 980 + components/covers/AuthorImage.vue | 91 + components/covers/BookCover.vue | 182 + components/covers/CollectionCover.vue | 61 + components/covers/GroupCover.vue | 204 + components/covers/PlaylistCover.vue | 51 + components/covers/PreviewCover.vue | 103 + components/forms/NewPodcastForm.vue | 182 + components/home/BookshelfNavBar.vue | 153 + components/home/BookshelfToolbar.vue | 170 + .../AutoSleepTimerRewindLengthModal.vue | 99 + components/modals/BookmarksModal.vue | 182 + components/modals/ChaptersModal.vue | 125 + components/modals/CustomHeadersModal.vue | 96 + components/modals/Dialog.vue | 96 + components/modals/FilterModal.vue | 310 + components/modals/FullscreenCover.vue | 74 + components/modals/FullscreenModal.vue | 77 + components/modals/ItemDetailsModal.vue | 58 + components/modals/ItemMoreMenuModal.vue | 511 + components/modals/LibrariesModal.vue | 62 + components/modals/Modal.vue | 113 + components/modals/OrderModal.vue | 191 + components/modals/PlaybackSpeedModal.vue | 121 + .../modals/PodcastEpisodesFeedModal.vue | 137 + components/modals/SelectLocalFolderModal.vue | 83 + components/modals/SleepTimerLengthModal.vue | 100 + components/modals/SleepTimerModal.vue | 140 + components/modals/bookmarks/BookmarkItem.vue | 45 + components/modals/downloads/DownloadItem.vue | 52 + .../modals/playlists/AddCreateModal.vue | 231 + components/modals/playlists/PlaylistRow.vue | 45 + .../rssfeeds/RssFeedMetadataBuilder.vue | 90 + components/modals/rssfeeds/RssFeedModal.vue | 191 + components/readers/ComicReader.vue | 387 + components/readers/EpubReader.vue | 459 + components/readers/MobiReader.vue | 130 + components/readers/PdfReader.vue | 261 + components/readers/Reader.vue | 549 + components/stats/DailyListeningChart.vue | 225 + components/stats/YearInReview.vue | 283 + components/stats/YearInReviewBanner.vue | 134 + components/stats/YearInReviewServer.vue | 262 + components/stats/YearInReviewShort.vue | 192 + components/tables/ChaptersTable.vue | 76 + components/tables/TracksTable.vue | 56 + components/tables/collection/BookTableRow.vue | 123 + .../collection/CollectionBooksTable.vue | 82 + components/tables/ebook/EbookFilesTable.vue | 99 + .../tables/ebook/EbookFilesTableRow.vue | 60 + components/tables/playlist/ItemTableRow.vue | 191 + .../tables/playlist/PlaylistItemsTable.vue | 51 + components/tables/podcast/EpisodeRow.vue | 328 + components/tables/podcast/EpisodesTable.vue | 348 + .../tables/podcast/LatestEpisodeRow.vue | 335 + components/ui/Btn.vue | 96 + components/ui/Checkbox.vue | 71 + components/ui/Dropdown.vue | 89 + components/ui/DropdownMenu.vue | 105 + components/ui/IconBtn.vue | 84 + components/ui/LibraryIcon.vue | 37 + components/ui/LoadingIndicator.vue | 70 + components/ui/Menu.vue | 61 + components/ui/MultiSelect.vue | 261 + components/ui/RangeInput.vue | 90 + components/ui/ReadIconBtn.vue | 57 + components/ui/TextInput.vue | 98 + components/ui/TextInputWithLabel.vue | 40 + components/ui/TextareaInput.vue | 53 + components/ui/TextareaWithLabel.vue | 41 + components/ui/ToggleBtns.vue | 61 + components/ui/ToggleSwitch.vue | 48 + components/widgets/AbridgedIndicator.vue | 68 + components/widgets/CircleProgress.vue | 139 + components/widgets/ConnectionIndicator.vue | 82 + .../widgets/DownloadProgressIndicator.vue | 98 + components/widgets/ExplicitIndicator.vue | 50 + components/widgets/LoadingSpinner.vue | 251 + components/widgets/SpinnerIcon.vue | 235 + .../metadata/android/de/full_description.txt | 1 + .../metadata/android/de/short_description.txt | 1 + .../android/en-US/full_description.txt | 1 + .../android/en-US/images/featureGraphic.png | Bin 0 -> 8063 bytes .../metadata/android/en-US/images/icon.png | Bin 0 -> 9010 bytes .../en-US/images/phoneScreenshots/01.jpg | Bin 0 -> 50585 bytes .../en-US/images/phoneScreenshots/02.jpg | Bin 0 -> 41137 bytes .../en-US/images/phoneScreenshots/03.jpg | Bin 0 -> 34544 bytes .../en-US/images/phoneScreenshots/04.jpg | Bin 0 -> 23319 bytes .../en-US/images/phoneScreenshots/05.jpg | Bin 0 -> 36811 bytes .../en-US/images/phoneScreenshots/06.jpg | Bin 0 -> 33993 bytes .../en-US/images/phoneScreenshots/07.png | Bin 0 -> 22516 bytes .../en-US/images/phoneScreenshots/08.jpg | Bin 0 -> 46222 bytes .../android/en-US/short_description.txt | 1 + ionic.config.json | 7 + ios/.gitignore | 9 + ios/App/App.xcodeproj/project.pbxproj | 858 + .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/xcschemes/App.xcscheme | 89 + .../App.xcworkspace/contents.xcworkspacedata | 10 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + ios/App/App/App-Bridging-Header.h | 4 + ios/App/App/AppDelegate.swift | 132 + .../AppIcon.appiconset/1024.png | Bin 0 -> 170294 bytes .../AppIcon.appiconset/120-1.png | Bin 0 -> 12179 bytes .../AppIcon.appiconset/120.png | Bin 0 -> 12179 bytes .../AppIcon.appiconset/152.png | Bin 0 -> 15476 bytes .../AppIcon.appiconset/167.png | Bin 0 -> 17100 bytes .../AppIcon.appiconset/180.png | Bin 0 -> 18362 bytes .../Assets.xcassets/AppIcon.appiconset/20.png | Bin 0 -> 1301 bytes .../Assets.xcassets/AppIcon.appiconset/29.png | Bin 0 -> 2261 bytes .../AppIcon.appiconset/40-1.png | Bin 0 -> 3481 bytes .../AppIcon.appiconset/40-2.png | Bin 0 -> 3481 bytes .../Assets.xcassets/AppIcon.appiconset/40.png | Bin 0 -> 3481 bytes .../AppIcon.appiconset/58-1.png | Bin 0 -> 5471 bytes .../Assets.xcassets/AppIcon.appiconset/58.png | Bin 0 -> 5471 bytes .../Assets.xcassets/AppIcon.appiconset/60.png | Bin 0 -> 5741 bytes .../AppIcon.appiconset/76-1.png | Bin 0 -> 7406 bytes .../AppIcon.appiconset/80-1.png | Bin 0 -> 7920 bytes .../Assets.xcassets/AppIcon.appiconset/80.png | Bin 0 -> 7920 bytes .../Assets.xcassets/AppIcon.appiconset/87.png | Bin 0 -> 8791 bytes .../AppIcon.appiconset/Contents.json | 116 + ios/App/App/Assets.xcassets/Contents.json | 6 + .../Icons.appiconset/Contents.json | 314 + .../Icons.appiconset/icon-100.png | Bin 0 -> 7099 bytes .../Icons.appiconset/icon-1024.png | Bin 0 -> 619260 bytes .../Icons.appiconset/icon-120.png | Bin 0 -> 9045 bytes .../Icons.appiconset/icon-128.png | Bin 0 -> 9891 bytes .../Icons.appiconset/icon-152.png | Bin 0 -> 13101 bytes .../Icons.appiconset/icon-16.png | Bin 0 -> 743 bytes .../Icons.appiconset/icon-167.png | Bin 0 -> 15270 bytes .../Icons.appiconset/icon-172.png | Bin 0 -> 15625 bytes .../Icons.appiconset/icon-180.png | Bin 0 -> 17317 bytes .../Icons.appiconset/icon-196.png | Bin 0 -> 19744 bytes .../Icons.appiconset/icon-20.png | Bin 0 -> 927 bytes .../Icons.appiconset/icon-216.png | Bin 0 -> 23502 bytes .../Icons.appiconset/icon-256.png | Bin 0 -> 33633 bytes .../Icons.appiconset/icon-29.png | Bin 0 -> 1439 bytes .../Icons.appiconset/icon-32.png | Bin 0 -> 1570 bytes .../Icons.appiconset/icon-40.png | Bin 0 -> 2108 bytes .../Icons.appiconset/icon-48.png | Bin 0 -> 2645 bytes .../Icons.appiconset/icon-512.png | Bin 0 -> 121057 bytes .../Icons.appiconset/icon-55.png | Bin 0 -> 3210 bytes .../Icons.appiconset/icon-58.png | Bin 0 -> 3401 bytes .../Icons.appiconset/icon-60.png | Bin 0 -> 3682 bytes .../Icons.appiconset/icon-64.png | Bin 0 -> 3798 bytes .../Icons.appiconset/icon-76.png | Bin 0 -> 4766 bytes .../Icons.appiconset/icon-80.png | Bin 0 -> 5137 bytes .../Icons.appiconset/icon-87.png | Bin 0 -> 5977 bytes .../Icons.appiconset/icon-88.png | Bin 0 -> 5946 bytes .../IconsDebug.appiconset/Contents.json | 314 + .../IconsDebug.appiconset/icon-100.png | Bin 0 -> 5787 bytes .../IconsDebug.appiconset/icon-1024.png | Bin 0 -> 408949 bytes .../IconsDebug.appiconset/icon-120.png | Bin 0 -> 7266 bytes .../IconsDebug.appiconset/icon-128.png | Bin 0 -> 7737 bytes .../IconsDebug.appiconset/icon-152.png | Bin 0 -> 10275 bytes .../IconsDebug.appiconset/icon-16.png | Bin 0 -> 672 bytes .../IconsDebug.appiconset/icon-167.png | Bin 0 -> 11541 bytes .../IconsDebug.appiconset/icon-172.png | Bin 0 -> 11811 bytes .../IconsDebug.appiconset/icon-180.png | Bin 0 -> 13057 bytes .../IconsDebug.appiconset/icon-196.png | Bin 0 -> 14848 bytes .../IconsDebug.appiconset/icon-20.png | Bin 0 -> 845 bytes .../IconsDebug.appiconset/icon-216.png | Bin 0 -> 17390 bytes .../IconsDebug.appiconset/icon-256.png | Bin 0 -> 24435 bytes .../IconsDebug.appiconset/icon-29.png | Bin 0 -> 1321 bytes .../IconsDebug.appiconset/icon-32.png | Bin 0 -> 1448 bytes .../IconsDebug.appiconset/icon-40.png | Bin 0 -> 1941 bytes .../IconsDebug.appiconset/icon-48.png | Bin 0 -> 2374 bytes .../IconsDebug.appiconset/icon-512.png | Bin 0 -> 84222 bytes .../IconsDebug.appiconset/icon-55.png | Bin 0 -> 2801 bytes .../IconsDebug.appiconset/icon-58.png | Bin 0 -> 3005 bytes .../IconsDebug.appiconset/icon-60.png | Bin 0 -> 3173 bytes .../IconsDebug.appiconset/icon-64.png | Bin 0 -> 3321 bytes .../IconsDebug.appiconset/icon-76.png | Bin 0 -> 4091 bytes .../IconsDebug.appiconset/icon-80.png | Bin 0 -> 4308 bytes .../IconsDebug.appiconset/icon-87.png | Bin 0 -> 4881 bytes .../IconsDebug.appiconset/icon-88.png | Bin 0 -> 4953 bytes .../Splash.imageset/Contents.json | 23 + .../Splash.imageset/splash-2732x2732-1.png | Bin 0 -> 41273 bytes .../Splash.imageset/splash-2732x2732-2.png | Bin 0 -> 41273 bytes .../Splash.imageset/splash-2732x2732.png | Bin 0 -> 41273 bytes .../App/Base.lproj/LaunchScreen.storyboard | 32 + ios/App/App/Base.lproj/Main.storyboard | 18 + ios/App/App/Info.plist | 75 + ios/App/App/MyViewController.swift | 38 + ios/App/App/capacitor.config.json | 31 + ios/App/App/config.xml | 10 + ios/App/App/plugins/AbsAudioPlayer.swift | 332 + ios/App/App/plugins/AbsDatabase.swift | 361 + ios/App/App/plugins/AbsDownloader.swift | 518 + ios/App/App/plugins/AbsFileSystem.swift | 135 + ios/App/App/plugins/AbsLogger.swift | 124 + .../player/util/PlayerTimeUtilsTests.swift | 71 + ios/App/Podfile | 39 + ios/App/Podfile.lock | 111 + ios/App/Shared/models/DeviceSettings.swift | 43 + ios/App/Shared/models/LogEntry.swift | 17 + ios/App/Shared/models/PlaybackMetadata.swift | 14 + ios/App/Shared/models/PlaybackReport.swift | 14 + ios/App/Shared/models/PlaybackSession.swift | 170 + ios/App/Shared/models/PlayerSettings.swift | 32 + ios/App/Shared/models/PlayerState.swift | 15 + .../models/ServerConnectionConfig.swift | 38 + .../Shared/models/download/DownloadItem.swift | 97 + .../models/download/DownloadItemPart.swift | 108 + ios/App/Shared/models/local/LocalFile.swift | 73 + .../models/local/LocalLibraryItem.swift | 223 + .../models/local/LocalMediaProgress.swift | 218 + .../models/local/LocalPodcastEpisode.swift | 47 + ios/App/Shared/models/server/AudioFile.swift | 38 + ios/App/Shared/models/server/AudioTrack.swift | 79 + ios/App/Shared/models/server/Author.swift | 38 + ios/App/Shared/models/server/Chapter.swift | 51 + ios/App/Shared/models/server/EBookFile.swift | 85 + .../Shared/models/server/FileMetadata.swift | 44 + ios/App/Shared/models/server/Folder.swift | 35 + ios/App/Shared/models/server/Library.swift | 46 + .../Shared/models/server/LibraryFile.swift | 35 + .../Shared/models/server/LibraryItem.swift | 92 + .../Shared/models/server/MediaProgress.swift | 67 + ios/App/Shared/models/server/MediaType.swift | 75 + ios/App/Shared/models/server/Metadata.swift | 112 + .../Shared/models/server/PodcastEpisode.swift | 79 + ios/App/Shared/models/server/User.swift | 41 + ios/App/Shared/player/AudioPlayer.swift | 830 + .../player/AudioPlayerRateManager.swift | 23 + .../Shared/player/AudioPlayerSleepTimer.swift | 166 + .../DefaultedAudioPlayerRateManager.swift | 93 + .../player/LegacyAudioPlayerRateManager.swift | 83 + ios/App/Shared/player/PlayerHandler.swift | 167 + ios/App/Shared/player/PlayerProgress.swift | 197 + .../Shared/player/util/PlayerTimeUtils.swift | 47 + ios/App/Shared/util/ApiClient.swift | 747 + ios/App/Shared/util/DaoExtensions.swift | 45 + ios/App/Shared/util/Database.swift | 320 + ios/App/Shared/util/Extensions.swift | 184 + ios/App/Shared/util/Logger.swift | 72 + ios/App/Shared/util/NowPlayingInfo.swift | 118 + ios/App/Shared/util/PlayerEvents.swift | 17 + ios/App/Shared/util/SecureStorage.swift | 116 + ios/App/Shared/util/Store.swift | 74 + layouts/blank.vue | 9 + layouts/default.vue | 394 + middleware/authenticated.js | 6 + mixins/bookshelfCardsHelpers.js | 92 + mixins/cellularPermissionHelpers.js | 42 + mixins/jumpLabel.js | 12 + nuxt.config.js | 54 + objects/TouchEvent.js | 65 + package-lock.json | 17035 ++++++++++++++++ package.json | 52 + pages/account.vue | 65 + pages/bookshelf.vue | 45 + pages/bookshelf/add-podcast.vue | 149 + pages/bookshelf/authors.vue | 91 + pages/bookshelf/collections.vue | 14 + pages/bookshelf/index.vue | 350 + pages/bookshelf/latest.vue | 111 + pages/bookshelf/library.vue | 14 + pages/bookshelf/playlists.vue | 14 + pages/bookshelf/series/_id.vue | 184 + pages/bookshelf/series/index.vue | 14 + pages/collection/_id.vue | 154 + pages/connect.vue | 52 + pages/downloading.vue | 42 + pages/downloads.vue | 83 + pages/index.vue | 16 + pages/item/_id/_episode/index.vue | 622 + pages/item/_id/index.vue | 833 + pages/localMedia/folders/_id.vue | 162 + pages/localMedia/folders/index.vue | 119 + pages/localMedia/item/_id.vue | 476 + pages/logs.vue | 175 + pages/media/_id/history.vue | 206 + pages/playlist/_id.vue | 183 + pages/search.vue | 175 + pages/settings.vue | 688 + pages/stats.vue | 125 + plugins/axios.js | 133 + plugins/capacitor/AbsAudioPlayer.js | 293 + plugins/capacitor/AbsDatabase.js | 400 + plugins/capacitor/AbsDownloader.js | 13 + plugins/capacitor/AbsFileSystem.js | 15 + plugins/capacitor/AbsLogger.js | 55 + plugins/capacitor/index.js | 11 + plugins/constants.js | 55 + plugins/db.js | 143 + plugins/haptics.js | 62 + plugins/i18n.js | 170 + plugins/init.client.js | 337 + plugins/localStore.js | 167 + plugins/nativeHttp.js | 247 + plugins/server.js | 138 + plugins/toast.js | 10 + readme.md | 227 + screenshots/DeviceDemoScreens.png | Bin 0 -> 803097 bytes screenshots/dev_setup_android_cap_sync.png | Bin 0 -> 139000 bytes screenshots/dev_setup_android_npm_install.png | Bin 0 -> 90231 bytes screenshots/dev_setup_android_npm_run.png | Bin 0 -> 101837 bytes screenshots/dev_setup_cap_android.png | Bin 0 -> 148762 bytes screenshots/dev_setup_ios_cap_open.png | Bin 0 -> 912890 bytes screenshots/dev_setup_ios_cap_sync.png | Bin 0 -> 613357 bytes screenshots/dev_setup_ios_npm_generate.png | Bin 0 -> 556651 bytes screenshots/dev_setup_ios_npm_install.png | Bin 0 -> 403474 bytes screenshots/dev_setup_windows_winget.png | Bin 0 -> 111503 bytes static/Logo.png | Bin 0 -> 17619 bytes static/book_placeholder.jpg | Bin 0 -> 107910 bytes static/favicon.ico | Bin 0 -> 1411 bytes static/fonts/MaterialSymbolsRounded.woff2 | Bin 0 -> 426732 bytes static/fonts/Source_Sans_Pro/OFL.txt | 93 + .../Source_Sans_Pro/SourceSansPro-Light.ttf | Bin 0 -> 246624 bytes .../Source_Sans_Pro/SourceSansPro-Regular.ttf | Bin 0 -> 248132 bytes .../SourceSansPro-SemiBold.ttf | Bin 0 -> 247892 bytes static/fonts/Ubuntu_Mono/UFL.txt | 96 + .../fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf | Bin 0 -> 189892 bytes static/fonts/absicons/absicons.eot | Bin 0 -> 6168 bytes static/fonts/absicons/absicons.svg | 34 + static/fonts/absicons/absicons.ttf | Bin 0 -> 6000 bytes static/fonts/absicons/absicons.woff | Bin 0 -> 6076 bytes static/libarchive/wasm-gen/libarchive.js | 15 + static/libarchive/wasm-gen/libarchive.wasm | Bin 0 -> 896488 bytes static/libarchive/worker-bundle.js | 1 + static/wood_panels.jpg | Bin 0 -> 116707 bytes store/globals.js | 193 + store/index.js | 214 + store/libraries.js | 185 + store/user.js | 275 + strings/ar.json | 376 + strings/be.json | 383 + strings/bg.json | 376 + strings/bn.json | 337 + strings/ca.json | 300 + strings/cs.json | 383 + strings/da.json | 376 + strings/de.json | 383 + strings/el.json | 276 + strings/en-us.json | 383 + strings/es.json | 376 + strings/et.json | 179 + strings/fa.json | 57 + strings/fi.json | 380 + strings/fr.json | 383 + strings/gu.json | 37 + strings/he.json | 357 + strings/hi.json | 52 + strings/hr.json | 383 + strings/hu.json | 383 + strings/is.json | 12 + strings/it.json | 382 + strings/ja.json | 288 + strings/ko.json | 362 + strings/lt.json | 220 + strings/lv.json | 1 + strings/nl.json | 376 + strings/no.json | 376 + strings/pl.json | 376 + strings/pt-br.json | 376 + strings/ro.json | 376 + strings/ru.json | 383 + strings/sk.json | 376 + strings/sl.json | 376 + strings/sv.json | 376 + strings/ta.json | 376 + strings/tr.json | 361 + strings/uk.json | 376 + strings/uz.json | 1 + strings/vi-vn.json | 319 + strings/zh-cn.json | 383 + strings/zh_Hant.json | 383 + tailwind.config.js | 63 + utils/coverAverageColor.js | 59 + utils/imageHttpBlob.js | 40 + 665 files changed, 85467 insertions(+) create mode 100644 .editorconfig create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/testing-page-template.html create mode 100644 .github/workflows/build-apk.yml create mode 100644 .github/workflows/build-ios.yml create mode 100644 .github/workflows/close-issues-on-release.yml create mode 100644 .github/workflows/close_blank_issues.yaml create mode 100644 .github/workflows/deploy-apk.yml create mode 100644 .github/workflows/i18n-check.yml create mode 100644 .github/workflows/translate-credits.yml create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json create mode 100644 INSTALL.md create mode 100644 LICENSE create mode 100644 android/.gitignore create mode 100644 android/.vscode/settings.json create mode 100644 android/app/.gitignore create mode 100644 android/app/build.gradle create mode 100644 android/app/capacitor.build.gradle create mode 100644 android/app/proguard-rules.pro create mode 100644 android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java create mode 100644 android/app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 android/app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 android/app/src/debug/res/mipmap-hdpi/ic_launcher.png create mode 100644 android/app/src/debug/res/mipmap-hdpi/ic_launcher_background.png create mode 100644 android/app/src/debug/res/mipmap-hdpi/ic_launcher_foreground.png create mode 100644 android/app/src/debug/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 android/app/src/debug/res/mipmap-mdpi/ic_launcher.png create mode 100644 android/app/src/debug/res/mipmap-mdpi/ic_launcher_background.png create mode 100644 android/app/src/debug/res/mipmap-mdpi/ic_launcher_foreground.png create mode 100644 android/app/src/debug/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 android/app/src/debug/res/mipmap-xhdpi/ic_launcher.png create mode 100644 android/app/src/debug/res/mipmap-xhdpi/ic_launcher_background.png create mode 100644 android/app/src/debug/res/mipmap-xhdpi/ic_launcher_foreground.png create mode 100644 android/app/src/debug/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 android/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 android/app/src/debug/res/mipmap-xxhdpi/ic_launcher_background.png create mode 100644 android/app/src/debug/res/mipmap-xxhdpi/ic_launcher_foreground.png create mode 100644 android/app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_background.png create mode 100644 android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_foreground.png create mode 100644 android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 android/app/src/debug/res/values/strings.xml create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/assets/capacitor.config.json create mode 100644 android/app/src/main/assets/capacitor.plugins.json create mode 100644 android/app/src/main/ic_launcher-playstore.png create mode 100644 android/app/src/main/java/com/audiobookshelf/app/CastOptionsProvider.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/MainActivity.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/MediaPlayerWidget.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/data/AudioTrack.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/data/CollapsedSeries.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/data/EBookFile.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/data/ItemInProgress.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/data/LibraryAuthorItem.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/data/LibraryCategory.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/data/LibraryCollection.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/data/LibrarySeriesItem.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaItem.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaProgress.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/data/MediaItemEvent.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/data/MediaItemHistory.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/data/MediaProgress.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/data/PlaybackMetadata.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/device/FolderScanner.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/managers/DbManager.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/managers/IncompleteDownloadCleanup.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/managers/SecureStorage.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/managers/SleepTimerManager.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/media/MediaEventManager.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/media/MediaProgressSyncer.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/media/icons.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/models/DownloadItem.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/models/User.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/player/AbMediaDescriptionAdapter.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/player/CastManager.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/player/CastPlayer.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/player/CastTimeline.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/player/CastTimelineTracker.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/player/CastTrackSelection.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/player/CoverImageLoader.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionCallback.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionPlaybackPreparer.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/player/PlayerConstants.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/player/PlayerListener.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationListener.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/player/ShakeDetector.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/plugins/AbsLogger.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/services/DownloadServiceHost.kt create mode 100644 android/app/src/main/res/drawable-anydpi-v24/exo_icon_downloaddone.xml create mode 100644 android/app/src/main/res/drawable-anydpi-v24/exo_icon_localaudio.xml create mode 100644 android/app/src/main/res/drawable-anydpi/exo_icon_fastforward.xml create mode 100644 android/app/src/main/res/drawable-anydpi/exo_icon_rewind.xml create mode 100644 android/app/src/main/res/drawable-anydpi/ic_play_speed_0_5x.xml create mode 100644 android/app/src/main/res/drawable-anydpi/ic_play_speed_1_0x.xml create mode 100644 android/app/src/main/res/drawable-anydpi/ic_play_speed_1_2x.xml create mode 100644 android/app/src/main/res/drawable-anydpi/ic_play_speed_1_5x.xml create mode 100644 android/app/src/main/res/drawable-anydpi/ic_play_speed_2_0x.xml create mode 100644 android/app/src/main/res/drawable-anydpi/ic_play_speed_3_0x.xml create mode 100644 android/app/src/main/res/drawable-anydpi/icon_library_folder.xml create mode 100644 android/app/src/main/res/drawable-anydpi/md_clock_outline.xml create mode 100644 android/app/src/main/res/drawable-anydpi/skip_next_24.xml create mode 100644 android/app/src/main/res/drawable-anydpi/skip_previous_24.xml create mode 100644 android/app/src/main/res/drawable-hdpi/exo_icon_downloaddone.png create mode 100644 android/app/src/main/res/drawable-hdpi/exo_icon_fastforward.png create mode 100644 android/app/src/main/res/drawable-hdpi/exo_icon_localaudio.png create mode 100644 android/app/src/main/res/drawable-hdpi/exo_icon_rewind.png create mode 100644 android/app/src/main/res/drawable-hdpi/ic_play_speed_0_5x.png create mode 100644 android/app/src/main/res/drawable-hdpi/ic_play_speed_1_0x.png create mode 100644 android/app/src/main/res/drawable-hdpi/ic_play_speed_1_2x.png create mode 100644 android/app/src/main/res/drawable-hdpi/ic_play_speed_1_5x.png create mode 100644 android/app/src/main/res/drawable-hdpi/ic_play_speed_2_0x.png create mode 100644 android/app/src/main/res/drawable-hdpi/ic_play_speed_3_0x.png create mode 100644 android/app/src/main/res/drawable-hdpi/icon_library_folder.png create mode 100644 android/app/src/main/res/drawable-hdpi/md_account_outline.png create mode 100644 android/app/src/main/res/drawable-hdpi/md_clock_outline.png create mode 100644 android/app/src/main/res/drawable-mdpi/exo_icon_downloaddone.png create mode 100644 android/app/src/main/res/drawable-mdpi/exo_icon_fastforward.png create mode 100644 android/app/src/main/res/drawable-mdpi/exo_icon_localaudio.png create mode 100644 android/app/src/main/res/drawable-mdpi/exo_icon_rewind.png create mode 100644 android/app/src/main/res/drawable-mdpi/ic_play_speed_0_5x.png create mode 100644 android/app/src/main/res/drawable-mdpi/ic_play_speed_1_0x.png create mode 100644 android/app/src/main/res/drawable-mdpi/ic_play_speed_1_2x.png create mode 100644 android/app/src/main/res/drawable-mdpi/ic_play_speed_1_5x.png create mode 100644 android/app/src/main/res/drawable-mdpi/ic_play_speed_2_0x.png create mode 100644 android/app/src/main/res/drawable-mdpi/ic_play_speed_3_0x.png create mode 100644 android/app/src/main/res/drawable-mdpi/icon_library_folder.png create mode 100644 android/app/src/main/res/drawable-mdpi/md_account_outline.png create mode 100644 android/app/src/main/res/drawable-mdpi/md_clock_outline.png create mode 100644 android/app/src/main/res/drawable-nodpi/media_player_widget_preview.png create mode 100644 android/app/src/main/res/drawable-v21/app_widget_background.xml create mode 100644 android/app/src/main/res/drawable-v21/app_widget_inner_view_background.xml create mode 100644 android/app/src/main/res/drawable-xhdpi/exo_icon_downloaddone.png create mode 100644 android/app/src/main/res/drawable-xhdpi/exo_icon_fastforward.png create mode 100644 android/app/src/main/res/drawable-xhdpi/exo_icon_localaudio.png create mode 100644 android/app/src/main/res/drawable-xhdpi/exo_icon_rewind.png create mode 100644 android/app/src/main/res/drawable-xhdpi/ic_play_speed_0_5x.png create mode 100644 android/app/src/main/res/drawable-xhdpi/ic_play_speed_1_0x.png create mode 100644 android/app/src/main/res/drawable-xhdpi/ic_play_speed_1_2x.png create mode 100644 android/app/src/main/res/drawable-xhdpi/ic_play_speed_1_5x.png create mode 100644 android/app/src/main/res/drawable-xhdpi/ic_play_speed_2_0x.png create mode 100644 android/app/src/main/res/drawable-xhdpi/ic_play_speed_3_0x.png create mode 100644 android/app/src/main/res/drawable-xhdpi/icon_library_folder.png create mode 100644 android/app/src/main/res/drawable-xhdpi/md_account_outline.png create mode 100644 android/app/src/main/res/drawable-xhdpi/md_clock_outline.png create mode 100644 android/app/src/main/res/drawable-xxhdpi/exo_icon_downloaddone.png create mode 100644 android/app/src/main/res/drawable-xxhdpi/exo_icon_fastforward.png create mode 100644 android/app/src/main/res/drawable-xxhdpi/exo_icon_localaudio.png create mode 100644 android/app/src/main/res/drawable-xxhdpi/exo_icon_rewind.png create mode 100644 android/app/src/main/res/drawable-xxhdpi/ic_play_speed_0_5x.png create mode 100644 android/app/src/main/res/drawable-xxhdpi/ic_play_speed_1_0x.png create mode 100644 android/app/src/main/res/drawable-xxhdpi/ic_play_speed_1_2x.png create mode 100644 android/app/src/main/res/drawable-xxhdpi/ic_play_speed_1_5x.png create mode 100644 android/app/src/main/res/drawable-xxhdpi/ic_play_speed_2_0x.png create mode 100644 android/app/src/main/res/drawable-xxhdpi/ic_play_speed_3_0x.png create mode 100644 android/app/src/main/res/drawable-xxhdpi/icon_library_folder.png create mode 100644 android/app/src/main/res/drawable-xxhdpi/md_account_outline.png create mode 100644 android/app/src/main/res/drawable-xxhdpi/md_clock_outline.png create mode 100644 android/app/src/main/res/drawable/abs_audiobookshelf.xml create mode 100644 android/app/src/main/res/drawable/abs_authors.xml create mode 100644 android/app/src/main/res/drawable/abs_book_1.xml create mode 100644 android/app/src/main/res/drawable/abs_books_1.xml create mode 100644 android/app/src/main/res/drawable/abs_books_2.xml create mode 100644 android/app/src/main/res/drawable/abs_columns.xml create mode 100644 android/app/src/main/res/drawable/abs_database.xml create mode 100644 android/app/src/main/res/drawable/abs_file_picture.xml create mode 100644 android/app/src/main/res/drawable/abs_headphones.xml create mode 100644 android/app/src/main/res/drawable/abs_heart.xml create mode 100644 android/app/src/main/res/drawable/abs_microphone_1.xml create mode 100644 android/app/src/main/res/drawable/abs_microphone_2.xml create mode 100644 android/app/src/main/res/drawable/abs_microphone_3.xml create mode 100644 android/app/src/main/res/drawable/abs_music.xml create mode 100644 android/app/src/main/res/drawable/abs_podcast.xml create mode 100644 android/app/src/main/res/drawable/abs_radio.xml create mode 100644 android/app/src/main/res/drawable/abs_rocket.xml create mode 100644 android/app/src/main/res/drawable/abs_rss.xml create mode 100644 android/app/src/main/res/drawable/abs_star.xml create mode 100644 android/app/src/main/res/drawable/exo_icon_fastforward.xml create mode 100644 android/app/src/main/res/drawable/exo_icon_rewind.xml create mode 100644 android/app/src/main/res/drawable/icon.png create mode 100644 android/app/src/main/res/drawable/icon_32.png create mode 100644 android/app/src/main/res/drawable/icon_monochrome.xml create mode 100644 android/app/src/main/res/drawable/md_account_outline.xml create mode 100644 android/app/src/main/res/drawable/md_book_multiple_outline.xml create mode 100644 android/app/src/main/res/drawable/md_book_open_blank_variant_outline.xml create mode 100644 android/app/src/main/res/drawable/md_clock_outline.xml create mode 100644 android/app/src/main/res/drawable/md_telescope.xml create mode 100644 android/app/src/main/res/drawable/skip_next_24.xml create mode 100644 android/app/src/main/res/drawable/skip_previous_24.xml create mode 100644 android/app/src/main/res/layout/activity_main.xml create mode 100644 android/app/src/main/res/layout/media_player_widget.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png create mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png create mode 100644 android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png create mode 100644 android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png create mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 android/app/src/main/res/raw/bell.mp3 create mode 100644 android/app/src/main/res/values-night-v31/themes.xml create mode 100644 android/app/src/main/res/values-v21/styles.xml create mode 100644 android/app/src/main/res/values-v31/styles.xml create mode 100644 android/app/src/main/res/values-v31/themes.xml create mode 100644 android/app/src/main/res/values/attrs.xml create mode 100644 android/app/src/main/res/values/colors.xml create mode 100644 android/app/src/main/res/values/dimens.xml create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/values/styles.xml create mode 100644 android/app/src/main/res/values/themes.xml create mode 100644 android/app/src/main/res/xml/automotive_app_desc.xml create mode 100644 android/app/src/main/res/xml/config.xml create mode 100644 android/app/src/main/res/xml/file_paths.xml create mode 100644 android/app/src/main/res/xml/media_player_widget_info.xml create mode 100644 android/app/src/main/res/xml/network_security_config.xml create mode 100644 android/app/src/main/res/xml/samsung_cover_widget_info.xml create mode 100644 android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java create mode 100644 android/build.gradle create mode 100644 android/capacitor.settings.gradle create mode 100644 android/gradle.properties create mode 100644 android/gradle/wrapper/gradle-wrapper.jar create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100755 android/gradlew create mode 100644 android/gradlew.bat create mode 100644 android/settings.gradle create mode 100644 android/variables.gradle create mode 100644 assets/WrappingMarquee.js create mode 100644 assets/absicons.css create mode 100644 assets/app.css create mode 100644 assets/defaultStyles.css create mode 100644 assets/ebooks/basic.js create mode 100644 assets/ebooks/htmlParser.js create mode 100644 assets/ebooks/mobi.js create mode 100644 assets/fonts.css create mode 100644 assets/tailwind.css create mode 100644 assets/transitions.css create mode 100644 capacitor.config.json create mode 100644 components/app/Appbar.vue create mode 100644 components/app/AudioPlayer.vue create mode 100644 components/app/AudioPlayerContainer.vue create mode 100644 components/app/SideDrawer.vue create mode 100644 components/bookshelf/LazyBookshelf.vue create mode 100644 components/bookshelf/Shelf.vue create mode 100644 components/cards/AuthorCard.vue create mode 100644 components/cards/AuthorSearchCard.vue create mode 100644 components/cards/EpisodeSearchCard.vue create mode 100644 components/cards/ItemSearchCard.vue create mode 100644 components/cards/LazyBookCard.vue create mode 100644 components/cards/LazyCollectionCard.vue create mode 100644 components/cards/LazyListBookCard.vue create mode 100644 components/cards/LazyPlaylistCard.vue create mode 100644 components/cards/LazySeriesCard.vue create mode 100644 components/cards/NarratorSearchCard.vue create mode 100644 components/cards/SeriesSearchCard.vue create mode 100644 components/cards/TagSearchCard.vue create mode 100644 components/connection/ServerConnectForm.vue create mode 100644 components/covers/AuthorImage.vue create mode 100644 components/covers/BookCover.vue create mode 100644 components/covers/CollectionCover.vue create mode 100644 components/covers/GroupCover.vue create mode 100644 components/covers/PlaylistCover.vue create mode 100644 components/covers/PreviewCover.vue create mode 100644 components/forms/NewPodcastForm.vue create mode 100644 components/home/BookshelfNavBar.vue create mode 100644 components/home/BookshelfToolbar.vue create mode 100644 components/modals/AutoSleepTimerRewindLengthModal.vue create mode 100644 components/modals/BookmarksModal.vue create mode 100644 components/modals/ChaptersModal.vue create mode 100644 components/modals/CustomHeadersModal.vue create mode 100644 components/modals/Dialog.vue create mode 100644 components/modals/FilterModal.vue create mode 100644 components/modals/FullscreenCover.vue create mode 100644 components/modals/FullscreenModal.vue create mode 100644 components/modals/ItemDetailsModal.vue create mode 100644 components/modals/ItemMoreMenuModal.vue create mode 100644 components/modals/LibrariesModal.vue create mode 100644 components/modals/Modal.vue create mode 100644 components/modals/OrderModal.vue create mode 100644 components/modals/PlaybackSpeedModal.vue create mode 100644 components/modals/PodcastEpisodesFeedModal.vue create mode 100644 components/modals/SelectLocalFolderModal.vue create mode 100644 components/modals/SleepTimerLengthModal.vue create mode 100644 components/modals/SleepTimerModal.vue create mode 100644 components/modals/bookmarks/BookmarkItem.vue create mode 100644 components/modals/downloads/DownloadItem.vue create mode 100644 components/modals/playlists/AddCreateModal.vue create mode 100644 components/modals/playlists/PlaylistRow.vue create mode 100644 components/modals/rssfeeds/RssFeedMetadataBuilder.vue create mode 100644 components/modals/rssfeeds/RssFeedModal.vue create mode 100644 components/readers/ComicReader.vue create mode 100644 components/readers/EpubReader.vue create mode 100644 components/readers/MobiReader.vue create mode 100644 components/readers/PdfReader.vue create mode 100644 components/readers/Reader.vue create mode 100644 components/stats/DailyListeningChart.vue create mode 100644 components/stats/YearInReview.vue create mode 100644 components/stats/YearInReviewBanner.vue create mode 100644 components/stats/YearInReviewServer.vue create mode 100644 components/stats/YearInReviewShort.vue create mode 100644 components/tables/ChaptersTable.vue create mode 100644 components/tables/TracksTable.vue create mode 100644 components/tables/collection/BookTableRow.vue create mode 100644 components/tables/collection/CollectionBooksTable.vue create mode 100644 components/tables/ebook/EbookFilesTable.vue create mode 100644 components/tables/ebook/EbookFilesTableRow.vue create mode 100644 components/tables/playlist/ItemTableRow.vue create mode 100644 components/tables/playlist/PlaylistItemsTable.vue create mode 100644 components/tables/podcast/EpisodeRow.vue create mode 100644 components/tables/podcast/EpisodesTable.vue create mode 100644 components/tables/podcast/LatestEpisodeRow.vue create mode 100644 components/ui/Btn.vue create mode 100644 components/ui/Checkbox.vue create mode 100644 components/ui/Dropdown.vue create mode 100644 components/ui/DropdownMenu.vue create mode 100644 components/ui/IconBtn.vue create mode 100644 components/ui/LibraryIcon.vue create mode 100644 components/ui/LoadingIndicator.vue create mode 100644 components/ui/Menu.vue create mode 100644 components/ui/MultiSelect.vue create mode 100644 components/ui/RangeInput.vue create mode 100644 components/ui/ReadIconBtn.vue create mode 100644 components/ui/TextInput.vue create mode 100644 components/ui/TextInputWithLabel.vue create mode 100644 components/ui/TextareaInput.vue create mode 100644 components/ui/TextareaWithLabel.vue create mode 100644 components/ui/ToggleBtns.vue create mode 100644 components/ui/ToggleSwitch.vue create mode 100644 components/widgets/AbridgedIndicator.vue create mode 100644 components/widgets/CircleProgress.vue create mode 100644 components/widgets/ConnectionIndicator.vue create mode 100644 components/widgets/DownloadProgressIndicator.vue create mode 100644 components/widgets/ExplicitIndicator.vue create mode 100644 components/widgets/LoadingSpinner.vue create mode 100644 components/widgets/SpinnerIcon.vue create mode 100644 fastlane/metadata/android/de/full_description.txt create mode 100644 fastlane/metadata/android/de/short_description.txt create mode 100644 fastlane/metadata/android/en-US/full_description.txt create mode 100644 fastlane/metadata/android/en-US/images/featureGraphic.png create mode 100644 fastlane/metadata/android/en-US/images/icon.png create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/01.jpg create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/02.jpg create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/03.jpg create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/04.jpg create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/05.jpg create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/06.jpg create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/07.png create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/08.jpg create mode 100644 fastlane/metadata/android/en-US/short_description.txt create mode 100644 ionic.config.json create mode 100644 ios/.gitignore create mode 100644 ios/App/App.xcodeproj/project.pbxproj create mode 100644 ios/App/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme create mode 100644 ios/App/App.xcworkspace/contents.xcworkspacedata create mode 100644 ios/App/App.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 ios/App/App/App-Bridging-Header.h create mode 100644 ios/App/App/AppDelegate.swift create mode 100644 ios/App/App/Assets.xcassets/AppIcon.appiconset/1024.png create mode 100644 ios/App/App/Assets.xcassets/AppIcon.appiconset/120-1.png create mode 100644 ios/App/App/Assets.xcassets/AppIcon.appiconset/120.png create mode 100644 ios/App/App/Assets.xcassets/AppIcon.appiconset/152.png create mode 100644 ios/App/App/Assets.xcassets/AppIcon.appiconset/167.png create mode 100644 ios/App/App/Assets.xcassets/AppIcon.appiconset/180.png create mode 100644 ios/App/App/Assets.xcassets/AppIcon.appiconset/20.png create mode 100644 ios/App/App/Assets.xcassets/AppIcon.appiconset/29.png create mode 100644 ios/App/App/Assets.xcassets/AppIcon.appiconset/40-1.png create mode 100644 ios/App/App/Assets.xcassets/AppIcon.appiconset/40-2.png create mode 100644 ios/App/App/Assets.xcassets/AppIcon.appiconset/40.png create mode 100644 ios/App/App/Assets.xcassets/AppIcon.appiconset/58-1.png create mode 100644 ios/App/App/Assets.xcassets/AppIcon.appiconset/58.png create mode 100644 ios/App/App/Assets.xcassets/AppIcon.appiconset/60.png create mode 100644 ios/App/App/Assets.xcassets/AppIcon.appiconset/76-1.png create mode 100644 ios/App/App/Assets.xcassets/AppIcon.appiconset/80-1.png create mode 100644 ios/App/App/Assets.xcassets/AppIcon.appiconset/80.png create mode 100644 ios/App/App/Assets.xcassets/AppIcon.appiconset/87.png create mode 100644 ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 ios/App/App/Assets.xcassets/Contents.json create mode 100644 ios/App/App/Assets.xcassets/Icons.appiconset/Contents.json create mode 100644 ios/App/App/Assets.xcassets/Icons.appiconset/icon-100.png create mode 100644 ios/App/App/Assets.xcassets/Icons.appiconset/icon-1024.png create mode 100644 ios/App/App/Assets.xcassets/Icons.appiconset/icon-120.png create mode 100644 ios/App/App/Assets.xcassets/Icons.appiconset/icon-128.png create mode 100644 ios/App/App/Assets.xcassets/Icons.appiconset/icon-152.png create mode 100644 ios/App/App/Assets.xcassets/Icons.appiconset/icon-16.png create mode 100644 ios/App/App/Assets.xcassets/Icons.appiconset/icon-167.png create mode 100644 ios/App/App/Assets.xcassets/Icons.appiconset/icon-172.png create mode 100644 ios/App/App/Assets.xcassets/Icons.appiconset/icon-180.png create mode 100644 ios/App/App/Assets.xcassets/Icons.appiconset/icon-196.png create mode 100644 ios/App/App/Assets.xcassets/Icons.appiconset/icon-20.png create mode 100644 ios/App/App/Assets.xcassets/Icons.appiconset/icon-216.png create mode 100644 ios/App/App/Assets.xcassets/Icons.appiconset/icon-256.png create mode 100644 ios/App/App/Assets.xcassets/Icons.appiconset/icon-29.png create mode 100644 ios/App/App/Assets.xcassets/Icons.appiconset/icon-32.png create mode 100644 ios/App/App/Assets.xcassets/Icons.appiconset/icon-40.png create mode 100644 ios/App/App/Assets.xcassets/Icons.appiconset/icon-48.png create mode 100644 ios/App/App/Assets.xcassets/Icons.appiconset/icon-512.png create mode 100644 ios/App/App/Assets.xcassets/Icons.appiconset/icon-55.png create mode 100644 ios/App/App/Assets.xcassets/Icons.appiconset/icon-58.png create mode 100644 ios/App/App/Assets.xcassets/Icons.appiconset/icon-60.png create mode 100644 ios/App/App/Assets.xcassets/Icons.appiconset/icon-64.png create mode 100644 ios/App/App/Assets.xcassets/Icons.appiconset/icon-76.png create mode 100644 ios/App/App/Assets.xcassets/Icons.appiconset/icon-80.png create mode 100644 ios/App/App/Assets.xcassets/Icons.appiconset/icon-87.png create mode 100644 ios/App/App/Assets.xcassets/Icons.appiconset/icon-88.png create mode 100644 ios/App/App/Assets.xcassets/IconsDebug.appiconset/Contents.json create mode 100644 ios/App/App/Assets.xcassets/IconsDebug.appiconset/icon-100.png create mode 100644 ios/App/App/Assets.xcassets/IconsDebug.appiconset/icon-1024.png create mode 100644 ios/App/App/Assets.xcassets/IconsDebug.appiconset/icon-120.png create mode 100644 ios/App/App/Assets.xcassets/IconsDebug.appiconset/icon-128.png create mode 100644 ios/App/App/Assets.xcassets/IconsDebug.appiconset/icon-152.png create mode 100644 ios/App/App/Assets.xcassets/IconsDebug.appiconset/icon-16.png create mode 100644 ios/App/App/Assets.xcassets/IconsDebug.appiconset/icon-167.png create mode 100644 ios/App/App/Assets.xcassets/IconsDebug.appiconset/icon-172.png create mode 100644 ios/App/App/Assets.xcassets/IconsDebug.appiconset/icon-180.png create mode 100644 ios/App/App/Assets.xcassets/IconsDebug.appiconset/icon-196.png create mode 100644 ios/App/App/Assets.xcassets/IconsDebug.appiconset/icon-20.png create mode 100644 ios/App/App/Assets.xcassets/IconsDebug.appiconset/icon-216.png create mode 100644 ios/App/App/Assets.xcassets/IconsDebug.appiconset/icon-256.png create mode 100644 ios/App/App/Assets.xcassets/IconsDebug.appiconset/icon-29.png create mode 100644 ios/App/App/Assets.xcassets/IconsDebug.appiconset/icon-32.png create mode 100644 ios/App/App/Assets.xcassets/IconsDebug.appiconset/icon-40.png create mode 100644 ios/App/App/Assets.xcassets/IconsDebug.appiconset/icon-48.png create mode 100644 ios/App/App/Assets.xcassets/IconsDebug.appiconset/icon-512.png create mode 100644 ios/App/App/Assets.xcassets/IconsDebug.appiconset/icon-55.png create mode 100644 ios/App/App/Assets.xcassets/IconsDebug.appiconset/icon-58.png create mode 100644 ios/App/App/Assets.xcassets/IconsDebug.appiconset/icon-60.png create mode 100644 ios/App/App/Assets.xcassets/IconsDebug.appiconset/icon-64.png create mode 100644 ios/App/App/Assets.xcassets/IconsDebug.appiconset/icon-76.png create mode 100644 ios/App/App/Assets.xcassets/IconsDebug.appiconset/icon-80.png create mode 100644 ios/App/App/Assets.xcassets/IconsDebug.appiconset/icon-87.png create mode 100644 ios/App/App/Assets.xcassets/IconsDebug.appiconset/icon-88.png create mode 100644 ios/App/App/Assets.xcassets/Splash.imageset/Contents.json create mode 100644 ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png create mode 100644 ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png create mode 100644 ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png create mode 100644 ios/App/App/Base.lproj/LaunchScreen.storyboard create mode 100644 ios/App/App/Base.lproj/Main.storyboard create mode 100644 ios/App/App/Info.plist create mode 100644 ios/App/App/MyViewController.swift create mode 100644 ios/App/App/capacitor.config.json create mode 100644 ios/App/App/config.xml create mode 100644 ios/App/App/plugins/AbsAudioPlayer.swift create mode 100644 ios/App/App/plugins/AbsDatabase.swift create mode 100644 ios/App/App/plugins/AbsDownloader.swift create mode 100644 ios/App/App/plugins/AbsFileSystem.swift create mode 100644 ios/App/App/plugins/AbsLogger.swift create mode 100644 ios/App/AudiobookshelfUnitTests/Shared/player/util/PlayerTimeUtilsTests.swift create mode 100644 ios/App/Podfile create mode 100644 ios/App/Podfile.lock create mode 100644 ios/App/Shared/models/DeviceSettings.swift create mode 100644 ios/App/Shared/models/LogEntry.swift create mode 100644 ios/App/Shared/models/PlaybackMetadata.swift create mode 100644 ios/App/Shared/models/PlaybackReport.swift create mode 100644 ios/App/Shared/models/PlaybackSession.swift create mode 100644 ios/App/Shared/models/PlayerSettings.swift create mode 100644 ios/App/Shared/models/PlayerState.swift create mode 100644 ios/App/Shared/models/ServerConnectionConfig.swift create mode 100644 ios/App/Shared/models/download/DownloadItem.swift create mode 100644 ios/App/Shared/models/download/DownloadItemPart.swift create mode 100644 ios/App/Shared/models/local/LocalFile.swift create mode 100644 ios/App/Shared/models/local/LocalLibraryItem.swift create mode 100644 ios/App/Shared/models/local/LocalMediaProgress.swift create mode 100644 ios/App/Shared/models/local/LocalPodcastEpisode.swift create mode 100644 ios/App/Shared/models/server/AudioFile.swift create mode 100644 ios/App/Shared/models/server/AudioTrack.swift create mode 100644 ios/App/Shared/models/server/Author.swift create mode 100644 ios/App/Shared/models/server/Chapter.swift create mode 100644 ios/App/Shared/models/server/EBookFile.swift create mode 100644 ios/App/Shared/models/server/FileMetadata.swift create mode 100644 ios/App/Shared/models/server/Folder.swift create mode 100644 ios/App/Shared/models/server/Library.swift create mode 100644 ios/App/Shared/models/server/LibraryFile.swift create mode 100644 ios/App/Shared/models/server/LibraryItem.swift create mode 100644 ios/App/Shared/models/server/MediaProgress.swift create mode 100644 ios/App/Shared/models/server/MediaType.swift create mode 100644 ios/App/Shared/models/server/Metadata.swift create mode 100644 ios/App/Shared/models/server/PodcastEpisode.swift create mode 100644 ios/App/Shared/models/server/User.swift create mode 100644 ios/App/Shared/player/AudioPlayer.swift create mode 100644 ios/App/Shared/player/AudioPlayerRateManager.swift create mode 100644 ios/App/Shared/player/AudioPlayerSleepTimer.swift create mode 100644 ios/App/Shared/player/DefaultedAudioPlayerRateManager.swift create mode 100644 ios/App/Shared/player/LegacyAudioPlayerRateManager.swift create mode 100644 ios/App/Shared/player/PlayerHandler.swift create mode 100644 ios/App/Shared/player/PlayerProgress.swift create mode 100644 ios/App/Shared/player/util/PlayerTimeUtils.swift create mode 100644 ios/App/Shared/util/ApiClient.swift create mode 100644 ios/App/Shared/util/DaoExtensions.swift create mode 100644 ios/App/Shared/util/Database.swift create mode 100644 ios/App/Shared/util/Extensions.swift create mode 100644 ios/App/Shared/util/Logger.swift create mode 100644 ios/App/Shared/util/NowPlayingInfo.swift create mode 100644 ios/App/Shared/util/PlayerEvents.swift create mode 100644 ios/App/Shared/util/SecureStorage.swift create mode 100644 ios/App/Shared/util/Store.swift create mode 100644 layouts/blank.vue create mode 100644 layouts/default.vue create mode 100644 middleware/authenticated.js create mode 100644 mixins/bookshelfCardsHelpers.js create mode 100644 mixins/cellularPermissionHelpers.js create mode 100644 mixins/jumpLabel.js create mode 100644 nuxt.config.js create mode 100644 objects/TouchEvent.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 pages/account.vue create mode 100644 pages/bookshelf.vue create mode 100644 pages/bookshelf/add-podcast.vue create mode 100644 pages/bookshelf/authors.vue create mode 100644 pages/bookshelf/collections.vue create mode 100644 pages/bookshelf/index.vue create mode 100644 pages/bookshelf/latest.vue create mode 100644 pages/bookshelf/library.vue create mode 100644 pages/bookshelf/playlists.vue create mode 100644 pages/bookshelf/series/_id.vue create mode 100644 pages/bookshelf/series/index.vue create mode 100644 pages/collection/_id.vue create mode 100644 pages/connect.vue create mode 100644 pages/downloading.vue create mode 100644 pages/downloads.vue create mode 100644 pages/index.vue create mode 100644 pages/item/_id/_episode/index.vue create mode 100644 pages/item/_id/index.vue create mode 100644 pages/localMedia/folders/_id.vue create mode 100644 pages/localMedia/folders/index.vue create mode 100644 pages/localMedia/item/_id.vue create mode 100644 pages/logs.vue create mode 100644 pages/media/_id/history.vue create mode 100644 pages/playlist/_id.vue create mode 100644 pages/search.vue create mode 100644 pages/settings.vue create mode 100644 pages/stats.vue create mode 100644 plugins/axios.js create mode 100644 plugins/capacitor/AbsAudioPlayer.js create mode 100644 plugins/capacitor/AbsDatabase.js create mode 100644 plugins/capacitor/AbsDownloader.js create mode 100644 plugins/capacitor/AbsFileSystem.js create mode 100644 plugins/capacitor/AbsLogger.js create mode 100644 plugins/capacitor/index.js create mode 100644 plugins/constants.js create mode 100644 plugins/db.js create mode 100644 plugins/haptics.js create mode 100644 plugins/i18n.js create mode 100644 plugins/init.client.js create mode 100644 plugins/localStore.js create mode 100644 plugins/nativeHttp.js create mode 100644 plugins/server.js create mode 100644 plugins/toast.js create mode 100644 readme.md create mode 100644 screenshots/DeviceDemoScreens.png create mode 100755 screenshots/dev_setup_android_cap_sync.png create mode 100755 screenshots/dev_setup_android_npm_install.png create mode 100755 screenshots/dev_setup_android_npm_run.png create mode 100755 screenshots/dev_setup_cap_android.png create mode 100644 screenshots/dev_setup_ios_cap_open.png create mode 100644 screenshots/dev_setup_ios_cap_sync.png create mode 100644 screenshots/dev_setup_ios_npm_generate.png create mode 100644 screenshots/dev_setup_ios_npm_install.png create mode 100755 screenshots/dev_setup_windows_winget.png create mode 100644 static/Logo.png create mode 100644 static/book_placeholder.jpg create mode 100644 static/favicon.ico create mode 100644 static/fonts/MaterialSymbolsRounded.woff2 create mode 100644 static/fonts/Source_Sans_Pro/OFL.txt create mode 100644 static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf create mode 100644 static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf create mode 100644 static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf create mode 100644 static/fonts/Ubuntu_Mono/UFL.txt create mode 100644 static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf create mode 100644 static/fonts/absicons/absicons.eot create mode 100644 static/fonts/absicons/absicons.svg create mode 100644 static/fonts/absicons/absicons.ttf create mode 100644 static/fonts/absicons/absicons.woff create mode 100644 static/libarchive/wasm-gen/libarchive.js create mode 100644 static/libarchive/wasm-gen/libarchive.wasm create mode 100644 static/libarchive/worker-bundle.js create mode 100644 static/wood_panels.jpg create mode 100644 store/globals.js create mode 100644 store/index.js create mode 100644 store/libraries.js create mode 100644 store/user.js create mode 100644 strings/ar.json create mode 100644 strings/be.json create mode 100644 strings/bg.json create mode 100644 strings/bn.json create mode 100644 strings/ca.json create mode 100644 strings/cs.json create mode 100644 strings/da.json create mode 100644 strings/de.json create mode 100644 strings/el.json create mode 100644 strings/en-us.json create mode 100644 strings/es.json create mode 100644 strings/et.json create mode 100644 strings/fa.json create mode 100644 strings/fi.json create mode 100644 strings/fr.json create mode 100644 strings/gu.json create mode 100644 strings/he.json create mode 100644 strings/hi.json create mode 100644 strings/hr.json create mode 100644 strings/hu.json create mode 100644 strings/is.json create mode 100644 strings/it.json create mode 100644 strings/ja.json create mode 100644 strings/ko.json create mode 100644 strings/lt.json create mode 100644 strings/lv.json create mode 100644 strings/nl.json create mode 100644 strings/no.json create mode 100644 strings/pl.json create mode 100644 strings/pt-br.json create mode 100644 strings/ro.json create mode 100644 strings/ru.json create mode 100644 strings/sk.json create mode 100644 strings/sl.json create mode 100644 strings/sv.json create mode 100644 strings/ta.json create mode 100644 strings/tr.json create mode 100644 strings/uk.json create mode 100644 strings/uz.json create mode 100644 strings/vi-vn.json create mode 100644 strings/zh-cn.json create mode 100644 strings/zh_Hant.json create mode 100644 tailwind.config.js create mode 100644 utils/coverAverageColor.js create mode 100644 utils/imageHttpBlob.js diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..fd66659 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +charset = utf-8 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.swift] +indent_style = space +indent_size = 4 diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..1af2bb8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,98 @@ +name: 🐞 ABS App Bug Report +description: File a bug/issue and help us improve the Audiobookshelf mobile apps. +title: '[Bug]: ' +labels: ['bug', 'triage'] +body: + - type: markdown + attributes: + value: '## App Bug Description' + - type: markdown + attributes: + value: 'Thank you for filing a bug report! 🐛' + - type: markdown + attributes: + value: 'Join the [discord server](https://discord.gg/HQgCbd6E75) for questions or if you are not sure about a bug.' + - type: dropdown + id: confirm-check + attributes: + label: I have verified that the [bug is not already awaiting release](https://github.com/advplyr/audiobookshelf-app/issues?q=is%3Aissue%20label%3A%22awaiting%20release%22) + multiple: false + options: + - 'Yes' + - 'No' + validations: + required: true + - type: textarea + id: what-happened + attributes: + label: What was the Problem? + description: Please help us understand the problem. + placeholder: Describe the problem you saw or experienced in detail! Include a screenshot or video, if applicable. + validations: + required: true + - type: textarea + id: steps-to-reproduce + attributes: + label: Steps to Reproduce the Issue + description: Please help us understand how we can reliably reproduce the issue. + placeholder: '1. Go to the library page of a Podcast library and...' + validations: + required: true + - type: textarea + id: what-was-expected + attributes: + label: What was Expected? + description: Please help us understand what should have happened. + placeholder: Please help us visualize by including a doodle or screenshot. + validations: + required: true + - type: markdown + attributes: + value: '## Mobile Environment' + - type: input + id: phone-model + attributes: + label: Phone Model + description: What kind of phone are you using? + placeholder: e.g. Pixel 6, iPhone 14, Samsung Galaxy s23, etc + validations: + required: true + - type: input + id: phone-os + attributes: + label: Phone OS + description: What operating system? If Android, is it stock or a custom system? + placeholder: e.g. Android 14, iOS 17.4, etc + validations: + required: true + - type: dropdown + id: app-version + attributes: + label: Audiobookshelf App Version + description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.* + multiple: true + options: + - 'Android App - 0.14.1' + - 'iOS App - 0.14.1' + - 'Android App - 0.14.0' + - 'iOS App - 0.14.0' + validations: + required: true + - type: dropdown + id: install-source + attributes: + label: Installation Source + multiple: true + options: + - 'Google Play Store' + - 'Testflight' + - 'SideStore' + - 'Other (List in "Additional Notes")' + validations: + required: true + - type: textarea + id: additional-notes + attributes: + label: Additional Notes + description: Anything else you want to add? + placeholder: 'e.g. I have tried X, Y, and Z.' diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..d04ad81 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Discord + url: https://discord.gg/HQgCbd6E75 + about: Ask questions, get help troubleshooting, and join the Abs community here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..6ee8787 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,57 @@ +name: 🚀 App Feature Request +description: Request a feature/enhancement +title: '[Enhancement]: ' +labels: ['enhancement'] +body: + - type: markdown + attributes: + value: '## App Feature Request Description' + - type: markdown + attributes: + value: 'Please first search in both issues & discussions for your enhancement and make sure your app is up to date.' + - type: textarea + id: describe + attributes: + label: Describe the Feature/Enhancement + description: Please help us understand what you want. + placeholder: What is your vision? + validations: + required: true + - type: textarea + id: the-why + attributes: + label: Why would this be helpful? + description: Please help us understand why this would enhance your experience. + placeholder: Explain the "why" or "use case". + validations: + required: true + - type: textarea + id: image + attributes: + label: Future Implementation (Screenshot) + description: Please help us visualize by including a doodle or screenshot. + placeholder: How could this look or where could it go? + validations: + required: true + - type: markdown + attributes: + value: '## App Current Implementation' + - type: dropdown + id: version + attributes: + label: Audiobookshelf App Version + description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.* + multiple: true + options: + - 'Android App - 0.14.1' + - 'iOS App - 0.14.1' + - 'Android App - 0.14.0' + - 'iOS App - 0.14.0' + validations: + required: true + - type: textarea + id: current-image + attributes: + label: Current Implementation (Screenshot) + description: What screen were you looking at when you thought of this enhancement? + placeholder: If an image is not applicable, please explain why. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..165671c --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,41 @@ + + +## Brief summary + + + +## Which issue is fixed? + + + +## Pull Request Type + + + +## In-depth Description + + + +## How have you tested this? + + + +## Screenshots + + diff --git a/.github/testing-page-template.html b/.github/testing-page-template.html new file mode 100644 index 0000000..8a3d33f --- /dev/null +++ b/.github/testing-page-template.html @@ -0,0 +1,81 @@ + + + + + +Audiobookshelf :: Testers + + + + + + + +

Audiobookshelf :: Testers

+ +

+ Here you can download the latest version of Audiobookshelf for Android for testing. + The app is automatically built for every new patch added to the project. +

+ +
+
Built on
+
__DATE__
+ +
Git commit
+ __COMMIT__ + +
Download
+ __APK__ +
+ +

+ Using this requires some technical knowledge to install the APK manually. + Regular users should use the releases published on the Play Store. +

+ +
+ +

Report bugs, request features, provide feedback, and contribute on GitHub

+

+ + + + + + + + + diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml new file mode 100644 index 0000000..a486a50 --- /dev/null +++ b/.github/workflows/build-apk.yml @@ -0,0 +1,53 @@ +name: Build APK + +on: + push: + paths-ignore: + - 'ios/**' + - 'readme.md' + pull_request: + paths-ignore: + - 'ios/**' + - 'readme.md' +jobs: + main: + runs-on: ubuntu-latest + steps: + - name: checkout sources + uses: actions/checkout@v3 + + - name: use Node.js 20.x + uses: actions/setup-node@v3 + with: + node-version: 20 + + - name: Set up Java + uses: actions/setup-java@v2 + with: + distribution: 'temurin' + java-version: 21 + + - name: install dependencies + run: npm ci --omit=optional + + - name: build Nuxt project + run: npm run generate + + - name: copy to Android project + run: npx cap sync + + - name: build Android app + run: ./android/gradlew assembleDebug -p android --no-daemon + + - name: rename apk + working-directory: android/app/build/outputs/apk/debug/ + run: | + build="$(date +%Y%m%d-%H%M%S)-$(git rev-parse --short HEAD)" + name="audiobookshelf-${build}.apk" + mv -v app-debug.apk "${name}" + + - name: upload app + uses: actions/upload-artifact@v4 + with: + name: audiobookshelf-apk + path: android/app/build/outputs/apk/debug/*.apk diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml new file mode 100644 index 0000000..59825c3 --- /dev/null +++ b/.github/workflows/build-ios.yml @@ -0,0 +1,68 @@ +name: Build iOS + +on: + push: + paths-ignore: + - 'android/**' + - '.github/workflows/build-apk.yml' + - '.github/workflows/deploy-apk.yml' + - 'readme.md' + pull_request: + paths-ignore: + - 'android/**' + - '.github/workflows/build-apk.yml' + - '.github/workflows/deploy-apk.yml' + - 'readme.md' + +permissions: + contents: read + +jobs: + build: + name: Build iOS simulator app + runs-on: macos-latest + steps: + - name: Check out sources + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + + - name: Install JavaScript dependencies + run: npm ci --omit=optional + + - name: Build shared web app + run: npm run generate + + - name: Sync Capacitor iOS project + run: npx cap sync ios + + - name: Install CocoaPods dependencies + working-directory: ios/App + run: pod install --deployment + + - name: Build iOS simulator app + working-directory: ios/App + run: | + xcodebuild -quiet \ + -workspace App.xcworkspace \ + -scheme App \ + -configuration Debug \ + -sdk iphonesimulator \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath build \ + ASSETCATALOG_COMPILER_APPICON_NAME=IconsDebug \ + CODE_SIGNING_ALLOWED=NO \ + build + + echo "iOS simulator build succeeded." + + - name: Upload iOS simulator app + uses: actions/upload-artifact@v4 + with: + name: audiobookshelf-ios-simulator + path: ios/App/build/Build/Products/Debug-iphonesimulator/Audiobookshelf.app + if-no-files-found: error diff --git a/.github/workflows/close-issues-on-release.yml b/.github/workflows/close-issues-on-release.yml new file mode 100644 index 0000000..9c59075 --- /dev/null +++ b/.github/workflows/close-issues-on-release.yml @@ -0,0 +1,20 @@ +name: Close fixed issues on release. +on: + release: + types: [published] + +permissions: + contents: read + issues: write + +jobs: + comment: + runs-on: ubuntu-latest + steps: + - name: Close issues marked as fixed upon a release. + uses: gcampbell-msft/fixed-pending-release@7fa1b75a0c04bcd4b375110522878e5f6100cff5 + with: + label: 'awaiting release' + removeLabel: true + applyToAll: true + message: Fixed in [${releaseTag}](${releaseUrl}). diff --git a/.github/workflows/close_blank_issues.yaml b/.github/workflows/close_blank_issues.yaml new file mode 100644 index 0000000..c710882 --- /dev/null +++ b/.github/workflows/close_blank_issues.yaml @@ -0,0 +1,42 @@ +name: Close Issues not using a template + +on: + issues: + types: + - opened + +permissions: + issues: write + +jobs: + close_issue: + runs-on: ubuntu-latest + + steps: + - name: Check issue headings + uses: actions/github-script@v6 + with: + script: | + const issueBody = context.payload.issue.body || ""; + + // Match Markdown headings (e.g., # Heading, ## Heading) + const headingRegex = /^(#{1,6})\s.+/gm; + const headings = [...issueBody.matchAll(headingRegex)]; + + if (headings.length < 3) { + // Post a comment + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.issue.number, + body: "Thank you for opening an issue! To help us review your request efficiently, please use one of the provided issue templates. If you're seeking information or have a general question, consider opening a Discussion or joining the conversation on our Discord. Thanks!" + }); + + // Close the issue + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.issue.number, + state: "closed" + }); + } \ No newline at end of file diff --git a/.github/workflows/deploy-apk.yml b/.github/workflows/deploy-apk.yml new file mode 100644 index 0000000..c1b81c2 --- /dev/null +++ b/.github/workflows/deploy-apk.yml @@ -0,0 +1,82 @@ +name: Publish Test App + +on: + push: + branches: + - master + paths-ignore: + - 'ios/**' + - 'readme.md' + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: checkout sources + uses: actions/checkout@v3 + + - name: use Node.js 20.x + uses: actions/setup-node@v3 + with: + node-version: 20 + + - name: Set up Java + uses: actions/setup-java@v2 + with: + distribution: 'temurin' + java-version: 21 + + - name: install dependencies + run: npm ci --omit=optional + + - name: build Nuxt project + run: npm run generate + + - name: copy to Android project + run: npx cap sync + + - name: build Android app + run: ./android/gradlew assembleDebug -p android --no-daemon + + - name: rename apk + working-directory: android/app/build/outputs/apk/debug/ + run: | + build="$(date +%Y%m%d-%H%M%S)-$(git rev-parse --short HEAD)" + name="audiobookshelf-${build}.apk" + mv -v app-debug.apk "${name}" + + - name: prepare test page ressources + run: | + mkdir ghpages + cp android/app/build/outputs/apk/debug/*apk ghpages/ + cp static/Logo.png ghpages/logo.png + cp .github/testing-page-template.html ghpages/index.html + + - name: build test page + working-directory: ghpages + run: | + sed -i "s/__DATE__/$(date)/g" index.html + sed -i "s/__COMMIT__/$(git rev-parse --short HEAD)/g" index.html + sed -i "s/__APK__/$(ls *apk)/g" index.html + + - name: upload test page artifact + uses: actions/upload-pages-artifact@v3 + with: + path: ./ghpages + + deploy: + needs: build + + permissions: + pages: write + id-token: write + + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + runs-on: ubuntu-latest + steps: + - name: deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/i18n-check.yml b/.github/workflows/i18n-check.yml new file mode 100644 index 0000000..a2eb73e --- /dev/null +++ b/.github/workflows/i18n-check.yml @@ -0,0 +1,28 @@ +name: Verify all i18n files are alphabetized + +on: + push: + pull_request: + paths: + - strings/** # Should only check if any strings changed + +jobs: + update_translations: + runs-on: ubuntu-latest + steps: + # Check out the repository + - name: Checkout repository + uses: actions/checkout@v4 + + # Set up node to run the javascript + - name: Set up node + uses: actions/setup-node@v4 + with: + node-version: '20' + + # The only argument is the `directory`, which is where the i18n files are + # stored. + - name: Run Update JSON Files action + uses: audiobookshelf/audiobookshelf-i18n-updater@v1.3.0 + with: + directory: 'strings/' # Adjust the directory path as needed diff --git a/.github/workflows/translate-credits.yml b/.github/workflows/translate-credits.yml new file mode 100644 index 0000000..74fbb97 --- /dev/null +++ b/.github/workflows/translate-credits.yml @@ -0,0 +1,25 @@ +name: Prepare translation credits + +on: + push: + branches: + - master + workflow_dispatch: + +jobs: + release-notes: + if: github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, 'weblate') + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - id: translator-credits + uses: audiobookshelf/get-translator-credits@v1.1.0 + + - name: Show translator credits + run: | + printf '%s\n' "${{ steps.translator-credits.outputs.credits }}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..551e9ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,93 @@ +# Created by .ignore support plugin (hsz.mobi) +### Node template +# Logs +/logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + +# parcel-bundler cache (https://parceljs.org/) +.cache + +# next.js build output +.next + +# nuxt.js build output +.nuxt + +# Nuxt generate +dist + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless + +# IDE / Editor +.idea + +# Service worker +sw.* + +# macOS +.DS_Store + +# Vim swap files +*.swp + +/resources/ +/android/app/release/ \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..0414236 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,17 @@ +{ + "semi": false, + "singleQuote": true, + "printWidth": 400, + "proseWrap": "never", + "trailingComma": "none", + "overrides": [ + { + "files": ["*.html"], + "options": { + "singleQuote": false, + "wrapAttributes": false, + "sortAttributes": false + } + } + ] +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..0e06f63 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "EditorConfig.EditorConfig", + "esbenp.prettier-vscode", + "octref.vetur" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..0debc1d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,27 @@ +{ + "vetur.format.defaultFormatterOptions": { + "prettier": { + "semi": false, + "singleQuote": true, + "printWidth": 400, + "proseWrap": "never", + "trailingComma": "none" + }, + "prettyhtml": { + "printWidth": 400, + "singleQuote": false, + "wrapAttributes": false, + "sortAttributes": false + } + }, + "editor.formatOnSave": true, + "editor.detectIndentation": true, + "editor.tabSize": 2, + "javascript.format.semicolons": "remove", + "[javascript][json][jsonc]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[vue]": { + "editor.defaultFormatter": "octref.vetur" + } +} diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..807b91c --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,29 @@ +git clone https://github.com/advplyr/audiobookshelf-app.git +cd audiobookshelf-app +npm install +npm run generate + +ls dist +index.html +_nuxt/ +icons/ +manifest.json + +npx cap sync android + +verifica dove é sdk + +sudo find ~/Library/Android -type d -name "sdk" 2>/dev/null +/Users/fabio/Library/Android/sdk + +installa java21 su macos +brew install --cask temurin@21 + +java -version + +export JAVA_HOME=$(/usr/libexec/java_home -v 21) +export PATH="$JAVA_HOME/bin:$PATH" +cd android +./gradlew assembleDebug + +ls android/app/build/outputs/apk/debug/app-debug.apk diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f40e2f0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + Audiobookshelf Self-hosted audiobook server + Copyright (C) 2021 advplyr + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Audiobookshelf Copyright (C) 2021 advplyr + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..63c86fe --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,96 @@ +# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore + +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ + +# Android Profiling +*.hprof + +# Cordova plugins for Capacitor +capacitor-cordova-android-plugins + +# Copied web assets +app/src/main/assets/public diff --git a/android/.vscode/settings.json b/android/.vscode/settings.json new file mode 100644 index 0000000..b621253 --- /dev/null +++ b/android/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "java.project.root": "android", + "java.import.gradle.enabled": true, + "java.import.gradle.wrapper.enabled": true, + "java.import.gradle.user.home": "${workspaceFolder}/.gradle" +} diff --git a/android/app/.gitignore b/android/app/.gitignore new file mode 100644 index 0000000..043df80 --- /dev/null +++ b/android/app/.gitignore @@ -0,0 +1,2 @@ +/build/* +!/build/.npmkeep diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..78393d9 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,133 @@ +plugins { + id 'com.android.application' + id 'kotlin-android' + id 'kotlin-kapt' +} + +kotlin { + kotlinDaemonJvmArgs = [ + "-Dfile.encoding=UTF-8", + "--add-opens=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED", + "--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED", + "--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED", + "--add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED", + "--add-opens=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED", + "--add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED", + "--add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED", + "--add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED", + "--add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED", + "--add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED" + ] + + jvmToolchain(17) +} + +android { + namespace 'com.audiobookshelf.app' + buildFeatures { + viewBinding true + buildConfig true + } + kotlinOptions { + freeCompilerArgs = ['-Xjvm-default=all'] + } + compileSdk rootProject.ext.compileSdkVersion + defaultConfig { + applicationId "com.audiobookshelf.app" + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode 120 + versionName "0.14.1-beta" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + manifestPlaceholders = [ + "appAuthRedirectScheme": "com.audiobookshelf.app" + ] + aaptOptions { + // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. + // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61 + ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' + } + } + buildTypes { + debug { + applicationIdSuffix ".debug" + versionNameSuffix "-debug" + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +repositories { + flatDir { + dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs' + } + mavenCentral() +} + +configurations.configureEach { + resolutionStrategy { + force("com.google.android.gms:play-services-base:18.0.1") + force("androidx.appcompat:appcompat:$androidxAppCompatVersion") + force("androidx.core:core-ktx:$androidx_core_ktx_version") + force("androidx.media:media:$androidx_media_version") + force("org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version") + force("org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version") + } +} + +dependencies { + implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" + implementation fileTree(include: ['*.jar'], dir: 'libs') + implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" + implementation project(':capacitor-android') + + implementation 'androidx.constraintlayout:constraintlayout:2.2.1' + implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" + + implementation project(':capacitor-cordova-android-plugins') + + implementation "androidx.core:core-ktx:$androidx_core_ktx_version" + implementation "androidx.work:work-runtime-ktx:2.9.1" + + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version" + + implementation "androidx.media:media:$androidx_media_version" + + // Exo Player + if (findProject(':exoplayer-library-core') != null) { + implementation project(':exoplayer-library-core') + implementation project(':exoplayer-library-ui') + implementation project(':exoplayer-extension-mediasession') + implementation project(':exoplayer-extension-cast') + implementation project(':exoplayer-hls') + } else { + implementation "com.google.android.exoplayer:exoplayer-core:$exoplayer_version" + implementation "com.google.android.exoplayer:exoplayer-ui:$exoplayer_version" + implementation "com.google.android.exoplayer:extension-mediasession:$exoplayer_version" + implementation "com.google.android.exoplayer:extension-cast:$exoplayer_version" + implementation "com.google.android.exoplayer:exoplayer-hls:$exoplayer_version" + } + + // Glide for images + implementation "com.github.bumptech.glide:glide:$glide_version" + + // Paper NoSQL Db + implementation 'io.github.pilgr:paperdb:2.7.2' + + // Simple Storage + implementation "com.anggrayudi:storage:1.5.6" + + // OK HTTP + implementation 'com.squareup.okhttp3:okhttp:4.9.2' + + // Jackson for JSON + implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.12.2' +} + +apply from: 'capacitor.build.gradle' diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle new file mode 100644 index 0000000..47375cf --- /dev/null +++ b/android/app/capacitor.build.gradle @@ -0,0 +1,29 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN + +android { + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } +} + +apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" +dependencies { + implementation project(':webnativellc-capacitor-filesharer') + implementation project(':capacitor-community-keep-awake') + implementation project(':capacitor-community-volume-buttons') + implementation project(':capacitor-app') + implementation project(':capacitor-browser') + implementation project(':capacitor-clipboard') + implementation project(':capacitor-dialog') + implementation project(':capacitor-haptics') + implementation project(':capacitor-network') + implementation project(':capacitor-preferences') + implementation project(':capacitor-status-bar') + +} + + +if (hasProperty('postBuildExtras')) { + postBuildExtras() +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java b/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java new file mode 100644 index 0000000..f2c2217 --- /dev/null +++ b/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package com.getcapacitor.myapp; + +import static org.junit.Assert.*; + +import android.content.Context; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + + @Test + public void useAppContext() throws Exception { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + + assertEquals("com.getcapacitor.app", appContext.getPackageName()); + } +} diff --git a/android/app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..80bcc6f --- /dev/null +++ b/android/app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..80bcc6f --- /dev/null +++ b/android/app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/debug/res/mipmap-hdpi/ic_launcher.png b/android/app/src/debug/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..dc6df9996fb40f31d67c602ec4bd8ecabc9262e1 GIT binary patch literal 5879 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 F{{!Eo@xTB8 literal 0 HcmV?d00001 diff --git a/android/app/src/debug/res/mipmap-hdpi/ic_launcher_background.png b/android/app/src/debug/res/mipmap-hdpi/ic_launcher_background.png new file mode 100644 index 0000000000000000000000000000000000000000..9fb5816ac635c02973af0e2bb159317180bae150 GIT binary patch literal 879 zcmeAS@N?(olHy`uVBq!ia0vp^i$Iuz8AyKBd8P@ZSkfJR9T^xl_H+M9WCijSl0AZa z85pY67#JE_7#My5g&JNkFq9fFFuY1&V6d9Oz#v{QXIG#N0|TR4fKP~Pd#rj#oO)-x zMpuGHcYwjdegG?rf2I< z&(WWeqdzm(U}m1dtUQC+`9Neir@(M-ArKkOD>9m2Y`mbvcwwo@A|NU=SzKnixZHF} zh3V1?vt^ZL%d5HeXo-L>8-REmqe7k>#3t%e6q%V70E%YCRA&S#M~v-q>ut zvBhQ+5VhKDZnfFcX1lfBc3X$t_D;JUo%TDr?00tC@9K8g)#I=mhro(@=6Y-qox|K}?u!+}A`nB?v5lDKJuPaBZKUgGKN%KnT)oKID-a@FeNKxsEm z7srr_TW`-@4U}LIa5&h$d-rZ@7WIR7o2~zbI||qAnZ0vug5uvMCR?trN+R7~G_Ean z=?}6!^+n_QVyAvD+f`pyT4b8&H)zSBlOAdc&2+skp(&34w8jBATs`@?K+eaX1K z*ty@&cGs5|*A~0?#{s47Y<~ewWB(#?CE24t(bnyY#MNY<{z9M(r>)x88JCiy`Xz1C zzRtLu9M{ikEA}aWe%Z+Qr7w%Yme4Si% z)tlFa3yv?8bFY$lUAXA@O1bo^Ij;*B9$zXKUKR7&RpZ`^!qwsnf5i7&+rIl+ap7@b zzq;+buN4;`hxVJ>-uwFF!sFn6ecOFsH_dSE)=9gl)E&9yM3R^;VXk~~!B^I#)mdKI;Vst09=l7p#T5? literal 0 HcmV?d00001 diff --git a/android/app/src/debug/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/debug/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..da357caeace992fc3c657d5e08a51be1a816e902 GIT binary patch literal 1931 zcma)-Sy0mn62|{PIOG;0AVh{$39KBG1TYY;0RjjnoH2+bnm`f`!x&6ZK%D`Gn~_6~ zNP@D0a>}h>5RqU&MM#tzTrk`>!XhIm=(4pBTT`{QwN+pDS6_Gi9{Q|Jsq59M#@ z5BCrPb~;OSdO4%CExvrQe_O7w&k7V_3HsoL&TgJx`mKz+2~m!_y!I8g{k_cwDf(5k zXFuF&_4z$dBmr_z{l9eYjp4P5DC;3|aUL#sJ#OrxXMu&f6(Ac?W@!%>8C%zxzaYQ{bqvgG@8=9Cifl& z8++GC1H#$ISF$aDc4zu0^f?}iu0QEt{)D~C6MExpR@8YNn6CN(1D`j1@`-xebw@ao}KO>N6QQ91sWq zRduPKFp9vR!W$?e+t*S^+Vd|Inm^?jb6-Iq9ltskP*KbmVo&Bss@qPJyhM{^Q)EW59lj=0hI=!}=T^v=~QpwPZ` zyKghCYkmV(NmxokZD3KAQ~hbvD@?N-Y#BW4sION_CYd1kuF4O~Wjq=^P7?{(8}~>qOe^(Iu+H(f@XL`@>Yt7y0vy*YqtKb{A}4V7qJToz&a|g@qNj z#7Y~&jQ-}^*2&sx9t9`8M-Qf`^wJh`u}iIs;J$iL>`s~cZYrus)=PWy20}N&{rNNn zbNrCP9iIayf{rdmX#sE5ZfR6O@#KWIGiT!PKzp=^i4R9kh+>X9u`0UMLm76SVu?Ls zWTwA^ykzN;?KkJ$GwflYM;!U3KKIz@ijKbk{`(gfU63WWT6iINhnLY~QTvX@)#_|C zl>P}E7_>fE6*yBO-AKqO-|nf^T}fo>Qu7Qm&l1j@X2roKExYHviGp497O^`EZk4&hefG z_uF`iEv8yNdYVpabF5PceaA4Dj_1?o8xPE5j2=l|U6|~`c~ZC{(yU^R#M-CST(bjW z6}#*b%jyCg>)r$VU~bA=+FR3*o30}Nesd7ze9sRj&$3ft8%Z?omWl_vnN*}^ocR_(l< zK|S%#7NxMFME0$hl+4qH5C@F?w~l*jO>Xpr)wzQe7iYD13B@ZCc%s z6@K{JGVKpTg%b2>1|*=6S#meY&rfUeFE=J?R@Y}(W|5~x>3Dt}`)`@Fe$a?q7|T(oG+?ZDF=Ns`|g zSaF{cC56#kWoteXk{}oSL}Tx&wEYUL?c#}T_mQxh{7eD@3HnOXW-pV${(`!-Ku)ro z%|gS*9Wi|-@Z=K>c*6^P77VtO-q%Ug6T0n+3?EojX&?Oq+fGmhwJCWcpU z{`~a33cIVd^&{-*##jjLvR}{GRLmcFH{pa1D0^L|W;!;YTtCCezu(ma1PJFJXzk1r zI`4D0Z$Rc_CfY`Nkcu`b6OEH--5_J?^&EfV_N-pbl%43xErshgbmx2R{N09eAxCCy za=CEkDjty?v<v-0PoYjJhMx1DKabn!aGH6jq_Ao@lrKvGiHaXIT_7i;4=mOx-sC0Ye~6V3sBbGaTF; zWd=vV%#2_#6b#lgFCY6SAp8;~lotIT0EUCXQ5HxP9QJQO@#&|B`vBnP?18RvB*p&| Di_%@o literal 0 HcmV?d00001 diff --git a/android/app/src/debug/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/debug/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..dc6df9996fb40f31d67c602ec4bd8ecabc9262e1 GIT binary patch literal 5879 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 F{{!Eo@xTB8 literal 0 HcmV?d00001 diff --git a/android/app/src/debug/res/mipmap-mdpi/ic_launcher.png b/android/app/src/debug/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..2aa9e1ae6061aba9b49b6ed683da31e1200bd403 GIT binary patch literal 3747 zcmV;U4qWkxP)004R>004l5008;`004mK004C`008P>0026e000+ooVrmw00006 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 N002ovPDHLkV1kfT8iW7< literal 0 HcmV?d00001 diff --git a/android/app/src/debug/res/mipmap-mdpi/ic_launcher_background.png b/android/app/src/debug/res/mipmap-mdpi/ic_launcher_background.png new file mode 100644 index 0000000000000000000000000000000000000000..4854004ca01fe5b4502a5ef610fbf271d2b08a7d GIT binary patch literal 685 zcmeAS@N?(olHy`uVBq!ia0vp^IUvlz3?z5#SpFYKv7|ftIx;Y9?C1WI$O_~uBzpw; zGB8xBF)%c=FfjZA3N^f7U???UV0e|lz+g3lfkC`r&aOZkpv3I}pAgsfSoMxL_0D*W zt^|$l1kIjA&E6!fzGSWb6zvJA+7r@rCZ_95O4ps7p*tm0cWRd2v~0cUIr=kl^=IZ8 z%*r#Eoo_g&z;JG%(Yzv~`NhTyN{knlnk*_aSzKnaq}+6Ah3T?Nv*lH0E2_;`)|juV zwOCzexu)K7U4zxSMyvHr)*G6wH@4VpYPH$iYP+S)c5A!swhp`Popw9A?00tC@9J^b z-RrQY&v9>`GKJ2}(+)GYVY zvpvqt@i;rz^Z)<<7WWs<26~e*$=lt<;{(%`S3nMXiKnkC`!f!4K2`1g%Zf^YLgzhQ z978hhy*(Ql#mFGxa8X)StHoq`W!EChVAqWQ)|*`7Zn#3*Eii30uzM_ zHI#IflplE<+1Md07%6BeSSh$%iA$+V$xG>+%M{l+-8%#dH6M8$+1ep3xLxU;%bo5O z9V@z4bgt;m=*Z~0vqp81?jq$y+Kbd5Il9CjaX7NLLtW5WP+9Q2Qk=`4t_tCgdXJos zEbq`4OcoRt3>GvNEEc@4ROj-iE21N!>xb}0p^ai21wZO{>1;Zo)QwEYp4z6D9v7Pa zyDk>!HPsT=h?11Vl2ohYqEsNoU}Ruqq-$WIYh)Z^Xkuk-Xk}omZD3$!U~uL+R|ATM o-29Zxv`T~qgAfBlD^qhTLj#Bgp50&805vdpy85}Sb4q9e0C>3pR{#J2 literal 0 HcmV?d00001 diff --git a/android/app/src/debug/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/debug/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..45cd75faa80f0166d958d8b738f40f4db8349869 GIT binary patch literal 1351 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 N002ovPDHLkV1kfT8iW7< literal 0 HcmV?d00001 diff --git a/android/app/src/debug/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/debug/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..8a8ff5f6c57ac16a54ec9e089b479da542fe9a26 GIT binary patch literal 8193 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 literal 0 HcmV?d00001 diff --git a/android/app/src/debug/res/mipmap-xhdpi/ic_launcher_background.png b/android/app/src/debug/res/mipmap-xhdpi/ic_launcher_background.png new file mode 100644 index 0000000000000000000000000000000000000000..818c74543e15c9b348f25724ad45143f815ca749 GIT binary patch literal 1038 zcmbu+drVVT90%~@UUfLw{EDxZdVnpIDm5o%q9 zP}>27{(z;npAp(lAoNQGLZly|WR|&hSg`OIbw^r2sR;I5GhGeM%l4#Kz`gIKLNHYKHQ-gY|3JrvVzS(8EjFW1g*-UR@Kl>)iBtl z8d;Kk>b>mKyFt#GJvnDV8#xO0l4D?B?nhvMZfJk*xdZBR2i51n`*|OO_Pp`-yoruA z7o2Ot%-T;n*G_ikPaev@2weG7F3r?o%`|XpF1ZUn?JD@p1J-@+UH3&dpk{p3mpxhz z^lBsENa0t&UwGNCyWFSy8uaU;AfUez&|e(@hS{Tr#l^)<%<$50EVGz&lzog>9)}6f zVK8fXKQ2#BN|)W7y)h2k;|5Ao+86L!k>$c zsrRqlN-ZBI?z4Ug_XNJu7}7HwE%I1Kd@5xccO1LImWsIqyu=tXM)`d2LZrmhVx6JC zkEP(YtyGj>CgeyMO=EmECb;dY_gc4-se+2ERXVczQURC&4w21Sg`put_*T_i=^q<#3kdl zJEyUE_K}!-9^Y-eXJY2z$(QlR#(V2eg%qw}oycshOEG;{+8?>D=so#AmuB~bJ8aWD zM|=kUG~J;sbyQV4s!3~OH54QxWU@>mJ5wevmMTcOlqBRrf*=Xv>Q4!6{|VIFs_JT5 g{tXbtgp|xuky7HHz;hQLoPhu`Xp5-Pf{J~A1FrY7`~Uy| literal 0 HcmV?d00001 diff --git a/android/app/src/debug/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/debug/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..189bd835e43ac1d8ec2502d3da04ea53d60ce6d1 GIT binary patch literal 2632 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 literal 0 HcmV?d00001 diff --git a/android/app/src/debug/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/debug/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..8a8ff5f6c57ac16a54ec9e089b479da542fe9a26 GIT binary patch literal 8193 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 literal 0 HcmV?d00001 diff --git a/android/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..96edad9183972711935afcd1efab090c1f26a5b1 GIT binary patch literal 11890 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 literal 0 HcmV?d00001 diff --git a/android/app/src/debug/res/mipmap-xxhdpi/ic_launcher_background.png b/android/app/src/debug/res/mipmap-xxhdpi/ic_launcher_background.png new file mode 100644 index 0000000000000000000000000000000000000000..a780d0f7f668cef33fcdaf2cda5c5c93e1c9fa2e GIT binary patch literal 1487 zcmds$|4$Ql7{@;yI2le8W*EBc2WBD&_2k;pQVNw8uCzsAfL9$|*ueHwx-l435k;w@ zUQ*hT5*n~E-d`89}F%_%#z07T*$iTLzgW37nZzw_j#Vr z^L{_i-REk}%~7uneGJwRM2Asi*!9Hwn7@ClI6_Gv;p zP@3Q@C7qv<9Y7h`StjT#7r21W1zi~77Zt*bm4K+*EV^_svFA`?FHj}!0}hM(F_Qjj z$z|Y(WB`*icrcr0(ND@|D`;tAIr|44jZ%15N@d*MYi}8^Ed5FM-pkUt#1o z>*Y5auKJ^gEY`t*4q z<4#M)H?7p&3)Hs`)jfb!eFwB^jndkXw{E= zjkc<8t}AOmQYYB*R|%`sDW^l!TtDa(4^t3uE6C7kKevmDC=?N~P(iR~O5 zQD|PJ55~{7Rh6^VH-M z7j@n!pC5k{P@aS}RX%^lKkn0bM%?CqzWCz}ey@fs87t7iziqQw^?}(~Y-VJJ3I=XS zND74nDJ6s=T97Cc31p;*Pm(f{eE2Nf@*hEInXzPF^}hm=CIvEal1xAb1#eEgxB>#C MRpn5_=|w0126-1kQUCw| literal 0 HcmV?d00001 diff --git a/android/app/src/debug/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/debug/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..b0db2ab8455b0ffd41c0ea7e0accd3c1d66ddbdb GIT binary patch literal 3978 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 P2>|wIoo&e00U7@S8i_$D literal 0 HcmV?d00001 diff --git a/android/app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..96edad9183972711935afcd1efab090c1f26a5b1 GIT binary patch literal 11890 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 literal 0 HcmV?d00001 diff --git a/android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..bda38f3b67d5f6c94740ad1224a7a1be3e4f5638 GIT binary patch literal 16479 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 literal 0 HcmV?d00001 diff --git a/android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..8ad328877a70706230bb885049d0510bf910da94 GIT binary patch literal 5522 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 + + Audiobookshelf + Audiobookshelf + com.audiobookshelf.app + audiobookshelf + Add widget + Simple widget for audiobookshelf playback + Jump Forward + Jump Backward + Skip Forward + Skip Backward + Change Playback Speed + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..3c7cadc --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/assets/capacitor.config.json b/android/app/src/main/assets/capacitor.config.json new file mode 100644 index 0000000..0551bb2 --- /dev/null +++ b/android/app/src/main/assets/capacitor.config.json @@ -0,0 +1,17 @@ +{ + "appId": "com.audiobookshelf.app", + "appName": "audiobookshelf-app", + "webDir": "dist", + "plugins": { + "CapacitorHttp": { + "enabled": false + }, + "StatusBar": { + "backgroundColor": "#232323", + "style": "DARK" + } + }, + "server": { + "androidScheme": "http" + } +} diff --git a/android/app/src/main/assets/capacitor.plugins.json b/android/app/src/main/assets/capacitor.plugins.json new file mode 100644 index 0000000..b5bb88b --- /dev/null +++ b/android/app/src/main/assets/capacitor.plugins.json @@ -0,0 +1,46 @@ +[ + { + "pkg": "@webnativellc/capacitor-filesharer", + "classpath": "com.byteowls.capacitor.filesharer.FileSharerPlugin" + }, + { + "pkg": "@capacitor-community/keep-awake", + "classpath": "com.getcapacitor.community.keepawake.KeepAwakePlugin" + }, + { + "pkg": "@capacitor-community/volume-buttons", + "classpath": "com.ryltsov.alex.plugins.volume.buttons.VolumeButtonsPlugin" + }, + { + "pkg": "@capacitor/app", + "classpath": "com.capacitorjs.plugins.app.AppPlugin" + }, + { + "pkg": "@capacitor/browser", + "classpath": "com.capacitorjs.plugins.browser.BrowserPlugin" + }, + { + "pkg": "@capacitor/clipboard", + "classpath": "com.capacitorjs.plugins.clipboard.ClipboardPlugin" + }, + { + "pkg": "@capacitor/dialog", + "classpath": "com.capacitorjs.plugins.dialog.DialogPlugin" + }, + { + "pkg": "@capacitor/haptics", + "classpath": "com.capacitorjs.plugins.haptics.HapticsPlugin" + }, + { + "pkg": "@capacitor/network", + "classpath": "com.capacitorjs.plugins.network.NetworkPlugin" + }, + { + "pkg": "@capacitor/preferences", + "classpath": "com.capacitorjs.plugins.preferences.PreferencesPlugin" + }, + { + "pkg": "@capacitor/status-bar", + "classpath": "com.capacitorjs.plugins.statusbar.StatusBarPlugin" + } +] diff --git a/android/app/src/main/ic_launcher-playstore.png b/android/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000000000000000000000000000000000000..c9397c020417ed9fa15438d63f55dcad9efb2a9d GIT binary patch literal 20340 zcmY&=bzGBQ*!S56qg$l}rh=dn5`uuRp@JaNEvb}rgXD%_(+DUbLkU3|rPBcE?o_%4 z(zWfq{XNh7`MmERaI<^Yxz2Us%JV(E)YiPiK+8o70ANs4y{!uX9QqRusFBcP=kdT1 z^q_H9HSq!fHt_EUcJ9h0N&rwm?Y5Hs+!XhAAIRl;Ubd!>{K_A zzZhb|&&8;JWA4$KvL7sXC72Ybxo&O{I9ynVwL~wn@jnmLO7qIw8*?hETZt}rn=S;8 zT{PQYr{um_(Ii*N6UhYu3Azf2jMkT`jU`v_51L!uitIX z0{w6RKB-9ebUPd_j@DKr7>}{i3!l3R@{|C0nPXsps>HT$VfjLH>r%U%3EbFllzMi$y)3dgFp4c7aA;|EuLdFuKbxMh7m zjQMo@XbZU$iF`(Zu%TvVfP-++@qnbxtvb!+3dSW<8Pes$?foKW12NYCe&V7d)qdU= zB+bW*ztGWYB>xV@I~TFiLRL#!h)`t_Q`S%v3qakjTgIWgD?ZdYdV{xG%lsJRpFng#x$~$~Qo~>%+1WxppdnGo66vqj-995)=6|S% z4AxhF0;6H%zshmq--j%xR1W)KBu@Xnn^D#4EOA)3(PxG5Q0)JgNz2LtP!H8KNOS zM;rS;bZx2LGetozXzm@`Mm}Rdc0Qr}CArNX{HWry^un45n5!DXb{uM3V= z;`8xjrr*g~WN1eeKLbG8JEx4Oo_B9sa_RdWldD&M&q%t|R)9J@|lwZy-Uwc8E#V1L^(i%zYLMxoJTY`kB)y?vHR*UsG+~{ z(Zfb2)P~~X*`Q~U1y6_BsPVKEK*(A%;9QV@?StTVV;iDBxR4g<+5PzP=KY((m#r7 z+iu2t=9dpc9~j@~#Tzs8umG#;lMWs_Q2X-DR_FBKV;nCH#pS8HkAuhJ!xZ5oG%#@A zV5sr=ZZQ6A6;)HF$)eF|>K<+CA|-&kCm<4N8*Hgd%-=u^#>l~-hGQzKuOF;o08Vx} z-%913-;+?j;?I9Dc(Kl%5^K~$3h;MDgNmfHIa9n**FymKSitZ0-!YM+6uQN zHxaveSDAmUAkJ*YHa`U@QLnzQ4c}Hz=udE#m+2{v`tPF>DbFhb=I4FOA){UU2a}e1 z|7^h8hQLe~tsg_NFf$Dtw`m@iy+@mMY&(<0i5@@1*~MUHaBO0_HPPv23%iV&!;w#e z)_$J{)WaQ8la`;V(W?`JGE15u3_;Y}|VIrB{eMVNoq5ld) zkj*Alk8|Hc<1xDSk8^cM#XD>no6I0QOFuUK)IqBpy6z~nSYKaAHP*_C8hq~aF#A<{w()cE7keuuOv|())yH@C=x{pxRFBWdp41IPUJbF*5 zeEB4oz?5QEdne*-{MnBkYtL%lMkim*vTie~zaZv%e>)i~revLX>1eXNKq`#(%_*9; zkKx_Dnw2IH+mAA7yWP^kKXvDZvBwEtHe7^8TNFIke#~j*E_9KwHK9`p!dFMi=UX$~ z!h;&-v}n#7-*>!t7_yrh;PB4%iF&^?FsItz1V;V+RS$&Xt&$gtp(PAMYl-wolcyvRI3 z=?dBs+*f1D1sg=a>}M@ggOdw_t&|sw?uy26CkEe9*iHEvTdN!rH(Ixr%PYV9>;CKQ zn-|n6OEU$Sd{G1HrLg04cb(p0_h7lIP(i45Z_1fY4yeZ`l?w~qdcQ5>3Gd_eheHJg zu+7EKEGvZNuNJ#rMd1QjC-&a^I`pC{#6%qJl5kW|)2o6!Cy~0O(wy{QB0+rVSV_W? zm4cam;8rztn>mt>Lp2PHQ4dD|d`NR-@Z2}SvXJV-s`#w^dEQm}BT{S!jsoU+M?H~d zB9<%Zl6_G2lC07!KA8n__wph;lJ__41JmU_b=r}^Lpv$A;2`&T2Cj!eR_kKJ?|x-E ziXtsKiqXr)2skd_)cuX5Y{gSYqk`<+o8H#jUg>MjsNc`oON81MY5i z)q7cWQ)0&-h9`n#*~Qy!%Eb)2I`+QD+fv%R1bc6cSvgdlGM3n9m|-~6Qp$z<@zcMi zLTcu23Ov2HW#xhn(!GLG!r4$>&sh_y;&(qsgik?%!VqPn1RNYkye2+cbkdUEEUS->pn$VJVx!hCT5V)A*lhr%=E@J+qnR=eBwwPkSE&v_(F;gm`+G9QJD! zH#D4&vv?z);IV41ui@xvXbh>X_Z>!ZaZaw`Ws~&+R@U7Ui(wY*+2l9$)k>>+Vpl zUeBd>Z!$J=PHJMU1Shq znKGDQ4UabuLR;E8-Zp7?T}~wuQt2_EMEiJt;^Idm&88F@7K%5}B&7Dvh)GgjCbq|( zN59)`(Vi3wJAR$Qt7f-e5EjCw>JGX5{Pnw5%(q;g(UUx*+P8RLU8%f>dSBNX-q;}=L;p#hX$3OLsASN-x#^4 zpbEkmSiq9?rk~Ik%@BzZ^#EgD=W~G~RWs@+RQrt#VNTV$=m8|2;|VyX20I)Kq_w|d zIX^mY)t+CTX;LCH*3%*9o=i`Ad9u<=1&`WqE?6Y7?VRQx#zERC*UG_(qVBm3VxpU- z&yCXZ6ThV-Z`t=08B#X^$|v)`{4f%*XKe5%fPn%WCN7;h5kFuT6s$T$lKNX1yj9iC z$h7>o(cUe(11`WEv!JDs&uOKGuY97^*e6Ae;idp-4sN^Fn_`-xYjF!sx^0$<_V0RI zH6DK2cGs%E!b&YAVl^kHW7dSii9F$9247lcHZc8pCt2T2k->JyAu$_~!ra#`UBQI9 zdr-gz&jSwLum*%@Z{jZLV$9_b&I6N{-6RTl9Lu9NQMdz*XefUNGll+hF!Pn;geS(; zQbTpRZH!d*+09PNFHF@??S7l|Ip&y+q*K)2=#?Gu(~CZ{E+s2WmoG{6T)8e zUco}#7%L4$5R;TZ4gciLs>HXK9*g`o{04=FB+U8mPCoB{%~;-J+uN7ie0~QWiqNe6 zwdxXXruXU+2Zh;NyMT{(z||K`)1^<$>r!J)My&nJZ#^(<46@|HUoYl(g8M@a-Xy;x z>~6FLo)5!{D^CB^QtWTpAaOaJu*708_0ft1E)=y;Hir3X%pSEdVKn3V=|Ipp+-#HgSn$=mGKP)Y=Ktz+FlM@laML0g$q2|-?!fsYi?<>RQwKUa;yEz;TT)k zvf6Lc(924=D!3Bn%Ra-4Yrr`3O0p9GHKv{l zxXWyXYU728BS9(E&cigpyJ3u@XOO|3u!QGgnbnqdMCa8o(NSrkaf9G75e&1HW};8# zNPnY&gV@_gKhA(hZu{1oGK>8k=_cs5tpe2;RHIoa3YE7WDozP>mrCv}zLa>R`tXpPaSP2EFTc%2`nbgu0m2Jg$=QKe*Z-G31 zdCEP-8x_vKXr2JCXG)D2xd&#ez2}TsZUMacUopv+=VyosDNtx>iNvZMqCMN^rFKHE zDNa@=qZ;ppswnBK(Z4tcdh!l(dZT3_8rN*NkqzpdXS02+5VP5CnHK9NS4=mw3Ru-w zH?C1ONX|qnJZ6v<>6s)5Cj6mC9r4HQ{k9;*5d_`Cu8a;@Ib1Z5X;nE0G15r?WiR+u z2|4_Vu1}ob_?$i95BB=p#Wo+FXu`j9;BL()`_eY2d4VKr+gi0 z%Z08E2;h{fPm}3MN2NED<-~mBax>&F1c@78KS}x;!dDh?cBhp!N{PkRNG_W~@CTTY zOM8XA^!`CjI_Jx`t7cwrtRmkr2E`9j4#>WX+3qX;t+6wA*Q0%t)2`sAM-C}b)hg_TFVyH!Oz%U(cK(B&8!QqO0I9Gw->WY z1xzXk4vtM*p7%*U#?$aqf08T{QhwQ21OiIeB14}p8?%LL!*K*Dg^vb1Ek`Bw)q1a` zZYcE+Ols(tQ*o{NM8A)qik=RRB#ieYL|vOaSL>ER0qDGkh?kBeFDPUCsOq9zG)MO? zBC%3BXD9|@9nLx%&fRJGmVH&2HkIWr7RL<7`O8)8CkRDa71S|%o5&MDknEnvjfj-L$AiBl_^`svq!d!W#0pHh9T_$3TeL8xxXU&N&6 zvk#RJ+#Z+0A`OPmC@lwz5pUpg8V`DZ6@+TggD>SLt#XWEuM|gR%v!3^$0sZ))lb0k zVv{aT@p}MH#g)PNKDc_vzCC^7d?Jx#w_+^#<{df={g8h;a5Rw6XQj|+8#GP-%ihMl z{qO63@jUV5Q{Yh+DXSZ;Fx&Ynu|fR!?DCG^UED>=WK6z|?{dJ0^XfqpVvP|Ig3nA5 z+-0YFG6AOVih{qE#6%0Hf*P@guq{?2LEVQ7xF6qssQK9{IBXYK4h08j#V5j(S^qJ& zRk^hP`nlq8F1BifGxZ*3(`*A5yG_KgEwetlc~Jz(+1@NnDFtcqU1B@U51!c$)xn1L z0kYK{6~YhdFPD2l3#CkGt&DJ-oR&5(P4q56e3^mS&6S1#2|9Q4T{0BCr=(&qQpgR1 zKfWdr<}f?@3glj_j8 zOMCWRwkwvd-){Hv;k5{6D_aY;g?_MWX}@(F>C=r!{PgmrR=QVMVGmN(TS0o}Ii_LWN!wK?VwN`dj70)>&TI(r%O{jE)XwI_e@%2fkg{X9 zLj^>Kv}gCboUINqgB_* zPRTyg+OlAU5mfu+oxgN)rP<(=m?Rrvi)v)C6vkx&yd*R^eW~+z6PtXfKvl(2!WZ;Y z?1EV*iRy|~YZoY_M^5Z*oW?-h!WcupPD@mF0lewrjG74h?BxKzU?wk=Z&;o7gc}A+P=gJoJ%=^zyA9t8CE6NlL%@ zwE2u8d*=earNKKHhKd~A=<>6a-FaXb!dkiu_GcBxiFu(C{89IyPuzXn!@2F=i6%q! zGx#Wg+K*C|IJO_R6c^RhSLADCihJr6PkB9Fs}{6AwxFAl12KEg%j6Z5>U$>c z)N{zHx#XTsp)zUBhkbxO4#pSh;c5W9blaHwEEK@+N`Fpk3W_;raGBBPlqynJ<4hvq2z@h6)gjz2v9JJe)V9T z??uw*R<@tGpPUhHU8SH94(!NhTC1^s-D*QvrZQ}!rRYU%VZVxKXSGmQoyW_&AY z6zc(bCQ~s|yqMVkkmwX% zS3ij1UIW}$?CVUSJ4Zg_QpE((Y7&eAHM!_KeF0M_h{w|(LFbiG8vb`--CG$8`wgYb?#|t1WtYH-_$`OE@Q{pS;3%J8GWNGPC^R( zQ8Yy!?o0(-S`#H80Y)^xm+Ue-OCdQNEpWOapM)aofYb$mmIk3x$K>E0_xf z==)Zj)6X`CSA)tRzI{4|Sj8}OPf&Tcbt0@S_GO|SbC2~$h2ltn@~e9?iuEr~A#0yd zNu{ql6$m5nsdD28BOWkUPV_0cbIn_{kKCt2k11>Qcis-Tat)N1#b!t@ebx03oXJDr zCa9mbk++)HtoOsM_n5-J?@3^WLxmA zc?Bu3RhJm?l0u`@)u;tDd9?UU*p4bT%$K2nkXF6*ar)+yuKfUuQ84rG^v7bMA?arL z9Y~))yn~XN`|qYh2foMz_ugm03-_KJJvVf{ZX4-IA+-~8KOCUNI4miLo297q?+8ke_8O({yn3H0<^ z)%<#TyO`6f9_`)jQR5Zo%hoFAcduoEE;~HhIP*sDSa)>m%!osSq}4lMWjE!5eMdC1 zpEIrzG-Q(itQ4(3hkUQZNTVv#5mc7@Idlz#x6w8GU(~t%Co~>U zN*@Q0Ohj>VnMX3#ew#GX-uh_U3k1* z-Icc4DRTI0T0TFvzUA++!y-k~i`&aTHTK{khxMMFMT87auye{BDZ~Z5g-P5K7p;p{ z^&^}aDWt`yZPl`T{G#g?1VsN8nP=%Wsar%6w#0cWYD3c9qO7-r+#F{XuFwGIDb%M& zphtZE>f>8s>o+b3j1bC*Z9|b$A%;|`k7Ea|{cQVs7w`B_#IY_`5(DVpZ|p@RyE6}y zUhy_yZ9fwjo}^NO?XCM{-4i2`ai>@HcEEbl7T)fSPKb7cWMCA<|* z<#aI1id7CirkWC|mj1%?Ux6|lC!0pn?K|DwdW796G4+X_6ge~GJbG}&EjsXmcJF`cuZt4gsZJinX7nB@G68fbWSmfSVmX1MlTLIiXe=; z!6SXLQ=K+PaTUU9{0utq-9~RM0pz-t}XW7zXjU6!&we8;;+&QCP6l|TTX@9CBvUNpHVp+ z%fB?h`M}~T`-(?&a*uzYaej+Ad&)cXcunF%TWik7Iager|1vrMfN8L#4l(0Tm|lNl zBmMBkolJ?;*}WF&c23Y7y3$(O`Tp=}ufno%fzNBJczR6Buk#M1vc0aQ*VpnwBDmaS zpDwMIJdc=NsB{W(4xDlYSMPGcJ&1)ne<$v*ukVhT;M(AzkKB2y4#_N*QsJ1xj@gY9 z*$Ys@`2u_mMNh$-djeh{V{_pel~}ma%~eFwGedOkV#=VZa6Jf)S6DbW;Z#HMBGQsu1a7|OU z0nm;UMm+k(0K6T694RJrm#K8XS|`8VJ0m1#YsxE1Uby7F^&8+lToM0&u>d0iFkCZ3 z|6MAug{YQOc+h^NsOa!oWyG1X!{k2GtKWZEo!)eQ{mGS!Sdu+N_@nEu6d;VJ2sHlc zfae1BrSs$EKP_Y~jZA8Ney0Klk8d}dcb+FinI8B^02c<7sEYrS1N6LrdMMm<2k37q{qW-P4-6bY-mSM z?yrx9!J4U#`4RXGL7x6$#$d-9fS+Bn6x~`QGmZZ$fsO<&@#)V7YPBh7R&i)35G8hx zjTG^`#CddX4j;*X&5chxSM5$(4&(K`NtdwAh!dh!jC*##(eIRjF?rHU{0%FK!?%_G6CsdHUMe>HpAYhkKi?OL%b%I!w-zMMP zC%BK@M-k>0;}E4EjboH5iKKeKYrhN?)pMss zNkV1OzkdV}+$U6QHqmPn4NfV9;d4sge=m~K?gy_X4fJ0*Yrnv8A(i9t2Q9&0+`jkR zl3;HqmjxXPlI8G!zd*z%7NxvunC4U%sLM-8*jQ%3;6v7vOYB+gEIgE%0SN=Ci z&cSw_QIjG!))bu`gV#UqrG8+Pn-5DMNmo7IP2Fm`xuNyzx@(mDhWPwWw4wBYeRe&` zy49bsa@;&JvruwKvrcp9)a*M8RSd{dfHvCf?7d+QpS9p$?>cS(M0`}h>jbZhxxN0x zmIER9h1rsYsAxwBlQEB*oiaak{y5lFMCMS3;gGUgj5i3ALsOS!y9F9C#!@Zwui&^5 zFHuoGX%{2MABENf^@(EC65BBraFq?yeEc`6>KeH57zhV{CtLW>8$vM`a0I9 z$?L`Jp5Eye^E>Vnwl&IEt)J?7nhzfEu1hs@Q-ad2#yeb$CUAF+5c^R%*6G*HTNb9h z!(AT7xvyq<4f?Adu~|Pg6?5xt>+v&#FO9$-bE^X-&flMAJ`MizIR3FY1Xo#EidSO5 z7+X9Zs}*o}0#Jk}hODGR-5ra3&A7=m(1I5Z%NpddCcgMhns=9~-~>Ddv9Rl<{Q*vm z*ImK-2_bNls4_l1UL7b|C3kNqv4C#7R`eb$qXU;9Pu7F+{7fj}Ej16+fY zAAF5t)f7rH=pjtjX8eLC?tvuWVP}*Yr-uU^*9qZtfk!}#arz+eP9?rB2#BSOaSEK{ z`SwsDBuZYiBz@6m8M`(=prz zPTucg0k~PBhs0kPBE!JuG%kdvG3tC{lmf7rEKiV^|G+waZG)A|+q#mGs>S2Ez`};S zwTgn&^xl8%Yd3dRp4CB6u^%lqwOvq|Lmu|6jzFxs#euWqDjPnfSjF{dZ%ysL-j{do zIf98*h2IK}&8GEd#~&c5e~52XUaX30DwuW&R9f5@uqM4YS#EzpM6l$DP+0n%1uX2S0u_ogyUFd zBs8~-th9{-ni9?4s?S=6`|>UQu^@f)NzZyIe5_hwk|u5P3**?ag_*) zB~4gW3j+f+=F@VAJ6QbpVUG!a=WU9GM;41^@|NvGZdA_pu|QJad;G6|Ci@@1dL0+i z&2tSWeZP(!jFWdZD5X;u3>#e&fg|c?NC_he;QLUR!Z1&T+h5BcNh8`i0YCMFD;JzB z`@2tWqweT|8ZgQ*{BQD(_szr1m^Ome(yGf0^>gGakT|BS`|*r{)~ttcfRu!JnumKy zdL@A+xN$-7!3RX6rZMtUqHBNFgd!O4CueZ&wD6v2{uKVVk$a=osAq!ny3~_lKp6*c z_e+HDN3&G)bB=W8K&cp&s%=-F#^h^u+6R#z+x3T19e z!Jl|R@843lKs%#GbV_jl7sQD9+&@_4`P(e0iC1;9avFcf$-yt65v0s0xJUidN0tvP z%&D)5^2)bTD}RuJhkqW=@pAsx1p0*u|KHw5nEC!cAu-Ja1?5#MPf1V{nexdZfTUK> z{pt2v&4&?&VbXuAGD)$aHQ(I()_G59$6l z;w^5N3ttd0cu71ywffgoER59T@m- zB!AkCx1qt$F4ecY1`I+#N4$8VIw*eEO$TtFoU^H!0S@=8uP%zkpA+|&szZ0Y!m`B= zk?^u^5U;Gs#1m(#yNyC;@WKo5+!{J{be%rd36?n8F!*;N{lxS0V5 z%n1Tjk+>fae6b*;Vx6*{OGvAt%%(B;fgCTog(neOOQaAyzxg_pXXjW*H<8(9;9muyf^a?9DrdUBfzTu+XI^C!ry-_i4ps`=P%Bxms2$Iz<6TC zEI`C|<5`SsilF=p&Kku+3Y zeTTBGZ;KLOJ?ob#6u6D13XejzC?#OvgTQdZF?l0rAls+?fVDCy;?RTv(?XBC>n3(^ zqWAfG0G;E`NEliVp;c(uLL5EaOKDspwp((F_uoGI9$R8_7-^~9jjn2IU>~+Zu992d zPT!{($$699OBk(*IIHnF`;|h2M^^PZH+!Bn`Gn5nG_skb@Tb1=-{=&7G$)X=j$CV> zd}*L;y`EMi7O3LHhl?@+@pVso1O=PYrVoqn`yYR;bplT=4p$wM*0B-?rXOTAH~#Zf zQ8JWV$~-uOD{kcM>g^h=hB?5^{PNK#bn{FLQ>a-zy#~>>g2y&`WC@@4LF{`eIyjD5 zwGCZWxU08jpR#Z}xW#z-KBC885VOz!%H-_h`T2sCsEr_F4MkGH%BG~^**S4Y+z%Y0 zq9wfhpT{j!?M|X6wr??6Xq=T2T#;`+8iE2nhS0cQ=l8Bu+=`wc-rDULvvnLwAsczh zG~<7e6dv+NsE4-@PvSohmhzGL@=kc-;F!+rV5lvcmta}W7EEW_GB;;(v`cr1nWBfo zvo3D+Ojv^{vVG?}5!Qs z@~ufnt9h@Cc7|DdrrNsFUGl30d3Q~ zA=~L;A`c#^dlj8?z3<~lXg^f{EV zb<0tD`3fKS#7}oMK@NwEjW;Q0AdE_?^GF;w9J5h_%_nBvd=^IxyYzl*PN_6?W!{#h zhLB(O4Ru)snKLsEa9nfj&8D1w%uEj#0>%nF{!|h#Ajq^xl1jfBnXkN)+~b1dTmkTl zf;Fi-;7{|+bWq@mEqKP$KW+;$O?!T>hs1TD-nCpaH&5r|hK*AHi-zVPhrYktWTY$y z?8cz%R1)-gJQ}DBULr?4eAqA*Ll^yeqlM@AaKuWNSTqWA9}%ycWW{;Yt!(V{OZ7vZ zxbcZX;8!lNTj8`lEsDVRy~xO!v}_Y2Y#ei@JKP%N+-Xt#ZFT-EdwO&VCC;utzr`t~ z7fQsm*N5mOyeXW^tGR+|E8p2(XkTOpHMXge#;l3dTwSf(TAV6GhiT=a-`NHVJPsFL{!Qw?-BGN zYkT+_-d}WUb34NXjCjyPu1!u9u@<=GASVXi2aI`)=}0i<%fgH1kIeM15jFMg#p==? zL&btxV5O7O@Mib9jyc;)ChjBShKD1Bm?8Q?ua$bEbF-DQgkeQ9u^h(tWHYLigBTAZ zFJi+iM`0Po)t+UVz~!uSGw0q#);KSG3Hj*~#DJ5Aawf=JXQL{bq{=pUhRF#k0KcV6 zt#%6qFKt#lN+SMfH9;wBwr)uH7v22f;WgpHyUKTeC7;86Hx2!QG&Vf#OQ%OgVC~k{(A+#h=(@A{i=>CN560V@kmz(JIu) zT94oKhlbxz5RYQEZ+G1>MM@X?#@XT&l2^$@|5fr`NucdaML(T3O%6}6Ub7_Wy&?mI z1g6MRhZqHDLQ5raxS{tRQJq-n(D+ z^ygGkjK{=TzpmMOZc(kS^vk=%7K2uQ-s7)@txOK&@d7^tKFZoRJB}Xr)_cjJ1S=hh z%{SkRZ+il&9*b1%-BNp?7rQTDb5^D$Z9isPr7Jz}36L$CKZzo84evZxDzEFKZ?wzO zZz&W%*0AS9QJ64m+jUy>C3*gRILRkO7?h$G>Mzj>Y33nswa(S5Y_I#o7#?9AHaV{Y!IHdD5xeqJ=nU!-YqSs6^K z_>?sqqRJE0H$L#di}c)e*YBpIn+*+tl8Z#4q!`#qk25^7JWUPi`v_uX?01@sIOaZ_ zW9Ow=3$Ra13o2h1RqWGoU*AgSN1)3>@ByC*CrX|ntyLrP+iP>aYu{tHwF;Y{mm!|B zBH`arL^mifOmP)ia@Y^&2ah3)IlmVA2(zVaQnE;}_LHmbj|bJZc&y+>V@mHW;vM(}HX-7b`3E zpLkpqm5wrfMUj3N;q^nQ*@$7R3~Q@R@wTe4-;yg|2Z{h0(h{vSnwK)}K{;z7ZVkFo zgqKU6%`@C@FwBPG+SJ7#WnTgShvZ(y8sL$bEC%G*5X`0>Xccn!+^C1G3rEgRts9ODp#uov;Tk}WIv4Fch_sC4ATEQt3ICP8(fK^h*iAxsQ zpot!5{OL_Ke$tY$eo3zt4!%FUe3o4o2&veSlb6X)l|z(z=`jRXuxt!vsjo^5i0PM- zVcK#Up(<u?ZmuHX;4hQ_JB+ou4a*YhKR^SO0NVbs&nNP4 z_HjVLarDGvmmS{tyU|VlZAeh%#FVT9WfNDqW$22?pK0L`c46j^rq+_e(Hc*?)s4q* zZ`{oEIynpZ^F{x+ikXi?8oqzYBeSPW9Qj?heFY?0LpxmdlWGXkhCEWM&bv#9QwTUs z+wa+%EGYUlbg-|4tE14hW24hHK))I(4p}EE9t|yMrMB+%UwO|bR6{Dd+y*|?ii8I8 zd4J)WRM4p__P17UVEk>3HZ#8NiqhK83>)N)&t9(8%A&#=Kw-o3mvgDUZSt3o3;+mU zWG4P;xLJUGo_86<+zii3d+*Nt6L*82_uT4^qWi?}-?fVw+Z@8Aen(Q-7w5m0*?9sW zA)9}!J^(5u=`UTdkUuNaGc;#nzj}sj5JxgPZ3o_HiqD+O>0yKRNd{xD>BXYV12H{Y z$X;5!Jtd>6gaUx$3;98g?DC0>eu6r_fK_s+M} zH#SJ%C-z;QB&bS*?W3Kg_o0dhs&0MbN9+A}fBVwN&+_Dewj4`atlMKg+l(7>=$k{! zBeH}|_rmR^P1WXG3JG1Lgr@?aGIT&t4hIPp7UxWGtrPrEvvaLaoTN#)LF;Jik4V%7u_M<1 z)Zwg8^YP$?{eDj7@6n^)X zK>i#X@0loW4*oK1XL-Z0(d2=>&^RYhJ_D_eQhf(0;Ulne2_XL|Z`P&*fI2n8%X-I- zoKQjJiCP!HMckIC@C~?GdSRxXo2Tw9US<4(2Cdh`i`1sI>HG88D@L%!I4QcTHIr<} zGO+mR+CCcv%3111B;9xPk2lauBULfqAY0e~~DkEMxPU)(|#o%f0yslk_0(3scp%u<-Z)yv zT%7_mUJ1M>XRIM>So3Lec?_Ff2v9O~s3`Qlu1paU;AnaL2)5PewLCdHa$YtUu0!B% zuK(TKLSL=xGn=xGf0W7w;-hcOocx3h^{|MEdcz~Seze~C9Grdjqt^(+r;}t5$q5ps zX0KOF{`T-f-)(XZkB-ry@AlZHKK5uE|Jt(iRC;4SJ*Nv`vZ$t^YQpS(nq6rW9AI2B zLtHv|k5^8PLW~s1Xoix!tP$)$OoyXj3&V6Smf^|wEUSWa*k2Kx9qJFkG zmr(l>5oYLe%ItzgcFlT`7|*ezfjx`WXZ0;t_Mnm64SgpD!qclcx_n3<3um14(;`=)g_Od0y9*vMjvb-smt)#C!=^R%O=Oe^#6I0!W} zZvu)9bIxxaRGp$|o*CU`qVj*!t=M~5`A~rn=|u3_yKhFjTe(T)iKuyZbj^`&)$vI3 zY&xWJhRVJZXM`xvuu{ah(;l;|VcwoKI_?%7nw>hD2w<|X-Q9{xxwFmJvdT`{9Uk4W zzg~X|R-T-0#C(0XQs%S4KFQd{%&P&~D>BmgjN4@ivnvA}QL~KdQyw2j8E-t)-0GP- zamD%vwF9BaCyjd3^|&k?&L!A4*j-+6h#2rPU_PtM={IENZJxyWO*|6i#A?3%Jh?HQ z@~y#85ee?*pxiqSf(JWfEanKOo2sU#_UiCc-kj7v?h_A;gEykX}G$%d&7ZM89}!(A8l)d`IH%N7LR=DeD&8VF6sGf)N18#F-}W*J@VJ7 z6{D8bGs2Hf)=32|kJN#-X zQ=3^8RAs^RMFL@=sS#9sIl-$M_90&5`vVBFe`JD`dq+#~4!4+uNuW>jg2#8JxFhU% z4)$Rm!OxBjvJ($rxVQ1c7matwD??2;&r-Pb4OkEMVgqza1uI;2*X!5Ng*qu`JK(Z} zqiEC1#Et6T>Y7p9yK=L2mWB#<-WMKoY3`TITFPtC0sZ^N9*_dM9#7dj{9vImNs@oK zCm#>{W@{~^=3i91cqfl+q#oB1?{wDBtc78pf7Sbsze+AvOrG?${|CU!M;6<82gGI0 zg={fx&=odZ-)e2d$Du!`U$T=ah`rA??LOhbBeNDavsD`%Y`O2Zlf}8Bl1Y+;kiYuX z&85N{YH{yPce4B{{gnX@^{bkkLRv{53@0-7Dx{j!ZyfSz>iNRxzv?_mlKSK5G*GD! z|BWG+9||dN6lHS)jR;Coeerk8C>&Q zkj8fkcc#2J%BP9M$-PVW8@D>gxA69Fykl?%01c7TZpm_L4NGo~`xa#XWL}UzcnEAb zXFA9sSt&*o;L%x={iN0pO8vQq^-~K*M*T@Z;=U=umae;RBY0@^wPSM0?7qh3ym_rr z$7_ZS>jHqu#osr$)K-V|CXL>7q<$okp414vV8#>TEr_icnc(|p$IH$J~v^qsd8j5I{82|lWfPI>=)u|@g#qX9foF_$0 z4x2_WJ0Q{SvjXGaGG{f>!Kbcyah$-u92^dd88~4R0@J_ZJH+Wij{0mD@ZpKDUmg9+ zOej9mmsw)X*F2H9!|NsuJ(L*!g;cVC{5$tqwchlm8FWn4r6wBamdNMh&{lSA+=PWb z>$y;L)S(-n>QF7fCz!EohP1bQsASyZ6}R6s-tBZc3L*PKR~GcEFJP_=6Px#g1rF{$ zen)J1vkt6O3YjwYNinM@seZvzbLc~}c*2I;X+bvLRQX!=M1mx0Cvvh`lIhtKLk1bP0S5G%`{!-E3 zG}6`&X7K3??$&Bz;LedAsC6Rk;J5)*z+09?x#S242)EH-IuJt!!>Gp(c(NOMbQ~cZl5@y z_zEJ^eX#g{Iym!qDA)Io-?LzB%_K`GGaX0D$r8~b&1Cu3w8&AB#K>2nM8{HDWmTJk^gVV5jQ4%L#YS{Y)jg4Vy{HH(_KplJQDUPRCq6d{y6I~l+NfJY zH$RxK_~r1R53KZNr=y=qK=3&>dZ0FZl6bZ>%?z)BQdt zc3g@g`M=wAOc5%ymYo-{wGkx$mr4keUd2sOQw?L*ILufcc7kOfZM{CQLH*mhEN z;{+MgiU`0@_Y**gKlzh7VSiJ+&>-q=JVPIY1WZlc<;n23_SV;4akdg~zocwHFb?Dq zNUm4XlmjH$HGsJemcjN+GGDNL1-lDl9z=&%sO(swJ%$iP29BCO7XHGdZ?2|mx> z&@&Ver-o1rgOPiud<2v(e#k#YjH&86Op<3AGS}Y#~aR!FPa8-pg`8{=QE+! zviyd^1*Ld884ccBs2~ky*KRegN}yhwgW%6}JGnodZ&EW^{pr}Gq9UY%1E*#*3I^Q6 z>7!ODY!`@T^a2St2EM^*+z9?$v2YA>;eE>Vl$d?Ke9{0xs`>r57j7csBW#9#S-v}9 z)OXl$m&~lomUVPOiO?KuErh0*eQj3y`HFckISEx@17Fn}UzYhlbeCZBJ>1|O*Ms4m zR^RoCwa|%8AyQW3OdCCD_&k!qISEaDx-_jnaYgcvdVaJtbIHy9Z_sR@jMUy<5EV{u zG3=)21~XU(wA0 zs|yPi2iJ_gaEB0Wt+Z-k&0WyU`BA-<6#bYvpm2CA{~pIvKV}&C?v8Am?d+j{-uB6t zJI}MGE7JeQfU$c7zHj>EHBkze^`p=XkWkseLHl{x6f0biZ~+a1b+2sDOkqEVYtHxF zU(XFYqM*HaNpuZ2G7R5pL#%^sSWRpZNDEiUI1(3t10O<0E9RbH<cjUp)8uJWWD z`HCSBv^)tXlbXy(V@ChKlrgCdIQzc;`_!fC&9+lth^d9rOu@%N*!%cfbIAO^`uo^2 zO@%n{B(?2;zt;_?du<1jR%l!t5YO!6H3AQf`uTSElLSmND=Xwi<|A3{32m9ySj(gp zj1;hJ%=4N4nXqu0$o+A$v-Th;?y6T_V^vL4a)rcvmt6ey#U)JygL|>i`0m>@h9An0 zOuD8DNFG%a0<(yz2D(ReKifRJznnYi0Gpe4zOKBn2B&@b!FU=P8Ki@DakQN0mJ3QL z1*TM;^42~I8uVJ2_bP5QRsOvOL2Nv0nr8zTv1wKE*ziGaqG$}}9Q3|@tca*OPWj7K z|Eg&X0_-dC8RYKTEPb{Z0+5X9oxz*(-vu?JcGGQV;q=TQTRe?@mj1?cplkP1!?NV& zZ2|}=v{#5T`L197c@3h3W*!8o+MJM>1+7bCH)Tm29ZiX~CX9{HGQ(+N$CgbahEg~z zI7}G{Me7sab&R*OMna(cebW1p7uG$s=PDY40M6b(8)p0RVLuO_P7#+$i*BD9o^&9X zyn7|epK_x8kC?r}ds7D!CtkUj=nIBhL@Zl+c!cW?4B?#X*mcPVqDOUd$ZZcg0bb#{ zN1s4j>UJViK{t|g=9k)3YQ&TENKg&{m-D0gd*M%KM9qKen(GF#iN1B9KfLk{eK~8v z?aQ^Wn zW)AJ!$KlDN%||T--FG3zl$X@y#o9M1SfY^(FDoU!UgIrE_Z=|80p{sJ$Kq2|#u-_&>74vAC*zv|@cF?gWkLO)N^XcM zBWRCSec--?T9NGwmvb({@yMW7J2Ig>YQ+3T9zy+HnJDq(^l58BNq9D-p-5AM3kv5b zUikT0k`HO0s#)W{PPMCSgpdEbN|At=RrJr^c{^6gIn+}(O>wRv5`6u7d`QhdlUn!L z8^x8g^#^1`NIL?Vm{YpY&7{A6`oz7~o&Ja3DhVb=9qS?cYZjmo6-VaKH0NwEWfd_v zpMDVH=N;X>5LmOzj`-|bf;JLp{uu~M=TRCMsf4Rtu^%QvJ%SSFc+#X;ywiHXyt_A$ zRdLEH*R66t0b7a0+^YVXk;Ul||53ZgbnpWI(3W4&oeQmTx}7S1FF_45K+oetCW2z1 z)hE1 z{AL6=_w2ckg@Xmq*`y{(dUc@Q5rCm69?6H6(T&R5vGGjZl))uMmm{@><|uh=fGl zK-xeY{0lFCq}9#T-3_0ID;nE}g2t%&K7io>j<%?%y zb1O+Cc}$Q(a)R$)&AIJHD4LDHwsOySTYC_Gf&dt7_(e&`I9)UEPk3ZplQITrxrhIh zAJwcwon>$nM-Id|0>;*dBWb$D;kM_EHE{diL%5@g>XyNM)c3DZMaC%EgdNWdD>fPD zWcDs+?p}T2D8_*PT~@rP*dtBqbEz=d36pcK&i>!%ufZz=jar>$r~wDoVnM({^Q>|4 z9VLTDYT|W+f&{C6*PeD}N*e)?(tD4tsqX1jt4?F`JLL&!K0boJgaOc`ZZcFe!@0D8 zDk`~S$4fs*_oc>Dw?3XVCqrnx1ORf2FJv*ZBe3J?Xr;x}pqJfn9uS8P-x}Yf_H6{1 zV?CNg=vgZI764niR4_9m-*&n}OT^1JEP3rTGW-6iq1DdXW{%VJhivu#@Zf|og q{h@B;Jm5qg&X8#+G@m%~3{u+ro&gMI!Y1KM7C?4#b}X~^iTgiAD&k`R literal 0 HcmV?d00001 diff --git a/android/app/src/main/java/com/audiobookshelf/app/CastOptionsProvider.kt b/android/app/src/main/java/com/audiobookshelf/app/CastOptionsProvider.kt new file mode 100644 index 0000000..c1f9238 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/CastOptionsProvider.kt @@ -0,0 +1,30 @@ +package com.audiobookshelf.app + +import android.content.Context +import android.util.Log +import com.google.android.gms.cast.CastMediaControlIntent +import com.google.android.gms.cast.framework.CastOptions +import com.google.android.gms.cast.framework.OptionsProvider +import com.google.android.gms.cast.framework.SessionProvider +import com.google.android.gms.cast.framework.media.CastMediaOptions + +class CastOptionsProvider : OptionsProvider { + override fun getCastOptions(context: Context): CastOptions { + Log.d("CastOptionsProvider", "getCastOptions") + var appId = "FD1F76C5" +// var defaultId =CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID + return CastOptions.Builder() + .setReceiverApplicationId(appId).setCastMediaOptions( + CastMediaOptions.Builder() + // We manage the media session and the notifications ourselves. + .setMediaSessionEnabled(false) + .setNotificationOptions(null) + .build() + ) + .setStopReceiverApplicationWhenEndingSession(true).build() + } + + override fun getAdditionalSessionProviders(context: Context): List? { + return null + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/MainActivity.kt b/android/app/src/main/java/com/audiobookshelf/app/MainActivity.kt new file mode 100644 index 0000000..b1bfac7 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/MainActivity.kt @@ -0,0 +1,185 @@ +package com.audiobookshelf.app + +import android.Manifest +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.os.IBinder +import android.util.Log +import android.view.ViewGroup +import android.view.WindowInsets +import android.webkit.WebView +import androidx.core.app.ActivityCompat +import androidx.core.view.updateLayoutParams +import com.anggrayudi.storage.SimpleStorage +import com.anggrayudi.storage.SimpleStorageHelper +import com.audiobookshelf.app.managers.DbManager +import com.audiobookshelf.app.player.PlayerNotificationService +import com.audiobookshelf.app.plugins.AbsAudioPlayer +import com.audiobookshelf.app.plugins.AbsDatabase +import com.audiobookshelf.app.plugins.AbsDownloader +import com.audiobookshelf.app.plugins.AbsFileSystem +import com.audiobookshelf.app.plugins.AbsLogger +import com.getcapacitor.BridgeActivity + + +class MainActivity : BridgeActivity() { + private val tag = "MainActivity" + + private var mBounded = false + lateinit var foregroundService : PlayerNotificationService + private lateinit var mConnection : ServiceConnection + + lateinit var pluginCallback : () -> Unit + + val storageHelper = SimpleStorageHelper(this) + val storage = SimpleStorage(this) + + val REQUEST_PERMISSIONS = 1 + + public override fun onCreate(savedInstanceState: Bundle?) { + DbManager.initialize(applicationContext) + + registerPlugin(AbsAudioPlayer::class.java) + registerPlugin(AbsDownloader::class.java) + registerPlugin(AbsFileSystem::class.java) + registerPlugin(AbsDatabase::class.java) + registerPlugin(AbsLogger::class.java) + + super.onCreate(savedInstanceState) + Log.d(tag, "onCreate") + + // Update the margins to handle edge-to-edge enforced in SDK 35 + // See: https://developer.android.com/develop/ui/views/layout/edge-to-edge + val webView: WebView = findViewById(R.id.webview) + webView.setOnApplyWindowInsetsListener { v, insets -> + val (left, top, right, bottom) = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val sysInsets = insets.getInsets(WindowInsets.Type.systemBars()) + Log.d(tag, "safe sysInsets: $sysInsets") + arrayOf(sysInsets.left, sysInsets.top, sysInsets.right, sysInsets.bottom) + } else { + arrayOf( + insets.systemWindowInsetLeft, + insets.systemWindowInsetTop, + insets.systemWindowInsetRight, + insets.systemWindowInsetBottom + ) + } + + // Inject as CSS variables + // NOTE: Possibly able to use in the future to support edge-to-edge better. + val js = """ + document.documentElement.style.setProperty('--safe-area-inset-top', '${top}px'); + document.documentElement.style.setProperty('--safe-area-inset-bottom', '${bottom}px'); + document.documentElement.style.setProperty('--safe-area-inset-left', '${left}px'); + document.documentElement.style.setProperty('--safe-area-inset-right', '${right}px'); + """.trimIndent() + webView.evaluateJavascript(js, null) + + // Set margins + v.updateLayoutParams { + leftMargin = left + bottomMargin = bottom + rightMargin = right + topMargin = top + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + WindowInsets.CONSUMED + } else { + insets + } + } + + requestNeededPermissions() + } + + private fun requestNeededPermissions() { + val needed = mutableListOf() + if (ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { + needed.add(Manifest.permission.READ_EXTERNAL_STORAGE) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + needed.add(Manifest.permission.POST_NOTIFICATIONS) + } + if (needed.isNotEmpty()) { + ActivityCompat.requestPermissions(this, needed.toTypedArray(), REQUEST_PERMISSIONS) + } + } + + override fun onDestroy() { + super.onDestroy() + } + + override fun onPostCreate(savedInstanceState: Bundle?) { + super.onPostCreate(savedInstanceState) + Log.d(tag, "onPostCreate MainActivity") + + mConnection = object : ServiceConnection { + override fun onServiceDisconnected(name: ComponentName) { + Log.w(tag, "Service Disconnected $name") + mBounded = false + } + + override fun onServiceConnected(name: ComponentName, service: IBinder) { + Log.d(tag, "Service Connected $name") + + mBounded = true + val mLocalBinder = service as PlayerNotificationService.LocalBinder + foregroundService = mLocalBinder.getService() + + // Let NativeAudio know foreground service is ready and setup event listener + pluginCallback() + } + } + + Intent(this, PlayerNotificationService::class.java).also { intent -> + Log.d(tag, "Binding PlayerNotificationService") + bindService(intent, mConnection, Context.BIND_AUTO_CREATE) + } + } + + fun isPlayerNotificationServiceInitialized():Boolean { + return ::foregroundService.isInitialized + } + + fun stopMyService() { + if (mBounded) { + mConnection.let { unbindService(it) }; + mBounded = false; + } + val stopIntent = Intent(this, PlayerNotificationService::class.java) + stopService(stopIntent) + } + + override fun onSaveInstanceState(outState: Bundle) { + storageHelper.onSaveInstanceState(outState) + super.onSaveInstanceState(outState) + outState.clear() + } + + override fun onRestoreInstanceState(savedInstanceState: Bundle) { + super.onRestoreInstanceState(savedInstanceState) + storageHelper.onRestoreInstanceState(savedInstanceState) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + // Mandatory for Activity, but not for Fragment & ComponentActivity + storageHelper.storage.onActivityResult(requestCode, resultCode, data) + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + Log.d(tag, "onRequestPermissionResult $requestCode") + permissions.forEach { Log.d(tag, "PERMISSION $it") } + grantResults.forEach { Log.d(tag, "GRANTREUSLTS $it") } + // Mandatory for Activity, but not for Fragment & ComponentActivity + storageHelper.onRequestPermissionsResult(requestCode, permissions, grantResults) + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/MediaPlayerWidget.kt b/android/app/src/main/java/com/audiobookshelf/app/MediaPlayerWidget.kt new file mode 100644 index 0000000..acb015a --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/MediaPlayerWidget.kt @@ -0,0 +1,103 @@ +package com.audiobookshelf.app + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.net.Uri +import android.support.v4.media.session.PlaybackStateCompat +import android.util.Log +import android.view.View +import android.widget.RemoteViews +import androidx.media.session.MediaButtonReceiver +import com.audiobookshelf.app.data.PlaybackSession +import com.audiobookshelf.app.device.DeviceManager +import com.audiobookshelf.app.managers.DbManager +import com.bumptech.glide.Glide +import com.bumptech.glide.request.RequestOptions +import com.bumptech.glide.request.target.AppWidgetTarget +import com.bumptech.glide.request.transition.Transition + +/** + * Implementation of App Widget functionality. + */ +class MediaPlayerWidget : AppWidgetProvider() { + val tag = "MediaPlayerWidget" + override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { + Log.d(tag, "onUpdate $appWidgetIds") + } + + override fun onEnabled(context: Context) { + Log.i(tag, "onEnabled check context ${context.packageName}") + + DbManager.initialize(context) + + DeviceManager.deviceData.lastPlaybackSession?.let { + val appWidgetManager = AppWidgetManager.getInstance(context) + val componentName = ComponentName(context, MediaPlayerWidget::class.java) + val ids = appWidgetManager.getAppWidgetIds(componentName) + Log.d(tag, "Setting initial widget state with last playback session ${it.displayTitle}") + for (widgetId in ids) { + updateAppWidget(context, appWidgetManager, widgetId, it, false, true) + } + } + + // Enter relevant functionality for when the first widget is created + DeviceManager.initializeWidgetUpdater(context) + } +} + +internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, playbackSession: PlaybackSession?, isPlaying:Boolean, isAppClosed:Boolean) { + val tag = "MediaPlayerWidget" + val views = RemoteViews(context.packageName, R.layout.media_player_widget) + Log.i(tag, "updateAppWidget ${playbackSession?.displayTitle ?: "No Title"} isPlaying=$isPlaying isAppClosed=$isAppClosed") + val wholeWidgetClickI = Intent(context, MainActivity::class.java) + wholeWidgetClickI.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK + val wholeWidgetClickPI = PendingIntent.getActivity( + context, + System.currentTimeMillis().toInt(), + wholeWidgetClickI, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val playPausePI = MediaButtonReceiver.buildMediaButtonPendingIntent(context, PlaybackStateCompat.ACTION_PLAY_PAUSE) + views.setOnClickPendingIntent(R.id.widgetPlayPauseButton, playPausePI) + + val fastForwardPI = MediaButtonReceiver.buildMediaButtonPendingIntent(context, PlaybackStateCompat.ACTION_FAST_FORWARD) + views.setOnClickPendingIntent(R.id.widgetFastForwardButton, fastForwardPI) + + val rewindPI = MediaButtonReceiver.buildMediaButtonPendingIntent(context, PlaybackStateCompat.ACTION_REWIND) + views.setOnClickPendingIntent(R.id.widgetRewindButton, rewindPI) + + // Show/Hide button container + views.setViewVisibility(R.id.widgetButtonContainer, if (isAppClosed) View.GONE else View.VISIBLE) + + views.setOnClickPendingIntent(R.id.widgetBackground, wholeWidgetClickPI) + + val imageUri = playbackSession?.getCoverUri(context) ?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon) + val awt: AppWidgetTarget = object : AppWidgetTarget(context.applicationContext, R.id.widgetAlbumArt, views, appWidgetId) { + override fun onResourceReady(resource: Bitmap, transition: Transition?) { + super.onResourceReady(resource, transition) + } + } + + val artist = playbackSession?.displayAuthor ?: "Unknown" + views.setTextViewText(R.id.widgetArtistText, artist) + + val title = playbackSession?.displayTitle ?: "Unknown" + views.setTextViewText(R.id.widgetMediaTitle, title) + + val options = RequestOptions().override(300, 300).placeholder(R.drawable.icon).error(R.drawable.icon) + Glide.with(context.applicationContext).asBitmap().load(imageUri).apply(options).into(awt) + + Log.i(tag, "Update App Widget | Is Playing=$isPlaying | isAppClosed=$isAppClosed") + + val playPauseResource = if (isPlaying) androidx.mediarouter.R.drawable.ic_media_pause_dark else androidx.mediarouter.R.drawable.ic_media_play_dark + views.setImageViewResource(R.id.widgetPlayPauseButton, playPauseResource) + + // Instruct the widget manager to update the widget + appWidgetManager.updateAppWidget(appWidgetId, views) +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/AudioTrack.kt b/android/app/src/main/java/com/audiobookshelf/app/data/AudioTrack.kt new file mode 100644 index 0000000..a7dd1b8 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/AudioTrack.kt @@ -0,0 +1,38 @@ +package com.audiobookshelf.app.data + +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +@JsonIgnoreProperties(ignoreUnknown = true) +data class AudioTrack( + var index: Int, + var startOffset: Double, + var duration: Double, + var title: String, + var contentUrl: String, + var mimeType: String?, + var metadata: FileMetadata?, + var isLocal: Boolean, + var localFileId: String?, + // TODO: This should no longer be necessary + var serverIndex: Int? // Need to know if server track index is different +) { + + @get:JsonIgnore + val startOffsetMs + get() = (startOffset * 1000L).toLong() + @get:JsonIgnore + val durationMs + get() = (duration * 1000L).toLong() + @get:JsonIgnore + val endOffsetMs + get() = startOffsetMs + durationMs + @get:JsonIgnore + val relPath + get() = metadata?.relPath ?: "" + + @JsonIgnore + fun getBookChapter(): BookChapter { + return BookChapter(index + 1, startOffset, startOffset + duration, title) + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/CollapsedSeries.kt b/android/app/src/main/java/com/audiobookshelf/app/data/CollapsedSeries.kt new file mode 100644 index 0000000..6369071 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/CollapsedSeries.kt @@ -0,0 +1,36 @@ +package com.audiobookshelf.app.data + +import android.content.Context +import android.os.Bundle +import android.support.v4.media.MediaDescriptionCompat +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +@JsonIgnoreProperties(ignoreUnknown = true) +class CollapsedSeries( + id:String, + var libraryId:String?, + var name:String, + //var nameIgnorePrefix:String, + var sequence:String?, + var libraryItemIds:MutableList +) : LibraryItemWrapper(id) { + @get:JsonIgnore + val title get() = name + @get:JsonIgnore + val numBooks get() = libraryItemIds.size + + @JsonIgnore + override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat { + val extras = Bundle() + + val mediaId = "__LIBRARY__${libraryId}__SERIE__${id}" + return MediaDescriptionCompat.Builder() + .setMediaId(mediaId) + .setTitle(title) + //.setIconUri(getCoverUri()) + .setSubtitle("${numBooks} books") + .setExtras(extras) + .build() + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt b/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt new file mode 100644 index 0000000..f6e8800 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt @@ -0,0 +1,514 @@ +package com.audiobookshelf.app.data + +import android.content.Context +import android.icu.text.DateFormat +import android.os.Bundle +import android.support.v4.media.MediaDescriptionCompat +import android.support.v4.media.MediaMetadataCompat +import androidx.media.utils.MediaConstants +import com.audiobookshelf.app.media.MediaManager +import com.fasterxml.jackson.annotation.* +import com.audiobookshelf.app.media.getUriToAbsIconDrawable +import java.util.Date + +// This auto-detects whether it is a Book or Podcast +@JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION) +@JsonSubTypes( + JsonSubTypes.Type(Book::class), + JsonSubTypes.Type(Podcast::class) +) +open class MediaType(var metadata:MediaTypeMetadata, var coverPath:String?) { + @JsonIgnore + open fun getAudioTracks():List { return mutableListOf() } + @JsonIgnore + open fun setAudioTracks(audioTracks:MutableList) { } + @JsonIgnore + open fun addAudioTrack(audioTrack:AudioTrack) { } + @JsonIgnore + open fun removeAudioTrack(localFileId:String) { } + @JsonIgnore + open fun getLocalCopy():MediaType { return MediaType(MediaTypeMetadata("", false),null) } + @JsonIgnore + open fun checkHasTracks():Boolean { return false } +} + +@JsonIgnoreProperties(ignoreUnknown = true) +class Podcast( + metadata:PodcastMetadata, + coverPath:String?, + var tags:MutableList, + var episodes:MutableList?, + var autoDownloadEpisodes:Boolean, + var numEpisodes:Int? +) : MediaType(metadata, coverPath) { + @JsonIgnore + override fun getAudioTracks():List { + val tracks = episodes?.map { it.audioTrack } + return tracks?.filterNotNull() ?: mutableListOf() + } + @JsonIgnore + override fun setAudioTracks(audioTracks:MutableList) { + // Remove episodes no longer there in tracks + episodes = episodes?.filter { ep -> + audioTracks.find { it.localFileId == ep.audioTrack?.localFileId } != null + } as MutableList + + // Add new episodes + audioTracks.forEach { at -> + if (episodes?.find{ it.audioTrack?.localFileId == at.localFileId } == null) { + val localEpisodeId = "local_ep_" + at.localFileId + val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,null,null,at.title,null,null,null, null, null, at,null,at.duration,0, null, localEpisodeId) + episodes?.add(newEpisode) + } + } + + var index = 1 + episodes?.forEach { + it.index = index + index++ + } + } + @JsonIgnore + override fun addAudioTrack(audioTrack:AudioTrack) { + val localEpisodeId = "local_ep_" + audioTrack.localFileId + val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,null,null,audioTrack.title,null,null,null, null, null,audioTrack,null,audioTrack.duration,0, null, localEpisodeId) + episodes?.add(newEpisode) + + var index = 1 + episodes?.forEach { + it.index = index + index++ + } + } + @JsonIgnore + override fun removeAudioTrack(localFileId:String) { + episodes?.removeIf { it.audioTrack?.localFileId == localFileId } + + var index = 1 + episodes?.forEach { + it.index = index + index++ + } + } + + // Used for FolderScanner local podcast item to get copy of Podcast excluding episodes + @JsonIgnore + override fun getLocalCopy(): Podcast { + return Podcast(metadata as PodcastMetadata,coverPath,tags, mutableListOf(),autoDownloadEpisodes, 0) + } + + @JsonIgnore + override fun checkHasTracks():Boolean { + return (episodes?.size ?: numEpisodes ?: 0) > 0 + } + + @JsonIgnore + fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode { + val localEpisodeId = "local_ep_" + episode.id + val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,null,null,audioTrack,episode.chapters,audioTrack.duration,episode.size, episode.id, localEpisodeId) + episodes?.add(newEpisode) + + var index = 1 + episodes?.forEach { + it.index = index + index++ + } + return newEpisode + } + + @JsonIgnore + fun getNextUnfinishedEpisode(libraryItemId:String, mediaManager: MediaManager):PodcastEpisode? { + val sortedEpisodes = episodes?.sortedByDescending { it.publishedAt } + val podcastEpisode = sortedEpisodes?.find { episode -> + val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItemId && it.episodeId == episode.id } + progress == null || !progress.isFinished + } + return podcastEpisode + } +} + +@JsonIgnoreProperties(ignoreUnknown = true) +class Book( + metadata:BookMetadata, + coverPath:String?, + var tags:List, + var audioFiles:List?, + var chapters:List?, + var tracks:MutableList?, + var ebookFile: EBookFile?, + var size:Long?, + var duration:Double?, + var numTracks:Int? +) : MediaType(metadata, coverPath) { + @JsonIgnore + override fun getAudioTracks():List { + return tracks ?: mutableListOf() + } + @JsonIgnore + override fun setAudioTracks(audioTracks:MutableList) { + tracks = audioTracks + tracks?.sortBy { it.index } + + var totalDuration = 0.0 + var currentStartOffset = 0.0 + tracks?.forEach { + totalDuration += it.duration + it.startOffset = currentStartOffset + currentStartOffset += it.duration + } + duration = totalDuration + } + @JsonIgnore + override fun addAudioTrack(audioTrack:AudioTrack) { + tracks?.add(audioTrack) + + var totalDuration = 0.0 + tracks?.forEach { + totalDuration += it.duration + } + duration = totalDuration + } + @JsonIgnore + override fun removeAudioTrack(localFileId:String) { + tracks?.removeIf { it.localFileId == localFileId } + + tracks?.sortBy { it.index } + + var index = 1 + var startOffset = 0.0 + var totalDuration = 0.0 + tracks?.forEach { + it.index = index + it.startOffset = startOffset + totalDuration += it.duration + + index++ + startOffset += it.duration + } + duration = totalDuration + } + @JsonIgnore + override fun getLocalCopy(): Book { + return Book(metadata as BookMetadata,coverPath,tags, mutableListOf(),chapters,mutableListOf(), ebookFile, null,null, 0) + } + + @JsonIgnore + override fun checkHasTracks():Boolean { + return (tracks?.size ?: numTracks ?: 0) > 0 + } +} + +// This auto-detects whether it is a BookMetadata or PodcastMetadata +@JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION) +@JsonSubTypes( + JsonSubTypes.Type(BookMetadata::class), + JsonSubTypes.Type(PodcastMetadata::class) +) +open class MediaTypeMetadata(var title:String, var explicit:Boolean) { + @JsonIgnore + open fun getAuthorDisplayName():String { return "Unknown" } +} + +@JsonIgnoreProperties(ignoreUnknown = true) +class BookMetadata( + title:String, + var subtitle:String?, + var authors:MutableList?, + var narrators:MutableList?, + var genres:MutableList, + var publishedYear:String?, + var publishedDate:String?, + var publisher:String?, + var description:String?, + var isbn:String?, + var asin:String?, + var language:String?, + explicit:Boolean, + // In toJSONExpanded + var authorName:String?, + var authorNameLF:String?, + var narratorName:String?, + var seriesName:String?, + @JsonFormat(with=[JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY]) + var series:List? +) : MediaTypeMetadata(title, explicit) { + @JsonIgnore + override fun getAuthorDisplayName():String { return authorName ?: "Unknown" } +} + +@JsonIgnoreProperties(ignoreUnknown = true) +class PodcastMetadata( + title:String, + var author:String?, + var feedUrl:String?, + var genres:MutableList, + explicit:Boolean +) : MediaTypeMetadata(title, explicit) { + @JsonIgnore + override fun getAuthorDisplayName():String { return author ?: "Unknown" } +} + +@JsonIgnoreProperties(ignoreUnknown = true) +data class Author( + var id:String, + var name:String, + var coverPath:String? +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class PodcastEpisode( + var id:String, + var index:Int, + var episode:String?, + var episodeType:String?, + var title:String?, + var subtitle:String?, + var description:String?, + var pubDate:String?, + var publishedAt:Long?, + var audioFile:AudioFile?, + var audioTrack:AudioTrack?, + var chapters:List?, + var duration:Double?, + var size:Long?, + var serverEpisodeId:String?, // For local podcasts to match with server podcasts + var localEpisodeId:String? // For Android Auto server episodes with local copy +) { + @JsonIgnore + fun getMediaDescription(libraryItem:LibraryItemWrapper, progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat { + val coverUri = if (libraryItem is LocalLibraryItem) { + libraryItem.getCoverUri(ctx) + } else { + (libraryItem as LibraryItem).getCoverUri() + } + + val extras = Bundle() + + if (localEpisodeId != null) { + extras.putLong( + MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS, + MediaDescriptionCompat.STATUS_DOWNLOADED + ) + } + + if (progress != null) { + if (progress.isFinished) { + extras.putInt( + MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS, + MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED + ) + } else { + extras.putInt( + MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS, + MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED + ) + extras.putDouble( + MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress + ) + } + } else { + extras.putInt( + MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS, + MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED + ) + } + + val libraryItemDescription = libraryItem.getMediaDescription(null, ctx) + val mediaId = localEpisodeId ?: id + var subtitle = libraryItemDescription.title + if (publishedAt !== null) { + val sdf = DateFormat.getDateInstance() + val publishedAtDT = Date(publishedAt!!) + subtitle = "${sdf.format(publishedAtDT)} / $subtitle" + } + + val mediaDescriptionBuilder = MediaDescriptionCompat.Builder() + .setMediaId(mediaId) + .setTitle(title) + .setIconUri(coverUri) + .setSubtitle(subtitle) + .setExtras(extras) + + libraryItemDescription.iconBitmap?.let { + mediaDescriptionBuilder.setIconBitmap(it) + } + + return mediaDescriptionBuilder.build() + } +} + +@JsonIgnoreProperties(ignoreUnknown = true) +data class LibraryFile( + var ino:String, + var metadata:FileMetadata +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class FileMetadata( + var filename:String, + var ext:String, + var path:String, + var relPath:String, + var size:Long? +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class AudioFile( + var index:Int, + var ino:String, + var metadata:FileMetadata +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class Library( + var id:String, + var name:String, + var folders:MutableList, + var icon:String, + var mediaType:String, + var stats: LibraryStats? +) { + @JsonIgnore + fun getMediaMetadata(context: Context, targetType: String? = null): MediaMetadataCompat { + var mediaId = id + if (targetType !== null) { + mediaId = "__RECENTLY__$id" + } + return MediaMetadataCompat.Builder().apply { + putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, mediaId) + putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, name) + putString(MediaMetadataCompat.METADATA_KEY_TITLE, name) + putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToAbsIconDrawable(context, icon).toString()) + }.build() + } +} + +@JsonIgnoreProperties(ignoreUnknown = true) +data class LibraryStats( + var totalItems: Int, + var totalSize: Long, + var totalDuration: Double, + var numAudioFiles: Int +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class SeriesType( + var id: String, + var name: String, + var sequence: String? +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class Folder( + var id:String, + var fullPath:String +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class BookChapter( + var id:Int, + var start:Double, + var end:Double, + var title:String? +) { + @get:JsonIgnore + val startMs get() = (start * 1000L).toLong() + @get:JsonIgnore + val endMs get() = (end * 1000L).toLong() +} + +@JsonTypeInfo(use= JsonTypeInfo.Id.DEDUCTION, defaultImpl = MediaProgress::class) +@JsonSubTypes( + JsonSubTypes.Type(MediaProgress::class), + JsonSubTypes.Type(LocalMediaProgress::class) +) +open class MediaProgressWrapper(var isFinished:Boolean, var currentTime:Double, var progress:Double) { + open val mediaItemId get() = "" +} + +// Helper class +data class LibraryItemWithEpisode( + var libraryItemWrapper:LibraryItemWrapper, + var episode:PodcastEpisode +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class LibraryItemSearchResultSeriesItemType( + var series: LibrarySeriesItem, + var books: List? +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class LibraryItemSearchResultLibraryItemType( + val libraryItem: LibraryItem +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class LibraryItemSearchResultType( + var book:List?, + var podcast:List?, + var series:List?, + var authors:List? +) + +// For personalized shelves +@JsonTypeInfo( + use=JsonTypeInfo.Id.NAME, + property = "type", + include = JsonTypeInfo.As.PROPERTY, + visible = true +) +@JsonSubTypes( + JsonSubTypes.Type(LibraryShelfBookEntity::class, name = "book"), + JsonSubTypes.Type(LibraryShelfSeriesEntity::class, name = "series"), + JsonSubTypes.Type(LibraryShelfAuthorEntity::class, name = "authors"), + JsonSubTypes.Type(LibraryShelfEpisodeEntity::class, name = "episode"), + JsonSubTypes.Type(LibraryShelfPodcastEntity::class, name = "podcast") +) +@JsonIgnoreProperties(ignoreUnknown = true) +sealed class LibraryShelfType( + open val id: String, + open val label: String, + open val total: Int, + open val type: String, +) + +data class LibraryShelfBookEntity( + override val id: String, + override val label: String, + override val total: Int, + override val type: String, + val entities: List? +) : LibraryShelfType(id, label, total, type) + +data class LibraryShelfSeriesEntity( + override val id: String, + override val label: String, + override val total: Int, + override val type: String, + val entities: List? +) : LibraryShelfType(id, label, total, type) + +data class LibraryShelfAuthorEntity( + override val id: String, + override val label: String, + override val total: Int, + override val type: String, + val entities: List? +) : LibraryShelfType(id, label, total, type) + +data class LibraryShelfEpisodeEntity( + override val id: String, + override val label: String, + override val total: Int, + override val type: String, + val entities: List? +) : LibraryShelfType(id, label, total, type) + +data class LibraryShelfPodcastEntity( + override val id: String, + override val label: String, + override val total: Int, + override val type: String, + val entities: List? +) : LibraryShelfType(id, label, total, type) diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt b/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt new file mode 100644 index 0000000..fc9beb5 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt @@ -0,0 +1,254 @@ +package com.audiobookshelf.app.data + +import android.content.Context +import android.net.Uri +import android.support.v4.media.MediaDescriptionCompat +import android.util.Log +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonSubTypes +import com.fasterxml.jackson.annotation.JsonTypeInfo +import java.io.File + +enum class LockOrientationSetting { + NONE, + PORTRAIT, + LANDSCAPE +} + +enum class HapticFeedbackSetting { + OFF, + LIGHT, + MEDIUM, + HEAVY +} + +enum class ShakeSensitivitySetting { + VERY_LOW, + LOW, + MEDIUM, + HIGH, + VERY_HIGH +} + +enum class DownloadUsingCellularSetting { + ASK, + ALWAYS, + NEVER +} + +enum class StreamingUsingCellularSetting { + ASK, + ALWAYS, + NEVER +} + +enum class AndroidAutoBrowseSeriesSequenceOrderSetting { + ASC, + DESC +} + +@JsonIgnoreProperties(ignoreUnknown = true) +data class ServerConnectionConfig( + var id: String, + var index: Int, + var name: String, + var address: String, + // version added after 0.9.81-beta + var version: String?, + var userId: String, + var username: String, + var token: String, + var customHeaders: Map? +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class LocalFile( + var id: String, + var filename: String?, + var contentUrl: String, + var basePath: String, + var absolutePath: String, + var mimeType: String?, + var size: Long +) { + @JsonIgnore + fun exists(ctx: Context): Boolean { + if (contentUrl.startsWith("content:")) { + return try { + ctx.contentResolver.openFileDescriptor(Uri.parse(contentUrl), "r")?.use { true } ?: false + } catch (e: Exception) { + Log.w("LocalFile", "Cannot access SAF file $contentUrl", e) + false + } + } + return File(absolutePath).exists() + } + + @JsonIgnore + fun isAudioFile(): Boolean { + if (mimeType == "application/octet-stream") return true + if (mimeType == "video/mp4") return true + return mimeType?.startsWith("audio") == true + } + @JsonIgnore + fun isEBookFile(): Boolean { + return getEBookFormat() != null + } + @JsonIgnore + fun getEBookFormat(): String? { + if (mimeType == "application/epub+zip") return "epub" + if (mimeType == "application/pdf") return "pdf" + if (mimeType == "application/x-mobipocket-ebook") return "mobi" + if (mimeType == "application/vnd.comicbook+zip") return "cbz" + if (mimeType == "application/vnd.comicbook-rar") return "cbr" + if (mimeType == "application/vnd.amazon.mobi8-ebook") return "azw3" + return null + } +} + +@JsonIgnoreProperties(ignoreUnknown = true) +data class LocalFolder( + var id: String, + var name: String, + var contentUrl: String, + var basePath: String, + var absolutePath: String, + var storageType: String, + var mediaType: String +) + +@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION) +@JsonSubTypes(JsonSubTypes.Type(LibraryItem::class), JsonSubTypes.Type(LocalLibraryItem::class)) +open class LibraryItemWrapper(var id: String) { + @JsonIgnore + open fun getMediaDescription( + progress: MediaProgressWrapper?, + ctx: Context + ): MediaDescriptionCompat { + return MediaDescriptionCompat.Builder().build() + } +} + +@JsonIgnoreProperties(ignoreUnknown = true) +data class DeviceInfo( + var deviceId: String, + var manufacturer: String, + var model: String, + var sdkVersion: Int, + var clientVersion: String +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class PlayItemRequestPayload( + var mediaPlayer: String, + var forceDirectPlay: Boolean, + var forceTranscode: Boolean, + var deviceInfo: DeviceInfo +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class DeviceSettings( + var disableAutoRewind: Boolean, + var enableAltView: Boolean, + var allowSeekingOnMediaControls: Boolean, + var jumpBackwardsTime: Int, + var jumpForwardTime: Int, + var enableMp3IndexSeeking: Boolean, + var disableShakeToResetSleepTimer: Boolean, + var shakeSensitivity: ShakeSensitivitySetting, + var lockOrientation: LockOrientationSetting, + var hapticFeedback: HapticFeedbackSetting, + var autoSleepTimer: Boolean, + var autoSleepTimerStartTime: String, + var autoSleepTimerEndTime: String, + var autoSleepTimerAutoRewind: Boolean, + var autoSleepTimerAutoRewindTime: Long, // Time in milliseconds + var sleepTimerLength: Long, // Time in milliseconds + var disableSleepTimerFadeOut: Boolean, + var disableSleepTimerResetFeedback: Boolean, + var enableSleepTimerAlmostDoneChime: Boolean, + var languageCode: String, + var downloadUsingCellular: DownloadUsingCellularSetting, + var streamingUsingCellular: StreamingUsingCellularSetting, + var androidAutoBrowseLimitForGrouping: Int, + var androidAutoBrowseSeriesSequenceOrder: AndroidAutoBrowseSeriesSequenceOrderSetting +) { + companion object { + // Static method to get default device settings + fun default(): DeviceSettings { + return DeviceSettings( + disableAutoRewind = false, + enableAltView = true, + allowSeekingOnMediaControls = false, + jumpBackwardsTime = 10, + jumpForwardTime = 10, + enableMp3IndexSeeking = false, + disableShakeToResetSleepTimer = false, + shakeSensitivity = ShakeSensitivitySetting.MEDIUM, + lockOrientation = LockOrientationSetting.NONE, + hapticFeedback = HapticFeedbackSetting.LIGHT, + autoSleepTimer = false, + autoSleepTimerStartTime = "22:00", + autoSleepTimerEndTime = "06:00", + sleepTimerLength = 900000L, // 15 minutes + autoSleepTimerAutoRewind = false, + autoSleepTimerAutoRewindTime = 300000L, // 5 minutes + disableSleepTimerFadeOut = false, + disableSleepTimerResetFeedback = false, + enableSleepTimerAlmostDoneChime = false, + languageCode = "en-us", + downloadUsingCellular = DownloadUsingCellularSetting.ALWAYS, + streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS, + androidAutoBrowseLimitForGrouping = 100, + androidAutoBrowseSeriesSequenceOrder = AndroidAutoBrowseSeriesSequenceOrderSetting.ASC + ) + } + } + + @get:JsonIgnore + val jumpBackwardsTimeMs + get() = jumpBackwardsTime * 1000L + @get:JsonIgnore + val jumpForwardTimeMs + get() = jumpForwardTime * 1000L + @get:JsonIgnore + val autoSleepTimerStartHour + get() = autoSleepTimerStartTime.split(":")[0].toInt() + @get:JsonIgnore + val autoSleepTimerStartMinute + get() = autoSleepTimerStartTime.split(":")[1].toInt() + @get:JsonIgnore + val autoSleepTimerEndHour + get() = autoSleepTimerEndTime.split(":")[0].toInt() + @get:JsonIgnore + val autoSleepTimerEndMinute + get() = autoSleepTimerEndTime.split(":")[1].toInt() + + @JsonIgnore + fun getShakeThresholdGravity(): Float { // Used in ShakeDetector + return if (shakeSensitivity == ShakeSensitivitySetting.VERY_HIGH) 1.1f + else if (shakeSensitivity == ShakeSensitivitySetting.HIGH) 1.3f + else if (shakeSensitivity == ShakeSensitivitySetting.MEDIUM) 1.5f + else if (shakeSensitivity == ShakeSensitivitySetting.LOW) 2f + else if (shakeSensitivity == ShakeSensitivitySetting.VERY_LOW) 2.7f + else { + Log.e("DeviceSetting", "Invalid ShakeSensitivitySetting $shakeSensitivity") + 1.6f + } + } +} + +data class DeviceData( + var serverConnectionConfigs: MutableList, + var lastServerConnectionConfigId: String?, + var deviceSettings: DeviceSettings?, + var lastPlaybackSession: PlaybackSession? +) { + @JsonIgnore + fun getLastServerConnectionConfig(): ServerConnectionConfig? { + return lastServerConnectionConfigId?.let { lsccid -> + return serverConnectionConfigs.find { it.id == lsccid } + } + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/EBookFile.kt b/android/app/src/main/java/com/audiobookshelf/app/data/EBookFile.kt new file mode 100644 index 0000000..88bdd8b --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/EBookFile.kt @@ -0,0 +1,13 @@ +package com.audiobookshelf.app.data + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +@JsonIgnoreProperties(ignoreUnknown = true) +data class EBookFile( + var ino:String, + var metadata:FileMetadata?, + var ebookFormat:String, + var isLocal:Boolean, + var localFileId:String?, + var contentUrl:String? +) diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/ItemInProgress.kt b/android/app/src/main/java/com/audiobookshelf/app/data/ItemInProgress.kt new file mode 100644 index 0000000..77d4c70 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/ItemInProgress.kt @@ -0,0 +1,32 @@ +/* + 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.databind.ObjectMapper +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, jacksonMapper: ObjectMapper):ItemInProgress { + val libraryItem = jacksonMapper.readValue(serverItem.toString()) + + var episode:PodcastEpisode? = null + if (serverItem.has("recentEpisode")) { + episode = jacksonMapper.readValue(serverItem.get("recentEpisode").toString()) + } + + val progressLastUpdate:Long = serverItem.getLong("progressLastUpdate") + return ItemInProgress(libraryItem, episode, progressLastUpdate, false) + } + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LibraryAuthorItem.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryAuthorItem.kt new file mode 100644 index 0000000..97ce555 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryAuthorItem.kt @@ -0,0 +1,63 @@ +package com.audiobookshelf.app.data + +import android.content.Context +import android.net.Uri +import android.os.Bundle +import android.support.v4.media.MediaDescriptionCompat +import androidx.media.utils.MediaConstants +import com.audiobookshelf.app.BuildConfig +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 LibraryAuthorItem( + id:String, + var libraryId:String, + var name:String, + var description:String?, + var imagePath:String?, + var addedAt:Long, + var updatedAt:Long, + var numBooks:Int?, + var libraryItems:MutableList?, + var series:MutableList? +) : LibraryItemWrapper(id) { + @get:JsonIgnore + val title get() = name + + @get:JsonIgnore + val bookCount get() = if (numBooks != null) numBooks else libraryItems!!.size + + @JsonIgnore + fun getPortraitUri(): Uri { + if (imagePath == null) { + return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.md_account_outline) + } + + return Uri.parse("${DeviceManager.serverAddress}/api/authors/$id/image?token=${DeviceManager.token}") + } + + @JsonIgnore + fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context, groupTitle: String?): MediaDescriptionCompat { + val extras = Bundle() + if (groupTitle !== null) { + extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, groupTitle) + } + + val mediaId = "__LIBRARY__${libraryId}__AUTHOR__${id}" + return MediaDescriptionCompat.Builder() + .setMediaId(mediaId) + .setTitle(title) + .setIconUri(getPortraitUri()) + .setSubtitle("${bookCount} books") + .setExtras(extras) + .build() + } + + @JsonIgnore + override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat { + return getMediaDescription(progress, ctx, null) + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LibraryCategory.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryCategory.kt new file mode 100644 index 0000000..2ab2bc9 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryCategory.kt @@ -0,0 +1,12 @@ +package com.audiobookshelf.app.data + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +@JsonIgnoreProperties(ignoreUnknown = true) +data class LibraryCategory( + var id:String, + var label:String, + var type:String, + var entities:List, + var isLocal:Boolean +) diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LibraryCollection.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryCollection.kt new file mode 100644 index 0000000..e43985b --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryCollection.kt @@ -0,0 +1,40 @@ +package com.audiobookshelf.app.data + +import android.content.Context +import android.os.Bundle +import android.support.v4.media.MediaDescriptionCompat +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +@JsonIgnoreProperties(ignoreUnknown = true) +class LibraryCollection( + id:String, + var libraryId:String, + var name:String, + //var userId:String?, + var description:String?, + var books:MutableList?, +) : LibraryItemWrapper(id) { + @get:JsonIgnore + val title get() = name + + @get:JsonIgnore + val bookCount get() = if (books != null) books!!.size else 0 + + @get:JsonIgnore + val audiobookCount get() = books?.filter { book -> (book.media as Book).getAudioTracks().isNotEmpty() }?.size ?: 0 + + @JsonIgnore + override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat { + val extras = Bundle() + + val mediaId = "__LIBRARY__${libraryId}__COLLECTION__${id}" + return MediaDescriptionCompat.Builder() + .setMediaId(mediaId) + .setTitle(title) + //.setIconUri(getCoverUri()) + .setSubtitle("${bookCount} books") + .setExtras(extras) + .build() + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt new file mode 100644 index 0000000..0b08f1a --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt @@ -0,0 +1,178 @@ +package com.audiobookshelf.app.data + +import android.content.Context +import android.net.Uri +import android.os.Bundle +import android.support.v4.media.MediaDescriptionCompat +import androidx.media.utils.MediaConstants +import com.audiobookshelf.app.BuildConfig +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?, + var userMediaProgress:MediaProgress?, // Only included when requesting library item with progress (for downloads) + var collapsedSeries: CollapsedSeries?, + var localLibraryItemId:String?, // For Android Auto + val recentEpisode: PodcastEpisode? // Podcast episode shelf uses this +) : LibraryItemWrapper(id) { + @get:JsonIgnore + val title: String + get() { + if (collapsedSeries != null) { + return collapsedSeries!!.title + } + return media.metadata.title + } + @get:JsonIgnore + val authorName get() = media.metadata.getAuthorDisplayName() + + @JsonIgnore + fun getCoverUri(): Uri { + if (media.coverPath == null) { + return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon) + } + + // As of v2.17.0 token is not needed with cover image requests + if (DeviceManager.isServerVersionGreaterThanOrEqualTo("2.17.0")) { + return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover") + } + + return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}") + } + + @JsonIgnore + fun checkHasTracks():Boolean { + return media.checkHasTracks() + } + + @get:JsonIgnore + val seriesSequence: String + get() { + if (mediaType != "podcast") { + return ((media as Book).metadata as BookMetadata).series?.get(0)?.sequence.orEmpty() + } else { + return "" + } + } + + @get:JsonIgnore + val seriesSequenceParts: List + get() { + if (seriesSequence.isEmpty()) { + return listOf("") + } + return seriesSequence.split(".", limit = 2) + } + + @JsonIgnore + fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context, authorId: String?, showSeriesNumber: Boolean?, groupTitle: String?): MediaDescriptionCompat { + val extras = Bundle() + + if (collapsedSeries == null) { + if (localLibraryItemId != null) { + extras.putLong( + MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS, + MediaDescriptionCompat.STATUS_DOWNLOADED + ) + } + + 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 + ) + } + + if (media.metadata.explicit) { + extras.putLong( + MediaConstants.METADATA_KEY_IS_EXPLICIT, + MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT + ) + } + } + if (groupTitle !== null) { + extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, groupTitle) + } + + val mediaId = if (localLibraryItemId != null) { + localLibraryItemId + } else if (collapsedSeries != null) { + if (authorId != null) { + "__LIBRARY__${libraryId}__AUTHOR_SERIES__${authorId}__${collapsedSeries!!.id}" + } else { + "__LIBRARY__${libraryId}__SERIES__${collapsedSeries!!.id}" + } + } else { + id + } + var subtitle = authorName + if (collapsedSeries != null) { + subtitle = "${collapsedSeries!!.numBooks} books" + } + var itemTitle = title + if (showSeriesNumber == true && seriesSequence != "") { + itemTitle = "$seriesSequence. $itemTitle" + } + return MediaDescriptionCompat.Builder() + .setMediaId(mediaId) + .setTitle(itemTitle) + .setIconUri(getCoverUri()) + .setSubtitle(subtitle) + .setExtras(extras) + .build() + } + + @JsonIgnore + fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context, authorId: String?, showSeriesNumber: Boolean?): MediaDescriptionCompat { + return getMediaDescription(progress, ctx, authorId, showSeriesNumber, null) + } + + @JsonIgnore + fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context, authorId: String?): MediaDescriptionCompat { + return getMediaDescription(progress, ctx, authorId, null, null) + } + + @JsonIgnore + override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat { + /* + This is needed so Android auto library hierarchy for author series can be implemented + */ + return getMediaDescription(progress, ctx, null, null, null) + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LibrarySeriesItem.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LibrarySeriesItem.kt new file mode 100644 index 0000000..47ff50c --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LibrarySeriesItem.kt @@ -0,0 +1,60 @@ +package com.audiobookshelf.app.data + +import android.content.Context +import android.os.Bundle +import android.support.v4.media.MediaDescriptionCompat +import androidx.media.utils.MediaConstants +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +@JsonIgnoreProperties(ignoreUnknown = true) +class LibrarySeriesItem( + id:String, + var libraryId:String, + var name:String, + var description:String?, + var addedAt:Long, + var updatedAt:Long, + var books:MutableList?, + var localLibraryItemId:String? // For Android Auto +) : LibraryItemWrapper(id) { + @get:JsonIgnore + val title get() = name + + @get:JsonIgnore + val audiobookCount: Int + get() { + if (books == null) return 0 + val booksWithAudio = books?.filter { b -> b.media.checkHasTracks() } + return booksWithAudio?.size ?: 0 + } + + @JsonIgnore + fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context, groupTitle: String?): MediaDescriptionCompat { + val extras = Bundle() + + if (localLibraryItemId != null) { + extras.putLong( + MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS, + MediaDescriptionCompat.STATUS_DOWNLOADED + ) + } + if (groupTitle !== null) { + extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, groupTitle) + } + + val mediaId = "__LIBRARY__${libraryId}__SERIES__${id}" + return MediaDescriptionCompat.Builder() + .setMediaId(mediaId) + .setTitle(title) + //.setIconUri(getCoverUri()) + .setSubtitle("$audiobookCount books") + .setExtras(extras) + .build() + } + + @JsonIgnore + override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat { + return getMediaDescription(progress, ctx, null) + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt new file mode 100644 index 0000000..d1c513c --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt @@ -0,0 +1,197 @@ +package com.audiobookshelf.app.data + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.ImageDecoder +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.provider.MediaStore +import android.support.v4.media.MediaDescriptionCompat +import android.util.Log +import androidx.core.content.FileProvider +import androidx.core.net.toFile +import androidx.media.utils.MediaConstants +import com.audiobookshelf.app.BuildConfig +import com.audiobookshelf.app.R +import com.audiobookshelf.app.device.DeviceManager +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.audiobookshelf.app.player.PLAYMETHOD_LOCAL +import java.io.File +import java.util.* + +@JsonIgnoreProperties(ignoreUnknown = true) +class LocalLibraryItem( + id:String, + var folderId:String, + var basePath:String, + var absolutePath:String, + var contentUrl:String, + var isInvalid:Boolean, + var mediaType:String, + var media:MediaType, + var localFiles:MutableList, + var coverContentUrl:String?, + var coverAbsolutePath:String?, + var isLocal:Boolean, + // If local library item is linked to a server item + var serverConnectionConfigId:String?, + var serverAddress:String?, + var serverUserId:String?, + var libraryItemId:String? + ) : LibraryItemWrapper(id) { + @get:JsonIgnore + val title get() = media.metadata.title + @get:JsonIgnore + val authorName get() = media.metadata.getAuthorDisplayName() + @get:JsonIgnore + val isPodcast get() = mediaType == "podcast" + + @JsonIgnore + fun getCoverUri(ctx:Context): Uri { + if (coverContentUrl?.startsWith("file:") == true) { + return FileProvider.getUriForFile(ctx, "${BuildConfig.APPLICATION_ID}.fileprovider", Uri.parse(coverContentUrl).toFile()) + } + return if (coverContentUrl != null) Uri.parse(coverContentUrl) else Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon) + } + + @JsonIgnore + fun getDuration():Double { + var total = 0.0 + val audioTracks = media.getAudioTracks() + audioTracks.forEach{ total += it.duration } + return total + } + + @JsonIgnore + fun updateFromScan(audioTracks:MutableList, _localFiles:MutableList) { + localFiles = _localFiles + media.setAudioTracks(audioTracks) + + if (coverContentUrl != null) { + if (localFiles.find { it.contentUrl == coverContentUrl } == null) { + // Cover was removed + coverContentUrl = null + coverAbsolutePath = null + media.coverPath = null + } + } + } + + @JsonIgnore + fun hasTracks(ctx: Context, episode:PodcastEpisode?): Boolean { + var audioTracks = media.getAudioTracks() as MutableList + if (episode != null) { // Get podcast episode audio track + episode.audioTrack?.let { at -> mutableListOf(at) }?.let { tracks -> audioTracks = tracks } + } + if (audioTracks.size == 0) return false + audioTracks.forEach { + // Check that metadata is not null + if (it.metadata === null) { + return false + } + if (!trackExists(ctx, it.contentUrl, it.metadata!!.path)) { + return false + } + } + return true + } + + @JsonIgnore + private fun trackExists(ctx: Context, contentUrl: String?, path: String): Boolean { + return LocalFile("", null, contentUrl ?: "", "", path, null, 0).exists(ctx) + } + + @JsonIgnore + fun getPlaybackSession(episode:PodcastEpisode?, deviceInfo:DeviceInfo):PlaybackSession { + val localEpisodeId = episode?.id + val sessionId = "${UUID.randomUUID()}" + + // Get current progress for local media + val mediaProgressId = if (localEpisodeId.isNullOrEmpty()) id else "$id-$localEpisodeId" + val mediaProgress = DeviceManager.dbManager.getLocalMediaProgress(mediaProgressId) + val currentTime = if (mediaProgress?.isFinished == true) 0.0 else mediaProgress?.currentTime ?: 0.0 + + + val mediaMetadata = media.metadata + var chapters = if (mediaType == "book") (media as Book).chapters else mutableListOf() + var audioTracks = media.getAudioTracks() as MutableList + val authorName = mediaMetadata.getAuthorDisplayName() + val displayTitle = episode?.title ?: mediaMetadata.title + var duration = getDuration() + if (episode != null) { // Get podcast episode audio track + episode.audioTrack?.let { at -> mutableListOf(at) }?.let { tracks -> audioTracks = tracks } + chapters = episode.chapters + duration = episode.audioTrack?.duration ?: 0.0 + Log.d("LocalLibraryItem", "getPlaybackSession: Got podcast episode audio track ${audioTracks.size}") + } + + val dateNow = System.currentTimeMillis() + return PlaybackSession(sessionId,serverUserId,libraryItemId,episode?.serverEpisodeId, mediaType, mediaMetadata, deviceInfo,chapters ?: mutableListOf(), displayTitle, authorName,null,duration,PLAYMETHOD_LOCAL,dateNow,0L,0L, audioTracks,currentTime,null,this,localEpisodeId,serverConnectionConfigId, serverAddress, "exo-player") + } + + @JsonIgnore + fun removeLocalFile(localFileId:String) { + localFiles.removeIf { it.id == localFileId } + } + + @JsonIgnore + override fun getMediaDescription(progress:MediaProgressWrapper?, ctx:Context): MediaDescriptionCompat { + val coverUri = getCoverUri(ctx) + + var bitmap:Bitmap? = null + if (coverContentUrl != null) { + bitmap = if (Build.VERSION.SDK_INT < 28) { + MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri) + } else { + val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, coverUri) + ImageDecoder.decodeBitmap(source) + } + } + + val extras = Bundle() + extras.putLong( + MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS, + MediaDescriptionCompat.STATUS_DOWNLOADED + ) + 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 + ) + } + + if (media.metadata.explicit) { + extras.putLong(MediaConstants.METADATA_KEY_IS_EXPLICIT, MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT) + } + + val mediaDescriptionBuilder = MediaDescriptionCompat.Builder() + .setMediaId(id) + .setTitle(title) + .setIconUri(coverUri) + .setSubtitle(authorName) + .setExtras(extras) + + bitmap?.let { + mediaDescriptionBuilder.setIconBitmap(bitmap) + } + + return mediaDescriptionBuilder.build() + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaItem.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaItem.kt new file mode 100644 index 0000000..11673e8 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaItem.kt @@ -0,0 +1,67 @@ +package com.audiobookshelf.app.data + +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +/* + Used as a helper class to generate LocalLibraryItem from scan results +*/ + +@JsonIgnoreProperties(ignoreUnknown = true) +data class LocalMediaItem( + var id: String, + var name: String, + var mediaType: String, + var folderId: String, + var contentUrl: String, + var basePath: String, + var absolutePath: String, + var audioTracks: MutableList, + var ebookFile: EBookFile?, + var localFiles: MutableList, + var coverContentUrl: String?, + var coverAbsolutePath: String? +) { + + @JsonIgnore + fun getDuration(): Double { + var total = 0.0 + audioTracks.forEach { total += it.duration } + return total + } + + @JsonIgnore + fun getTotalSize(): Long { + var total = 0L + localFiles.forEach { total += it.size } + return total + } + + @JsonIgnore + fun getMediaMetadata(): MediaTypeMetadata { + return if (mediaType == "book") { + BookMetadata( + name, + null, + mutableListOf(), + mutableListOf(), + mutableListOf(), + null, + null, + null, + null, + null, + null, + null, + false, + null, + null, + null, + null, + null + ) + } else { + PodcastMetadata(name, null, null, mutableListOf(), false) + } + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaProgress.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaProgress.kt new file mode 100644 index 0000000..be86355 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaProgress.kt @@ -0,0 +1,86 @@ +package com.audiobookshelf.app.data + +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import kotlin.math.roundToInt + +@JsonIgnoreProperties(ignoreUnknown = true) +class LocalMediaProgress( + var id:String, + var localLibraryItemId:String, + var localEpisodeId:String?, + var duration:Double, + progress:Double, // 0 to 1 + currentTime:Double, + isFinished:Boolean, + var ebookLocation:String?, // cfi tag + var ebookProgress:Double?, // 0 to 1 + var lastUpdate:Long, + var startedAt:Long, + var finishedAt:Long?, + // For local lib items from server to support server sync + var serverConnectionConfigId:String?, + var serverAddress:String?, + var serverUserId:String?, + var libraryItemId:String?, + var episodeId:String? +) : MediaProgressWrapper(isFinished, currentTime, progress) { + @get:JsonIgnore + val progressPercent get() = if (progress.isNaN()) 0 else (progress * 100).roundToInt() + @get:JsonIgnore + override val mediaItemId get() = if (libraryItemId != null) { + if (episodeId.isNullOrEmpty()) libraryItemId ?: "" else "$libraryItemId-$episodeId" + } else { + if (localEpisodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId" + } + + @JsonIgnore + fun isMatch(mediaProgress:MediaProgress):Boolean { + if (episodeId != null) return libraryItemId == mediaProgress.libraryItemId && episodeId == mediaProgress.episodeId + return libraryItemId == mediaProgress.libraryItemId + } + + @JsonIgnore + fun updateIsFinished(finished:Boolean) { + if (isFinished != finished) { // If finished changed then set progress + progress = if (finished) 1.0 else 0.0 + } + + if (finished) { + currentTime = 0.0 + } + + isFinished = finished + lastUpdate = System.currentTimeMillis() + finishedAt = if (isFinished) lastUpdate else null + } + + @JsonIgnore + fun updateFromPlaybackSession(playbackSession:PlaybackSession) { + currentTime = playbackSession.currentTime + progress = playbackSession.progress + lastUpdate = playbackSession.updatedAt + isFinished = playbackSession.progress >= 0.99 + finishedAt = if (isFinished) lastUpdate else null + } + + @JsonIgnore + fun updateEbookProgress(ebookLocation:String, ebookProgress:Double) { + lastUpdate = System.currentTimeMillis() + this.ebookProgress = ebookProgress + this.ebookLocation = ebookLocation + } + + @JsonIgnore + fun updateFromServerMediaProgress(serverMediaProgress:MediaProgress) { + isFinished = serverMediaProgress.isFinished + progress = serverMediaProgress.progress + currentTime = serverMediaProgress.currentTime + ebookProgress = serverMediaProgress.ebookProgress + ebookLocation = serverMediaProgress.ebookLocation + duration = serverMediaProgress.duration + lastUpdate = serverMediaProgress.lastUpdate + finishedAt = serverMediaProgress.finishedAt + startedAt = serverMediaProgress.startedAt + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/MediaItemEvent.kt b/android/app/src/main/java/com/audiobookshelf/app/data/MediaItemEvent.kt new file mode 100644 index 0000000..a5be2f9 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/MediaItemEvent.kt @@ -0,0 +1,15 @@ +package com.audiobookshelf.app.data + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +@JsonIgnoreProperties(ignoreUnknown = true) +class MediaItemEvent( + var name:String, // e.g. Play/Pause/Stop/Seek/Save + var type:String, // Playback/Info + var description:String?, + var currentTime:Number?, // Seconds + var serverSyncAttempted:Boolean?, + var serverSyncSuccess:Boolean?, + var serverSyncMessage:String?, + var timestamp: Long +) diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/MediaItemHistory.kt b/android/app/src/main/java/com/audiobookshelf/app/data/MediaItemHistory.kt new file mode 100644 index 0000000..7b1d094 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/MediaItemHistory.kt @@ -0,0 +1,17 @@ +package com.audiobookshelf.app.data + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +@JsonIgnoreProperties(ignoreUnknown = true) +class MediaItemHistory( + var id: String, // media id + var mediaDisplayTitle: String, + var libraryItemId: String, + var episodeId: String?, + var isLocal: Boolean, + var serverConnectionConfigId: String?, + var serverAddress: String?, + var serverUserId: String?, + var createdAt: Long, + var events: MutableList, +) diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/MediaProgress.kt b/android/app/src/main/java/com/audiobookshelf/app/data/MediaProgress.kt new file mode 100644 index 0000000..2f256b8 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/MediaProgress.kt @@ -0,0 +1,24 @@ +package com.audiobookshelf.app.data + +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +@JsonIgnoreProperties(ignoreUnknown = true) +class MediaProgress( + var id:String, + var libraryItemId:String, + var episodeId:String?, + var duration:Double, // seconds + progress:Double, // 0 to 1 + currentTime:Double, + isFinished:Boolean, + var ebookLocation:String?, // cfi tag + var ebookProgress:Double?, // 0 to 1 + var lastUpdate:Long, + var startedAt:Long, + var finishedAt:Long? +) : MediaProgressWrapper(isFinished, currentTime, progress) { + + @get:JsonIgnore + override val mediaItemId get() = if (episodeId.isNullOrEmpty()) libraryItemId else "$libraryItemId-$episodeId" +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackMetadata.kt b/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackMetadata.kt new file mode 100644 index 0000000..33cfa9d --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackMetadata.kt @@ -0,0 +1,11 @@ +package com.audiobookshelf.app.data + +enum class PlayerState { + IDLE, BUFFERING, READY, ENDED +} + +data class PlaybackMetadata( + val duration:Double, + val currentTime:Double, + val playerState:PlayerState +) diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt b/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt new file mode 100644 index 0000000..6caba55 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt @@ -0,0 +1,449 @@ +package com.audiobookshelf.app.data + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.ImageDecoder +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import android.support.v4.media.MediaMetadataCompat +import androidx.core.content.FileProvider +import androidx.core.net.toFile +import com.audiobookshelf.app.BuildConfig +import com.audiobookshelf.app.R +import com.audiobookshelf.app.device.DeviceManager +import com.audiobookshelf.app.media.MediaProgressSyncData +import com.audiobookshelf.app.player.* +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.MediaMetadata +import com.google.android.gms.cast.MediaInfo +import com.google.android.gms.cast.MediaQueueItem +import com.google.android.gms.common.images.WebImage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +@JsonIgnoreProperties(ignoreUnknown = true) +class PlaybackSession( + var id: String, + var userId: String?, + var libraryItemId: String?, + var episodeId: String?, + var mediaType: String, + var mediaMetadata: MediaTypeMetadata, + var deviceInfo: DeviceInfo, + var chapters: List, + var displayTitle: String?, + var displayAuthor: String?, + var coverPath: String?, + var duration: Double, + var playMethod: Int, + var startedAt: Long, + var updatedAt: Long, + var timeListening: Long, + var audioTracks: MutableList, + var currentTime: Double, + var libraryItem: LibraryItem?, + var localLibraryItem: LocalLibraryItem?, + var localEpisodeId: String?, + var serverConnectionConfigId: String?, + var serverAddress: String?, + var mediaPlayer: String? +) { + + @get:JsonIgnore + val isHLS + get() = playMethod == PLAYMETHOD_TRANSCODE + @get:JsonIgnore + val isDirectPlay + get() = playMethod == PLAYMETHOD_DIRECTPLAY + @get:JsonIgnore + val isLocal + get() = playMethod == PLAYMETHOD_LOCAL + @get:JsonIgnore + val isPodcastEpisode + get() = mediaType == "podcast" + @get:JsonIgnore + val currentTimeMs + get() = (currentTime * 1000L).toLong() + @get:JsonIgnore + val totalDurationMs + get() = (getTotalDuration() * 1000L).toLong() + @get:JsonIgnore + val localLibraryItemId + get() = localLibraryItem?.id ?: "" + @get:JsonIgnore + val localMediaProgressId + get() = + if (localEpisodeId.isNullOrEmpty()) localLibraryItemId + else "$localLibraryItemId-$localEpisodeId" + @get:JsonIgnore + val progress + get() = currentTime / getTotalDuration() + @get:JsonIgnore + val mediaItemId + get() = if (episodeId.isNullOrEmpty()) libraryItemId ?: "" else "$libraryItemId-$episodeId" + + @JsonIgnore + fun getCurrentTrackIndex(): Int { + for (i in 0 until audioTracks.size) { + val track = audioTracks[i] + if (currentTimeMs >= track.startOffsetMs && (track.endOffsetMs > currentTimeMs)) { + return i + } + } + return audioTracks.size - 1 + } + + @JsonIgnore + fun getNextTrackIndex(): Int { + for (i in 0 until audioTracks.size) { + val track = audioTracks[i] + if (currentTimeMs < track.startOffsetMs) { + return i + } + } + return audioTracks.size - 1 + } + + @JsonIgnore + fun getChapterForTime(time: Long): BookChapter? { + if (chapters.isEmpty()) return null + return chapters.find { time >= it.startMs && it.endMs > time } + } + + @JsonIgnore + fun getCurrentTrackEndTime(): Long { + val currentTrack = audioTracks[this.getCurrentTrackIndex()] + return currentTrack.startOffsetMs + currentTrack.durationMs + } + + @JsonIgnore + fun getNextChapterForTime(time: Long): BookChapter? { + if (chapters.isEmpty()) return null + return chapters.find { time < it.startMs } // First chapter where start time is > then time + } + + @JsonIgnore + fun getNextTrackEndTime(): Long { + val currentTrack = audioTracks[this.getNextTrackIndex()] + return currentTrack.startOffsetMs + currentTrack.durationMs + } + + @JsonIgnore + fun getCurrentTrackTimeMs(): Long { + val currentTrack = audioTracks[this.getCurrentTrackIndex()] + val time = currentTime - currentTrack.startOffset + return (time * 1000L).toLong() + } + + @JsonIgnore + fun getTrackStartOffsetMs(index: Int): Long { + if (index < 0 || index >= audioTracks.size) return 0L + val currentTrack = audioTracks[index] + return (currentTrack.startOffset * 1000L).toLong() + } + + @JsonIgnore + fun getTotalDuration(): Double { + var total = 0.0 + audioTracks.forEach { total += it.duration } + return total + } + + @JsonIgnore + fun checkIsServerVersionGte(compareVersion: String): Boolean { + // Safety check this playback session is the same one currently connected (should always be) + if (DeviceManager.serverConnectionConfigId != serverConnectionConfigId) { + return false + } + + return DeviceManager.isServerVersionGreaterThanOrEqualTo(compareVersion) + } + + @JsonIgnore + fun getCoverUri(ctx: Context): Uri { + if (localLibraryItem?.coverContentUrl != null) { + var coverUri = Uri.parse(localLibraryItem?.coverContentUrl.toString()) + if (coverUri.toString().startsWith("file:")) { + coverUri = + FileProvider.getUriForFile( + ctx, + "${BuildConfig.APPLICATION_ID}.fileprovider", + coverUri.toFile() + ) + } + + return coverUri + ?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon) + } + + if (coverPath == null) + return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon) + + // As of v2.17.0 token is not needed with cover image requests + if (checkIsServerVersionGte("2.17.0")) { + return Uri.parse("$serverAddress/api/items/$libraryItemId/cover") + } + return Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}") + } + + @JsonIgnore + fun getContentUri(audioTrack: AudioTrack): Uri { + if (isLocal) return Uri.parse(audioTrack.contentUrl) // Local content url + // As of v2.22.0 tracks use a different endpoint + // See: https://github.com/advplyr/audiobookshelf/pull/4263 + if (checkIsServerVersionGte("2.22.0")) { + return if (isDirectPlay) { + Uri.parse("$serverAddress/public/session/$id/track/${audioTrack.index}") + } else { + // Transcode uses HlsRouter on server + Uri.parse("$serverAddress${audioTrack.contentUrl}") + } + } + return Uri.parse("$serverAddress${audioTrack.contentUrl}?token=${DeviceManager.token}") + } + + /** Bitmap for the cover art, once resolved */ + @JsonIgnore + private var resolvedCoverBitmap: Bitmap? = null + + /** + * Builds the current session metadata, including the cover art bitmap if it has already been resolved. + */ + @JsonIgnore + fun getMediaMetadataCompat(ctx: Context): MediaMetadataCompat { + val coverUri = getCoverUri(ctx) + + val metadataBuilder = + MediaMetadataCompat.Builder() + .putString(MediaMetadataCompat.METADATA_KEY_TITLE, displayTitle) + .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle) + .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, displayAuthor) + .putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, displayAuthor) + .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, displayAuthor) + .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, displayAuthor) + .putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, displayAuthor) + .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, displayAuthor) + .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id) + .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, totalDurationMs) + .putString( + MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, + coverUri.toString() + ) + + if (resolvedCoverBitmap != null) { + metadataBuilder + .putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, resolvedCoverBitmap) + .putBitmap(MediaMetadataCompat.METADATA_KEY_ART, resolvedCoverBitmap) + } else { + metadataBuilder + .putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString()) + .putString(MediaMetadataCompat.METADATA_KEY_ART_URI, coverUri.toString()) + } + + return metadataBuilder.build() + } + + /** + * Resolves the cover art bitmap (local covers are decoded synchronously, server-side covers + * are fetched asynchronously) and calls `onArtResolved` once it's available + * + * Returns the Job for the async fetch, or `null` if the bitmap was resolved synchronously. + */ + @JsonIgnore + fun resolveCoverBitmapAsync( + ctx: Context, + coroutineScope: CoroutineScope, + onArtResolved: () -> Unit + ): Job? { + val coverUri = getCoverUri(ctx) + + // Local covers get bitmap synchronously, no async fetch needed + if (localLibraryItem?.coverContentUrl != null) { + resolvedCoverBitmap = + if (Build.VERSION.SDK_INT < 28) { + MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri) + } else { + val source: ImageDecoder.Source = + ImageDecoder.createSource(ctx.contentResolver, coverUri) + ImageDecoder.decodeBitmap(source) + } + onArtResolved() + return null + } + + // Server-side cover: resolve the art bitmap async + return coroutineScope.launch { + val bitmap = resolveUriAsBitmap(ctx, coverUri) + bitmap?.let { + resolvedCoverBitmap = it + onArtResolved() + } + } + } + + @JsonIgnore + fun getExoMediaMetadata(ctx: Context): MediaMetadata { + val coverUri = getCoverUri(ctx) + + val metadataBuilder = + MediaMetadata.Builder() + .setTitle(displayTitle) + .setDisplayTitle(displayTitle) + .setArtist(displayAuthor) + .setAlbumArtist(displayAuthor) + .setSubtitle(displayAuthor) + .setAlbumTitle(displayAuthor) + .setDescription(displayAuthor) + .setArtworkUri(coverUri) + .setMediaType(MediaMetadata.MEDIA_TYPE_AUDIO_BOOK) + + return metadataBuilder.build() + } + + @JsonIgnore + fun getMediaItems(ctx: Context): List { + val mediaItems: MutableList = mutableListOf() + + for (audioTrack in audioTracks) { + val mediaMetadata = this.getExoMediaMetadata(ctx) + val mediaUri = this.getContentUri(audioTrack) + val mimeType = audioTrack.mimeType + + val queueItem = getQueueItem(audioTrack) // Queue item used in exo player CastManager + val mediaItem = + MediaItem.Builder() + .setUri(mediaUri) + .setTag(queueItem) + .setMediaMetadata(mediaMetadata) + .setMimeType(mimeType) + .build() + mediaItems.add(mediaItem) + } + return mediaItems + } + + @JsonIgnore + fun getCastMediaMetadata(audioTrack: AudioTrack): com.google.android.gms.cast.MediaMetadata { + val castMetadata = + com.google.android.gms.cast.MediaMetadata( + com.google.android.gms.cast.MediaMetadata.MEDIA_TYPE_AUDIOBOOK_CHAPTER + ) + + // As of v2.17.0 token is not needed with cover image requests + val coverUri = if (checkIsServerVersionGte("2.17.0")) { + Uri.parse("$serverAddress/api/items/$libraryItemId/cover") + } else { + Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}") + } + + // Cast always uses server cover uri + coverPath?.let { + castMetadata.addImage(WebImage(coverUri)) + } + + castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_TITLE, displayTitle ?: "") + castMetadata.putString( + com.google.android.gms.cast.MediaMetadata.KEY_ARTIST, + displayAuthor ?: "" + ) + castMetadata.putString( + com.google.android.gms.cast.MediaMetadata.KEY_ALBUM_TITLE, + displayAuthor ?: "" + ) + castMetadata.putString( + com.google.android.gms.cast.MediaMetadata.KEY_CHAPTER_TITLE, + audioTrack.title + ) + + castMetadata.putInt( + com.google.android.gms.cast.MediaMetadata.KEY_TRACK_NUMBER, + audioTrack.index + ) + return castMetadata + } + + @JsonIgnore + fun getQueueItem(audioTrack: AudioTrack): MediaQueueItem { + val castMetadata = getCastMediaMetadata(audioTrack) + + val mediaUri = getContentUri(audioTrack) + + val mediaInfo = + MediaInfo.Builder(mediaUri.toString()) + .apply { + setContentUrl(mediaUri.toString()) + setContentType(audioTrack.mimeType ?: "") + setMetadata(castMetadata) + setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) + } + .build() + + return MediaQueueItem.Builder(mediaInfo) + .apply { setPlaybackDuration(audioTrack.duration) } + .build() + } + + @JsonIgnore + fun clone(): PlaybackSession { + return PlaybackSession( + id, + userId, + libraryItemId, + episodeId, + mediaType, + mediaMetadata, + deviceInfo, + chapters, + displayTitle, + displayAuthor, + coverPath, + duration, + playMethod, + startedAt, + updatedAt, + timeListening, + audioTracks, + currentTime, + libraryItem, + localLibraryItem, + localEpisodeId, + serverConnectionConfigId, + serverAddress, + mediaPlayer + ) + } + + @JsonIgnore + fun syncData(syncData: MediaProgressSyncData) { + timeListening += syncData.timeListened + updatedAt = System.currentTimeMillis() + currentTime = syncData.currentTime + } + + @JsonIgnore + fun getNewLocalMediaProgress(): LocalMediaProgress { + return LocalMediaProgress( + localMediaProgressId, + localLibraryItemId, + localEpisodeId, + getTotalDuration(), + progress, + currentTime, + false, + null, + null, + updatedAt, + startedAt, + null, + serverConnectionConfigId, + serverAddress, + userId, + libraryItemId, + episodeId + ) + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt b/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt new file mode 100644 index 0000000..de931ca --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt @@ -0,0 +1,228 @@ +package com.audiobookshelf.app.device + +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.util.Log +import com.audiobookshelf.app.MediaPlayerWidget +import com.audiobookshelf.app.data.* +import com.audiobookshelf.app.managers.DbManager +import com.audiobookshelf.app.player.PlayerNotificationService +import com.audiobookshelf.app.updateAppWidget + +/** Interface for widget event handling. */ +interface WidgetEventEmitter { + /** + * Called when the player state changes. + * @param pns The PlayerNotificationService instance. + */ + fun onPlayerChanged(pns: PlayerNotificationService) + + /** Called when the player is closed. */ + fun onPlayerClosed() +} + +/** Singleton object for managing device-related operations. */ +object DeviceManager { + const val tag = "DeviceManager" + + val dbManager: DbManager = DbManager() + var deviceData: DeviceData = dbManager.getDeviceData() + var serverConnectionConfig: ServerConnectionConfig? = null + + val serverConnectionConfigId get() = serverConnectionConfig?.id ?: "" + val serverConnectionConfigName get() = serverConnectionConfig?.name ?: "" + val serverConnectionConfigString get() = serverConnectionConfig?.name ?: "No server connection" + val serverAddress + get() = serverConnectionConfig?.address ?: "" + val serverUserId + get() = serverConnectionConfig?.userId ?: "" + val token + get() = serverConnectionConfig?.token ?: "" + val serverVersion get() = serverConnectionConfig?.version ?: "" + val isConnectedToServer + get() = serverConnectionConfig != null + + var widgetUpdater: WidgetEventEmitter? = null + + init { + Log.d(tag, "Device Manager Singleton invoked") + + // Initialize new sleep timer settings and shake sensitivity added in v0.9.61 + if (deviceData.deviceSettings?.autoSleepTimerStartTime == null || + deviceData.deviceSettings?.autoSleepTimerEndTime == null + ) { + deviceData.deviceSettings?.autoSleepTimerStartTime = "22:00" + deviceData.deviceSettings?.autoSleepTimerEndTime = "06:00" + deviceData.deviceSettings?.sleepTimerLength = 900000L + } + if (deviceData.deviceSettings?.shakeSensitivity == null) { + deviceData.deviceSettings?.shakeSensitivity = ShakeSensitivitySetting.MEDIUM + } + // Initialize auto sleep timer auto rewind added in v0.9.64 + if (deviceData.deviceSettings?.autoSleepTimerAutoRewindTime == null) { + deviceData.deviceSettings?.autoSleepTimerAutoRewindTime = 300000L // 5 minutes + } + // Initialize sleep timer almost done chime added in v0.9.81 + if (deviceData.deviceSettings?.enableSleepTimerAlmostDoneChime == null) { + deviceData.deviceSettings?.enableSleepTimerAlmostDoneChime = false + } + + // Language added in v0.9.69 + if (deviceData.deviceSettings?.languageCode == null) { + deviceData.deviceSettings?.languageCode = "en-us" + } + + if (deviceData.deviceSettings?.downloadUsingCellular == null) { + deviceData.deviceSettings?.downloadUsingCellular = DownloadUsingCellularSetting.ALWAYS + } + + if (deviceData.deviceSettings?.streamingUsingCellular == null) { + deviceData.deviceSettings?.streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS + } + if (deviceData.deviceSettings?.androidAutoBrowseLimitForGrouping == null) { + deviceData.deviceSettings?.androidAutoBrowseLimitForGrouping = 100 + } + if (deviceData.deviceSettings?.androidAutoBrowseSeriesSequenceOrder == null) { + deviceData.deviceSettings?.androidAutoBrowseSeriesSequenceOrder = + AndroidAutoBrowseSeriesSequenceOrderSetting.ASC + } + } + + /** + * Encodes the given ID to a Base64 string. + * @param id The ID to encode. + * @return The Base64 encoded string. + */ + fun getBase64Id(id: String): String { + return android.util.Base64.encodeToString( + id.toByteArray(), + android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP + ) + } + + /** + * Retrieves the server connection configuration for the given ID. + * @param id The ID of the server connection configuration. + * @return The ServerConnectionConfig instance or null if not found. + */ + fun getServerConnectionConfig(id: String?): ServerConnectionConfig? { + return id?.let { deviceData.serverConnectionConfigs.find { it.id == id } } + } + + /** + * Check if the currently connected server version is >= compareVersion + * Abs server only uses major.minor.patch + * Note: Version is returned in Abs auth payloads starting v2.6.0 + * Note: Version is saved with the server connection config starting after v0.9.81 + * + * @example + * serverVersion=2.25.1 + * isServerVersionGreaterThanOrEqualTo("2.26.0") = false + * + * serverVersion=2.26.1 + * isServerVersionGreaterThanOrEqualTo("2.26.0") = true + */ + fun isServerVersionGreaterThanOrEqualTo(compareVersion:String):Boolean { + if (serverVersion == "") return false + if (compareVersion == "") return true + + val serverVersionParts = serverVersion.split(".").map { it.toIntOrNull() ?: 0 } + val compareVersionParts = compareVersion.split(".").map { it.toIntOrNull() ?: 0 } + + // Compare major, minor, and patch components + for (i in 0 until maxOf(serverVersionParts.size, compareVersionParts.size)) { + val serverVersionComponent = serverVersionParts.getOrElse(i) { 0 } + val compareVersionComponent = compareVersionParts.getOrElse(i) { 0 } + + if (serverVersionComponent < compareVersionComponent) { + return false // Server version is less than compareVersion + } else if (serverVersionComponent > compareVersionComponent) { + return true // Server version is greater than compareVersion + } + } + + return true // versions are equal in major, minor, and patch + } + + /** + * Checks the network connectivity status. + * @param ctx The context to use for checking connectivity. + * @return True if connected to the internet, false otherwise. + */ + fun checkConnectivity(ctx: Context): Boolean { + val connectivityManager = + ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val capabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork) + if (capabilities != null) { + if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) { + Log.i("Internet", "NetworkCapabilities.TRANSPORT_CELLULAR") + return true + } else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) { + Log.i("Internet", "NetworkCapabilities.TRANSPORT_WIFI") + return true + } else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) { + Log.i("Internet", "NetworkCapabilities.TRANSPORT_ETHERNET") + return true + } + } + return false + } + + /** + * Sets the last playback session. + * @param playbackSession The playback session to set. + */ + fun setLastPlaybackSession(playbackSession: PlaybackSession) { + deviceData.lastPlaybackSession = playbackSession + dbManager.saveDeviceData(deviceData) + } + + /** + * Initializes the widget updater. + * @param context The context to use for initializing the widget updater. + */ + fun initializeWidgetUpdater(context: Context) { + Log.d(tag, "Initializing widget updater") + widgetUpdater = + (object : WidgetEventEmitter { + override fun onPlayerChanged(pns: PlayerNotificationService) { + val isPlaying = pns.currentPlayer.isPlaying + + val appWidgetManager = AppWidgetManager.getInstance(context) + val componentName = ComponentName(context, MediaPlayerWidget::class.java) + val ids = appWidgetManager.getAppWidgetIds(componentName) + val playbackSession = pns.getCurrentPlaybackSessionCopy() + + for (widgetId in ids) { + updateAppWidget( + context, + appWidgetManager, + widgetId, + playbackSession, + isPlaying, + PlayerNotificationService.isClosed + ) + } + } + + override fun onPlayerClosed() { + val appWidgetManager = AppWidgetManager.getInstance(context) + val componentName = ComponentName(context, MediaPlayerWidget::class.java) + val ids = appWidgetManager.getAppWidgetIds(componentName) + for (widgetId in ids) { + updateAppWidget( + context, + appWidgetManager, + widgetId, + deviceData.lastPlaybackSession, + false, + PlayerNotificationService.isClosed + ) + } + } + }) + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/device/FolderScanner.kt b/android/app/src/main/java/com/audiobookshelf/app/device/FolderScanner.kt new file mode 100644 index 0000000..9051149 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/device/FolderScanner.kt @@ -0,0 +1,312 @@ +package com.audiobookshelf.app.device + +import android.content.Context +import android.net.Uri +import android.util.Log +import androidx.documentfile.provider.DocumentFile +import com.anggrayudi.storage.file.* +import com.audiobookshelf.app.data.* +import com.audiobookshelf.app.models.DownloadItem +import com.audiobookshelf.app.models.DownloadItemPart +import java.io.File + +/** Creates local-library records from a completed download manifest. */ +class FolderScanner(private val ctx: Context) { + private val tag = "FolderScanner" + + data class DownloadItemScanResult( + val localLibraryItem: LocalLibraryItem, + var localMediaProgress: LocalMediaProgress? + ) + + private fun localLibraryItemId(mediaItemId: String) = + "local_${DeviceManager.getBase64Id(mediaItemId)}" + + private fun createLocalFile( + part: DownloadItemPart, + externalFile: DocumentFile? = null + ): LocalFile? { + if (part.isInternalStorage) { + val file = File(part.finalDestinationPath) + if (!file.exists()) return null + return LocalFile( + DeviceManager.getBase64Id(file.name), + file.name, + Uri.fromFile(file).toString(), + file.getBasePath(ctx), + file.absolutePath, + file.mimeType, + file.length() + ) + } + + part.completedDestinationUri?.let { contentUrl -> + val uri = Uri.parse(contentUrl) + val size = + try { + ctx.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor -> + descriptor.statSize.coerceAtLeast(0L) + } + ?: 0L + } catch (e: Exception) { + Log.e(tag, "Could not open completed SAF file: $contentUrl", e) + return null + } + // Android 10 DownloadsProvider may not reconstruct a DocumentFile for an audio URI even + // though the URI remains readable. Keep the URI as the authoritative local-file location. + return LocalFile( + DeviceManager.getBase64Id(contentUrl), + part.filename, + contentUrl, + part.localFolderName, + part.finalDestinationPath, + mimeTypeFor(part), + size + ) + } + + // Do not reconstruct a DocumentFile from an absolute path: on Android 10 that becomes a + // file:// URI, which DocumentsContract rejects. The caller resolves this from the persisted + // SAF tree grant instead. + val document = externalFile + if (document == null || !document.exists()) { + Log.e(tag, "Could not resolve downloaded SAF file: ${part.finalDestinationPath}") + return null + } + return LocalFile( + DeviceManager.getBase64Id(document.id), + document.name, + document.uri.toString(), + document.getBasePath(ctx), + document.getAbsolutePath(ctx), + document.mimeType, + document.length() + ) + } + + private fun newLocalLibraryItem( + id: String, + downloadItem: DownloadItem, + basePath: String, + absolutePath: String, + contentUrl: String + ) = + LocalLibraryItem( + id, + downloadItem.localFolder.id, + basePath, + absolutePath, + contentUrl, + false, + downloadItem.mediaType, + downloadItem.media.getLocalCopy(), + mutableListOf(), + null, + null, + true, + downloadItem.serverConnectionConfigId, + downloadItem.serverAddress, + downloadItem.serverUserId, + downloadItem.libraryItemId + ) + + private fun scanParts( + item: DownloadItem, + localItem: LocalLibraryItem, + externalFolder: DocumentFile? = null, + callback: (DownloadItemScanResult?) -> Unit + ) { + val tracks = mutableListOf() + var foundEbook = false + var localEpisodeId: String? = null + + item.downloadItemParts.forEach { part -> + val externalFile = + if (part.isInternalStorage) { + null + } else { + part.completedDestinationUri?.let { DocumentFileCompat.fromUri(ctx, Uri.parse(it)) } + ?: resolveExternalFile(externalFolder, part) + } + Log.d(tag, "Resolve part ${part.filename}: externalFile=${externalFile?.uri}") + val localFile = createLocalFile(part, externalFile) ?: return@forEach + when { + part.audioTrack != null -> { + val serverTrack = part.audioTrack + localItem.localFiles.removeAll { it.id == localFile.id } + localItem.localFiles.add(localFile) + val metadata = + FileMetadata( + localFile.filename ?: "", + File(localFile.filename ?: "").extension, + localFile.absolutePath, + localFile.basePath, + localFile.size + ) + val track = + AudioTrack( + serverTrack.index, + serverTrack.startOffset, + serverTrack.duration, + localFile.filename ?: "", + localFile.contentUrl, + localFile.mimeType ?: "", + metadata, + true, + localFile.id, + serverTrack.index + ) + tracks.add(track) + Log.d(tag, "Added local audio track ${track.contentUrl} (${track.metadata?.path})") + part.episode?.let { episode -> + val podcast = localItem.media as Podcast + localEpisodeId = podcast.addEpisode(track, episode).id + } + } + part.ebookFile != null -> { + foundEbook = true + localItem.localFiles.removeAll { it.id == localFile.id } + localItem.localFiles.add(localFile) + (localItem.media as Book).ebookFile = + EBookFile( + part.ebookFile.ino, + part.ebookFile.metadata, + part.ebookFile.ebookFormat, + true, + localFile.id, + localFile.contentUrl + ) + } + else -> { + localItem.coverAbsolutePath = localFile.absolutePath + localItem.coverContentUrl = localFile.contentUrl + localItem.localFiles.removeAll { it.id == localFile.id } + localItem.localFiles.add(localFile) + } + } + } + + if (tracks.isEmpty() && !foundEbook) { + callback(null) + return + } + if (item.mediaType == "book") { + tracks.sortBy { it.index } + var expectedIndex = 1 + var offset = 0.0 + tracks.forEach { track -> + track.index = expectedIndex++ + track.startOffset = offset + offset += track.duration + } + localItem.media.setAudioTracks(tracks) + } + + val result = DownloadItemScanResult(localItem, null) + item.userMediaProgress?.let { progress -> + val progressId = + if (item.episodeId.isNullOrEmpty()) localItem.id + else "${localItem.id}-$localEpisodeId" + result.localMediaProgress = + LocalMediaProgress( + progressId, + localItem.id, + localEpisodeId, + progress.duration, + progress.progress, + progress.currentTime, + progress.isFinished, + progress.ebookLocation, + progress.ebookProgress, + progress.lastUpdate, + progress.startedAt, + progress.finishedAt, + item.serverConnectionConfigId, + item.serverAddress, + item.serverUserId, + item.libraryItemId, + item.episodeId + ) + DeviceManager.dbManager.saveLocalMediaProgress(result.localMediaProgress!!) + } + DeviceManager.dbManager.saveLocalLibraryItem(localItem) + callback(result) + } + + private fun findFolderByPath(root: DocumentFile, subPath: String): DocumentFile? { + if (subPath.isBlank()) return root + var current = root + subPath.split('/').filter { it.isNotBlank() }.forEach { segment -> + if (segment == "." || segment == "..") return null + current = current.findFile(segment) ?: return null + } + return current + } + + /** + * DownloadsProvider on Android 10 may expose an audio document without its extension through + * DocumentFile.findFile(). Match the manifest first, then match the provider-normalized base + * filename. MIME type and server-reported size are unreliable for Opus on this platform. + */ + private fun resolveExternalFile(folder: DocumentFile?, part: DownloadItemPart): DocumentFile? { + if (folder == null) return null + folder.findFile(part.filename)?.let { + return it + } + val expectedBaseName = part.filename.substringBeforeLast('.') + return folder.listFiles().firstOrNull { document -> + document.name == part.filename || + document.fullName == part.filename || + (part.audioTrack != null && + document.isFile && + (document.name ?: "").substringBeforeLast('.') == expectedBaseName) || + (part.audioTrack != null && + document.isFile && + document.fullName.substringBeforeLast('.') == expectedBaseName) + } + } + + private fun mimeTypeFor(part: DownloadItemPart): String? { + return part.audioTrack?.mimeType + ?: when (part.ebookFile?.ebookFormat?.lowercase()) { + "epub" -> "application/epub+zip" + "pdf" -> "application/pdf" + else -> "image/jpeg" + } + } + + fun scanDownloadItem(item: DownloadItem, callback: (DownloadItemScanResult?) -> Unit) { + if (item.isInternalStorage) { + val id = "local_${item.libraryItemId}" + val localItem = + DeviceManager.dbManager.getLocalLibraryItem(id) + ?: newLocalLibraryItem(id, item, item.itemFolderPath, item.itemFolderPath, "") + scanParts(item, localItem, callback = callback) + return + } + + val root = DocumentFileCompat.fromUri(ctx, Uri.parse(item.localFolder.contentUrl)) + if (root == null) { + Log.e(tag, "Invalid SAF root: ${item.localFolder.contentUrl}") + callback(null) + return + } + val itemFolder = findFolderByPath(root, item.itemSubfolder) + if (itemFolder == null) { + Log.e(tag, "SAF item folder not found: ${item.itemSubfolder}") + callback(null) + return + } + val id = localLibraryItemId(itemFolder.id) + val localItem = + DeviceManager.dbManager.getLocalLibraryItem(id) + ?: newLocalLibraryItem( + id, + item, + itemFolder.getBasePath(ctx), + itemFolder.getAbsolutePath(ctx), + itemFolder.uri.toString() + ) + scanParts(item, localItem, itemFolder, callback) + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/DbManager.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/DbManager.kt new file mode 100644 index 0000000..63c97a7 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/DbManager.kt @@ -0,0 +1,325 @@ +package com.audiobookshelf.app.managers + +import android.content.Context +import android.util.Log +import com.audiobookshelf.app.data.* +import com.audiobookshelf.app.models.DownloadItem +import com.audiobookshelf.app.plugins.AbsLog +import com.audiobookshelf.app.plugins.AbsLogger +import io.paperdb.Paper + +class DbManager { + val tag = "DbManager" + + companion object { + private var isDbInitialized = false + + fun initialize(ctx: Context) { + if (isDbInitialized) return + Paper.init(ctx) + isDbInitialized = true + Log.i("DbManager", "Initialized Paper db") + } + } + + fun getDeviceData(): DeviceData { + return Paper.book("device").read("data") + ?: DeviceData(mutableListOf(), null, DeviceSettings.default(), null) + } + fun saveDeviceData(deviceData: DeviceData) { + Paper.book("device").write("data", deviceData) + } + + fun getLocalLibraryItems(mediaType: String? = null): MutableList { + val localLibraryItems: MutableList = mutableListOf() + Paper.book("localLibraryItems").allKeys.forEach { + val localLibraryItem: LocalLibraryItem? = Paper.book("localLibraryItems").read(it) + if (localLibraryItem != null && + (mediaType.isNullOrEmpty() || mediaType == localLibraryItem.mediaType) + ) { + localLibraryItems.add(localLibraryItem) + } + } + return localLibraryItems + } + + fun getLocalLibraryItemsInFolder(folderId: String): List { + val localLibraryItems = getLocalLibraryItems() + return localLibraryItems.filter { it.folderId == folderId } + } + + fun getLocalLibraryItemByLId(libraryItemId: String): LocalLibraryItem? { + return getLocalLibraryItems().find { it.libraryItemId == libraryItemId } + } + + fun getLocalLibraryItem(localLibraryItemId: String): LocalLibraryItem? { + return Paper.book("localLibraryItems").read(localLibraryItemId) + } + + fun getLocalLibraryItemWithEpisode(podcastEpisodeId: String): LibraryItemWithEpisode? { + var podcastEpisode: PodcastEpisode? = null + val localLibraryItem = + getLocalLibraryItems("podcast").find { localLibraryItem -> + val podcast = localLibraryItem.media as Podcast + podcastEpisode = podcast.episodes?.find { it.id == podcastEpisodeId } + podcastEpisode != null + } + return if (localLibraryItem != null) { + LibraryItemWithEpisode(localLibraryItem, podcastEpisode!!) + } else { + null + } + } + + fun removeLocalLibraryItem(localLibraryItemId: String) { + Paper.book("localLibraryItems").delete(localLibraryItemId) + } + + fun saveLocalLibraryItems(localLibraryItems: List) { + localLibraryItems.map { Paper.book("localLibraryItems").write(it.id, it) } + } + + fun saveLocalLibraryItem(localLibraryItem: LocalLibraryItem) { + Paper.book("localLibraryItems").write(localLibraryItem.id, localLibraryItem) + } + + fun saveLocalFolder(localFolder: LocalFolder) { + Paper.book("localFolders").write(localFolder.id, localFolder) + } + + fun getLocalFolder(folderId: String): LocalFolder? { + return Paper.book("localFolders").read(folderId) + } + + fun getAllLocalFolders(): List { + val localFolders: MutableList = mutableListOf() + Paper.book("localFolders").allKeys.forEach { localFolderId -> + Paper.book("localFolders").read(localFolderId)?.let { localFolders.add(it) } + } + return localFolders + } + + fun removeLocalFolder(folderId: String) { + val localLibraryItems = getLocalLibraryItemsInFolder(folderId) + localLibraryItems.forEach { Paper.book("localLibraryItems").delete(it.id) } + Paper.book("localFolders").delete(folderId) + } + + fun saveDownloadItem(downloadItem: DownloadItem) { + Paper.book("downloadItems").write(downloadItem.id, downloadItem) + } + + fun removeDownloadItem(downloadItemId: String) { + Paper.book("downloadItems").delete(downloadItemId) + } + + fun getDownloadItems(): List { + val downloadItems: MutableList = mutableListOf() + Paper.book("downloadItems").allKeys.forEach { downloadItemId -> + Paper.book("downloadItems").read(downloadItemId)?.let { downloadItems.add(it) } + } + return downloadItems + } + + fun saveLocalMediaProgress(mediaProgress: LocalMediaProgress) { + Paper.book("localMediaProgress").write(mediaProgress.id, mediaProgress) + } + // For books this will just be the localLibraryItemId for podcast episodes this will be + // "{localLibraryItemId}-{episodeId}" + fun getLocalMediaProgress(localMediaProgressId: String): LocalMediaProgress? { + return Paper.book("localMediaProgress").read(localMediaProgressId) + } + fun getAllLocalMediaProgress(): List { + val mediaProgress: MutableList = mutableListOf() + Paper.book("localMediaProgress").allKeys.forEach { localMediaProgressId -> + Paper.book("localMediaProgress").read(localMediaProgressId)?.let { + mediaProgress.add(it) + } + } + return mediaProgress + } + fun removeLocalMediaProgress(localMediaProgressId: String) { + Paper.book("localMediaProgress").delete(localMediaProgressId) + } + + fun removeAllLocalMediaProgress() { + Paper.book("localMediaProgress").destroy() + } + + // Make sure all local file ids still exist + fun cleanLocalLibraryItems(context: Context) { + val localLibraryItems = getLocalLibraryItems() + + localLibraryItems.forEach { lli -> + var hasUpdates = false + + // Check local files + lli.localFiles = + lli.localFiles.filter { localFile -> + val exists = localFile.exists(context) + if (!exists) { + Log.d( + tag, + "cleanLocalLibraryItems: Local file ${localFile.absolutePath} was removed from library item ${lli.media.metadata.title}" + ) + hasUpdates = true + } + exists + } as + MutableList + + // Check audio tracks and episodes + if (lli.isPodcast) { + val podcast = lli.media as Podcast + podcast.episodes = + podcast.episodes?.filter { ep -> + if (lli.localFiles.find { lf -> lf.id == ep.audioTrack?.localFileId } == null) { + Log.d( + tag, + "cleanLocalLibraryItems: Podcast episode ${ep.title} was removed from library item ${lli.media.metadata.title}" + ) + hasUpdates = true + } + ep.audioTrack != null && + lli.localFiles.find { lf -> lf.id == ep.audioTrack?.localFileId } != null + } as + MutableList + } else { + val book = lli.media as Book + book.tracks = + book.tracks?.filter { track -> + if (lli.localFiles.find { lf -> lf.id == track.localFileId } == null) { + Log.d( + tag, + "cleanLocalLibraryItems: Audio track ${track.title} was removed from library item ${lli.media.metadata.title}" + ) + hasUpdates = true + } + lli.localFiles.find { lf -> lf.id == track.localFileId } != null + } as + MutableList + } + + // Check cover still there + lli.coverAbsolutePath?.let { + val coverExists = + lli.localFiles.any { localFile -> + localFile.absolutePath == it && localFile.exists(context) + } + if (!coverExists) { + Log.d( + tag, + "cleanLocalLibraryItems: Cover $it was removed from library item ${lli.media.metadata.title}" + ) + lli.coverAbsolutePath = null + lli.coverContentUrl = null + hasUpdates = true + } + } + + if (lli.serverConnectionConfigId == null) { + // Local-only item support was removed in app version 0.9.67, remove any remaining local + // only items beginning in 0.9.80 + Log.d(tag, "cleanLocalLibraryItems: Local only item ${lli.id} - removing from ABS") + Paper.book("localLibraryItems").delete(lli.id) + } else if (hasUpdates) { + Log.d(tag, "cleanLocalLibraryItems: Saving local library item ${lli.id}") + Paper.book("localLibraryItems").write(lli.id, lli) + } + } + } + + // Remove any local media progress where the local media item is not found + fun cleanLocalMediaProgress() { + val localMediaProgress = getAllLocalMediaProgress() + val localLibraryItems = getLocalLibraryItems() + localMediaProgress.forEach { + val matchingLLI = localLibraryItems.find { lli -> lli.id == it.localLibraryItemId } + if (!it.id.startsWith("local")) { + // A bug on the server when syncing local media progress was replacing the media progress id + // causing duplicate progress. Remove them. + Log.d( + tag, + "cleanLocalMediaProgress: Invalid local media progress does not start with 'local' (fixed on server 2.0.24)" + ) + Paper.book("localMediaProgress").delete(it.id) + } else if (matchingLLI == null) { + Log.d( + tag, + "cleanLocalMediaProgress: No matching local library item for local media progress ${it.id} - removing" + ) + Paper.book("localMediaProgress").delete(it.id) + } else if (matchingLLI.isPodcast) { + if (it.localEpisodeId.isNullOrEmpty()) { + Log.d(tag, "cleanLocalMediaProgress: Podcast media progress has no episode id - removing") + Paper.book("localMediaProgress").delete(it.id) + } else { + val podcast = matchingLLI.media as Podcast + val matchingLEp = podcast.episodes?.find { ep -> ep.id == it.localEpisodeId } + if (matchingLEp == null) { + Log.d( + tag, + "cleanLocalMediaProgress: Podcast media progress for episode ${it.localEpisodeId} not found - removing" + ) + Paper.book("localMediaProgress").delete(it.id) + } + } + } + } + } + + fun saveMediaItemHistory(mediaItemHistory: MediaItemHistory) { + Paper.book("mediaItemHistory").write(mediaItemHistory.id, mediaItemHistory) + } + fun getMediaItemHistory(id: String): MediaItemHistory? { + return Paper.book("mediaItemHistory").read(id) + } + + fun savePlaybackSession(playbackSession: PlaybackSession) { + Paper.book("playbackSession").write(playbackSession.id, playbackSession) + } + fun removePlaybackSession(playbackSessionId: String) { + Paper.book("playbackSession").delete(playbackSessionId) + } + fun getPlaybackSessions(): List { + val sessions: MutableList = mutableListOf() + Paper.book("playbackSession").allKeys.forEach { playbackSessionId -> + Paper.book("playbackSession").read(playbackSessionId)?.let { + sessions.add(it) + } + } + return sessions + } + + fun saveLog(log: AbsLog) { + Paper.book("log").write(log.id, log) + } + fun getAllLogs(): List { + val logs: MutableList = mutableListOf() + Paper.book("log").allKeys.forEach { logId -> + Paper.book("log").read(logId)?.let { logs.add(it) } + } + return logs.sortedBy { it.timestamp } + } + fun removeAllLogs() { + Paper.book("log").destroy() + } + fun cleanLogs() { + val numberOfHoursToKeep = 48 + val keepLogCutoff = System.currentTimeMillis() - (3600000 * numberOfHoursToKeep) + val allLogs = getAllLogs() + var logsRemoved = 0 + allLogs.forEach { + if (it.timestamp < keepLogCutoff) { + Paper.book("log").delete(it.id) + logsRemoved++ + } + } + if (logsRemoved > 0) { + AbsLogger.info( + "DbManager", + "cleanLogs: Removed $logsRemoved logs older than $numberOfHoursToKeep hours" + ) + } + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt new file mode 100644 index 0000000..7a25ddd --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt @@ -0,0 +1,794 @@ +package com.audiobookshelf.app.managers + +import android.content.Context +import android.net.Uri +import android.os.StatFs +import androidx.documentfile.provider.DocumentFile +import com.anggrayudi.storage.file.fullName +import com.audiobookshelf.app.device.DeviceManager +import com.audiobookshelf.app.device.FolderScanner +import com.audiobookshelf.app.models.DownloadItem +import com.audiobookshelf.app.models.DownloadItemPart +import com.audiobookshelf.app.plugins.AbsLogger +import com.audiobookshelf.app.server.ApiHandler +import com.fasterxml.jackson.core.json.JsonReadFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.getcapacitor.JSObject +import java.io.File +import java.io.FileInputStream +import java.util.concurrent.ConcurrentHashMap +import kotlin.math.max +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** Manages the process-owned queue for app-managed downloads. */ +class DownloadItemManager( + private val folderScanner: FolderScanner, + private val context: Context, + private var clientEventEmitter: DownloadEventEmitter +) { + private val tag = "DownloadItemManager" + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val activeCalls = ConcurrentHashMap() + private val safFolderLocks = ConcurrentHashMap() + private val scanLocks = ConcurrentHashMap() + private val reservations = mutableMapOf() + private val lastPersistTime = mutableMapOf() + private val finalizingItems = mutableSetOf() + private val refreshingServerIds = mutableSetOf() + private val apiHandler = ApiHandler(context) + private var watcherRunning = false + private val jacksonMapper = + jacksonObjectMapper() + .enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) + + var downloadItemQueue: MutableList = mutableListOf() + private set + var currentDownloadItemParts: MutableList = mutableListOf() + private set + + interface DownloadEventEmitter { + fun onDownloadItem(downloadItem: DownloadItem) + fun onDownloadItemPartUpdate(downloadItemPart: DownloadItemPart) + fun onDownloadItemComplete(jsobj: JSObject) + fun onQueueChanged(hasWork: Boolean) + } + + interface InternalProgressCallback { + fun onSizeResolved(totalBytes: Long) + fun onProgress(totalBytesWritten: Long, progress: Long) + fun onComplete(failed: Boolean) + fun onAuthError() + } + + @Synchronized + fun setEventEmitter(eventEmitter: DownloadEventEmitter) { + clientEventEmitter = eventEmitter + downloadItemQueue.forEach(clientEventEmitter::onDownloadItem) + notifyQueueChanged() + } + + @Synchronized + fun restoreQueue() { + if (downloadItemQueue.isNotEmpty()) return + DeviceManager.dbManager.getDownloadItems().forEach { item -> + item.downloadItemParts.filter { it.moved }.forEach { part -> + if (!finalizedFileExists(part)) { + AbsLogger.error(tag, "Finalized file is missing; resetting ${part.filename}") + part.moved = false + part.completed = false + part.completedDestinationUri = null + part.downloadId = null + part.reusedExistingFile = false + } + } + if (item.isDownloadFinished) { + downloadItemQueue.add(item) + checkDownloadItemFinished(item) + return@forEach + } + var resetFailed = false + item.downloadItemParts.forEach { part -> + if (part.moved) return@forEach + if (!resetPartForFreshDownload(part)) resetFailed = true + } + if (resetFailed) { + item.terminalFailureAt = item.terminalFailureAt ?: System.currentTimeMillis() + item.stagingCleanupAt = null + } + if (item.terminalFailureAt != null) { + item.downloadItemParts.filter { !it.moved }.forEach { it.failed = true } + } + downloadItemQueue.add(item) + if (item.terminalFailureAt != null) IncompleteDownloadCleanup.schedule(context, item) + clientEventEmitter.onDownloadItem(item) + } + notifyQueueChanged() + } + + @Synchronized + fun addDownloadItem(downloadItem: DownloadItem) { + val existingItem = downloadItemQueue.find { it.id == downloadItem.id } + if (existingItem != null) { + return + } + persist(downloadItem, force = true) + downloadItemQueue.add(downloadItem) + clientEventEmitter.onDownloadItem(downloadItem) + notifyQueueChanged() + } + + @Synchronized + fun retryDownloadItem(downloadItemId: String): Boolean { + val item = downloadItemQueue.find { it.id == downloadItemId } ?: return false + if (item.downloadItemParts.any { it in currentDownloadItemParts }) return false + if (item.isDownloadFinished) return false + synchronized(IncompleteDownloadCleanup) { + var resetFailed = false + item.downloadItemParts.filter { !it.moved }.forEach { part -> + if (!resetPartForFreshDownload(part)) resetFailed = true + } + if (resetFailed) { + item.downloadItemParts.filter { !it.moved }.forEach { it.failed = true } + persist(item, force = true) + return false + } + item.terminalFailureAt = null + item.stagingCleanupAt = null + IncompleteDownloadCleanup.cancel(context, item.id) + persist(item, force = true) + } + clientEventEmitter.onDownloadItem(item) + notifyQueueChanged() + return true + } + + @Synchronized + fun resumeWork() { + checkUpdateDownloadQueue() + notifyQueueChanged() + } + + @Synchronized + fun cancelAll() { + activeCalls.values.forEach(InternalDownloadManager.DownloadHandle::cancel) + activeCalls.clear() + downloadItemQueue.forEach { item -> + item.downloadItemParts.forEach { part -> + File(part.destinationPath).delete() + if (part.moved && !part.reusedExistingFile && part.isInternalStorage) { + File(part.finalDestinationPath).delete() + } else if (part.moved && !part.reusedExistingFile) { + part.completedDestinationUri?.let { uri -> + try { + DocumentFile.fromSingleUri(context, Uri.parse(uri))?.delete() + } catch (e: Exception) { + AbsLogger.error( + tag, + "Could not delete cancelled SAF file ${part.filename}: ${e.message}" + ) + } + } + } + } + IncompleteDownloadCleanup.cancel(context, item.id) + DeviceManager.dbManager.removeDownloadItem(item.id) + } + currentDownloadItemParts.clear() + reservations.clear() + downloadItemQueue.clear() + notifyQueueChanged() + } + + @Synchronized + fun hasWork(): Boolean = + finalizingItems.isNotEmpty() || + downloadItemQueue.any { item -> + item.downloadItemParts.any { part -> + (!part.moved && !part.failed) || part.isMoving + } + } + + @Synchronized + private fun checkUpdateDownloadQueue() { + downloadItemQueue.toList().forEach { item -> + var slots = MAX_SIMULTANEOUS_DOWNLOADS - currentDownloadItemParts.size + if (slots <= 0) return@forEach + item.downloadItemParts + .filter { part -> + part.completed && + !part.moved && + !part.failed && + !part.isMoving && + part !in currentDownloadItemParts && + File(part.destinationPath).exists() && + !hasActiveDestinationConflict(part) + } + .take(slots) + .forEach { part -> + currentDownloadItemParts.add(part) + part.downloadId = APP_MANAGED_DOWNLOAD_ID + } + slots = MAX_SIMULTANEOUS_DOWNLOADS - currentDownloadItemParts.size + if (slots <= 0) return@forEach + item.getNextDownloadItemParts(slots).forEach { part -> + val existingFile = findSharedStorageFile(part) + if (existingFile != null) { + part.bytesDownloaded = existingFile.length() + part.progress = 100L + part.completedDestinationUri = existingFile.uri.toString() + part.reusedExistingFile = true + File(part.destinationPath).delete() + completePart(item, part) + clientEventEmitter.onDownloadItemPartUpdate(part) + return@forEach + } + if (completeFromExistingInternalCover(item, part)) return@forEach + if (hasActiveDestinationConflict(part)) { + leaveQueued(item, part) + } else if (part.fileSize <= 0L && currentDownloadItemParts.any { it.fileSize <= 0L }) { + leaveQueued(item, part) + } else if (tryReserve(part)) startDownload(item, part) + else { + part.waitingForSpace = true + part.lastUpdateTime = System.currentTimeMillis() + persist(item) + clientEventEmitter.onDownloadItemPartUpdate(part) + } + } + } + if (hasWork()) startWatchingDownloads() else notifyQueueChanged() + } + + private fun startDownload(item: DownloadItem, part: DownloadItemPart) { + val stagingFile = File(part.destinationPath) + stagingFile.parentFile?.mkdirs() + part.downloadId = APP_MANAGED_DOWNLOAD_ID + part.waitingForSpace = false + part.lastUpdateTime = System.currentTimeMillis() + currentDownloadItemParts.add(part) + persist(item, force = true) + AbsLogger.info(tag, "Starting download for ${part.filename}") + val activeConfig = DeviceManager.serverConnectionConfig + val token = + if (activeConfig?.id == item.serverConnectionConfigId) activeConfig.token + else + DeviceManager.getServerConnectionConfig(item.serverConnectionConfigId)?.token + ?: DeviceManager.token + val handle = + InternalDownloadManager( + stagingFile, + part.fileSize, + object : InternalProgressCallback { + override fun onSizeResolved(totalBytes: Long) { + synchronized(this@DownloadItemManager) { + if (part !in currentDownloadItemParts || totalBytes < 0L) return + if (part.fileSize == totalBytes) return + AbsLogger.info( + tag, + "Using server size $totalBytes instead of metadata size ${part.fileSize} for ${part.filename}" + ) + part.fileSize = totalBytes + part.lastUpdateTime = System.currentTimeMillis() + persist(item, force = true) + clientEventEmitter.onDownloadItemPartUpdate(part) + } + } + + override fun onProgress(totalBytesWritten: Long, progress: Long) { + synchronized(this@DownloadItemManager) { + if (part !in currentDownloadItemParts) return + part.bytesDownloaded = totalBytesWritten + part.progress = progress + part.lastUpdateTime = System.currentTimeMillis() + persist(item) + } + } + + override fun onComplete(failed: Boolean) { + synchronized(this@DownloadItemManager) { + if (part !in currentDownloadItemParts) return + part.failed = failed + part.completed = !failed + part.lastUpdateTime = System.currentTimeMillis() + activeCalls.remove(part.id) + persist(item, force = true) + } + } + + override fun onAuthError() { + synchronized(this@DownloadItemManager) { + if (part !in currentDownloadItemParts) return + handleAuthError(item, part) + } + } + }, + { hasAvailableSpace(part) } + ) + .download(serverUrl(item, part), token) + if (part in currentDownloadItemParts && !part.completed && !part.failed) { + activeCalls[part.id] = handle + } + } + + @Synchronized + private fun startWatchingDownloads() { + if (watcherRunning) return + watcherRunning = true + scope.launch { + while (true) { + val activeParts = + synchronized(this@DownloadItemManager) { currentDownloadItemParts.toList() } + activeParts.forEach(::handlePartUpdate) + synchronized(this@DownloadItemManager) { + checkUpdateDownloadQueue() + if (!hasWork()) { + watcherRunning = false + notifyQueueChanged() + return@launch + } + } + delay(WATCH_INTERVAL_MS) + } + } + } + + private fun handlePartUpdate(part: DownloadItemPart) { + clientEventEmitter.onDownloadItemPartUpdate(part) + val item = + synchronized(this) { downloadItemQueue.find { it.id == part.downloadItemId } } + ?: run { + removeActivePart(part) + return + } + if (!part.completed && !part.failed) { + val lastUpdate = part.lastUpdateTime ?: return + if (System.currentTimeMillis() - lastUpdate > STALL_TIMEOUT_MS) { + AbsLogger.error(tag, "Download stalled: ${part.filename}") + activeCalls.remove(part.id)?.cancel() + failOrRetry(item, part, "Download stalled") + } + return + } + if (part.failed) { + failOrRetry(item, part, "Transfer failed") + return + } + if (part.isInternalStorage) finalizeInternalFile(item, part) else moveDownloadedFile(item, part) + } + + @Synchronized + private fun failOrRetry(item: DownloadItem, part: DownloadItemPart, reason: String) { + removeActivePart(part) + part.retryCount += 1 + reservations.remove(part.destinationPath) + if (part.retryCount > MAX_RETRIES) { + markTerminalFailure(item, part, "$reason after $MAX_RETRIES retries") + return + } + part.failed = false + part.completed = false + part.downloadId = null + part.isMoving = false + persist(item, force = true) + clientEventEmitter.onDownloadItemPartUpdate(part) + } + + /** A 401 refreshes the token for this queued item's server without consuming transfer retries. */ + @Synchronized + private fun handleAuthError(item: DownloadItem, part: DownloadItemPart) { + removeActivePart(part) + reservations.remove(part.destinationPath) + part.downloadId = null + part.isMoving = false + part.failed = false + part.completed = false + part.authRetryCount += 1 + part.lastUpdateTime = System.currentTimeMillis() + if (part.authRetryCount > MAX_AUTH_RETRIES) { + markTerminalFailure(item, part, "Unauthorized after $MAX_AUTH_RETRIES token refresh attempts") + return + } + + AbsLogger.info( + tag, + "Refreshing token after 401 for ${part.filename} (attempt ${part.authRetryCount})" + ) + persist(item, force = true) + clientEventEmitter.onDownloadItemPartUpdate(part) + refreshTokenThenResume(item.serverConnectionConfigId) + } + + private fun refreshTokenThenResume(serverConnectionConfigId: String) { + if (!refreshingServerIds.add(serverConnectionConfigId)) return + apiHandler.refreshAuthTokens(serverConnectionConfigId) { result -> + synchronized(this@DownloadItemManager) { + refreshingServerIds.remove(serverConnectionConfigId) + when (result) { + is ApiHandler.RefreshResult.Success -> { + AbsLogger.info( + tag, + "Token refresh succeeded; resuming downloads for $serverConnectionConfigId" + ) + checkUpdateDownloadQueue() + } + ApiHandler.RefreshResult.Rejected -> failParkedAuthParts(serverConnectionConfigId) + // Parked parts are still queued, so MAX_AUTH_RETRIES bounds the reattempts. + ApiHandler.RefreshResult.Transient -> { + AbsLogger.info( + tag, + "Token refresh could not be completed; retrying downloads for $serverConnectionConfigId" + ) + checkUpdateDownloadQueue() + } + } + } + } + } + + @Synchronized + private fun failParkedAuthParts(serverConnectionConfigId: String) { + downloadItemQueue.toList().forEach { item -> + if (item.serverConnectionConfigId != serverConnectionConfigId) return@forEach + item.downloadItemParts + .filter { + it.authRetryCount > 0 && + !it.completed && + !it.failed && + it.downloadId == null && + it !in currentDownloadItemParts + } + .forEach { part -> + markTerminalFailure(item, part, "Unable to refresh download authorization") + } + } + } + + @Synchronized + private fun markTerminalFailure(item: DownloadItem, part: DownloadItemPart, reason: String) { + AbsLogger.error(tag, "$reason: ${part.filename}") + removeActivePart(part) + reservations.remove(part.destinationPath) + part.failed = true + part.completed = false + part.downloadId = null + part.isMoving = false + item.terminalFailureAt = item.terminalFailureAt ?: System.currentTimeMillis() + item.stagingCleanupAt = null + persist(item, force = true) + IncompleteDownloadCleanup.schedule(context, item) + clientEventEmitter.onDownloadItemPartUpdate(part) + notifyQueueChanged() + } + + private fun finalizeInternalFile(item: DownloadItem, part: DownloadItemPart) { + if (part.moved || part.isMoving) return + part.isMoving = true + val stagingFile = File(part.destinationPath) + val finalFile = File(part.finalDestinationPath) + finalFile.parentFile?.mkdirs() + val backup = File(finalFile.parentFile, ".${finalFile.name}.abs-backup") + try { + if (backup.exists() && !backup.delete()) throw IllegalStateException("Could not clear backup") + if (finalFile.exists() && !finalFile.renameTo(backup)) + throw IllegalStateException("Could not protect existing file") + if (!stagingFile.renameTo(finalFile)) { + if (backup.exists()) backup.renameTo(finalFile) + throw IllegalStateException("Could not finalize internal staging file") + } + backup.delete() + AbsLogger.info(tag, "Move completed for ${part.filename} to ${finalFile.absolutePath}") + completePart(item, part) + } catch (e: Exception) { + part.isMoving = false + part.failed = true + failOrRetry(item, part, e.message ?: "Internal finalization failed") + } + } + + private fun moveDownloadedFile(item: DownloadItem, part: DownloadItemPart) { + if (part.moved || part.isMoving) return + val root = + DocumentFile.fromTreeUri(context, Uri.parse(part.localFolderUrl)) + ?: return failFinalization(item, part, "Could not resolve SAF destination") + part.isMoving = true + persist(item, force = true) + scope.launch { + try { + if (!hasAvailableSpace(part)) + throw IllegalStateException("Insufficient storage for SAF copy") + val folderKey = "${root.uri}/${part.finalDestinationSubfolder}" + val folderLock = safFolderLocks.computeIfAbsent(folderKey) { Any() } + val folder = + synchronized(folderLock) { getOrCreateFolder(root, part.finalDestinationSubfolder) } + ?: throw IllegalStateException("Could not create SAF destination folder") + val temporaryName = ".${part.filename}.${part.id.hashCode()}.part" + folder.findFile(temporaryName)?.delete() + val temporary = + folder.createFile(mimeTypeFor(part), temporaryName) + ?: throw IllegalStateException("Could not create SAF temporary file") + val staging = File(part.destinationPath) + FileInputStream(staging).use { input -> + context.contentResolver.openOutputStream(temporary.uri, "w")?.use { input.copyTo(it) } + ?: throw IllegalStateException("Could not open SAF output stream") + } + if (temporary.length() != staging.length()) + throw IllegalStateException("SAF copy size mismatch") + val existing = findDocumentByFilename(folder, part) + if (existing != null && !existing.delete()) + throw IllegalStateException("Could not replace existing file") + if (!temporary.renameTo(part.filename)) + throw IllegalStateException("Could not finalize SAF temporary file") + val destination = + findDocumentByFilename(folder, part) + ?: throw IllegalStateException("Could not reopen finalized SAF file") + if (destination.length() != staging.length()) + throw IllegalStateException("SAF final size mismatch") + if (!staging.delete()) AbsLogger.error(tag, "Could not remove staging file ${staging.name}") + part.completedDestinationUri = destination.uri.toString() + AbsLogger.info(tag, "Move completed for ${part.filename} to ${destination.uri}") + completePart(item, part) + } catch (e: Exception) { + failFinalization(item, part, "SAF copy failed: ${e.message}") + } + } + } + + @Synchronized + private fun failFinalization(item: DownloadItem, part: DownloadItemPart, message: String) { + AbsLogger.error(tag, message) + part.isMoving = false + part.failed = true + failOrRetry(item, part, message) + } + + @Synchronized + private fun completePart(item: DownloadItem, part: DownloadItemPart) { + part.moved = true + part.completed = true + part.failed = false + part.isMoving = false + reservations.remove(part.destinationPath) + removeActivePart(part) + persist(item, force = true) + checkDownloadItemFinished(item) + } + + @Synchronized + private fun checkDownloadItemFinished(item: DownloadItem) { + if (!item.isDownloadFinished || !finalizingItems.add(item.id)) return + IncompleteDownloadCleanup.cancel(context, item.id) + scope.launch { + try { + val scanLock = scanLocks.computeIfAbsent(scanDestinationKey(item)) { Any() } + synchronized(scanLock) { + folderScanner.scanDownloadItem(item) { scanResult -> + val event = + JSObject().apply { + put("libraryItemId", item.id) + put("localFolderId", item.localFolder.id) + scanResult?.localLibraryItem?.let { + put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(it))) + } + scanResult?.localMediaProgress?.let { + put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(it))) + } + } + clientEventEmitter.onDownloadItemComplete(event) + synchronized(this@DownloadItemManager) { + downloadItemQueue.remove(item) + DeviceManager.dbManager.removeDownloadItem(item.id) + } + } + } + } catch (e: Exception) { + // The files are already in place, so leave the item queued for restoreQueue to rescan. + AbsLogger.error(tag, "Could not finalize download item ${item.id}: ${e.message}") + } finally { + synchronized(this@DownloadItemManager) { + finalizingItems.remove(item.id) + notifyQueueChanged() + } + } + } + } + + private fun tryReserve(part: DownloadItemPart): Boolean { + val staging = File(part.destinationPath) + staging.parentFile?.mkdirs() + val expectedSize = if (part.fileSize > 0L) part.fileSize else UNKNOWN_PART_RESERVATION_BYTES + val remaining = + (expectedSize - (staging.takeIf(File::exists)?.length() ?: 0L)).coerceAtLeast(0L) + val required = if (part.isInternalStorage) remaining else remaining + expectedSize + val key = storageKey(staging) + val fs = statFsFor(staging) + val headroom = max(MIN_FREE_SPACE_BYTES, fs.totalBytes / 20L) + val alreadyReserved = reservations.filterKeys { storageKey(File(it)) == key }.values.sum() + if (fs.availableBytes - alreadyReserved < required + headroom) return false + reservations[part.destinationPath] = required + return true + } + + private fun hasAvailableSpace(part: DownloadItemPart): Boolean { + val staging = File(part.destinationPath) + val fs = statFsFor(staging) + return fs.availableBytes >= max(MIN_FREE_SPACE_BYTES, fs.totalBytes / 20L) + } + + private fun statFsFor(staging: File): StatFs { + var directory = staging.parentFile ?: context.filesDir + directory.mkdirs() + while (!directory.exists()) directory = directory.parentFile ?: context.filesDir + return StatFs(directory.absolutePath) + } + + private fun storageKey(file: File): String = + if (file.absolutePath.startsWith(context.filesDir.absolutePath)) "internal" + else "external" + + @Synchronized + private fun removeActivePart(part: DownloadItemPart) { + activeCalls.remove(part.id) + currentDownloadItemParts.remove(part) + } + + private fun persist(item: DownloadItem, force: Boolean = false) { + val now = System.currentTimeMillis() + if (!force && now - (lastPersistTime[item.id] ?: 0L) < PERSIST_INTERVAL_MS) return + lastPersistTime[item.id] = now + DeviceManager.dbManager.saveDownloadItem(item) + } + + private fun notifyQueueChanged() { + clientEventEmitter.onQueueChanged(hasWork()) + } + + fun destroy() { + activeCalls.values.forEach(InternalDownloadManager.DownloadHandle::cancel) + activeCalls.clear() + scope.cancel() + } + + private fun getOrCreateFolder(root: DocumentFile, relativePath: String): DocumentFile? { + var current = root + relativePath.split('/').filter { it.isNotBlank() }.forEach { segment -> + if (segment == "." || segment == "..") return null + current = current.findFile(segment) ?: current.createDirectory(segment) ?: return null + } + return current + } + + private fun findSharedStorageFile(part: DownloadItemPart): DocumentFile? { + if (part.isInternalStorage) return null + val root = DocumentFile.fromTreeUri(context, Uri.parse(part.localFolderUrl)) ?: return null + var folder = root + part.finalDestinationSubfolder.split('/').filter { it.isNotBlank() }.forEach { segment -> + if (segment == "." || segment == "..") return null + folder = folder.findFile(segment) ?: return null + } + val file = findDocumentByFilename(folder, part) ?: return null + if (!file.isFile) return null + if (part.fileSize > 0L && file.length() != part.fileSize) return null + if (part.fileSize <= 0L && file.length() <= 0L) return null + return file + } + + private fun completeFromExistingInternalCover( + item: DownloadItem, + part: DownloadItemPart + ): Boolean { + if (!part.isInternalStorage || !part.serverPath.endsWith("/cover")) return false + val file = File(part.finalDestinationPath) + if (!file.isFile || file.length() <= 0L) return false + if (part.fileSize > 0L && file.length() != part.fileSize) return false + part.bytesDownloaded = file.length() + part.progress = 100L + part.reusedExistingFile = true + AbsLogger.info(tag, "Reusing existing cover ${part.filename}") + File(part.destinationPath).delete() + completePart(item, part) + clientEventEmitter.onDownloadItemPartUpdate(part) + return true + } + + private fun hasActiveDestinationConflict(part: DownloadItemPart): Boolean = + currentDownloadItemParts.any { activePart -> + activePart !== part && + activePart.localFolderId == part.localFolderId && + activePart.finalDestinationPath == part.finalDestinationPath + } + + private fun leaveQueued(item: DownloadItem, part: DownloadItemPart) { + if (!part.waitingForSpace) return + part.waitingForSpace = false + part.downloadId = null + persist(item) + clientEventEmitter.onDownloadItemPartUpdate(part) + } + + private fun scanDestinationKey(item: DownloadItem): String = + "${item.localFolder.id}:${item.itemFolderPath}" + + private fun finalizedFileExists(part: DownloadItemPart): Boolean { + if (part.isInternalStorage) { + val file = File(part.finalDestinationPath) + return file.isFile && + if (part.fileSize > 0L) file.length() == part.fileSize else file.length() > 0L + } + part.completedDestinationUri?.let { uri -> + try { + val file = DocumentFile.fromSingleUri(context, Uri.parse(uri)) + if (file?.isFile == true && (part.fileSize <= 0L || file.length() == part.fileSize)) + return true + } catch (e: Exception) { + AbsLogger.error(tag, "Could not validate SAF file ${part.filename}: ${e.message}") + } + } + return findSharedStorageFile(part) != null + } + + /** Resets an unmoved part when recovery crosses a service-session boundary. */ + private fun resetPartForFreshDownload(part: DownloadItemPart): Boolean { + val stagingFile = File(part.destinationPath) + if (stagingFile.exists() && !stagingFile.delete()) { + AbsLogger.error(tag, "Could not delete staging file ${part.filename}") + part.failed = true + return false + } + part.completed = false + part.bytesDownloaded = 0L + part.progress = 0L + part.failed = false + part.isMoving = false + part.downloadId = null + part.retryCount = 0 + part.authRetryCount = 0 + part.waitingForSpace = false + part.reusedExistingFile = false + return true + } + + private fun findDocumentByFilename(folder: DocumentFile, part: DownloadItemPart): DocumentFile? { + folder.findFile(part.filename)?.let { + return it + } + val expectedBaseName = part.filename.substringBeforeLast('.') + return folder.listFiles().firstOrNull { document -> + document.name == part.filename || + document.fullName == part.filename || + (part.audioTrack != null && + document.isFile && + ((document.name ?: "").substringBeforeLast('.') == expectedBaseName || + document.fullName.substringBeforeLast('.') == expectedBaseName)) + } + } + + private fun mimeTypeFor(part: DownloadItemPart): String = + part.audioTrack?.mimeType + ?: when (part.ebookFile?.ebookFormat?.lowercase()) { + "epub" -> "application/epub+zip" + "pdf" -> "application/pdf" + else -> "image/jpeg" + } + + private fun serverUrl(item: DownloadItem, part: DownloadItemPart): String { + val rawCover = if (part.serverPath.endsWith("/cover")) "?raw=1" else "" + return "${item.serverAddress}${part.serverPath}$rawCover" + } + + private companion object { + const val APP_MANAGED_DOWNLOAD_ID = -1L + const val MAX_SIMULTANEOUS_DOWNLOADS = 3 + const val WATCH_INTERVAL_MS = 1_000L + const val STALL_TIMEOUT_MS = 60_000L + const val MAX_RETRIES = 5 + const val MAX_AUTH_RETRIES = 2 + const val PERSIST_INTERVAL_MS = 2_000L + const val MIN_FREE_SPACE_BYTES = 100L * 1024L * 1024L + const val UNKNOWN_PART_RESERVATION_BYTES = 100L * 1024L * 1024L + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/IncompleteDownloadCleanup.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/IncompleteDownloadCleanup.kt new file mode 100644 index 0000000..fdb90a4 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/IncompleteDownloadCleanup.kt @@ -0,0 +1,89 @@ +package com.audiobookshelf.app.managers + +import android.content.Context +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.Worker +import androidx.work.WorkerParameters +import com.audiobookshelf.app.device.DeviceManager +import com.audiobookshelf.app.models.DownloadItem +import com.audiobookshelf.app.plugins.AbsLogger +import java.io.File +import java.util.concurrent.TimeUnit + +/** Removes only staging data for terminally failed downloads after their retention window. */ +object IncompleteDownloadCleanup { + private const val tag = "IncompleteDownloadCleanup" + private const val RETENTION_MS = 24L * 60L * 60L * 1000L + private const val WORK_PREFIX = "incomplete-download-" + + fun schedule(context: Context, item: DownloadItem) { + val failedAt = item.terminalFailureAt ?: return + if (item.stagingCleanupAt != null) return + val delay = (failedAt + RETENTION_MS - System.currentTimeMillis()).coerceAtLeast(0L) + val request = OneTimeWorkRequestBuilder() + .setInitialDelay(delay, TimeUnit.MILLISECONDS) + .build() + WorkManager.getInstance(context).enqueueUniqueWork( + WORK_PREFIX + item.id, ExistingWorkPolicy.REPLACE, request) + } + + fun cancel(context: Context, itemId: String) { + WorkManager.getInstance(context).cancelUniqueWork(WORK_PREFIX + itemId) + } + + /** Cleans staging data retained longer than 24 hours when scheduled work did not run. */ + @Synchronized + fun cleanupExpired(context: Context) { + val now = System.currentTimeMillis() + DeviceManager.dbManager.getDownloadItems() + .filter { item -> isEligible(item, now) } + .forEach { item -> + deleteItem(context, item) + } + } + + private fun isEligible(item: DownloadItem, now: Long): Boolean { + val failedAt = item.terminalFailureAt ?: return false + if (item.stagingCleanupAt != null) return false + if (now - failedAt < RETENTION_MS) return false + return item.downloadItemParts.all { part -> + part.moved || (part.failed && !part.isMoving) + } + } + + private fun deleteItem(context: Context, item: DownloadItem) { + item.downloadItemParts.forEach { part -> + deleteAppOwnedFile(context, File(part.destinationPath)) + if (!part.moved) { + part.bytesDownloaded = 0L + part.completed = false + } + } + item.stagingCleanupAt = System.currentTimeMillis() + DeviceManager.dbManager.saveDownloadItem(item) + cancel(context, item.id) + AbsLogger.info(tag, "Deleted staging files for terminally failed download item ${item.id}") + } + + private fun deleteAppOwnedFile(context: Context, file: File) { + val path = file.absolutePath + val internal = context.filesDir.absolutePath + val external = context.getExternalFilesDir(null)?.absolutePath + if (path.startsWith(internal) || (external != null && path.startsWith(external))) { + if (file.exists() && !file.delete()) AbsLogger.error(tag, "Could not delete expired staging file $path") + file.parentFile?.takeIf { it.isDirectory && it.list()?.isEmpty() == true }?.delete() + } else { + AbsLogger.error(tag, "Refusing to delete non-app-owned path $path") + } + } +} + +class IncompleteDownloadCleanupWorker(context: Context, params: WorkerParameters) : Worker(context, params) { + override fun doWork(): Result { + DbManager.initialize(applicationContext) + IncompleteDownloadCleanup.cleanupExpired(applicationContext) + return Result.success() + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt new file mode 100644 index 0000000..f9a2217 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt @@ -0,0 +1,203 @@ +package com.audiobookshelf.app.managers + +import com.audiobookshelf.app.plugins.AbsLogger +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference +import java.util.concurrent.TimeUnit +import okhttp3.Call +import okhttp3.Callback +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response + +/** Streams a download into an app-owned staging file. */ +class InternalDownloadManager( + private val destinationFile: File, + private val expectedSize: Long, + private val progressCallback: DownloadItemManager.InternalProgressCallback, + private val hasAvailableSpace: () -> Boolean +) { + private val tag = "InternalDownloadManager" + + interface DownloadHandle { + fun cancel() + } + + private class ActiveDownloadHandle : DownloadHandle { + private val cancelled = AtomicBoolean(false) + private val activeCall = AtomicReference() + + fun setCall(call: Call) { + activeCall.set(call) + if (cancelled.get()) call.cancel() + } + + override fun cancel() { + cancelled.set(true) + activeCall.get()?.cancel() + } + } + /** + * Starts or resumes a download. + * + * @param url download URL + * @param token access token sent in the Authorization header + * @return logical handle used to cancel the active request, including a restarted request + */ + fun download(url: String, token: String): DownloadHandle { + destinationFile.parentFile?.mkdirs() + val handle = ActiveDownloadHandle() + startRequest(url, token, handle, allowRestart = true) + return handle + } + + private fun startRequest( + url: String, + token: String, + handle: ActiveDownloadHandle, + allowRestart: Boolean + ) { + var existingBytes = destinationFile.takeIf { it.exists() }?.length() ?: 0L + AbsLogger.info( + tag, + "Starting ${if (existingBytes > 0L) "resumed" else "new"} download for ${destinationFile.name} at byte $existingBytes") + val request = + Request.Builder() + .url(url) + .addHeader("Accept-Encoding", "identity") + .addHeader("Authorization", "Bearer $token") + .apply { if (existingBytes > 0L) header("Range", "bytes=$existingBytes-") } + .build() + val call = client.newCall(request) + handle.setCall(call) + call.enqueue( + object : Callback { + override fun onFailure(call: Call, e: IOException) { + AbsLogger.error(tag, "Download request failed for ${destinationFile.name}: ${e.message}") + progressCallback.onComplete(true) + } + + override fun onResponse(call: Call, response: Response) { + response.use { + try { + if (response.code == 401) { + AbsLogger.error(tag, "Download unauthorized (401) for ${destinationFile.name}") + progressCallback.onAuthError() + return + } + if (response.code == 416) { + val serverSize = + response.header("Content-Range") + ?.removePrefix("bytes */") + ?.toLongOrNull() + if (serverSize != null) progressCallback.onSizeResolved(serverSize) + if (serverSize != null && existingBytes == serverSize) { + progressCallback.onProgress(existingBytes, 100L) + AbsLogger.info(tag, "Download completed for ${destinationFile.name} ($existingBytes bytes)") + progressCallback.onComplete(false) + } else if (allowRestart && destinationFile.delete()) { + AbsLogger.info(tag, "Restarting stale range from byte zero for ${destinationFile.name}") + startRequest(url, token, handle, allowRestart = false) + } else { + AbsLogger.error(tag, "Could not recover invalid range for ${destinationFile.name} at byte $existingBytes") + progressCallback.onComplete(true) + } + return + } + val append = + existingBytes > 0L && + response.code == 206 && + hasExpectedRange(response, existingBytes) + if (existingBytes > 0L && !append && response.code != 200) { + AbsLogger.error( + tag, + "Invalid resume response ${response.code} for ${destinationFile.name} at byte $existingBytes" + ) + progressCallback.onComplete(true) + return + } + if (!response.isSuccessful || response.body == null) { + AbsLogger.error(tag, "Download HTTP failure ${response.code} for ${destinationFile.name}") + progressCallback.onComplete(true) + return + } + + val startingBytes = if (append) existingBytes else 0L + val responseLength = response.body!!.contentLength() + val serverSize = + if (append) contentRangeTotal(response) + else responseLength.takeIf { it >= 0L } + if (serverSize != null) progressCallback.onSizeResolved(serverSize) + val totalLength = + serverSize ?: if (expectedSize > 0L) expectedSize else 0L + + FileOutputStream(destinationFile, append).use { output -> + response.body!!.byteStream().use { input -> + val buffer = ByteArray(CHUNK_SIZE) + var totalBytes = startingBytes + while (true) { + val read = input.read(buffer) + if (read < 0) break + if (!hasAvailableSpace()) + throw IOException("Download paused to preserve free storage") + output.write(buffer, 0, read) + totalBytes += read + val progress = + if (totalLength > 0L) (totalBytes * 100L) / totalLength else 0L + progressCallback.onProgress(totalBytes, progress.coerceAtMost(100L)) + } + } + } + + val downloadedSize = destinationFile.length() + if (serverSize != null && downloadedSize != serverSize) { + AbsLogger.error( + tag, + "Downloaded size for ${destinationFile.name} was $downloadedSize, expected server size $serverSize" + ) + progressCallback.onComplete(true) + } else { + AbsLogger.info( + tag, + "Download completed for ${destinationFile.name} ($downloadedSize bytes)" + ) + progressCallback.onComplete(false) + } + } catch (e: IOException) { + AbsLogger.error(tag, "Could not write staging file ${destinationFile.name}: ${e.message}") + progressCallback.onComplete(true) + } + } + } + } + ) + } + + private fun hasExpectedRange(response: Response, offset: Long): Boolean { + val range = response.header("Content-Range") ?: return false + val match = CONTENT_RANGE.matchEntire(range) ?: return false + return match.groupValues[1].toLongOrNull() == offset && + match.groupValues[2].toLongOrNull()?.let { it >= offset } == true + } + + private fun contentRangeTotal(response: Response): Long? = + CONTENT_RANGE.matchEntire(response.header("Content-Range") ?: "") + ?.groupValues + ?.get(3) + ?.takeUnless { it == "*" } + ?.toLongOrNull() + + private companion object { + const val CHUNK_SIZE = 512 * 1024 // 512 KB + val CONTENT_RANGE = Regex("bytes (\\d+)-(\\d+)/(\\d+|\\*)") + val client = + OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .build() + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/SecureStorage.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/SecureStorage.kt new file mode 100644 index 0000000..c294f52 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/SecureStorage.kt @@ -0,0 +1,124 @@ +package com.audiobookshelf.app.managers + +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import android.util.Log +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +class SecureStorage(private val context: Context) { + companion object { + private const val TAG = "SecureStorage" + private const val KEYSTORE_PROVIDER = "AndroidKeyStore" + private const val KEY_ALIAS = "AudiobookshelfRefreshTokens" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val IV_LENGTH = 12 + private const val TAG_LENGTH = 128 + } + + private val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { + load(null) + } + + /** + * Encrypts and stores a refresh token for a specific server connection + */ + fun storeRefreshToken(serverConnectionId: String, refreshToken: String): Boolean { + return try { + val key = getOrCreateKey() + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, key) + + val encryptedBytes = cipher.doFinal(refreshToken.toByteArray(Charsets.UTF_8)) + val combined = cipher.iv + encryptedBytes + + val encoded = Base64.encodeToString(combined, Base64.DEFAULT) + + val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE) + sharedPrefs.edit().putString("refresh_token_$serverConnectionId", encoded).apply() + + Log.d(TAG, "Successfully stored encrypted refresh token for server: $serverConnectionId") + true + } catch (e: Exception) { + Log.e(TAG, "Failed to store refresh token for server: $serverConnectionId", e) + false + } + } + + /** + * Retrieves and decrypts a refresh token for a specific server connection + */ + fun getRefreshToken(serverConnectionId: String): String? { + return try { + val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE) + val encoded = sharedPrefs.getString("refresh_token_$serverConnectionId", null) ?: return null + + val combined = Base64.decode(encoded, Base64.DEFAULT) + val iv = combined.copyOfRange(0, IV_LENGTH) + val encryptedBytes = combined.copyOfRange(IV_LENGTH, combined.size) + + val key = getOrCreateKey() + val cipher = Cipher.getInstance(TRANSFORMATION) + val spec = GCMParameterSpec(TAG_LENGTH, iv) + cipher.init(Cipher.DECRYPT_MODE, key, spec) + + val decryptedBytes = cipher.doFinal(encryptedBytes) + String(decryptedBytes, Charsets.UTF_8) + } catch (e: Exception) { + Log.e(TAG, "Failed to retrieve refresh token for server: $serverConnectionId", e) + null + } + } + + /** + * Removes a refresh token for a specific server connection + */ + fun removeRefreshToken(serverConnectionId: String): Boolean { + return try { + val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE) + sharedPrefs.edit().remove("refresh_token_$serverConnectionId").apply() + Log.d(TAG, "Successfully removed refresh token for server: $serverConnectionId") + true + } catch (e: Exception) { + Log.e(TAG, "Failed to remove refresh token for server: $serverConnectionId", e) + false + } + } + + /** + * Checks if a refresh token exists for a specific server connection + */ + fun hasRefreshToken(serverConnectionId: String): Boolean { + val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE) + return sharedPrefs.contains("refresh_token_$serverConnectionId") + } + + private fun getOrCreateKey(): SecretKey { + return if (keyStore.containsAlias(KEY_ALIAS)) { + keyStore.getKey(KEY_ALIAS, null) as SecretKey + } else { + createKey() + } + } + + private fun createKey(): SecretKey { + val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER) + val keyGenSpec = KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setUserAuthenticationRequired(false) + .setRandomizedEncryptionRequired(true) + .build() + + keyGenerator.init(keyGenSpec) + return keyGenerator.generateKey() + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/SleepTimerManager.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/SleepTimerManager.kt new file mode 100644 index 0000000..ca359ed --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/SleepTimerManager.kt @@ -0,0 +1,544 @@ +package com.audiobookshelf.app.managers + +import android.content.Context +import android.media.MediaPlayer +import android.os.* +import android.util.Log +import com.audiobookshelf.app.R +import com.audiobookshelf.app.device.DeviceManager +import com.audiobookshelf.app.player.PlayerNotificationService +import com.audiobookshelf.app.player.SLEEP_TIMER_WAKE_UP_EXPIRATION +import com.audiobookshelf.app.plugins.AbsLogger +import java.util.* +import kotlin.concurrent.schedule +import kotlin.math.roundToInt + +const val SLEEP_TIMER_CHIME_SOUND_VOLUME = 0.7f + +class SleepTimerManager +constructor(private val playerNotificationService: PlayerNotificationService) { + private val tag = "SleepTimerManager" + + private var sleepTimerTask: TimerTask? = null + private var sleepTimerRunning: Boolean = false + private var sleepTimerEndTime: Long = 0L + private var sleepTimerLength: Long = 0L + private var sleepTimerElapsed: Long = 0L + private var sleepTimerFinishedAt: Long = 0L + private var isAutoSleepTimer: Boolean = false // When timer was auto-set + private var autoTimerDisabled: Boolean = false // Disable until out of auto timer period + private var sleepTimerSessionId: String = "" + + /** + * Gets the current time from the player notification service. + * @return Long - the current time in milliseconds. + */ + private fun getCurrentTime(): Long { + return playerNotificationService.getCurrentTime() + } + + /** + * Gets the duration of the current playback. + * @return Long - the duration in milliseconds. + */ + private fun getDuration(): Long { + return playerNotificationService.getDuration() + } + + /** + * Checks if the player is currently playing. + * @return Boolean - true if the player is playing, false otherwise. + */ + private fun getIsPlaying(): Boolean { + return playerNotificationService.currentPlayer.isPlaying + } + + /** + * Gets the playback speed of the player. + * @return Float - the playback speed. + */ + private fun getPlaybackSpeed(): Float { + return playerNotificationService.currentPlayer.playbackParameters.speed + } + + /** + * Sets the volume of the player. + * @param volume Float - the volume level to set. + */ + private fun setVolume(volume: Float) { + playerNotificationService.currentPlayer.volume = volume + } + + /** Pauses the player. */ + private fun pause() { + playerNotificationService.currentPlayer.pause() + } + + /** Plays the player. */ + private fun play() { + playerNotificationService.currentPlayer.play() + } + + /** + * Gets the remaining time of the sleep timer in seconds. + * @param speed Float - the playback speed of the player, default value is 1. + * @return Int - the remaining time in seconds. + */ + private fun getSleepTimerTimeRemainingSeconds(speed: Float = 1f): Int { + if (sleepTimerEndTime == 0L && sleepTimerLength > 0) { // For regular timer + return ((sleepTimerLength - sleepTimerElapsed) / 1000).toDouble().roundToInt() + } + // For chapter end timer + if (sleepTimerEndTime <= 0) return 0 + return (((sleepTimerEndTime - getCurrentTime()) / 1000).toDouble() / speed).roundToInt() + } + + /** + * Sets the sleep timer. + * @param time Long - the time to set the sleep timer for. When 0L, use end of chapter/track time. + * @return Boolean - true if the sleep timer was set successfully, false otherwise. + */ + private fun setSleepTimer(time: Long): Boolean { + Log.d(tag, "Setting Sleep Timer for $time") + sleepTimerTask?.cancel() + sleepTimerRunning = true + sleepTimerFinishedAt = 0L + sleepTimerElapsed = 0L + setVolume(1f) + + if (time == 0L) { + // Get the current chapter time and set the sleep timer to the end of the chapter + val chapterEndTime = this.getChapterEndTime() + + if (chapterEndTime == null) { + Log.e(tag, "Setting sleep timer to end of chapter/track but there is no current session") + return false + } + + val currentTime = getCurrentTime() + if (currentTime > chapterEndTime) { + Log.d(tag, "Invalid sleep timer - time is already past chapter time $chapterEndTime") + return false + } + + sleepTimerEndTime = chapterEndTime + + if (sleepTimerEndTime > getDuration()) { + sleepTimerEndTime = getDuration() + } + } else { + sleepTimerEndTime = 0L + } + + // Set sleep timer length. Will be 0L if using chapter end time + sleepTimerLength = time + + // Register shake sensor + playerNotificationService.registerSensor() + + playerNotificationService.clientEventEmitter?.onSleepTimerSet( + getSleepTimerTimeRemainingSeconds(getPlaybackSpeed()), + isAutoSleepTimer + ) + + sleepTimerTask = + Timer("SleepTimer", false).schedule(0L, 1000L) { + Handler(Looper.getMainLooper()).post { + if (getIsPlaying()) { + sleepTimerElapsed += 1000L + + val sleepTimeSecondsRemaining = + getSleepTimerTimeRemainingSeconds(getPlaybackSpeed()) + Log.d( + tag, + "Timer Elapsed $sleepTimerElapsed | Sleep TIMER time remaining $sleepTimeSecondsRemaining s" + ) + + if (sleepTimeSecondsRemaining > 0) { + playerNotificationService.clientEventEmitter?.onSleepTimerSet( + sleepTimeSecondsRemaining, + isAutoSleepTimer + ) + } + + if (sleepTimeSecondsRemaining == 30 && sleepTimerElapsed > 1 && DeviceManager.deviceData.deviceSettings?.enableSleepTimerAlmostDoneChime == true) { + playChimeSound() + } + + if (sleepTimeSecondsRemaining <= 0) { + Log.d(tag, "Sleep Timer Pausing Player on Chapter") + pause() + + playerNotificationService.clientEventEmitter?.onSleepTimerEnded( + getCurrentTime() + ) + clearSleepTimer() + sleepTimerFinishedAt = System.currentTimeMillis() + } else if (sleepTimeSecondsRemaining <= 60 && + DeviceManager.deviceData + .deviceSettings + ?.disableSleepTimerFadeOut != true + ) { + // Start fading out audio down to 10% volume + val percentToReduce = 1 - (sleepTimeSecondsRemaining / 60F) + val volume = 1f - (percentToReduce * 0.9f) + Log.d( + tag, + "SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining" + ) + setVolume(volume) + } else { + setVolume(1f) + } + } + } + } + return true + } + + /** + * Sets a manual sleep timer. + * @param playbackSessionId String - the playback session ID. + * @param time Long - the time to set the sleep timer for. + * @param isChapterTime Boolean - true if the time is for the end of a chapter, false otherwise. + * @return Boolean - true if the sleep timer was set successfully, false otherwise. + */ + fun setManualSleepTimer(playbackSessionId: String, time: Long, isChapterTime: Boolean): Boolean { + sleepTimerSessionId = playbackSessionId + isAutoSleepTimer = false + if (isChapterTime) { + Log.d(tag, "Setting manual sleep timer for end of chapter") + return setSleepTimer(0L) + } else { + Log.d(tag, "Setting manual sleep timer for $time") + return setSleepTimer(time) + } + } + + /** Clears the sleep timer. */ + private fun clearSleepTimer() { + sleepTimerTask?.cancel() + sleepTimerTask = null + sleepTimerEndTime = 0 + sleepTimerRunning = false + playerNotificationService.unregisterSensor() + + setVolume(1f) + } + + /** + * Gets the sleep timer end time. + * @return Long - the sleep timer end time in milliseconds. + */ + fun getSleepTimerTime(): Long { + return sleepTimerEndTime + } + + /** Cancels the sleep timer. */ + fun cancelSleepTimer() { + Log.d(tag, "Canceling Sleep Timer") + + if (isAutoSleepTimer) { + Log.i(tag, "Disabling auto sleep timer for this time period") + autoTimerDisabled = true + } + + clearSleepTimer() + playerNotificationService.clientEventEmitter?.onSleepTimerSet(0, false) + } + + /** Provides vibration feedback when resetting the sleep timer. */ + private fun vibrateFeedback() { + if (DeviceManager.deviceData.deviceSettings?.disableSleepTimerResetFeedback == true) return + + val context = playerNotificationService.getContext() + val vibrator: Vibrator + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val vibratorManager = + context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager + vibrator = vibratorManager.defaultVibrator + } else { + @Suppress("DEPRECATION") + vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + } + + vibrator.let { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val vibrationEffect = VibrationEffect.createWaveform(longArrayOf(0, 150, 150, 150), -1) + it.vibrate(vibrationEffect) + } else { + @Suppress("DEPRECATION") it.vibrate(10) + } + } + } + + /** Plays chime sound */ + private fun playChimeSound() { + AbsLogger.info(tag, "playChimeSound: Playing sleep timer chime sound") + val ctx = playerNotificationService.getContext() + val mediaPlayer = MediaPlayer.create(ctx, R.raw.bell) + mediaPlayer.setVolume(SLEEP_TIMER_CHIME_SOUND_VOLUME, SLEEP_TIMER_CHIME_SOUND_VOLUME) + mediaPlayer.start() + mediaPlayer.setOnCompletionListener { + mediaPlayer.release() + } + } + + /** + * Gets the chapter end time for use in End of Chapter timers. If less than 10 seconds remain in + * the chapter, then use the next chapter. + * @return Long? - the chapter end time in milliseconds, or null if there is no current session. + */ + private fun getChapterEndTime(): Long? { + val currentChapterEndTimeMs = playerNotificationService.getEndTimeOfChapterOrTrack() + if (currentChapterEndTimeMs == null) { + Log.e(tag, "Getting chapter sleep timer end of chapter/track but there is no current session") + return null + } + + val timeLeftInChapter = currentChapterEndTimeMs - getCurrentTime() + // If less than 10 seconds remain in the chapter, set the timer to the next chapter or track + // This handles the auto-rewind from not playing media for a little bit to select the next + // chapter + return if (timeLeftInChapter < 10000L) { + Log.i(tag, "Getting chapter sleep timer time and current chapter has less than 10s remaining") + val nextChapterEndTimeMs = playerNotificationService.getEndTimeOfNextChapterOrTrack() + if (nextChapterEndTimeMs == null || currentChapterEndTimeMs == nextChapterEndTimeMs) { + Log.e( + tag, + "Invalid next chapter time. No current session or equal to current chapter. $nextChapterEndTimeMs" + ) + null + } else { + nextChapterEndTimeMs + } + } else { + currentChapterEndTimeMs + } + } + + /** + * Rewind auto sleep timer if setting enabled. To ensure the first rewind of the time period does + * not take place, make sure to set `isAutoSleepTimer` after calling this function. + */ + private fun tryRewindAutoSleepTimer() { + DeviceManager.deviceData.deviceSettings?.let { deviceSettings -> + if (isAutoSleepTimer && deviceSettings.autoSleepTimerAutoRewind) { + Log.i( + tag, + "Auto sleep timer auto rewind seeking back ${deviceSettings.autoSleepTimerAutoRewindTime}ms" + ) + playerNotificationService.seekBackward(deviceSettings.autoSleepTimerAutoRewindTime) + } + } + } + + /** Checks if the sleep timer should be reset. */ + private fun checkShouldResetSleepTimer() { + if (sleepTimerRunning) { + // Reset the sleep timer if it has been running for at least 3 seconds or it is an end of + // chapter/track timer + if (sleepTimerLength == 0L || sleepTimerElapsed > 3000L) { + Log.d(tag, "Resetting running sleep timer") + vibrateFeedback() + setSleepTimer(sleepTimerLength) + play() + } + } else { + + if (sleepTimerFinishedAt <= 0L) return + + val finishedAtDistance = System.currentTimeMillis() - sleepTimerFinishedAt + if (finishedAtDistance > SLEEP_TIMER_WAKE_UP_EXPIRATION) // 2 minutes + { + Log.d(tag, "Sleep timer finished over 2 mins ago, clearing it") + sleepTimerFinishedAt = 0L + return + } + + // If timer was cleared by going negative on time, clear the sleep timer length so pressing + // play allows playback to continue without the sleep timer continuously setting for 1 second. + if (sleepTimerLength == 1000L) { + Log.d(tag, "Sleep timer cleared by manually subtracting time, clearing sleep timer") + sleepTimerFinishedAt = 0L + return + } + + // Automatically rewind in the book if settings are enabled + tryRewindAutoSleepTimer() + + // Set sleep timer + Log.d(tag, "Resetting stopped sleep timer") + vibrateFeedback() + setSleepTimer(sleepTimerLength) + play() + } + } + + /** + * Handles the shake event to reset the sleep timer. Shaking to reset only works during the 2 + * minute grace period after the timer ends or while media is playing. + */ + fun handleShake() { + if ((sleepTimerRunning && getIsPlaying()) || sleepTimerFinishedAt > 0L) { + if (DeviceManager.deviceData.deviceSettings?.disableShakeToResetSleepTimer == true) { + Log.d(tag, "Shake to reset sleep timer is disabled") + return + } + checkShouldResetSleepTimer() + } + } + + /** + * Increases the sleep timer time. + * @param time Long - the time to increase the sleep timer by. + */ + fun increaseSleepTime(time: Long) { + Log.d(tag, "Increase Sleep time $time") + if (!sleepTimerRunning) return + + // Increase the sleep timer time (if using fixed length) or end time (if using chapter end time) + // and ensure it doesn't go over the duration of the current playback item + if (sleepTimerEndTime == 0L) { + // Fixed length + sleepTimerLength += time + sleepTimerLength = minOf(sleepTimerLength, getDuration() - getCurrentTime()) + } else { + // Chapter end time + sleepTimerEndTime = + minOf(sleepTimerEndTime + (time * getPlaybackSpeed()).roundToInt(), getDuration()) + } + + setVolume(1F) + playerNotificationService.clientEventEmitter?.onSleepTimerSet( + getSleepTimerTimeRemainingSeconds(getPlaybackSpeed()), + isAutoSleepTimer + ) + } + + /** + * Decreases the sleep timer time. + * @param time Long - the time to decrease the sleep timer by. + */ + fun decreaseSleepTime(time: Long) { + Log.d(tag, "Decrease Sleep time $time") + if (!sleepTimerRunning) return + + // Decrease the sleep timer time (if using fixed length) or end time (if using chapter end time) + // and ensure it doesn't go below 1 second + if (sleepTimerEndTime == 0L) { + // Fixed length + sleepTimerLength = maxOf(sleepTimerLength - time, 1000L) + } else { + // Chapter end time + sleepTimerEndTime = + maxOf( + sleepTimerEndTime - (time * getPlaybackSpeed()).roundToInt(), + getCurrentTime() + 1000 + ) + } + + setVolume(1F) + playerNotificationService.clientEventEmitter?.onSleepTimerSet( + getSleepTimerTimeRemainingSeconds(getPlaybackSpeed()), + isAutoSleepTimer + ) + } + + /** Checks whether the auto sleep timer should be set, and set up auto sleep timer if so. */ + fun checkAutoSleepTimer() { + if (sleepTimerRunning) { // Sleep timer already running + return + } + DeviceManager.deviceData.deviceSettings?.let { deviceSettings -> + if (!deviceSettings.autoSleepTimer) return // Check auto sleep timer is enabled + + val startCalendar = Calendar.getInstance() + startCalendar.set(Calendar.HOUR_OF_DAY, deviceSettings.autoSleepTimerStartHour) + startCalendar.set(Calendar.MINUTE, deviceSettings.autoSleepTimerStartMinute) + val endCalendar = Calendar.getInstance() + endCalendar.set(Calendar.HOUR_OF_DAY, deviceSettings.autoSleepTimerEndHour) + endCalendar.set(Calendar.MINUTE, deviceSettings.autoSleepTimerEndMinute) + + val currentCalendar = Calendar.getInstance() + + // In cases where end time is before start time then we shift the time window forward or + // backward based on the current time. + // e.g. start time 22:00 and end time 06:00. + // If current time is less than start time (e.g. 00:30) then start time will be the + // previous day. + // If current time is greater than start time (e.g. 23:00) then end time will be the + // next day. + if (endCalendar.before(startCalendar)) { + if (currentCalendar.before(startCalendar)) { // Shift start back a day + startCalendar.add(Calendar.DAY_OF_MONTH, -1) + } else { // Shift end forward a day + endCalendar.add(Calendar.DAY_OF_MONTH, 1) + } + } + + val isDuringAutoTime = + currentCalendar.after(startCalendar) && currentCalendar.before(endCalendar) + + // Determine whether to set the auto sleep timer or not + if (autoTimerDisabled) { + if (!isDuringAutoTime) { + // Check if sleep timer was disabled during the previous period and enable again + Log.i(tag, "Leaving disabled auto sleep time period, enabling for next time period") + autoTimerDisabled = false + } else { + // Auto time is disabled, do not set sleep timer + Log.i(tag, "Auto sleep timer is disabled for this time period") + } + } else { + if (isDuringAutoTime) { + // Start an auto sleep timer + val currentHour = currentCalendar.get(Calendar.HOUR_OF_DAY) + val currentMin = currentCalendar.get(Calendar.MINUTE) + Log.i(tag, "Starting auto sleep timer at $currentHour:$currentMin") + + // Automatically rewind in the book if settings is enabled + tryRewindAutoSleepTimer() + + // Set `isAutoSleepTimer` to true to indicate that the timer was set automatically + // and to not cause the timer to rewind + isAutoSleepTimer = true + setSleepTimer(deviceSettings.sleepTimerLength) + } else { + Log.d(tag, "Not in auto sleep time period") + } + } + } + } + + /** + * Handles the media play event and checks if the sleep timer should be reset or set. + * @param playbackSessionId String - the playback session ID. + */ + fun handleMediaPlayEvent(playbackSessionId: String) { + // Check if the playback session has changed + // If it hasn't changed OR the sleep timer is running then check reset the timer + // e.g. You set a manual sleep timer for 10 mins, then decide to change books, the sleep timer + // will stay on and reset to 10 mins + if (sleepTimerSessionId == playbackSessionId || sleepTimerRunning) { + checkShouldResetSleepTimer() + } else {} + sleepTimerSessionId = playbackSessionId + + checkAutoSleepTimer() + } + + /** + * Sends the current sleep timer state to the client. + * Called when app resumes from background to sync UI state. + */ + fun sendCurrentSleepTimerState() { + if (sleepTimerRunning) { + val timeRemaining = getSleepTimerTimeRemainingSeconds(getPlaybackSpeed()) + playerNotificationService.clientEventEmitter?.onSleepTimerSet(timeRemaining, isAutoSleepTimer) + } else { + // No timer running - send 0 to clear any stale UI state + playerNotificationService.clientEventEmitter?.onSleepTimerSet(0, false) + } + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/media/MediaEventManager.kt b/android/app/src/main/java/com/audiobookshelf/app/media/MediaEventManager.kt new file mode 100644 index 0000000..82bf3f4 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/media/MediaEventManager.kt @@ -0,0 +1,132 @@ +package com.audiobookshelf.app.media + +import android.util.Log +import com.audiobookshelf.app.data.* +import com.audiobookshelf.app.device.DeviceManager +import com.audiobookshelf.app.player.PlayerNotificationService + +object MediaEventManager { + const val tag = "MediaEventManager" + + var clientEventEmitter: PlayerNotificationService.ClientEventEmitter? = null + + fun playEvent(playbackSession: PlaybackSession) { + Log.i(tag, "Play Event for media \"${playbackSession.displayTitle}\"") + addPlaybackEvent("Play", playbackSession, null) + } + + fun pauseEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) { + Log.i(tag, "Pause Event for media \"${playbackSession.displayTitle}\"") + addPlaybackEvent("Pause", playbackSession, syncResult) + } + + fun stopEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) { + Log.i(tag, "Stop Event for media \"${playbackSession.displayTitle}\"") + addPlaybackEvent("Stop", playbackSession, syncResult) + } + + fun saveEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) { + Log.i(tag, "Save Event for media \"${playbackSession.displayTitle}\"") + addPlaybackEvent("Save", playbackSession, syncResult) + } + + fun finishedEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) { + Log.i(tag, "Finished Event for media \"${playbackSession.displayTitle}\"") + addPlaybackEvent("Finished", playbackSession, syncResult) + } + + fun seekEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) { + Log.i( + tag, + "Seek Event for media \"${playbackSession.displayTitle}\", currentTime=${playbackSession.currentTime}" + ) + addPlaybackEvent("Seek", playbackSession, syncResult) + } + + fun syncEvent(mediaProgress: MediaProgressWrapper, description: String) { + Log.i( + tag, + "Sync Event for media item id \"${mediaProgress.mediaItemId}\", currentTime=${mediaProgress.currentTime}" + ) + addSyncEvent("Sync", mediaProgress, description) + } + + private fun addSyncEvent( + eventName: String, + mediaProgress: MediaProgressWrapper, + description: String + ) { + val mediaItemHistory = getMediaItemHistoryMediaItem(mediaProgress.mediaItemId) + if (mediaItemHistory == null) { + Log.w( + tag, + "addSyncEvent: Media Item History not created yet for media item id ${mediaProgress.mediaItemId}" + ) + return + } + + val mediaItemEvent = + MediaItemEvent( + name = eventName, + type = "Sync", + description = description, + currentTime = mediaProgress.currentTime, + serverSyncAttempted = false, + serverSyncSuccess = null, + serverSyncMessage = null, + timestamp = System.currentTimeMillis() + ) + mediaItemHistory.events.add(mediaItemEvent) + DeviceManager.dbManager.saveMediaItemHistory(mediaItemHistory) + + clientEventEmitter?.onMediaItemHistoryUpdated(mediaItemHistory) + } + + private fun addPlaybackEvent( + eventName: String, + playbackSession: PlaybackSession, + syncResult: SyncResult? + ) { + val mediaItemHistory = + getMediaItemHistoryMediaItem(playbackSession.mediaItemId) + ?: createMediaItemHistoryForSession(playbackSession) + + val mediaItemEvent = + MediaItemEvent( + name = eventName, + type = "Playback", + description = "", + currentTime = playbackSession.currentTime, + serverSyncAttempted = syncResult?.serverSyncAttempted ?: false, + serverSyncSuccess = syncResult?.serverSyncSuccess, + serverSyncMessage = syncResult?.serverSyncMessage, + timestamp = System.currentTimeMillis() + ) + mediaItemHistory.events.add(mediaItemEvent) + DeviceManager.dbManager.saveMediaItemHistory(mediaItemHistory) + + clientEventEmitter?.onMediaItemHistoryUpdated(mediaItemHistory) + } + + private fun getMediaItemHistoryMediaItem(mediaItemId: String): MediaItemHistory? { + return DeviceManager.dbManager.getMediaItemHistory(mediaItemId) + } + + private fun createMediaItemHistoryForSession(playbackSession: PlaybackSession): MediaItemHistory { + Log.i(tag, "Creating new media item history for media \"${playbackSession.displayTitle}\"") + val libraryItemId = playbackSession.libraryItemId ?: "" + val episodeId: String? = playbackSession.episodeId + return MediaItemHistory( + id = playbackSession.mediaItemId, + mediaDisplayTitle = playbackSession.displayTitle ?: "Unset", + libraryItemId, + episodeId, + false, // local-only items are not supported + playbackSession.serverConnectionConfigId, + playbackSession.serverAddress, + playbackSession.userId, + createdAt = System.currentTimeMillis(), + events = mutableListOf() + ) + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt b/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt new file mode 100644 index 0000000..ea5fbdf --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt @@ -0,0 +1,1028 @@ +package com.audiobookshelf.app.media + +import android.app.Activity +import android.content.Context +import android.support.v4.media.MediaBrowserCompat +import android.util.Log +import com.audiobookshelf.app.data.* +import com.audiobookshelf.app.device.DeviceManager +import com.audiobookshelf.app.server.ApiHandler +import com.getcapacitor.JSObject +import java.util.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import org.json.JSONException +import org.json.JSONObject +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine +import java.util.concurrent.atomic.AtomicInteger + +class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { + val tag = "MediaManager" + + private var serverLibraryItems = mutableListOf() // Store all items here + + private var cachedLibraryAuthors : MutableMap> = hashMapOf() + private var cachedLibraryAuthorItems : MutableMap>> = hashMapOf() + private var cachedLibraryAuthorSeriesItems : MutableMap>> = hashMapOf() + private var cachedLibrarySeries : MutableMap> = hashMapOf() + private var cachedLibrarySeriesItem : MutableMap>> = hashMapOf() + private var cachedLibraryCollections : MutableMap> = hashMapOf() + private var cachedLibraryRecentShelves : MutableMap> = hashMapOf() + private var cachedLibraryDiscovery : MutableMap> = hashMapOf() + private var cachedLibraryPodcasts : MutableMap> = hashMapOf() + private var isLibraryPodcastsCached : MutableMap = hashMapOf() + var allLibraryPersonalizationsDone : Boolean = false + private var libraryPersonalizationsDone : Int = 0 + + private var selectedPodcast:Podcast? = null + private var selectedLibraryItemId:String? = null + private var podcastEpisodeLibraryItemMap = mutableMapOf() + private var serverConfigIdUsed:String? = null + private var serverConfigLastPing:Long = 0L + var serverUserMediaProgress:MutableList = mutableListOf() + var serverItemsInProgress = listOf() + var serverLibraries = listOf() + + var userSettingsPlaybackRate:Float? = null + + // Android Auto browses on the media browser service main thread, so the server pings + // and authorize call must not run there or they block it and trigger an ANR + private val androidAutoIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + // Android Auto re-requests the browse root every ~2s and multiple clients do so at once, + // so overlapping loads are coalesced into one to avoid racing on the shared server state + private var isLoadingAndroidAutoItems = false + private val pendingAndroidAutoLoadCallbacks = mutableListOf<() -> Unit>() + private val androidAutoLoadLock = Any() + + fun getIsLibrary(id:String) : Boolean { + return serverLibraries.find { it.id == id } != null + } + + /** + * Check if there is discovery shelf for [libraryId] + * If personalized shelves are not yet populated for library then populate + * + */ + fun getHasDiscovery(libraryId: String) : Boolean { + if (cachedLibraryDiscovery.containsKey(libraryId)) { + if (cachedLibraryDiscovery[libraryId]!!.isNotEmpty()) { + return true + } + } else { + populatePersonalizedDataForLibrary(libraryId){} + } + return false + } + + fun getLibrary(id:String) : Library? { + return serverLibraries.find { it.id == id } + } + + /** + * Add [libraryItem] to [serverLibraryItems] if it is not already added + */ + private fun addServerLibrary(libraryItem: LibraryItem) { + if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) { + serverLibraryItems.add(libraryItem) + } + } + + fun getSavedPlaybackRate():Float { + if (userSettingsPlaybackRate != null) { + return userSettingsPlaybackRate ?: 1f + } + + val sharedPrefs = ctx.getSharedPreferences("CapacitorStorage", Activity.MODE_PRIVATE) + if (sharedPrefs != null) { + val userSettingsPref = sharedPrefs.getString("userSettings", null) + if (userSettingsPref != null) { + try { + val userSettings = JSObject(userSettingsPref) + if (userSettings.has("playbackRate")) { + userSettingsPlaybackRate = userSettings.getDouble("playbackRate").toFloat() + return userSettingsPlaybackRate ?: 1f + } + } catch(je:JSONException) { + Log.e(tag, "Failed to parse userSettings JSON ${je.localizedMessage}") + } + } + } + return 1f + } + + fun setSavedPlaybackRate(newRate: Float) { + val sharedPrefs = ctx.getSharedPreferences("CapacitorStorage", Activity.MODE_PRIVATE) + val sharedPrefEditor = sharedPrefs.edit() + if (sharedPrefs != null) { + val userSettingsPref = sharedPrefs.getString("userSettings", null) + if (userSettingsPref != null) { + try { + val userSettings = JSObject(userSettingsPref) + // toString().toDouble() to prevent float conversion issues (ex 1.2f becomes 1.2000000476837158d) + userSettings.put("playbackRate", newRate.toString().toDouble()) + sharedPrefEditor.putString("userSettings", userSettings.toString()) + sharedPrefEditor.apply() + userSettingsPlaybackRate = newRate + Log.d(tag, "Saved userSettings JSON from Android Auto with playbackRate=$newRate") + } catch(je:JSONException) { + Log.e(tag, "Failed to save userSettings JSON ${je.localizedMessage}") + } + } else { + // Not sure if this is the best place for this, but if a user has not changed any user settings in the app + // the object will not exist yet, could be moved to a centralized place or created on first app load + val userSettings = JSONObject() + userSettings.put("playbackRate", newRate.toString().toDouble()) + sharedPrefEditor.putString("userSettings", userSettings.toString()) + userSettingsPlaybackRate = newRate + Log.d(tag, "Created and saved userSettings JSON from Android Auto with playbackRate=$newRate") + } + } + } + + fun checkResetServerItems():Boolean { + // When opening android auto need to check if still connected to server + // and reset any server data already set + val serverConnConfig = if (DeviceManager.isConnectedToServer) DeviceManager.serverConnectionConfig else DeviceManager.deviceData.getLastServerConnectionConfig() + + if (!DeviceManager.isConnectedToServer || !DeviceManager.checkConnectivity(ctx) || serverConnConfig == null || serverConnConfig.id != serverConfigIdUsed) { + podcastEpisodeLibraryItemMap = mutableMapOf() + serverLibraries = listOf() + serverLibraryItems = mutableListOf() + cachedLibraryAuthors = hashMapOf() + cachedLibraryAuthorItems = hashMapOf() + cachedLibraryAuthorSeriesItems = hashMapOf() + cachedLibrarySeries = hashMapOf() + cachedLibrarySeriesItem = hashMapOf() + cachedLibraryCollections = hashMapOf() + cachedLibraryRecentShelves = hashMapOf() + cachedLibraryDiscovery = hashMapOf() + cachedLibraryPodcasts = hashMapOf() + isLibraryPodcastsCached = hashMapOf() + serverItemsInProgress = listOf() + allLibraryPersonalizationsDone = false + libraryPersonalizationsDone = 0 + return true + } + return false + } + + private fun loadItemsInProgressForAllLibraries(cb: (List) -> Unit) { + if (serverItemsInProgress.isNotEmpty()) { + cb(serverItemsInProgress) + } else { + apiHandler.getAllItemsInProgress { itemsInProgress -> + serverItemsInProgress = itemsInProgress.filter { + val libraryItem = it.libraryItemWrapper as LibraryItem + libraryItem.checkHasTracks() + } + cb(serverItemsInProgress) + } + } + } + + /** + * Load personalized shelves from server for all libraries. + * [cb] resolves when all libraries are processed + */ + fun populatePersonalizedDataForAllLibraries(cb: () -> Unit) { + val remaining = AtomicInteger(serverLibraries.size) + + serverLibraries.forEach { lib -> + Log.d(tag, "Loading personalization for library ${lib.name}") + populatePersonalizedDataForLibrary(lib.id) { + Log.d(tag, "Loaded personalization for library ${lib.name}") + if (remaining.decrementAndGet() == 0) { + Log.d(tag, "Finished loading all library personalization data") + allLibraryPersonalizationsDone = true + cb() + } + } + } + } + + /** + * Get personalized shelves from server for selected [libraryId]. + * Populates [cachedLibraryRecentShelves] and [cachedLibraryDiscovery]. + */ + private fun populatePersonalizedDataForLibrary(libraryId: String, cb: () -> Unit) { + apiHandler.getLibraryPersonalized(libraryId) { shelves -> + Log.d(tag, "populatePersonalizedDataForLibrary $libraryId") + if (shelves === null) return@getLibraryPersonalized + shelves.map { shelf -> + Log.d(tag, "$shelf") + if (shelf.type == "book") { + if (shelf.id == "continue-listening") return@map + else if (shelf.id == "listen-again") return@map + else if (shelf.id == "recently-added") { + if (!cachedLibraryRecentShelves.containsKey(libraryId)) { + cachedLibraryRecentShelves[libraryId] = mutableListOf() + } + if (cachedLibraryRecentShelves[libraryId]?.find { it.id == shelf.id } == null) { + cachedLibraryRecentShelves[libraryId]!!.add(shelf) + } + } + else if (shelf.id == "discover") { + if (!cachedLibraryDiscovery.containsKey(libraryId)) { + cachedLibraryDiscovery[libraryId] = mutableListOf() + } + (shelf as LibraryShelfBookEntity).entities?.map { + cachedLibraryDiscovery[libraryId]!!.add(it) + } + } + else if (shelf.id == "continue-reading") return@map + else if (shelf.id == "continue-series") return@map + shelf as LibraryShelfBookEntity + } else if (shelf.type == "series") { + if (shelf.id == "recent-series") { + if (!cachedLibraryRecentShelves.containsKey(libraryId)) { + cachedLibraryRecentShelves[libraryId] = mutableListOf() + } + if (cachedLibraryRecentShelves[libraryId]?.find { it.id == shelf.id } == null) { + cachedLibraryRecentShelves[libraryId]!!.add(shelf) + } + } + } else if (shelf.type == "episode") { + if (shelf.id == "continue-listening") return@map + else if (shelf.id == "listen-again") return@map + else if (shelf.id == "newest-episodes") { + if (!cachedLibraryRecentShelves.containsKey(libraryId)) { + cachedLibraryRecentShelves[libraryId] = mutableListOf() + } + if (cachedLibraryRecentShelves[libraryId]?.find { it.id == shelf.id } == null) { + cachedLibraryRecentShelves[libraryId]!!.add(shelf) + } + + val podcastLibraryItemIds = mutableListOf() + (shelf as LibraryShelfEpisodeEntity).entities?.forEach { libraryItem -> + if (!podcastLibraryItemIds.contains(libraryItem.id)) { + podcastLibraryItemIds.add(libraryItem.id) + loadPodcastItem(libraryItem.libraryId, libraryItem.id) {} + } + } + } + } else if (shelf.type == "podcast") { + if (shelf.id == "recently-added"){ + if (!cachedLibraryRecentShelves.containsKey(libraryId)) { + cachedLibraryRecentShelves[libraryId] = mutableListOf() + } + if (cachedLibraryRecentShelves[libraryId]?.find { it.id == shelf.id } == null) { + cachedLibraryRecentShelves[libraryId]!!.add(shelf) + } + } + else if (shelf.id == "discover"){ + return@map + } + } else if (shelf.type =="authors") { + if (shelf.id == "newest-authors") { + if (!cachedLibraryRecentShelves.containsKey(libraryId)) { + cachedLibraryRecentShelves[libraryId] = mutableListOf() + } + if (cachedLibraryRecentShelves[libraryId]?.find { it.id == shelf.id } == null) { + cachedLibraryRecentShelves[libraryId]!!.add(shelf) + } + } + } + + } + Log.d(tag, "populatePersonalizedDataForLibrary $libraryId DONE") + cb() + } + } + + /** + * Returns podcasts for selected library. + * If data is not found from local cache it is loaded from server + */ + fun loadLibraryPodcasts(libraryId:String, cb: (List?) -> Unit) { + // Without this there is possibility that only recent podcasts get loaded + // Loading recent podcasts will also create cachedLibraryPodcasts entry for library + if (!isLibraryPodcastsCached.containsKey(libraryId)) { + isLibraryPodcastsCached[libraryId] = false + } + // Ensure that there is map for library + if (!cachedLibraryPodcasts.containsKey(libraryId)) { + cachedLibraryPodcasts[libraryId] = mutableMapOf() + } + if (isLibraryPodcastsCached.getOrElse(libraryId) {false}) { + Log.d(tag, "loadLibraryPodcasts: Found from cache: $libraryId") + cb(cachedLibraryPodcasts[libraryId]?.values?.sortedBy { libraryItem -> (libraryItem.media as Podcast).metadata.title }) + } else { + apiHandler.getLibraryItems(libraryId) { libraryItems -> + val libraryItemsWithAudio = libraryItems.filter { li -> li.checkHasTracks() } + + libraryItemsWithAudio.forEach { libraryItem -> + cachedLibraryPodcasts[libraryId]?.set(libraryItem.id, libraryItem) + if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) { + serverLibraryItems.add(libraryItem) + } + } + isLibraryPodcastsCached[libraryId] = true + Log.d(tag, "loadLibraryPodcasts: loaded from server: $libraryId") + cb(libraryItemsWithAudio.sortedBy { libraryItem -> (libraryItem.media as Podcast).metadata.title }) + } + } + } + + /** + * Returns series with audio books from selected library. + * If data is not found from local cache then it will be fetched from server + */ + fun loadLibrarySeriesWithAudio(libraryId:String, cb: (List) -> Unit) { + // Check "cache" first + if (cachedLibrarySeries.containsKey(libraryId)) { + Log.d(tag, "Series with audio found from cache | Library $libraryId ") + cb(cachedLibrarySeries[libraryId] as List) + } else { + apiHandler.getLibrarySeries(libraryId) { seriesItems -> + Log.d(tag, "Series with audio loaded from server | Library $libraryId") + val seriesItemsWithAudio = seriesItems.filter { si -> si.audiobookCount > 0 } + + cachedLibrarySeries[libraryId] = seriesItemsWithAudio + + cb(seriesItemsWithAudio) + } + } + } + + /** + * Returns series with audiobooks from selected library using filter for paging. + * If data is not found from local cache then it will be fetched from server + */ + fun loadLibrarySeriesWithAudio(libraryId:String, seriesFilter:String, cb: (List) -> Unit) { + // Check "cache" first + if (!cachedLibrarySeries.containsKey(libraryId)) { + loadLibrarySeriesWithAudio(libraryId) {} + } else { + Log.d(tag, "Series with audio found from cache | Library $libraryId ") + } + val seriesWithBooks = cachedLibrarySeries[libraryId]!!.filter { ls -> ls.title.uppercase().startsWith(seriesFilter) }.toList() + cb(seriesWithBooks) + } + + /** + * Sorts books in series. Assumes that sequence is main.minor + */ + private fun sortSeriesBooks(seriesBooks: List) : List { + val sortingLogic = compareBy { it.seriesSequenceParts[0].length } + .thenBy { it.seriesSequenceParts[0].ifEmpty { "" } } + .thenBy { it.seriesSequenceParts.getOrElse(1) { "" }.length } + .thenBy { it.seriesSequenceParts.getOrElse(1) { "" } } + return seriesBooks.sortedWith(sortingLogic) + } + + /** + * Returns books for series from library. + * If data is not found from local cache then it will be fetched from server + */ + fun loadLibrarySeriesItemsWithAudio(libraryId:String, seriesId:String, cb: (List) -> Unit) { + // Check "cache" first + if (!cachedLibrarySeriesItem.containsKey(libraryId)) { + cachedLibrarySeriesItem[libraryId] = hashMapOf() + } + if (cachedLibrarySeriesItem[libraryId]!!.containsKey(seriesId)) { + Log.d(tag, "Items for series $seriesId found from cache | Library $libraryId") + cachedLibrarySeriesItem[libraryId]!![seriesId]?.let { cb(it) } + } else { + apiHandler.getLibrarySeriesItems(libraryId, seriesId) { libraryItems -> + Log.d(tag, "Items for series $seriesId loaded from server | Library $libraryId") + val libraryItemsWithAudio = libraryItems.filter { li -> li.checkHasTracks() } + + val sortedLibraryItemsWithAudio = sortSeriesBooks(libraryItemsWithAudio) + cachedLibrarySeriesItem[libraryId]!![seriesId] = sortedLibraryItemsWithAudio + + sortedLibraryItemsWithAudio.forEach { libraryItem -> + if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) { + serverLibraryItems.add(libraryItem) + } + } + cb(sortedLibraryItemsWithAudio) + } + } + } + + /** + * Returns authors with books from library. + * If data is not found from local cache then it will be fetched from server + */ + fun loadAuthorsWithBooks(libraryId:String, cb: (List) -> Unit) { + // Check "cache" first + if (cachedLibraryAuthors.containsKey(libraryId)) { + Log.d(tag, "Authors with books found from cache | Library $libraryId ") + cb(cachedLibraryAuthors[libraryId]!!.values.toList()) + } else { + // Fetch data from server and add it to local "cache" + apiHandler.getLibraryAuthors(libraryId) { authorItems -> + Log.d(tag, "Authors with books loaded from server | Library $libraryId ") + // TO-DO: This check won't ensure that there is audiobooks. Current API won't offer ability to do so + var authorItemsWithBooks = authorItems.filter { li -> li.bookCount != null && li.bookCount!! > 0 } + authorItemsWithBooks = authorItemsWithBooks.sortedBy { it.name } + // Ensure that there is map for library + cachedLibraryAuthors[libraryId] = mutableMapOf() + // Cache authors + authorItemsWithBooks.forEach { + if (!cachedLibraryAuthors[libraryId]!!.containsKey(it.id)) { + cachedLibraryAuthors[libraryId]!![it.id] = it + } + } + cb(authorItemsWithBooks) + } + } + } + + /** + * Returns authors with books from selected library using filter for paging. + * If data is not found from local cache then it will be fetched from server + */ + fun loadAuthorsWithBooks(libraryId:String, authorFilter: String, cb: (List) -> Unit) { + // Check "cache" first + if (cachedLibraryAuthors.containsKey(libraryId)) { + Log.d(tag, "Authors with books found from cache | Library $libraryId ") + } else { + loadAuthorsWithBooks(libraryId) {} + } + val authorsWithBooks = cachedLibraryAuthors[libraryId]!!.values.filter { lai -> lai.name.uppercase().startsWith(authorFilter) }.toList() + cb(authorsWithBooks) + } + + /** + * Returns audiobooks for author from library + * If data is not found from local cache then it will be fetched from server + */ + fun loadAuthorBooksWithAudio(libraryId:String, authorId:String, cb: (List) -> Unit) { + // Ensure that there is map for library + if (!cachedLibraryAuthorItems.containsKey(libraryId)) { + cachedLibraryAuthorItems[libraryId] = mutableMapOf() + } + // Check "cache" first + if (cachedLibraryAuthorItems[libraryId]!!.containsKey(authorId)) { + Log.d(tag, "Items for author $authorId found from cache | Library $libraryId") + cachedLibraryAuthorItems[libraryId]!![authorId]?.let { cb(it) } + } else { + apiHandler.getLibraryItemsFromAuthor(libraryId, authorId) { libraryItems -> + Log.d(tag, "Items for author $authorId loaded from server | Library $libraryId") + val libraryItemsWithAudio = libraryItems.filter { li -> li.checkHasTracks() } + + cachedLibraryAuthorItems[libraryId]!![authorId] = libraryItemsWithAudio + + libraryItemsWithAudio.forEach { libraryItem -> + if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) { + serverLibraryItems.add(libraryItem) + } + } + + cb(libraryItemsWithAudio) + } + } + } + + /** + * Returns audiobooks for author from specified series within library + * If data is not found from local cache then it will be fetched from server + */ + fun loadAuthorSeriesBooksWithAudio(libraryId:String, authorId:String, seriesId: String, cb: (List) -> Unit) { + val authorSeriesKey = "$authorId|$seriesId" + // Ensure that there is map for library + if (!cachedLibraryAuthorSeriesItems.containsKey(libraryId)) { + cachedLibraryAuthorSeriesItems[libraryId] = mutableMapOf() + } + // Check "cache" first + if (cachedLibraryAuthorSeriesItems[libraryId]!!.containsKey(authorSeriesKey)) { + Log.d(tag, "Items for series $seriesId with author $authorId found from cache | Library $libraryId") + cachedLibraryAuthorSeriesItems[libraryId]!![authorSeriesKey]?.let { cb(it) } + } else { + apiHandler.getLibrarySeriesItems(libraryId, seriesId) { libraryItems -> + Log.d(tag, "Items for series $seriesId with author $authorId loaded from server | Library $libraryId") + val libraryItemsWithAudio = libraryItems.filter { li -> li.checkHasTracks() } + if (!cachedLibraryAuthors[libraryId]!!.containsKey(authorId)) { + Log.d(tag, "Author data is missing") + } + val authorName = cachedLibraryAuthors[libraryId]!![authorId]?.name ?: "" + Log.d(tag, "Using author name: $authorName") + val libraryItemsFromAuthorWithAudio = libraryItemsWithAudio.filter { li -> li.authorName.indexOf(authorName, ignoreCase = true) >= 0 } + + val sortedLibraryItemsWithAudio = sortSeriesBooks(libraryItemsFromAuthorWithAudio) + cachedLibraryAuthorSeriesItems[libraryId]!![authorId] = sortedLibraryItemsWithAudio + + sortedLibraryItemsWithAudio.forEach { libraryItem -> + if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) { + serverLibraryItems.add(libraryItem) + } + } + + cb(sortedLibraryItemsWithAudio) + } + } + } + + /** + * Returns collections with audiobooks from library + * If data is not found from local cache then it will be fetched from server + */ + fun loadLibraryCollectionsWithAudio(libraryId:String, cb: (List) -> Unit) { + if (cachedLibraryCollections.containsKey(libraryId)) { + Log.d(tag, "Collections with books found from cache | Library $libraryId ") + cb(cachedLibraryCollections[libraryId]!!.values.toList()) + } else { + apiHandler.getLibraryCollections(libraryId) { libraryCollections -> + Log.d(tag, "Collections with books loaded from server | Library $libraryId ") + val libraryCollectionsWithAudio = libraryCollections.filter { lc -> lc.audiobookCount > 0 } + + // Cache collections + cachedLibraryCollections[libraryId] = hashMapOf() + libraryCollectionsWithAudio.forEach { + if (!cachedLibraryCollections[libraryId]!!.containsKey(it.id)) { + cachedLibraryCollections[libraryId]!![it.id] = it + } + } + cb(libraryCollectionsWithAudio) + } + } + } + + /** + * Returns audiobooks for collection from library + * If data is not found from local cache then it will be fetched from server + */ + fun loadLibraryCollectionBooksWithAudio(libraryId: String, collectionId: String, cb: (List) -> Unit) { + if (!cachedLibraryCollections.containsKey(libraryId)) { + loadLibraryCollectionsWithAudio(libraryId) {} + } + Log.d(tag, "Trying to find collection $collectionId items from from cache | Library $libraryId ") + if ( cachedLibraryCollections[libraryId]!!.containsKey(collectionId)) { + val libraryCollectionBookswithAudio = cachedLibraryCollections[libraryId]!![collectionId]?.books + libraryCollectionBookswithAudio?.forEach { libraryItem -> + if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) { + serverLibraryItems.add(libraryItem) + } + } + cb(libraryCollectionBookswithAudio as List) + } + } + + /** + * Returns audiobooks from discovery shelf for [libraryId] + * If data is not found from local cache then it will be fetched from server + */ + fun loadLibraryDiscoveryBooksWithAudio(libraryId: String, cb: (List) -> Unit) { + if (!cachedLibraryDiscovery.containsKey(libraryId)) { + cb(listOf()) + } + val libraryItemsWithAudio = cachedLibraryDiscovery[libraryId]?.filter { li -> li.checkHasTracks() } + libraryItemsWithAudio?.forEach { libraryItem -> addServerLibrary(libraryItem) } + cb(libraryItemsWithAudio as List) + } + + /** + * Returns recent shelves for [libraryId] + * If data is not shelves are found returns empty list + */ + fun getLibraryRecentShelfs(libraryId: String, cb: (List) -> Unit) { + if (!cachedLibraryRecentShelves.containsKey(libraryId)) { + Log.d(tag, "getLibraryRecentShelfs: No shelves $libraryId") + cb(listOf()) + return + } + cb(cachedLibraryRecentShelves[libraryId] as List) + } + + /** + * Returns recent shelf by [type] for [libraryId] + * If shelf is not found returns null + */ + fun getLibraryRecentShelfByType(libraryId: String, type:String, cb: (LibraryShelfType?) -> Unit) { + Log.d(tag, "getLibraryRecentShelfByType: $libraryId | $type") + if (!cachedLibraryRecentShelves.containsKey(libraryId)) { + cb(null) + return + } + for (shelf in cachedLibraryRecentShelves[libraryId]!!) { + if (shelf.type == type.lowercase()) { + cb(shelf) + return + } + } + cb(null) + } + + /** + * Loads podcasts for newest episodes shelf + */ + private fun loadPodcastItem(libraryId: String, libraryItemId: String, cb: (LibraryItem?) -> Unit) { + // Ensure that there is map for library + if (!cachedLibraryPodcasts.containsKey(libraryId)) { + cachedLibraryPodcasts[libraryId] = mutableMapOf() + } + if (cachedLibraryPodcasts[libraryId]!!.containsKey(libraryItemId)) { + Log.d(tag, "loadPodcastItem: Podcast found from cache | Library $libraryItemId ") + cb(cachedLibraryPodcasts[libraryId]?.get(libraryItemId)) + } else { + Log.d(tag, "loadPodcastItem: Calling getLibraryItem $libraryItemId") + apiHandler.getLibraryItem(libraryItemId) { libraryItem -> + if (libraryItem !== null) { + Log.d(tag, "loadPodcastItem: Got library item ${libraryItem.id} ${libraryItem.media.metadata.title}") + val podcast = libraryItem.media as Podcast + podcast.episodes?.forEach { podcastEpisode -> + podcastEpisodeLibraryItemMap[podcastEpisode.id] = LibraryItemWithEpisode(libraryItem, podcastEpisode) + } + cachedLibraryPodcasts[libraryId]?.set(libraryItemId, libraryItem) + cb(libraryItem) + } + } + } + } + + private fun loadLibraryItem(libraryItemId:String, cb: (LibraryItemWrapper?) -> Unit) { + if (libraryItemId.startsWith("local")) { + cb(DeviceManager.dbManager.getLocalLibraryItem(libraryItemId)) + } else { + Log.d(tag, "loadLibraryItem: $libraryItemId") + apiHandler.getLibraryItem(libraryItemId) { libraryItem -> + Log.d(tag, "loadLibraryItem: Got library item $libraryItem") + cb(libraryItem) + } + } + } + + fun loadPodcastEpisodeMediaBrowserItems(libraryItemId:String, ctx:Context, cb: (MutableList) -> Unit) { + loadLibraryItem(libraryItemId) { libraryItemWrapper -> + Log.d(tag, "Loaded Podcast library item $libraryItemWrapper") + + libraryItemWrapper?.let { + if (libraryItemWrapper is LocalLibraryItem) { // Local podcast episodes + if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) { + cb(mutableListOf()) + } else { + val podcast = libraryItemWrapper.media as Podcast + selectedLibraryItemId = libraryItemWrapper.id + selectedPodcast = podcast + + val children = podcast.episodes?.map { podcastEpisode -> + Log.d(tag, "Local Podcast Episode ${podcastEpisode.title} | ${podcastEpisode.id}") + + val progress = DeviceManager.dbManager.getLocalMediaProgress("${libraryItemWrapper.id}-${podcastEpisode.id}") + val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress, ctx) + + MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) + } + children?.let { cb(children as MutableList) } ?: cb(mutableListOf()) + } + } else if (libraryItemWrapper is LibraryItem) { // Server podcast episodes + if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) { + cb(mutableListOf()) + } else { + val podcast = libraryItemWrapper.media as Podcast + podcast.episodes?.forEach { podcastEpisode -> + podcastEpisodeLibraryItemMap[podcastEpisode.id] = LibraryItemWithEpisode(libraryItemWrapper, podcastEpisode) + } + selectedLibraryItemId = libraryItemWrapper.id + selectedPodcast = podcast + val episodes = podcast.episodes?.sortedByDescending { it.publishedAt } + val children = episodes?.map { podcastEpisode -> + + val progress = serverUserMediaProgress.find { it.libraryItemId == libraryItemWrapper.id && it.episodeId == podcastEpisode.id } + + // to show download icon + val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItemWrapper.id) + localLibraryItem?.let { lli -> + val localEpisode = (lli.media as Podcast).episodes?.find { it.serverEpisodeId == podcastEpisode.id } + podcastEpisode.localEpisodeId = localEpisode?.id + } + + val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress, ctx) + MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) + } + children?.let { cb(children as MutableList) } ?: cb(mutableListOf()) + } + } + } + } + } + + /** + * Loads libraries for selected server with stats + */ + private fun loadLibraries(cb: (List) -> Unit) { + if (serverLibraries.isNotEmpty()) { + cb(serverLibraries) + } else { + apiHandler.getLibraries { loadedLibraries -> + serverLibraries = loadedLibraries + cb(serverLibraries) + } + } + } + + private suspend fun checkServerConnection(config:ServerConnectionConfig) : Boolean { + var successfulPing = false + suspendCoroutine { cont -> + apiHandler.pingServer(config) { + Log.d(tag, "checkServerConnection: Checked server conn for ${config.address} result = $it") + successfulPing = it + cont.resume(it) + } + } + return successfulPing + } + + private suspend fun authorize(config:ServerConnectionConfig) : MutableList { + var mediaProgress:MutableList = mutableListOf() + suspendCoroutine { cont -> + apiHandler.authorize(config) { + Log.d(tag, "authorize: Authorized server config ${config.address} result = $it") + if (!it.isNullOrEmpty()) { + mediaProgress = it + } + cont.resume(mediaProgress) + } + } + return mediaProgress + } + + // Runs on [androidAutoIOScope] rather than blocking the caller: this is reached from + // onLoadChildren on the media browser service main thread, and the pings/authorize below + // are network calls, so runBlocking here caused ANRs while browsing in Android Auto + private fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = androidAutoIOScope.launch { + Log.d(tag, "checkSetValidServerConnectionConfig | serverConfigIdUsed=$serverConfigIdUsed | lastServerConnectionConfigId=${DeviceManager.deviceData.lastServerConnectionConfigId}") + + coroutineScope { + if (!DeviceManager.checkConnectivity(ctx)) { + serverUserMediaProgress = mutableListOf() + Log.d(tag, "checkSetValidServerConnectionConfig: No connectivity") + cb(false) + } else if (DeviceManager.deviceData.lastServerConnectionConfigId.isNullOrBlank()) { // If in offline mode last server connection config is unset + serverUserMediaProgress = mutableListOf() + Log.d(tag, "checkSetValidServerConnectionConfig: No last server connection config") + cb(false) + } else { + var hasValidConn = false + var lookupMediaProgress = true + + if (!serverConfigIdUsed.isNullOrEmpty() && serverConfigLastPing > 0L && System.currentTimeMillis() - serverConfigLastPing < 5000) { + Log.d(tag, "checkSetValidServerConnectionConfig last ping less than a 5 seconds ago") + hasValidConn = true + lookupMediaProgress = false + } else { + serverUserMediaProgress = mutableListOf() + } + + if (!hasValidConn) { + // First check if the current selected config is pingable + DeviceManager.serverConnectionConfig?.let { + hasValidConn = checkServerConnection(it) + Log.d( + tag, + "checkSetValidServerConnectionConfig: Current config ${DeviceManager.serverAddress} is pingable? $hasValidConn" + ) + } + } + + if (!hasValidConn) { + // Loop through available configs and check if can connect + for (config: ServerConnectionConfig in DeviceManager.deviceData.serverConnectionConfigs) { + val result = checkServerConnection(config) + + if (result) { + hasValidConn = true + DeviceManager.serverConnectionConfig = config + Log.d(tag, "checkSetValidServerConnectionConfig: Set server connection config ${DeviceManager.serverConnectionConfigId}") + break + } + } + } + + if (hasValidConn) { + serverConfigLastPing = System.currentTimeMillis() + + if (lookupMediaProgress) { + Log.d(tag, "Has valid conn now get user media progress") + DeviceManager.serverConnectionConfig?.let { + serverUserMediaProgress = authorize(it) + } + } + } + + cb(hasValidConn) + } + } + + } + + fun loadServerUserMediaProgress(cb: () -> Unit) { + Log.d(tag, "Loading server media progress") + if (DeviceManager.serverConnectionConfig == null) { + return cb() + } + + DeviceManager.serverConnectionConfig?.let { config -> + apiHandler.authorize(config) { + Log.d(tag, "loadServerUserMediaProgress: Authorized server config ${config.address} result = $it") + if (!it.isNullOrEmpty()) { + serverUserMediaProgress = it + } + cb() + } + } + } + + fun initializeInProgressItems(cb: () -> Unit) { + Log.d(tag, "Initializing inprogress items") + + loadItemsInProgressForAllLibraries { itemsInProgress -> + itemsInProgress.forEach { + val libraryItem = it.libraryItemWrapper as LibraryItem + if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) { + serverLibraryItems.add(libraryItem) + } + + if (it.episode != null) { + podcastEpisodeLibraryItemMap[it.episode.id] = LibraryItemWithEpisode(it.libraryItemWrapper, it.episode) + } + } + Log.d(tag, "Initializing inprogress items done") + cb() + } + } + + fun loadAndroidAutoItems(cb: () -> Unit) { + Log.d(tag, "Load android auto items") + + // Coalesce overlapping calls into a single load, every caller still gets its callback + synchronized(androidAutoLoadLock) { + pendingAndroidAutoLoadCallbacks.add(cb) + if (isLoadingAndroidAutoItems) { + Log.d(tag, "loadAndroidAutoItems: Load already in progress, queued callback") + return + } + isLoadingAndroidAutoItems = true + } + + val onLoadFinished = { + val callbacks: List<() -> Unit> + synchronized(androidAutoLoadLock) { + isLoadingAndroidAutoItems = false + callbacks = pendingAndroidAutoLoadCallbacks.toList() + pendingAndroidAutoLoadCallbacks.clear() + } + callbacks.forEach { it() } + } + + // Check if any valid server connection if not use locally downloaded books + checkSetValidServerConnectionConfig { isConnected -> + if (isConnected) { + serverConfigIdUsed = DeviceManager.serverConnectionConfigId + Log.d(tag, "loadAndroidAutoItems: Connected to server config id=$serverConfigIdUsed") + + loadLibraries { libraries -> + if (libraries.isEmpty()) { + Log.w(tag, "No libraries returned from server request") + onLoadFinished() + } else { + onLoadFinished() // Fully loaded + } + } + } else { // Not connected to server + Log.d(tag, "loadAndroidAutoItems: Not connected to server") + onLoadFinished() + } + } + } + + /** + * Handles search requests. + * Searches from books, series and authors + */ + suspend fun doSearch(libraryId: String, queryString: String) : Map> { + return suspendCoroutine { + apiHandler.getSearchResults(libraryId, queryString) { searchResult -> + Log.d(tag, "searchLocalCache: $searchResult") + // Nothing found from server + if (searchResult === null) { + it.resume(mapOf()) + return@getSearchResults + } + + val foundItems: MutableMap> = mutableMapOf() + + val serverLibrary = serverLibraries.find { sl -> sl.id == libraryId } + + // Books + if (searchResult.book !== null && searchResult.book!!.isNotEmpty()) { + Log.d(tag, "searchLocalCache: found ${searchResult.book!!.size} books") + val children = searchResult.book!!.filter { it.libraryItem.checkHasTracks() }.map { bookResult -> + val libraryItem = bookResult.libraryItem + + if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) { + serverLibraryItems.add(libraryItem) + } + val progress = serverUserMediaProgress.find { it.libraryItemId == libraryItem.id } + val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id) + libraryItem.localLibraryItemId = localLibraryItem?.id + val description = libraryItem.getMediaDescription(progress, ctx, null, null, "Books (${serverLibrary?.name})") + MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) + } + foundItems["book"] = children + } + if (searchResult.series !== null && searchResult.series!!.isNotEmpty()) { + Log.d(tag, "onSearch: found ${searchResult.series!!.size} series") + val children = searchResult.series!!.map { seriesResult -> + val seriesItem = seriesResult.series + seriesItem.books = seriesResult.books as MutableList + val description = seriesItem.getMediaDescription(null, ctx, "Series (${serverLibrary?.name})") + MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) + } + foundItems["series"] = children + } + if (searchResult.authors !== null && searchResult.authors!!.isNotEmpty()) { + Log.d(tag, "onSearch: found ${searchResult.authors!!.size} authors") + val children = searchResult.authors!!.map { authorItem -> + val description = authorItem.getMediaDescription(null, ctx, "Authors (${serverLibrary?.name})") + MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) + } + foundItems["authors"] = children + } + + it.resume(foundItems) + } + } + } + + fun getFirstItem() : LibraryItemWrapper? { + if (serverLibraryItems.isNotEmpty()) { + return serverLibraryItems[0] + } else { + val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book") + return if (localBooks.isNotEmpty()) return localBooks[0] else null + } + } + + fun getPodcastWithEpisodeByEpisodeId(id:String) : LibraryItemWithEpisode? { + return if (id.startsWith("local")) { + DeviceManager.dbManager.getLocalLibraryItemWithEpisode(id) + } else { + podcastEpisodeLibraryItemMap[id] + } + } + + fun getById(id:String) : LibraryItemWrapper? { + return if (id.startsWith("local")) { + DeviceManager.dbManager.getLocalLibraryItem(id) + } else { + serverLibraryItems.find { it.id == id } + } + } + + fun getFromSearch(query:String?) : LibraryItemWrapper? { + if (query.isNullOrEmpty()) return getFirstItem() + return serverLibraryItems.find { + it.title.lowercase(Locale.getDefault()).contains(query.lowercase(Locale.getDefault())) + } + } + + fun play(libraryItemWrapper:LibraryItemWrapper, episode:PodcastEpisode?, playItemRequestPayload:PlayItemRequestPayload, cb: (PlaybackSession?) -> Unit) { + if (libraryItemWrapper is LocalLibraryItem) { + cb(libraryItemWrapper.getPlaybackSession(episode, playItemRequestPayload.deviceInfo)) + } else { + val libraryItem = libraryItemWrapper as LibraryItem + apiHandler.playLibraryItem(libraryItem.id,episode?.id ?: "", playItemRequestPayload) { + if (it == null) { + cb(null) + } else { + cb(it) + } + } + } + } + + private fun levenshtein(lhs : CharSequence, rhs : CharSequence) : Int { + val lhsLength = lhs.length + 1 + val rhsLength = rhs.length + 1 + + var cost = Array(lhsLength) { it } + var newCost = Array(lhsLength) { 0 } + + for (i in 1..rhsLength-1) { + newCost[0] = i + + for (j in 1..lhsLength-1) { + val match = if(lhs[j - 1] == rhs[i - 1]) 0 else 1 + + val costReplace = cost[j - 1] + match + val costInsert = cost[j] + 1 + val costDelete = newCost[j - 1] + 1 + + newCost[j] = Math.min(Math.min(costInsert, costDelete), costReplace) + } + + val swap = cost + cost = newCost + newCost = swap + } + + return cost[lhsLength - 1] + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/media/MediaProgressSyncer.kt b/android/app/src/main/java/com/audiobookshelf/app/media/MediaProgressSyncer.kt new file mode 100644 index 0000000..1aeed0a --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/media/MediaProgressSyncer.kt @@ -0,0 +1,360 @@ +package com.audiobookshelf.app.media + +import android.os.Handler +import android.os.Looper +import android.util.Log +import com.audiobookshelf.app.data.LocalMediaProgress +import com.audiobookshelf.app.data.MediaProgress +import com.audiobookshelf.app.data.PlaybackSession +import com.audiobookshelf.app.device.DeviceManager +import com.audiobookshelf.app.player.PlayerNotificationService +import com.audiobookshelf.app.plugins.AbsLogger +import com.audiobookshelf.app.server.ApiHandler +import java.util.* +import kotlin.concurrent.schedule + +data class MediaProgressSyncData( + var timeListened: Long, // seconds + var duration: Double, // seconds + var currentTime: Double // seconds +) + +data class SyncResult( + var serverSyncAttempted: Boolean, + var serverSyncSuccess: Boolean?, + var serverSyncMessage: String? +) + +class MediaProgressSyncer( + val playerNotificationService: PlayerNotificationService, + private val apiHandler: ApiHandler +) { + private val tag = "MediaProgressSync" + private val METERED_CONNECTION_SYNC_INTERVAL = 60000 + + private var listeningTimerTask: TimerTask? = null + var listeningTimerRunning: Boolean = false + + private var lastSyncTime: Long = 0 + private var failedSyncs: Int = 0 + + var currentPlaybackSession: PlaybackSession? = null // copy of pb session currently syncing + var currentLocalMediaProgress: LocalMediaProgress? = null + + private val currentDisplayTitle + get() = currentPlaybackSession?.displayTitle ?: "Unset" + val currentIsLocal + get() = currentPlaybackSession?.isLocal == true + val currentSessionId + get() = currentPlaybackSession?.id ?: "" + private val currentPlaybackDuration + get() = currentPlaybackSession?.duration ?: 0.0 + + fun start(playbackSession: PlaybackSession) { + if (listeningTimerRunning) { + Log.d(tag, "start: Timer already running for $currentDisplayTitle") + if (playbackSession.id != currentSessionId) { + Log.d(tag, "Playback session changed, reset timer") + currentLocalMediaProgress = null + listeningTimerTask?.cancel() + lastSyncTime = 0L + Log.d(tag, "start: Set last sync time 0 $lastSyncTime") + failedSyncs = 0 + } else { + return + } + } else if (playbackSession.id != currentSessionId) { + currentLocalMediaProgress = null + } + + listeningTimerRunning = true + lastSyncTime = System.currentTimeMillis() + currentPlaybackSession = playbackSession.clone() + Log.d( + tag, + "start: init last sync time $lastSyncTime with playback session id=${currentPlaybackSession?.id}" + ) + + listeningTimerTask = + Timer("ListeningTimer", false).schedule(15000L, 15000L) { + Handler(Looper.getMainLooper()).post() { + if (playerNotificationService.currentPlayer.isPlaying) { + // Set auto sleep timer if enabled and within start/end time + playerNotificationService.sleepTimerManager.checkAutoSleepTimer() + + // Only sync with server on unmetered connection every 15s OR sync with server if + // last sync time is >= 60s + val shouldSyncServer = + PlayerNotificationService.isUnmeteredNetwork || + System.currentTimeMillis() - lastSyncTime >= + METERED_CONNECTION_SYNC_INTERVAL + + val currentTime = playerNotificationService.getCurrentTimeSeconds() + if (currentTime > 0) { + sync(shouldSyncServer, currentTime) { syncResult -> + Log.d(tag, "Sync complete") + + currentPlaybackSession?.let { playbackSession -> + MediaEventManager.saveEvent(playbackSession, syncResult) + } + } + } + } + } + } + } + + fun play(playbackSession: PlaybackSession) { + Log.d(tag, "play ${playbackSession.displayTitle}") + MediaEventManager.playEvent(playbackSession) + + start(playbackSession) + } + + fun stop(shouldSync: Boolean? = true, cb: () -> Unit) { + if (!listeningTimerRunning) { + reset() + return cb() + } + + listeningTimerTask?.cancel() + listeningTimerTask = null + listeningTimerRunning = false + Log.d(tag, "stop: Stopping listening for $currentDisplayTitle") + + val currentTime = + if (shouldSync == true) playerNotificationService.getCurrentTimeSeconds() else 0.0 + if (currentTime > 0) { // Current time should always be > 0 on stop + sync(true, currentTime) { syncResult -> + currentPlaybackSession?.let { playbackSession -> + MediaEventManager.stopEvent(playbackSession, syncResult) + } + + reset() + cb() + } + } else { + currentPlaybackSession?.let { playbackSession -> + MediaEventManager.stopEvent(playbackSession, null) + } + + reset() + cb() + } + } + + fun pause(cb: () -> Unit) { + if (!listeningTimerRunning) return + + listeningTimerTask?.cancel() + listeningTimerTask = null + listeningTimerRunning = false + Log.d(tag, "pause: Pausing progress syncer for $currentDisplayTitle") + Log.d(tag, "pause: Last sync time $lastSyncTime") + + val currentTime = playerNotificationService.getCurrentTimeSeconds() + if (currentTime > 0) { // Current time should always be > 0 on pause + sync(true, currentTime) { syncResult -> + lastSyncTime = 0L + Log.d(tag, "pause: Set last sync time 0 $lastSyncTime") + failedSyncs = 0 + + currentPlaybackSession?.let { playbackSession -> + MediaEventManager.pauseEvent(playbackSession, syncResult) + } + + cb() + } + } else { + lastSyncTime = 0L + Log.d(tag, "pause: Set last sync time 0 $lastSyncTime (current time < 0)") + failedSyncs = 0 + + currentPlaybackSession?.let { playbackSession -> + MediaEventManager.pauseEvent(playbackSession, null) + } + + cb() + } + } + + fun finished(cb: () -> Unit) { + if (!listeningTimerRunning) return + + listeningTimerTask?.cancel() + listeningTimerTask = null + listeningTimerRunning = false + Log.d(tag, "finished: Stopping listening for $currentDisplayTitle") + + sync(true, currentPlaybackSession?.duration ?: 0.0) { syncResult -> + reset() + + currentPlaybackSession?.let { playbackSession -> + MediaEventManager.finishedEvent(playbackSession, syncResult) + } + + cb() + } + } + + fun seek() { + currentPlaybackSession?.currentTime = playerNotificationService.getCurrentTimeSeconds() + Log.d(tag, "seek: $currentDisplayTitle, currentTime=${currentPlaybackSession?.currentTime}") + + if (currentPlaybackSession == null) { + Log.e(tag, "seek: Playback session not set") + return + } + + MediaEventManager.seekEvent(currentPlaybackSession!!, null) + } + + // Currently unused + fun syncFromServerProgress(mediaProgress: MediaProgress) { + currentPlaybackSession?.let { + it.updatedAt = mediaProgress.lastUpdate + it.currentTime = mediaProgress.currentTime + + MediaEventManager.syncEvent( + mediaProgress, + "Received from server get media progress request while playback session open" + ) + saveLocalProgress(it) + } + } + + fun sync(shouldSyncServer: Boolean, currentTime: Double, cb: (SyncResult?) -> Unit) { + if (lastSyncTime <= 0) { + Log.e(tag, "Last sync time is not set $lastSyncTime") + return cb(null) + } + + val diffSinceLastSync = System.currentTimeMillis() - lastSyncTime + if (diffSinceLastSync < 1000L) { + return cb(null) + } + val listeningTimeToAdd = diffSinceLastSync / 1000L + + val syncData = MediaProgressSyncData(listeningTimeToAdd, currentPlaybackDuration, currentTime) + currentPlaybackSession?.syncData(syncData) + + if (currentPlaybackSession?.progress?.isNaN() == true) { + Log.e( + tag, + "Current Playback Session invalid progress ${currentPlaybackSession?.progress} | Current Time: ${currentPlaybackSession?.currentTime} | Duration: ${currentPlaybackSession?.getTotalDuration()}" + ) + return cb(null) + } + + val hasNetworkConnection = DeviceManager.checkConnectivity(playerNotificationService) + + // Save playback session to db (server linked sessions only) + // Sessions are removed once successfully synced with the server + currentPlaybackSession?.let { DeviceManager.dbManager.savePlaybackSession(it) } + + if (currentIsLocal) { + // Save local progress sync + currentPlaybackSession?.let { + saveLocalProgress(it) + lastSyncTime = System.currentTimeMillis() + + Log.d( + tag, + "Sync local device current serverConnectionConfigId=${DeviceManager.serverConnectionConfig?.id}" + ) + AbsLogger.info("MediaProgressSyncer", "sync: Saved local progress (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: ${it.id})") + + // Local library item is linked to a server library item + // Send sync to server also if connected to this server and local item belongs to this + // server + val isConnectedToSameServer = it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId + if (hasNetworkConnection && + shouldSyncServer && + !it.libraryItemId.isNullOrEmpty() && + isConnectedToSameServer + ) { + apiHandler.sendLocalProgressSync(it) { syncSuccess, errorMsg -> + if (syncSuccess) { + failedSyncs = 0 + playerNotificationService.alertSyncSuccess() + DeviceManager.dbManager.removePlaybackSession(it.id) // Remove session from db + AbsLogger.info("MediaProgressSyncer", "sync: Successfully synced local progress (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: ${it.id})") + } else { + failedSyncs++ + if (failedSyncs == 2) { + playerNotificationService.alertSyncFailing() // Show alert in client + failedSyncs = 0 + } + AbsLogger.error("MediaProgressSyncer", "sync: Local progress sync failed (count: $failedSyncs) (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: ${it.id}) (${DeviceManager.serverConnectionConfigName})") + } + + cb(SyncResult(true, syncSuccess, errorMsg)) + } + } else { + AbsLogger.info("MediaProgressSyncer", "sync: Not sending local progress to server (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: ${it.id}) (hasNetworkConnection: $hasNetworkConnection) (isConnectedToSameServer: $isConnectedToSameServer)") + cb(SyncResult(false, null, null)) + } + } + } else if (hasNetworkConnection && shouldSyncServer) { + AbsLogger.info("MediaProgressSyncer", "sync: Sending progress sync to server (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: ${currentSessionId}) (${DeviceManager.serverConnectionConfigName})") + + apiHandler.sendProgressSync(currentSessionId, syncData) { syncSuccess, errorMsg -> + if (syncSuccess) { + AbsLogger.info("MediaProgressSyncer", "sync: Successfully synced progress (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: ${currentSessionId}) (${DeviceManager.serverConnectionConfigName})") + + failedSyncs = 0 + playerNotificationService.alertSyncSuccess() + lastSyncTime = System.currentTimeMillis() + DeviceManager.dbManager.removePlaybackSession(currentSessionId) // Remove session from db + } else { + failedSyncs++ + if (failedSyncs == 2) { + playerNotificationService.alertSyncFailing() // Show alert in client + failedSyncs = 0 + } + AbsLogger.error("MediaProgressSyncer", "sync: Progress sync failed (count: $failedSyncs) (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: $currentSessionId) (${DeviceManager.serverConnectionConfigName})") + } + cb(SyncResult(true, syncSuccess, errorMsg)) + } + } else { + AbsLogger.info("MediaProgressSyncer", "sync: Not sending progress to server (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: $currentSessionId) (${DeviceManager.serverConnectionConfigName}) (hasNetworkConnection: $hasNetworkConnection)") + cb(SyncResult(false, null, null)) + } + } + + private fun saveLocalProgress(playbackSession: PlaybackSession) { + if (currentLocalMediaProgress == null) { + val mediaProgress = + DeviceManager.dbManager.getLocalMediaProgress(playbackSession.localMediaProgressId) + if (mediaProgress == null) { + currentLocalMediaProgress = playbackSession.getNewLocalMediaProgress() + } else { + currentLocalMediaProgress = mediaProgress + currentLocalMediaProgress?.updateFromPlaybackSession(playbackSession) + } + } else { + currentLocalMediaProgress?.updateFromPlaybackSession(playbackSession) + } + + currentLocalMediaProgress?.let { + if (it.progress.isNaN()) { + Log.e(tag, "Invalid progress on local media progress") + } else { + DeviceManager.dbManager.saveLocalMediaProgress(it) + playerNotificationService.clientEventEmitter?.onLocalMediaProgressUpdate(it) + Log.d( + tag, + "Saved Local Progress Current Time: ID ${it.id} | ${it.currentTime} | Duration ${it.duration} | Progress ${it.progressPercent}%" + ) + } + } + } + + fun reset() { + currentPlaybackSession = null + currentLocalMediaProgress = null + lastSyncTime = 0L + Log.d(tag, "reset: Set last sync time 0 $lastSyncTime") + failedSyncs = 0 + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/media/icons.kt b/android/app/src/main/java/com/audiobookshelf/app/media/icons.kt new file mode 100644 index 0000000..0cfaa94 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/media/icons.kt @@ -0,0 +1,55 @@ +package com.audiobookshelf.app.media + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import androidx.annotation.AnyRes +import com.audiobookshelf.app.R + +/** + * get uri to drawable or any other resource type if u wish + * @param drawableId - drawable res id + * @return - uri + */ +fun getUriToDrawable(context: Context, @AnyRes drawableId: Int): Uri { + return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + + "://" + context.resources.getResourcePackageName(drawableId) + + '/' + context.resources.getResourceTypeName(drawableId) + + '/' + context.resources.getResourceEntryName(drawableId)) +} + + +/** + * get uri to drawable or any other resource type if u wish + * @param drawableId - drawable res id + * @return - uri + */ +fun getUriToAbsIconDrawable(context: Context, absIconName: String): Uri { + val drawableId = when(absIconName) { + "audiobookshelf" -> R.drawable.abs_audiobookshelf + "authors" -> R.drawable.abs_authors + "book-1" -> R.drawable.abs_book_1 + "books-1" -> R.drawable.abs_books_1 + "books-2" -> R.drawable.abs_books_2 + "columns" -> R.drawable.abs_columns + "database" -> R.drawable.abs_database + "file-picture" -> R.drawable.abs_file_picture + "headphones" -> R.drawable.abs_headphones + "heart" -> R.drawable.abs_heart + "microphone_1" -> R.drawable.abs_microphone_1 + "microphone_2" -> R.drawable.abs_microphone_2 + "microphone_3" -> R.drawable.abs_microphone_3 + "music" -> R.drawable.abs_music + "podcast" -> R.drawable.abs_podcast + "radio" -> R.drawable.abs_radio + "rocket" -> R.drawable.abs_rocket + "rss" -> R.drawable.abs_rss + "star" -> R.drawable.abs_star + else -> R.drawable.icon_library_folder + } + return Uri.parse( + ContentResolver.SCHEME_ANDROID_RESOURCE + + "://" + context.resources.getResourcePackageName(drawableId) + + '/' + context.resources.getResourceTypeName(drawableId) + + '/' + context.resources.getResourceEntryName(drawableId)) +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItem.kt b/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItem.kt new file mode 100644 index 0000000..cfab08d --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItem.kt @@ -0,0 +1,50 @@ +package com.audiobookshelf.app.models + +import com.audiobookshelf.app.data.LocalFolder +import com.audiobookshelf.app.data.MediaProgress +import com.audiobookshelf.app.data.MediaType +import com.fasterxml.jackson.annotation.JsonIgnore + +data class DownloadItem( + val id: String, + val libraryItemId: String, + val episodeId: String?, + val userMediaProgress: MediaProgress?, + val serverConnectionConfigId: String, + val serverAddress: String, + val serverUserId: String, + val mediaType: String, + val itemFolderPath: String, + val localFolder: LocalFolder, + val itemTitle: String, + val itemSubfolder: String, + val media: MediaType, + val downloadItemParts: MutableList, + @JsonIgnore var terminalFailureAt: Long? = null, + @JsonIgnore var stagingCleanupAt: Long? = null +) { + @get:JsonIgnore + val isInternalStorage + get() = localFolder.id.startsWith("internal-") + + @get:JsonIgnore + val isDownloadFinished + get() = downloadItemParts.isNotEmpty() && downloadItemParts.all { + it.completed && it.moved && !it.isMoving && !it.failed + } + + @JsonIgnore + fun getNextDownloadItemParts(limit: Int): MutableList { + val itemParts = mutableListOf() + if (limit == 0) return itemParts + + for (it in downloadItemParts) { + if (!it.completed && !it.failed && it.downloadId == null) { + itemParts.add(it) + if (itemParts.size >= limit) break + } + } + + return itemParts + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt b/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt new file mode 100644 index 0000000..a1e60f7 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt @@ -0,0 +1,88 @@ +package com.audiobookshelf.app.models + +import android.net.Uri +import android.util.Log +import com.audiobookshelf.app.data.AudioTrack +import com.audiobookshelf.app.data.EBookFile +import com.audiobookshelf.app.data.LocalFolder +import com.audiobookshelf.app.data.PodcastEpisode +import com.audiobookshelf.app.device.DeviceManager +import com.fasterxml.jackson.annotation.JsonIgnore +import java.io.File + +data class DownloadItemPart( + val id: String, + val downloadItemId: String, + val filename: String, + var fileSize: Long, + @JsonIgnore val destinationPath: String, + val finalDestinationPath:String, + val serverPath: String, + val localFolderName: String, + val localFolderUrl: String, + val localFolderId: String, + val ebookFile: EBookFile?, + val audioTrack: AudioTrack?, + val episode: PodcastEpisode?, + var completed:Boolean, + var moved:Boolean, + var isMoving:Boolean, + var failed:Boolean, + @JsonIgnore val uri: Uri, + @JsonIgnore val destinationUri: Uri, + @JsonIgnore val finalDestinationUri: Uri, + @JsonIgnore var completedDestinationUri: String?, + val finalDestinationSubfolder: String, + var downloadId: Long?, + @JsonIgnore var lastUpdateTime: Long?, + var progress: Long, + var bytesDownloaded: Long, + @JsonIgnore var retryCount: Int = 0, + @JsonIgnore var authRetryCount: Int = 0, + @JsonIgnore var waitingForSpace: Boolean = false, + @JsonIgnore var reusedExistingFile: Boolean = false +) { + companion object { + fun make(downloadItemId:String, filename:String, fileSize: Long, destinationFile: File, finalDestinationFile: File, subfolder:String, serverPath:String, localFolder: LocalFolder, ebookFile: EBookFile?, audioTrack: AudioTrack?, episode: PodcastEpisode?) :DownloadItemPart { + val destinationUri = Uri.fromFile(destinationFile) + val finalDestinationUri = Uri.fromFile(finalDestinationFile) + val rawCover = if (serverPath.endsWith("/cover")) "?raw=1" else "" + val downloadUri = Uri.parse("${DeviceManager.serverAddress}${serverPath}$rawCover") + + Log.d("DownloadItemPart", "Audio File Destination Uri: $destinationUri | Final Destination Uri: $finalDestinationUri | Server Path $serverPath") + return DownloadItemPart( + id = DeviceManager.getBase64Id(finalDestinationFile.absolutePath), + downloadItemId, + filename = filename, + fileSize = fileSize, + destinationPath = destinationFile.absolutePath, + finalDestinationPath = finalDestinationFile.absolutePath, + serverPath = serverPath, + localFolderName = localFolder.name, + localFolderUrl = localFolder.contentUrl, + localFolderId = localFolder.id, + ebookFile = ebookFile, + audioTrack = audioTrack, + episode = episode, + completed = false, + moved = false, + isMoving = false, + failed = false, + uri = downloadUri, + destinationUri = destinationUri, + finalDestinationUri = finalDestinationUri, + completedDestinationUri = null, + finalDestinationSubfolder = subfolder, + downloadId = null, + lastUpdateTime = null, + progress = 0, + bytesDownloaded = 0, + reusedExistingFile = false + ) + } + } + + @get:JsonIgnore + val isInternalStorage get() = localFolderId.startsWith("internal-") + +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/models/User.kt b/android/app/src/main/java/com/audiobookshelf/app/models/User.kt new file mode 100644 index 0000000..6d610bf --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/models/User.kt @@ -0,0 +1,11 @@ +package com.audiobookshelf.app.models + +import com.audiobookshelf.app.data.MediaProgress +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +@JsonIgnoreProperties(ignoreUnknown = true) +data class User( + val id:String, + val username: String, + val mediaProgress:List +) diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/AbMediaDescriptionAdapter.kt b/android/app/src/main/java/com/audiobookshelf/app/player/AbMediaDescriptionAdapter.kt new file mode 100644 index 0000000..3b2d9c4 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/AbMediaDescriptionAdapter.kt @@ -0,0 +1,72 @@ +package com.audiobookshelf.app.player + +import android.app.PendingIntent +import android.graphics.Bitmap +import android.graphics.ImageDecoder +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import android.support.v4.media.MediaMetadataCompat +import android.support.v4.media.session.MediaControllerCompat +import com.google.android.exoplayer2.Player +import com.google.android.exoplayer2.ui.PlayerNotificationManager +import kotlinx.coroutines.* + +class AbMediaDescriptionAdapter (private val controller: MediaControllerCompat, private val playerNotificationService: PlayerNotificationService) : PlayerNotificationManager.MediaDescriptionAdapter { + private val tag = "MediaDescriptionAdapter" + + private var currentIconUri: Uri? = null + private var currentBitmap: Bitmap? = null + + private val serviceJob = SupervisorJob() + private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob) + + override fun createCurrentContentIntent(player: Player): PendingIntent? = + controller.sessionActivity + + override fun getCurrentContentText(player: Player) = controller.metadata.description.subtitle.toString() + + override fun getCurrentContentTitle(player: Player) = controller.metadata.description.title.toString() + + override fun getCurrentLargeIcon( + player: Player, + callback: PlayerNotificationManager.BitmapCallback + ): Bitmap? { + val albumArtUri = controller.metadata.description.iconUri + val albumBitmap = controller.metadata.description.iconBitmap + ?: controller.metadata.getBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART) + + // Reuse bitmap from queue navigator (local) or PlaybackSession.resolveCoverBitmapAsync (streaming) + // For local cover images, bitmap is set in PlayerNotificationService TimelineQueueNavigator.getMediaDescription + if (albumBitmap != null) { + return albumBitmap + } + + return if (currentIconUri != albumArtUri || currentBitmap == null) { + // Cache the bitmap for the current audiobook so that successive calls to + // `getCurrentLargeIcon` don't cause the bitmap to be recreated. + currentIconUri = albumArtUri + + if (currentIconUri.toString().startsWith("content://")) { + currentBitmap = if (Build.VERSION.SDK_INT < 28) { + @Suppress("DEPRECATION") + MediaStore.Images.Media.getBitmap(playerNotificationService.contentResolver, currentIconUri) + } else { + val source: ImageDecoder.Source = ImageDecoder.createSource(playerNotificationService.contentResolver, currentIconUri!!) + ImageDecoder.decodeBitmap(source) + } + currentBitmap + } else { + serviceScope.launch { + currentBitmap = albumArtUri?.let { + resolveUriAsBitmap(playerNotificationService, it) + } + currentBitmap?.let { callback.onBitmap(it) } + } + null + } + } else { + currentBitmap + } + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt b/android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt new file mode 100644 index 0000000..8dd5cd1 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt @@ -0,0 +1,87 @@ +package com.audiobookshelf.app.player + +import android.content.Context +import android.support.v4.media.MediaMetadataCompat +import android.util.Log +import com.audiobookshelf.app.R +import com.audiobookshelf.app.data.* +import com.audiobookshelf.app.media.getUriToDrawable + +class BrowseTree( + val context: Context, + itemsInProgress: List, + libraries: List, + recentsLoaded: Boolean +) { + private val mediaIdToChildren = mutableMapOf>() + + init { + val rootList = mediaIdToChildren[AUTO_BROWSE_ROOT] ?: mutableListOf() + + val continueListeningMetadata = MediaMetadataCompat.Builder().apply { + putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, CONTINUE_ROOT) + putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Continue") + putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_localaudio).toString()) + }.build() + + val recentMetadata = MediaMetadataCompat.Builder().apply { + putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, RECENTLY_ROOT) + putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Recent") + putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.md_clock_outline).toString()) + }.build() + + val downloadsMetadata = MediaMetadataCompat.Builder().apply { + putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, DOWNLOADS_ROOT) + putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Downloads") + putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_downloaddone).toString()) + }.build() + + val librariesMetadata = MediaMetadataCompat.Builder().apply { + putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, LIBRARIES_ROOT) + putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Libraries") + putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.icon_library_folder).toString()) + }.build() + + if (itemsInProgress.isNotEmpty()) { + rootList += continueListeningMetadata + } + + if (libraries.isNotEmpty()) { + if (recentsLoaded) { + rootList += recentMetadata + } + rootList += librariesMetadata + + libraries.forEach { library -> + // Skip libraries without audio content + if (library.stats?.numAudioFiles == 0) return@forEach + Log.d("BrowseTree", "Library $library | ${library.icon}") + // Generate library list items for Libraries menu + val libraryMediaMetadata = library.getMediaMetadata(context) + val children = mediaIdToChildren[LIBRARIES_ROOT] ?: mutableListOf() + children += libraryMediaMetadata + mediaIdToChildren[LIBRARIES_ROOT] = children + + if (recentsLoaded) { + // Generate library list items for Recent menu + val recentlyMediaMetadata = library.getMediaMetadata(context,"recently") + val childrenRecently = mediaIdToChildren[RECENTLY_ROOT] ?: mutableListOf() + childrenRecently += recentlyMediaMetadata + mediaIdToChildren[RECENTLY_ROOT] = childrenRecently + } + } + } + + rootList += downloadsMetadata + + mediaIdToChildren[AUTO_BROWSE_ROOT] = rootList + } + + operator fun get(mediaId: String) = mediaIdToChildren[mediaId] +} + +const val AUTO_BROWSE_ROOT = "/" +const val CONTINUE_ROOT = "__CONTINUE__" +const val DOWNLOADS_ROOT = "__DOWNLOADS__" +const val LIBRARIES_ROOT = "__LIBRARIES__" +const val RECENTLY_ROOT = "__RECENTLY__" diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/CastManager.kt b/android/app/src/main/java/com/audiobookshelf/app/player/CastManager.kt new file mode 100644 index 0000000..dd177d8 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/CastManager.kt @@ -0,0 +1,381 @@ +package com.audiobookshelf.app.player + +import android.app.Activity +import android.app.AlertDialog +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.appcompat.R +import androidx.mediarouter.app.MediaRouteChooserDialog +import androidx.mediarouter.media.MediaRouteSelector +import androidx.mediarouter.media.MediaRouter +import com.getcapacitor.PluginCall +import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener +import com.google.android.gms.cast.* +import com.google.android.gms.cast.framework.* +import org.json.JSONObject + +class CastManager constructor(val mainActivity:Activity) { + private val tag = "CastManager" + + private var playerNotificationService:PlayerNotificationService? = null + private var newConnectionListener: SessionListener? = null + private var castStateListener: CastStateListener? = null + + /** + * The framework reports NO_DEVICES_AVAILABLE when a session ends, because it stops discovering - + * taken at face value that hides the cast button. The media router is the second opinion. + */ + private fun isReceiverAvailable(castState: Int): Boolean { + if (castState != CastState.NO_DEVICES_AVAILABLE) return true + + // The framework's own selector, which carries the receiver id this app is configured with - + // building one by hand here would ask about a different receiver than the one being cast to + val selector = getContext().mergedSelector ?: return false + + return getMediaRouter()?.isRouteAvailable( + selector, + MediaRouter.AVAILABILITY_FLAG_IGNORE_DEFAULT_ROUTE or + MediaRouter.AVAILABILITY_FLAG_REQUIRE_MATCH + ) == true + } + + /** Drops what this manager registered for the activity, which does not outlive it. */ + fun detach() { + castStateListener?.let { getContext().removeCastStateListener(it) } + castStateListener = null + } + + private fun switchToPlayer(useCastPlayer:Boolean) { + Handler(Looper.getMainLooper()).post() { + playerNotificationService?.switchToPlayer(useCastPlayer) + } + } + + private inner class CastSessionAvailabilityListener : SessionAvailabilityListener { + + /** + * Called when a Cast session has started and the user wishes to control playback on a + * remote Cast receiver rather than play audio locally. + */ + override fun onCastSessionAvailable() { + Log.d(tag, "SessionAvailabilityListener: onCastSessionAvailable") + switchToPlayer(true) + } + + /** + * Called when a Cast session has ended and the user wishes to control playback locally. + */ + override fun onCastSessionUnavailable() { + Log.d(tag, "onCastSessionUnavailable") + switchToPlayer(false) + } + } + + fun requestSession(playerNotificationService: PlayerNotificationService, callback: RequestSessionCallback) { + this.playerNotificationService = playerNotificationService + + mainActivity.runOnUiThread { + val session: CastSession? = getSession() + if (session == null) { + // show the "choose a connection" dialog + // Add the connection listener callback + listenForConnection(callback) + + val builder = MediaRouteChooserDialog(mainActivity, R.style.Theme_AppCompat_NoActionBar) + builder.routeSelector = MediaRouteSelector.Builder() + .addControlCategory(CastMediaControlIntent.categoryForCast(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID)) + .build() + builder.setCanceledOnTouchOutside(true) + builder.setOnCancelListener { + newConnectionListener?.let { ncl -> + getSessionManager()?.removeSessionManagerListener(ncl, CastSession::class.java) + } + callback.onCancel() + } + builder.show() + } else { + // We are are already connected, so show the "connection options" Dialog + val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity) + session.castDevice?.let { + builder.setTitle(it.friendlyName) + } + builder.setOnDismissListener { callback.onCancel() } + builder.setPositiveButton("Stop Casting") { _, _ -> endSession(true, null) } + builder.show() + } + } + } + + abstract class RequestSessionCallback : ConnectionCallback { + abstract fun onError(errorCode: Int) + abstract fun onCancel() + override fun onSessionEndedBeforeStart(errorCode: Int): Boolean { + onSessionStartFailed(errorCode) + return true + } + + override fun onSessionStartFailed(errorCode: Int): Boolean { + onError(errorCode) + return true + } + } + + fun endSession(stopCasting: Boolean, pluginCall: PluginCall?) { + + getSessionManager()!!.addSessionManagerListener(object : SessionListener() { + override fun onSessionEnded(castSession: CastSession, error: Int) { + getSessionManager()!!.removeSessionManagerListener(this, CastSession::class.java) + Log.d(tag, "CAST END SESSION") + pluginCall?.resolve() + } + }, CastSession::class.java) + getSessionManager()!!.endCurrentSession(stopCasting) + + } + + open class SessionListener : SessionManagerListener { + override fun onSessionStarting(castSession: CastSession) {} + override fun onSessionStarted(castSession: CastSession, sessionId: String) {} + override fun onSessionStartFailed(castSession: CastSession, error: Int) {} + override fun onSessionEnding(castSession: CastSession) {} + override fun onSessionEnded(castSession: CastSession, error: Int) {} + override fun onSessionResuming(castSession: CastSession, sessionId: String) {} + override fun onSessionResumed(castSession: CastSession, wasSuspended: Boolean) {} + override fun onSessionResumeFailed(castSession: CastSession, error: Int) {} + override fun onSessionSuspended(castSession: CastSession, reason: Int) {} + } + + fun startRouteScan(connListener:ChromecastListener) { + // ChromecastListener has always implemented CastStateListener, but nothing registered it, so + // availability hung entirely on the media router callback below - which does not always fire + val stateListener = CastStateListener { state -> connListener.onReceiverAvailableUpdate(isReceiverAvailable(state)) } + castStateListener = stateListener + getContext().addCastStateListener(stateListener) + connListener.onReceiverAvailableUpdate(isReceiverAvailable(getContext().castState)) + + val callback = object : ScanCallback() { + override fun onRouteUpdate(routes: List?) { + Log.d(tag, "CAST On ROUTE UPDATED ${routes?.size} | ${getContext().castState}") + // if the routes have changed, we may have an available device + // If there is at least one device available + if (getContext().castState != CastState.NO_DEVICES_AVAILABLE) { + routes?.forEach { Log.d(tag, "CAST ROUTE ${it.description} | ${it.deviceType} | ${it.isBluetooth} | ${it.name}") } + + // Stop the scan + stopRouteScan(this, null) + // Let the client know a receiver is available + connListener.onReceiverAvailableUpdate(true) + // Since we have a receiver we may also have an active session + val session = getSessionManager()?.currentCastSession + // If we do have a session + if (session != null) { + // Let the client know + } + } else { + Log.d(tag, "No cast devices available") + } + } + } + + callback.setMediaRouter(getMediaRouter()) + + callback.onFilteredRouteUpdate() + + getMediaRouter()?.addCallback(MediaRouteSelector.Builder() + .addControlCategory(CastMediaControlIntent.categoryForCast(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID)) + .build(), + callback, + MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN) + } + + internal interface CastListener : Cast.MessageReceivedCallback { + fun onMediaLoaded(jsonMedia: JSONObject?) + fun onMediaUpdate(jsonMedia: JSONObject?) + fun onSessionUpdate(jsonSession: JSONObject?) + fun onSessionEnd(jsonSession: JSONObject?) + } + + abstract class ChromecastListener : CastStateListener, CastListener { + abstract fun onReceiverAvailableUpdate(available: Boolean) + abstract fun onSessionRejoin(jsonSession: JSONObject?) + + /** CastStateListener functions. */ + override fun onCastStateChanged(state: Int) { + onReceiverAvailableUpdate(state != CastState.NO_DEVICES_AVAILABLE) + } + } + + fun stopRouteScan(callback: ScanCallback?, completionCallback: Runnable?) { + Log.d(tag, "stopRouteScan") + if (callback == null) { + completionCallback?.run() + return + } + +// mainActivity.runOnUiThread { + Log.d(tag, "Removing callback on media router") + callback.stop() + getMediaRouter()?.removeCallback(callback) + completionCallback?.run() +// } + + } + + abstract class ScanCallback : MediaRouter.Callback() { + /** + * Called whenever a route is updated. + * @param routes the currently available routes + */ + abstract fun onRouteUpdate(routes: List?) + + /** records whether we have been stopped or not. */ + private var stopped = false + + /** Global mediaRouter object. */ + private var mediaRouter: MediaRouter? = null + + /** + * Sets the mediaRouter object. + * @param router mediaRouter object + */ + fun setMediaRouter(router: MediaRouter?) { + mediaRouter = router + } + + /** + * Call this method when you wish to stop scanning. + * It is important that it is called, otherwise battery + * life will drain more quickly. + */ + fun stop() { + stopped = true + } + + fun onFilteredRouteUpdate() { + if (stopped || mediaRouter == null) { + return + } + val outRoutes: MutableList = ArrayList() + // Filter the routes + for (route in mediaRouter!!.routes) { + // We don't want default routes, or duplicate active routes + // or multizone duplicates https://github.com/jellyfin/cordova-plugin-chromecast/issues/32 + val extras: Bundle? = route.extras + if (extras != null) { + CastDevice.getFromBundle(extras) + if (extras.getString("com.google.android.gms.cast.EXTRA_SESSION_ID") != null) { + continue + } + } + if (!route.isDefault + && !route.description.equals("Google Cast Multizone Member") + && route.playbackType == MediaRouter.RouteInfo.PLAYBACK_TYPE_REMOTE) { + outRoutes.add(route) + } + } + onRouteUpdate(outRoutes) + } + + override fun onRouteAdded(router: MediaRouter, route: MediaRouter.RouteInfo) { + onFilteredRouteUpdate() + } + + override fun onRouteChanged(router: MediaRouter, route: MediaRouter.RouteInfo) { + onFilteredRouteUpdate() + } + + override fun onRouteRemoved(router: MediaRouter, route: MediaRouter.RouteInfo) { + onFilteredRouteUpdate() + } + } + + private fun listenForConnection(callback: ConnectionCallback) { + // We should only ever have one of these listeners active at a time, so remove previous + newConnectionListener?.let { ncl -> + getSessionManager()?.removeSessionManagerListener(ncl, CastSession::class.java) + } + + newConnectionListener = object : SessionListener() { + override fun onSessionStarted(castSession: CastSession, sessionId: String) { + Log.d(tag, "CAST SESSION STARTED ${castSession.castDevice?.friendlyName}") + getSessionManager()?.removeSessionManagerListener(this, CastSession::class.java) + + val castContext = CastContext.getSharedInstance(mainActivity) + + playerNotificationService?.let { + if (it.castPlayer == null) { + Log.d(tag, "Initializing castPlayer on session started - switch to cast player") + it.castPlayer = CastPlayer(castContext).apply { + addListener(PlayerListener(it)) + setSessionAvailabilityListener(CastSessionAvailabilityListener()) + } + switchToPlayer(true) + } else { + Log.d(tag, "castPlayer is already initialized on session started") + } + } + } + + override fun onSessionStartFailed(castSession: CastSession, error: Int) { + if (callback.onSessionStartFailed(error)) { + getSessionManager()?.removeSessionManagerListener(this, CastSession::class.java) + } + } + + override fun onSessionEnded(castSession: CastSession, error: Int) { + if (callback.onSessionEndedBeforeStart(error)) { + getSessionManager()?.removeSessionManagerListener(this, CastSession::class.java) + } + } + } + + newConnectionListener?.let { + Log.d(tag, "Add session manager listener") + getSessionManager()?.addSessionManagerListener(it, CastSession::class.java) + } + } + + private fun getContext(): CastContext { + return CastContext.getSharedInstance(mainActivity) + } + + private fun getSessionManager(): SessionManager? { + return getContext().sessionManager + } + + private fun getMediaRouter(): MediaRouter? { + return MediaRouter.getInstance(mainActivity) + } + + private fun getSession(): CastSession? { + return getSessionManager()?.currentCastSession + } + + internal interface ConnectionCallback { + /** + * Successfully joined a session on a route. + * @param jsonSession the session we joined + */ + fun onJoin(jsonSession: JSONObject?) + + /** + * Called if we received an error. + * @param errorCode You can find the error meaning here: + * https://developers.google.com/android/reference/com/google/android/gms/cast/CastStatusCodes + * @return true if we are done listening for join, false, if we to keep listening + */ + fun onSessionStartFailed(errorCode: Int): Boolean + + /** + * Called when we detect a session ended event before session started. + * See issues: + * https://github.com/jellyfin/cordova-plugin-chromecast/issues/49 + * https://github.com/jellyfin/cordova-plugin-chromecast/issues/48 + * @param errorCode error to output + * @return true if we are done listening for join, false, if we to keep listening + */ + fun onSessionEndedBeforeStart(errorCode: Int): Boolean + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/CastPlayer.kt b/android/app/src/main/java/com/audiobookshelf/app/player/CastPlayer.kt new file mode 100644 index 0000000..56687a8 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/CastPlayer.kt @@ -0,0 +1,1028 @@ +package com.audiobookshelf.app.player + +import android.annotation.SuppressLint +import android.os.Looper +import android.util.Log +import kotlin.math.roundToInt +import android.view.Surface +import android.view.SurfaceHolder +import android.view.SurfaceView +import android.view.TextureView +import com.google.android.exoplayer2.* +import com.google.android.exoplayer2.C.TrackType +import com.google.android.exoplayer2.MediaMetadata +import com.google.android.exoplayer2.Player.* +import com.google.android.exoplayer2.Tracks.Group +import com.google.android.exoplayer2.audio.AudioAttributes +import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener +import com.google.android.exoplayer2.source.TrackGroup +import com.google.android.exoplayer2.source.TrackGroupArray +import com.google.android.exoplayer2.text.CueGroup +import com.google.android.exoplayer2.trackselection.TrackSelection +import com.google.android.exoplayer2.trackselection.TrackSelectionArray +import com.google.android.exoplayer2.trackselection.TrackSelectionParameters +import com.google.android.exoplayer2.util.* +import com.google.android.exoplayer2.util.Util.castNonNull +import com.google.android.exoplayer2.video.VideoSize +import com.google.android.gms.cast.* +import com.google.android.gms.cast.framework.CastContext +import com.google.android.gms.cast.framework.CastSession +import com.google.android.gms.cast.framework.SessionManagerListener +import com.google.android.gms.cast.framework.media.RemoteMediaClient +import com.google.android.gms.common.api.PendingResult +import com.google.android.gms.common.api.ResultCallback +import org.json.JSONObject + + +class CastPlayer(var castContext: CastContext) : BasePlayer() { + val tag = "CastPlayer" + + private val RENDERER_COUNT = 3 + private val RENDERER_INDEX_VIDEO = 0 + private val RENDERER_INDEX_AUDIO = 1 + private val RENDERER_INDEX_TEXT = 2 + private val PROGRESS_REPORT_PERIOD_MS: Long = 1000 + private val EMPTY_TRACK_SELECTION_ARRAY = TrackSelectionArray() + private val EMPTY_TRACK_ID_ARRAY = LongArray(0) + + private val seekBackIncrementMs: Long = 5000L + private val seekForwardIncrementMs: Long = 5000L + private var myPlayWhenReady:Boolean = false + private var playbackParameters:PlaybackParameters = PlaybackParameters.DEFAULT + private var myAvailableCommands: Commands + private var myCurrentTracksInfo: Tracks + private var myCurrentTrackGroups: TrackGroupArray + private var myCurrentTrackSelections: TrackSelectionArray + + // Cache for pending volume to handle async setVolume + private var pendingVolume: Int? = null + private var pendingVolumeSetAt: Long = 0L + + var currentMediaItems:List = mutableListOf() + var remoteMediaClient:RemoteMediaClient? = null + var sessionAvailabilityListener:SessionAvailabilityListener? = null + var myCurrentTimeline: CastTimeline + val timelineTracker: CastTimelineTracker + val period: Timeline.Period + var listeners: ListenerSet + + /* package */ + val PERMANENT_AVAILABLE_COMMANDS = Commands.Builder() + .addAll( + COMMAND_PLAY_PAUSE, + COMMAND_PREPARE, + COMMAND_STOP, + COMMAND_SEEK_TO_DEFAULT_POSITION, + COMMAND_SEEK_TO_MEDIA_ITEM, + COMMAND_SET_REPEAT_MODE, + COMMAND_SET_SPEED_AND_PITCH, + COMMAND_GET_CURRENT_MEDIA_ITEM, + COMMAND_GET_TIMELINE, + COMMAND_GET_MEDIA_ITEMS_METADATA, + COMMAND_SET_MEDIA_ITEMS_METADATA, + COMMAND_CHANGE_MEDIA_ITEMS, + COMMAND_GET_TRACKS, + COMMAND_GET_DEVICE_VOLUME, + COMMAND_SET_DEVICE_VOLUME, + COMMAND_ADJUST_DEVICE_VOLUME) + .build() + + var currentPlaybackState = Player.STATE_IDLE + var statusListener:StatusListener + + val deviceInfo get() = castContext.sessionManager.currentCastSession?.castDevice.toString() + var lastReportedPositionMs = 0L + + private var currentMediaItemIndex = 0 + private var pendingMediaItemRemovalPosition:PositionInfo? = null + private var pendingSeekCount = 0 + private var pendingSeekWindowIndex = C.INDEX_UNSET + private var pendingSeekPositionMs = 0L + + init { + Log.d(tag, "Init CastPlayer") + myCurrentTrackGroups = TrackGroupArray.EMPTY + myCurrentTrackSelections = EMPTY_TRACK_SELECTION_ARRAY + myCurrentTracksInfo = Tracks.EMPTY + statusListener = StatusListener() + timelineTracker = CastTimelineTracker() + myCurrentTimeline = CastTimeline.EMPTY_CAST_TIMELINE + period = Timeline.Period() + myAvailableCommands = Commands.Builder().addAll(PERMANENT_AVAILABLE_COMMANDS).build() + + listeners = ListenerSet( + Looper.getMainLooper(), + Clock.DEFAULT + ) { listener: Player.Listener, flags: FlagSet? -> listener.onEvents(this, Player.Events(flags ?: FlagSet.Builder().build())) } + + val sessionManager = castContext.sessionManager + sessionManager.addSessionManagerListener(statusListener, CastSession::class.java) + val session = sessionManager.currentCastSession + setRemoteMediaClient(session?.remoteMediaClient) + } + + fun load(mediaItems:List, startIndex:Int, startTime:Long, playWhenReady:Boolean, playbackRate:Float, mediaType:String) { + Log.d(tag, "Load called") + + if (remoteMediaClient == null) { + Log.d(tag, "Remote Media Client not set") + return + } + + currentMediaItems = mediaItems + + var mediaQueueItems = mediaItems.map { toMediaQueueItem(it) } + + var queueData = MediaQueueData.Builder().apply { + setItems(mediaQueueItems) + setQueueType(if (mediaType == "book") MediaQueueData.MEDIA_QUEUE_TYPE_AUDIO_BOOK else MediaQueueData.MEDIA_QUEUE_TYPE_PODCAST_SERIES) + setRepeatMode(MediaStatus.REPEAT_MODE_REPEAT_OFF) + setStartIndex(startIndex) + setStartTime(startTime) + }.build() + + var loadRequestData = MediaLoadRequestData.Builder().apply { + setPlaybackRate(playbackRate.toDouble()) + setQueueData(queueData) + setAutoplay(playWhenReady) + setCurrentTime(startTime) + }.build() + + remoteMediaClient?.load(loadRequestData)?.setResultCallback { + Log.d(tag, "Loaded cast player result ${it.status} | ${it.mediaError} | ${it.customData}") + } + + Log.d(tag, "Loaded cast player request data $loadRequestData") + } + + private fun toMediaQueueItem(mediaItem: MediaItem): MediaQueueItem { + // The MediaQueueItem you build is expected to be in the tag. + return (mediaItem.localConfiguration!!.tag as MediaQueueItem?)!! + } + + @JvmName("setRemoteMediaClient1") + private fun setRemoteMediaClient(remoteMediaClient: RemoteMediaClient?) { + if (this.remoteMediaClient === remoteMediaClient) { + // Do nothing. + return + } + if (this.remoteMediaClient != null) { + this.remoteMediaClient?.unregisterCallback(statusListener) + this.remoteMediaClient?.removeProgressListener(statusListener) + } + this.remoteMediaClient = remoteMediaClient + if (remoteMediaClient != null) { + sessionAvailabilityListener?.let { + it.onCastSessionAvailable() + } + + remoteMediaClient.registerCallback(statusListener) + remoteMediaClient.addProgressListener(statusListener, PROGRESS_REPORT_PERIOD_MS) + updateInternalStateAndNotifyIfChanged() + } else { + updateTimelineAndNotifyIfChanged() + sessionAvailabilityListener?.let { + it.onCastSessionUnavailable() + } + } + } + + private fun setPlayerStateAndNotifyIfChanged( + playWhenReady: Boolean, + @PlayWhenReadyChangeReason playWhenReadyChangeReason: Int, + @Player.State playbackState: Int) { + + val wasPlaying = this.currentPlaybackState == STATE_READY && this.playWhenReady + val playWhenReadyChanged = this.playWhenReady != playWhenReady + val playbackStateChanged = this.currentPlaybackState != playbackState + + Log.d(tag, "setPlayerStateAndNotifyIfChanged newPlayWhenReady:$playWhenReady | playbackStateChanged:$playbackStateChanged | playWhenReadyChanged:$playWhenReadyChanged") + if (playWhenReadyChanged || playbackStateChanged) { + this.currentPlaybackState = playbackState + this.myPlayWhenReady = playWhenReady + + if (playbackStateChanged) { + Log.d(tag, "CastPlayer About to emit onPlaybackStateChanged") + listeners.queueEvent(EVENT_PLAYBACK_STATE_CHANGED) { + it.onPlaybackStateChanged(playbackState) + } + } + + if (playWhenReadyChanged) { + listeners.queueEvent(EVENT_PLAY_WHEN_READY_CHANGED) { + it.onPlayWhenReadyChanged(playWhenReady, playWhenReadyChangeReason) + } + } + + val isPlaying = playbackState == STATE_READY && playWhenReady + if (wasPlaying != isPlaying) { + Log.d(tag, "CastPlayer About to emit onIsPlayingChanged $isPlaying") + listeners.queueEvent(EVENT_IS_PLAYING_CHANGED) { + it.onIsPlayingChanged(isPlaying) + } + } else { + Log.d(tag, "Is playing and was playing are equal $wasPlaying") + } + } else { + Log.d(tag, "setPlayerStateAndNotifyIfChanged No changes") + } + listeners.flushEvents() + } + + private fun updatePlayerStateAndNotifyIfChanged() { + var newPlayWhenReadyValue = remoteMediaClient?.isPaused != true + + @PlayWhenReadyChangeReason val playWhenReadyChangeReason = if (newPlayWhenReadyValue != playWhenReady) PLAY_WHEN_READY_CHANGE_REASON_REMOTE else PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST + // We do not mask the playback state, so try setting it regardless of the playWhenReady masking. + setPlayerStateAndNotifyIfChanged( + newPlayWhenReadyValue, playWhenReadyChangeReason, fetchPlaybackState(remoteMediaClient)) + } + + private fun fetchPlaybackState(remoteMediaClient: RemoteMediaClient?): Int { + return when (remoteMediaClient?.playerState) { + MediaStatus.PLAYER_STATE_BUFFERING -> STATE_BUFFERING + MediaStatus.PLAYER_STATE_PLAYING, MediaStatus.PLAYER_STATE_PAUSED -> STATE_READY + MediaStatus.PLAYER_STATE_IDLE, MediaStatus.PLAYER_STATE_UNKNOWN -> STATE_IDLE + else -> STATE_IDLE + } + } + + private fun updatePlaybackRateAndNotifyIfChanged() { + val mediaStatus = remoteMediaClient!!.mediaStatus + val speed = mediaStatus?.playbackRate?.toFloat() ?: PlaybackParameters.DEFAULT.speed + if (speed > 0.0f) { + // Set the speed if not paused. + setPlaybackParametersAndNotifyIfChanged(PlaybackParameters(speed)) + } + } + + private fun setPlaybackParametersAndNotifyIfChanged(playbackParameters: PlaybackParameters) { + if (this.playbackParameters == playbackParameters) { + return + } + this.playbackParameters = playbackParameters + listeners.queueEvent( + EVENT_PLAYBACK_PARAMETERS_CHANGED + ) { listener -> listener.onPlaybackParametersChanged(playbackParameters) } + listeners.flushEvents() + updateAvailableCommandsAndNotifyIfChanged() + } + + private fun updateTimelineAndNotifyIfChanged(): Boolean { + val oldTimeline: Timeline = this.myCurrentTimeline + val oldWindowIndex = currentMediaItemIndex + var playingPeriodChanged = false + if (updateTimeline()) { + val timeline: Timeline = this.myCurrentTimeline + // Call onTimelineChanged. + listeners.queueEvent( + EVENT_TIMELINE_CHANGED + ) { listener: Player.Listener -> listener.onTimelineChanged(timeline, TIMELINE_CHANGE_REASON_SOURCE_UPDATE) } + + // Call onPositionDiscontinuity if required. + val currentTimeline = currentTimeline + var playingPeriodRemoved = false + if (!oldTimeline.isEmpty) { + val oldPeriodUid = castNonNull(oldTimeline.getPeriod(oldWindowIndex, period, /* setIds= */true).uid) + playingPeriodRemoved = currentTimeline.getIndexOfPeriod(oldPeriodUid) == C.INDEX_UNSET + } + if (playingPeriodRemoved) { + var oldPosition: PositionInfo = getCurrentPositionInfo() + if (pendingMediaItemRemovalPosition != null) { + pendingMediaItemRemovalPosition?.let { + oldPosition = it + } + pendingMediaItemRemovalPosition = null + } else { + // If the media item has been removed by another client, we don't know the removal + // position. We use the current position as a fallback. + oldTimeline.getPeriod(oldWindowIndex, period, /* setIds= */true) + oldTimeline.getWindow(period.windowIndex, window) + oldPosition = PositionInfo( + window.uid, + period.windowIndex, + window.mediaItem, + period.uid, + period.windowIndex, + currentPosition, + contentPosition, + C.INDEX_UNSET,/* adGroupIndex= */ + C.INDEX_UNSET) /* adIndexInAdGroup= */ + } + + val newPosition: PositionInfo = getCurrentPositionInfo() + listeners.queueEvent( + EVENT_POSITION_DISCONTINUITY + ) { listener: Player.Listener -> + listener.onPositionDiscontinuity( + oldPosition, newPosition, DISCONTINUITY_REASON_REMOVE) + } + } + + // Call onMediaItemTransition if required. + playingPeriodChanged = currentTimeline.isEmpty != oldTimeline.isEmpty || playingPeriodRemoved + if (playingPeriodChanged) { + listeners.queueEvent( + EVENT_MEDIA_ITEM_TRANSITION + ) { listener: Player.Listener -> + listener.onMediaItemTransition( + currentMediaItem, MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED) + } + } + updateAvailableCommandsAndNotifyIfChanged() + } + return playingPeriodChanged + } + + private fun updateInternalStateAndNotifyIfChanged() { + if (remoteMediaClient == null) { + // There is no session. We leave the state of the player as it is now. + return + } + + val oldWindowIndex = this.currentMediaItemIndex + val oldPeriodUid = if (!currentTimeline.isEmpty) currentTimeline.getPeriod(oldWindowIndex, period, /* setIds= */true).uid else null + updatePlayerStateAndNotifyIfChanged() +// updateRepeatModeAndNotifyIfChanged( /* resultCallback= */null) + updatePlaybackRateAndNotifyIfChanged() + val playingPeriodChangedByTimelineChange = updateTimelineAndNotifyIfChanged() + val currentTimeline = currentTimeline + currentMediaItemIndex = fetchCurrentWindowIndex(remoteMediaClient, currentTimeline) + val currentPeriodUid = if (!currentTimeline.isEmpty) currentTimeline.getPeriod(currentMediaItemIndex, period, /* setIds= */true).uid else null + if (!playingPeriodChangedByTimelineChange + && !Util.areEqual(oldPeriodUid, currentPeriodUid) + && pendingSeekCount == 0) { + // Report discontinuity and media item auto transition. + currentTimeline.getPeriod(oldWindowIndex, period, /* setIds= */true) + currentTimeline.getWindow(oldWindowIndex, window) + val windowDurationMs = window.durationMs + val oldPosition = PositionInfo( + window.uid, + period.windowIndex, + window.mediaItem, + period.uid, + period.windowIndex, /* positionMs= */ + windowDurationMs, /* contentPositionMs= */ + windowDurationMs, /* adGroupIndex= */ + C.INDEX_UNSET, /* adIndexInAdGroup= */ + C.INDEX_UNSET) + currentTimeline.getPeriod(currentMediaItemIndex, period, /* setIds= */true) + currentTimeline.getWindow(currentMediaItemIndex, window) + val newPosition = PositionInfo( + window.uid, + period.windowIndex, + window.mediaItem, + period.uid, + period.windowIndex, /* positionMs= */ + window.defaultPositionMs, /* contentPositionMs= */ + window.defaultPositionMs, /* adGroupIndex= */ + C.INDEX_UNSET, /* adIndexInAdGroup= */ + C.INDEX_UNSET) + listeners.queueEvent( + EVENT_POSITION_DISCONTINUITY + ) { listener: Listener -> + listener.onPositionDiscontinuity( + oldPosition, newPosition, DISCONTINUITY_REASON_AUTO_TRANSITION) + } + listeners.queueEvent( + EVENT_MEDIA_ITEM_TRANSITION + ) { listener: Listener -> + listener.onMediaItemTransition( + currentMediaItem, MEDIA_ITEM_TRANSITION_REASON_AUTO) + } + } + if (updateTracksAndSelectionsAndNotifyIfChanged()) { + listeners.queueEvent( + EVENT_TRACKS_CHANGED) { listener: Listener -> listener.onTracksChanged(currentTracks) } + } + updateAvailableCommandsAndNotifyIfChanged() + listeners.flushEvents() + } + + /** Updates the internal tracks and selection and returns whether they have changed. */ + @SuppressLint("WrongConstant") + private fun updateTracksAndSelectionsAndNotifyIfChanged(): Boolean { + if (remoteMediaClient == null) { + // There is no session. We leave the state of the player as it is now. + return false + } + val mediaStatus = getMediaStatus() + val mediaInfo = mediaStatus?.mediaInfo + val castMediaTracks = mediaInfo?.mediaTracks + if (castMediaTracks == null || castMediaTracks.isEmpty()) { + val hasChanged = !myCurrentTrackGroups.isEmpty + myCurrentTrackGroups = TrackGroupArray.EMPTY + myCurrentTrackSelections = EMPTY_TRACK_SELECTION_ARRAY + myCurrentTracksInfo = Tracks.EMPTY + return hasChanged + } + var activeTrackIds = mediaStatus.activeTrackIds + if (activeTrackIds == null) { + activeTrackIds = EMPTY_TRACK_ID_ARRAY + } + val trackGroups = arrayOfNulls(castMediaTracks.size) + val trackSelections = arrayOfNulls(RENDERER_COUNT) + val trackGroupInfos = arrayOfNulls(castMediaTracks.size) + for (i in castMediaTracks.indices) { + val mediaTrack = castMediaTracks[i] + trackGroups[i] = TrackGroup( /* id= */i.toString(), mediaTrackToFormat(mediaTrack)) + val id = mediaTrack.id + val trackType = MimeTypes.getTrackType(mediaTrack.contentType) + val rendererIndex: Int = getRendererIndexForTrackType(trackType) + val supported = rendererIndex != C.INDEX_UNSET + val selected = isTrackActive(id, activeTrackIds) && supported && trackSelections[rendererIndex] == null + if (selected) { + trackSelections[rendererIndex] = trackGroups[i]?.let { CastTrackSelection(it) } + } + val trackSupport = intArrayOf(if (supported) C.FORMAT_HANDLED else C.FORMAT_UNSUPPORTED_TYPE) + val trackSelected = booleanArrayOf(selected) + trackGroupInfos[i] = Group(trackGroups[i]!!, false, trackSupport, trackSelected) + } + + val tg = trackGroups.filterNotNull().toTypedArray() + val ts = trackSelections.filterNotNull().toTypedArray() + val tgi = trackGroupInfos.filterNotNull().toMutableList() + val newTrackGroups = TrackGroupArray(*tg) + val newTrackSelections = TrackSelectionArray(*ts) + val newTracksInfo = Tracks(tgi) + if (newTracksInfo != currentTracks) { + myCurrentTrackSelections = newTrackSelections + myCurrentTrackGroups = newTrackGroups + myCurrentTracksInfo = newTracksInfo + return true + } + return false + } + + /** + * Creates a [Format] instance containing all information contained in the given [ ] object. + * + * @param mediaTrack The [MediaTrack]. + * @return The equivalent [Format]. + */ + fun mediaTrackToFormat(mediaTrack: MediaTrack): Format { + return Format.Builder() + .setId(mediaTrack.contentId) + .setContainerMimeType(mediaTrack.contentType) + .setLanguage(mediaTrack.language) + .build() + } + + private fun getRendererIndexForTrackType(trackType: @TrackType Int): Int { + return if (trackType == C.TRACK_TYPE_VIDEO) RENDERER_INDEX_VIDEO else if (trackType == C.TRACK_TYPE_AUDIO) RENDERER_INDEX_AUDIO else if (trackType == C.TRACK_TYPE_TEXT) RENDERER_INDEX_TEXT else C.INDEX_UNSET + } + + private fun isTrackActive(id: Long, activeTrackIds: LongArray): Boolean { + for (activeTrackId in activeTrackIds) { + if (activeTrackId == id) { + return true + } + } + return false + } + + private fun getCurrentPositionInfo(): PositionInfo { + val currentTimeline = currentTimeline + var newPeriodUid: Any? = null + var newWindowUid: Any? = null + var newMediaItem: MediaItem? = null + if (!currentTimeline.isEmpty) { + newPeriodUid = currentTimeline.getPeriod(currentPeriodIndex, period, /* setIds= */true).uid + newWindowUid = currentTimeline.getWindow(period.windowIndex, window).uid + newMediaItem = window.mediaItem + } + return PositionInfo( + newWindowUid, + currentMediaItemIndex, + newMediaItem, + newPeriodUid, + currentPeriodIndex, + currentPosition, + contentPosition, + C.INDEX_UNSET,/* adGroupIndex= */ + C.INDEX_UNSET) /* adIndexInAdGroup= */ + } + + private fun updateTimeline(): Boolean { + val oldTimeline = this.myCurrentTimeline + val status: MediaStatus? = getMediaStatus() + + remoteMediaClient?.let { + this.myCurrentTimeline = if (status != null) timelineTracker.getCastTimeline(it) else CastTimeline.EMPTY_CAST_TIMELINE + } + + val timelineChanged = oldTimeline != this.myCurrentTimeline + if (timelineChanged) { + currentMediaItemIndex = fetchCurrentWindowIndex(remoteMediaClient, this.myCurrentTimeline) + Log.d(tag, "timelineChanged $currentMediaItemIndex") + } + return timelineChanged + } + + private fun fetchCurrentWindowIndex(remoteMediaClient: RemoteMediaClient?, timeline: Timeline): Int { + if (remoteMediaClient == null) { + return 0 + } + var currentWindowIndex = C.INDEX_UNSET + val currentItem = remoteMediaClient.currentItem + if (currentItem != null) { + currentWindowIndex = timeline.getIndexOfPeriod(currentItem.itemId) + } + if (currentWindowIndex == C.INDEX_UNSET) { + // The timeline is empty. Fall back to index 0. + currentWindowIndex = 0 + } + return currentWindowIndex + } + + private fun getMediaStatus():MediaStatus? { + return remoteMediaClient?.mediaStatus + } + + @JvmName("setSessionAvailabilityListener1") + fun setSessionAvailabilityListener(listener: SessionAvailabilityListener) { + sessionAvailabilityListener = listener + } + + override fun getApplicationLooper(): Looper { + return Looper.getMainLooper() + } + + override fun addListener(listener: Player.Listener) { + listeners.add(listener) + Log.d(tag, "addListener player listener $listener") + } + + override fun removeListener(listener: Player.Listener) { + listeners.remove(listener) + } + + override fun setMediaItems(mediaItems: MutableList, resetPosition: Boolean) { + } + + override fun setMediaItems(mediaItems: MutableList, startWindowIndex: Int, startPositionMs: Long) { + } + + override fun addMediaItems(index: Int, mediaItems: MutableList) { + } + + override fun moveMediaItems(fromIndex: Int, toIndex: Int, newIndex: Int) { + } + + override fun removeMediaItems(fromIndex: Int, toIndex: Int) { + Log.d(tag, "removeMediaItems called not configured yet $fromIndex to $toIndex") + } + + override fun getAvailableCommands(): Player.Commands { + return myAvailableCommands + } + + override fun prepare() { + } + + override fun getPlaybackState(): Int { + return currentPlaybackState + } + + override fun getPlaybackSuppressionReason(): Int { + return Player.PLAYBACK_SUPPRESSION_REASON_NONE + } + + override fun getPlayerError(): PlaybackException? { + return null + } + + override fun setPlayWhenReady(playWhenReady: Boolean) { + setPlayerStateAndNotifyIfChanged( + playWhenReady, PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, playbackState) + + listeners.flushEvents() + val pendingResult: PendingResult = if (playWhenReady) remoteMediaClient!!.play() else remoteMediaClient!!.pause() + + val resultCb = ResultCallback { + updatePlayerStateAndNotifyIfChanged() + } + + pendingResult.setResultCallback(resultCb) + } + + override fun getPlayWhenReady(): Boolean { + return myPlayWhenReady + } + + override fun setRepeatMode(repeatMode: Int) { + } + + override fun getRepeatMode(): Int { + return Player.REPEAT_MODE_OFF + } + + override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) { + } + + override fun getShuffleModeEnabled(): Boolean { + return false + } + + override fun isLoading(): Boolean { + return false + } + + override fun seekTo(mediaItemIndex: Int, positionMs: Long, seekCommand: Int, isRepeatingCurrentItem: Boolean) { + Log.d(tag, "seekTo $mediaItemIndex position $positionMs") + + val resultCb = ResultCallback { + val statusCode: Int = it.status.statusCode + if (statusCode != CastStatusCodes.SUCCESS && statusCode != CastStatusCodes.REPLACED) { + Log.e(tag, "Seek failed. Error code $statusCode") + } + if (--pendingSeekCount == 0) { + currentMediaItemIndex = pendingSeekWindowIndex + Log.d(tag, "seekTo CB $currentMediaItemIndex") + pendingSeekWindowIndex = C.INDEX_UNSET + pendingSeekPositionMs = C.TIME_UNSET + + // Playback state change will send metadata to client and stop seek loading + listeners.sendEvent(EVENT_PLAYBACK_STATE_CHANGED) { obj: Player.Listener -> obj.onPlaybackStateChanged(currentPlaybackState) } + } + } + + val mediaStatus = getMediaStatus() + + // We assume the default position is 0. There is no support for seeking to the default position + // in RemoteMediaClient. + val positionMsFinal = if (positionMs != C.TIME_UNSET) positionMs else 0 + if (mediaStatus != null) { + if (currentMediaItemIndex != mediaItemIndex) { + Log.d(tag, "seekTo: Changing media item index from $currentMediaItemIndex to $mediaItemIndex") + remoteMediaClient?.queueJumpToItem(myCurrentTimeline.getPeriod(mediaItemIndex, period).uid as Int, positionMsFinal, JSONObject())?.setResultCallback(resultCb) + } else { + Log.d(tag, "seekTo: Same media index seek to position $positionMsFinal") + val mediaSeekOptions = MediaSeekOptions.Builder().setPosition(positionMsFinal).build() + remoteMediaClient?.seek(mediaSeekOptions)?.setResultCallback(resultCb) + } + val oldPosition = getCurrentPositionInfo() + pendingSeekCount++ + pendingSeekWindowIndex = mediaItemIndex + pendingSeekPositionMs = positionMsFinal + val newPosition = getCurrentPositionInfo() + listeners.queueEvent( + EVENT_POSITION_DISCONTINUITY + ) { listener: Player.Listener -> + listener.onPositionDiscontinuity(oldPosition, newPosition, DISCONTINUITY_REASON_SEEK) + } + if (oldPosition.mediaItemIndex != newPosition.mediaItemIndex) { + val mediaItem = currentTimeline.getWindow(mediaItemIndex, window).mediaItem + listeners.queueEvent( + EVENT_MEDIA_ITEM_TRANSITION + ) { listener: Player.Listener -> listener.onMediaItemTransition(mediaItem, MEDIA_ITEM_TRANSITION_REASON_SEEK) } + } + updateAvailableCommandsAndNotifyIfChanged() + } else if (pendingSeekCount == 0) { + Log.w(tag, "seekTo Media Status is null") + // Playback state change will send metadata to client and stop seek loading + listeners.sendEvent(EVENT_PLAYBACK_STATE_CHANGED) { obj: Player.Listener -> obj.onPlaybackStateChanged(currentPlaybackState) } + } + listeners.flushEvents() + } + + private fun updateAvailableCommandsAndNotifyIfChanged() { + val previousAvailableCommands = availableCommands + myAvailableCommands = Util.getAvailableCommands(this,PERMANENT_AVAILABLE_COMMANDS) + if (availableCommands != previousAvailableCommands) { + listeners.queueEvent( + EVENT_AVAILABLE_COMMANDS_CHANGED + ) { listener: Listener -> listener.onAvailableCommandsChanged(availableCommands) } + } + } + + override fun getSeekBackIncrement(): Long { + return seekBackIncrementMs + } + + override fun getSeekForwardIncrement(): Long { + return seekForwardIncrementMs + } + + override fun getMaxSeekToPreviousPosition(): Long { + return C.DEFAULT_MAX_SEEK_TO_PREVIOUS_POSITION_MS + } + + override fun setPlaybackParameters(playbackParameters: PlaybackParameters) { + val actualPlaybackParameters = PlaybackParameters(playbackParameters.speed) + setPlaybackParametersAndNotifyIfChanged(actualPlaybackParameters) + listeners.flushEvents() + + val pendingResult: PendingResult? = remoteMediaClient?.setPlaybackRate(actualPlaybackParameters.speed.toDouble(), /* customData= */null) + + val resultCb = ResultCallback { + updatePlaybackRateAndNotifyIfChanged() + listeners.flushEvents() + } + pendingResult?.setResultCallback(resultCb) + } + + override fun getPlaybackParameters(): PlaybackParameters { + return playbackParameters + } + + override fun stop() { + Log.d(tag, "stop CastPlayer") + currentPlaybackState = Player.STATE_IDLE + remoteMediaClient?.stop() + } + + override fun stop(reset: Boolean) { + stop() + } + + override fun release() { + val sessionManager = castContext.sessionManager + sessionManager.removeSessionManagerListener(statusListener, CastSession::class.java) + sessionManager.endCurrentSession(false) + } + + override fun getCurrentTracks(): Tracks { + return this.myCurrentTracksInfo + } + + override fun getTrackSelectionParameters(): TrackSelectionParameters { + return TrackSelectionParameters.DEFAULT_WITHOUT_CONTEXT + } + + override fun setTrackSelectionParameters(parameters: TrackSelectionParameters) {} + + override fun getMediaMetadata(): MediaMetadata { + return MediaMetadata.EMPTY + } + + override fun getPlaylistMetadata(): MediaMetadata { + return MediaMetadata.EMPTY + } + + override fun setPlaylistMetadata(mediaMetadata: MediaMetadata) { + } + + override fun getCurrentTimeline(): Timeline { + return this.myCurrentTimeline + } + + override fun getCurrentPeriodIndex(): Int { + return getCurrentMediaItemIndex() + } + + override fun getCurrentMediaItemIndex(): Int { + return if (pendingSeekWindowIndex != C.INDEX_UNSET) pendingSeekWindowIndex else currentMediaItemIndex + } + + override fun getDuration(): Long { + return contentDuration + } + + override fun getCurrentPosition(): Long { + return remoteMediaClient?.approximateStreamPosition ?: 0L + } + + override fun getBufferedPosition(): Long { + return currentPosition + } + + override fun getTotalBufferedDuration(): Long { + return 0L + } + + override fun isPlayingAd(): Boolean { + return false + } + + override fun getCurrentAdGroupIndex(): Int { + return 0 + } + + override fun getCurrentAdIndexInAdGroup(): Int { + return 0 + } + + override fun getContentPosition(): Long { + return currentPosition + } + + override fun getContentBufferedPosition(): Long { + return currentPosition + } + + override fun getAudioAttributes(): AudioAttributes { + return AudioAttributes.DEFAULT + } + + /** + * The volume of the media stream, which is what the sleep timer fades - not the volume of the + * receiver. The device volume stays where the user put it, and on a cast group the balance + * between its members is left alone, because the receiver applies this to the group's output. + */ + override fun setVolume(audioVolume: Float) { + try { + remoteMediaClient?.setStreamVolume(audioVolume.toDouble().coerceIn(0.0, 1.0)) + } catch (e: Exception) { + Log.e(tag, "Failed to set the cast stream volume", e) + } + } + + override fun getVolume(): Float { + return remoteMediaClient?.mediaStatus?.streamVolume?.toFloat() ?: 1F + } + + override fun clearVideoSurface() { + } + + override fun clearVideoSurface(surface: Surface?) { + } + + override fun setVideoSurface(surface: Surface?) { + } + + override fun setVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) { + } + + override fun clearVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) { + } + + override fun setVideoSurfaceView(surfaceView: SurfaceView?) { + } + + override fun clearVideoSurfaceView(surfaceView: SurfaceView?) { + } + + override fun setVideoTextureView(textureView: TextureView?) { + + } + + override fun clearVideoTextureView(textureView: TextureView?) { + + } + + override fun getVideoSize(): VideoSize { + return VideoSize.UNKNOWN + } + + override fun getSurfaceSize(): Size { + return Size.UNKNOWN + } + + override fun getCurrentCues(): CueGroup { + return CueGroup.EMPTY_TIME_ZERO + } + + override fun getDeviceInfo(): DeviceInfo { + return DeviceInfo.UNKNOWN + } + + override fun getDeviceVolume(): Int { + // Short-lived cache without even-value quantization + val now = System.currentTimeMillis() + if (pendingVolume != null && (now - pendingVolumeSetAt) < 100) { + return pendingVolume!! + } + + val session = castContext.sessionManager.currentCastSession + val vol = if (session != null && !session.isMute) { + (session.volume * 100).roundToInt().coerceIn(0, 100) + } else { + 0 + } + + if (pendingVolume != null && vol == pendingVolume) { + pendingVolume = null + } + + return vol + } + + override fun isDeviceMuted(): Boolean { + val session = castContext.sessionManager.currentCastSession + return session?.isMute ?: false + } + + override fun setDeviceVolume(volume: Int) { + val session = castContext.sessionManager.currentCastSession + try { + val clamped = volume.coerceIn(0, 100) + val target = (clamped / 100.0).let { kotlin.math.round(it * 100.0) / 100.0 } + + // Cache for 100ms to return in getDeviceVolume during async period + pendingVolume = clamped + pendingVolumeSetAt = System.currentTimeMillis() + + session?.setVolume(target) + } catch (e: Exception) { + Log.e(tag, "Failed to set cast device volume", e) + } + } + + override fun increaseDeviceVolume() { + val current = getDeviceVolume() + val next = current + 1 + setDeviceVolume(next.coerceAtMost(100)) + } + + override fun decreaseDeviceVolume() { + val current = getDeviceVolume() + val next = current - 1 + setDeviceVolume(next.coerceAtLeast(0)) + } + + override fun setDeviceMuted(muted: Boolean) { + val session = castContext.sessionManager.currentCastSession + try { + session?.isMute = muted + } catch (e: Exception) { + Log.e(tag, "Failed to set cast device mute state", e) + } + } + + inner class StatusListener() : RemoteMediaClient.Callback(), SessionManagerListener, RemoteMediaClient.ProgressListener { + val TAG = "StatusListener" + + // RemoteMediaClient.ProgressListener implementation. + override fun onProgressUpdated(progressMs: Long, unusedDurationMs: Long) { + lastReportedPositionMs = progressMs + } + + // RemoteMediaClient.Callback implementation. + override fun onStatusUpdated() { + Log.d(TAG, "StatusListener status queue items " + remoteMediaClient?.mediaStatus?.queueItemCount) + if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_IDLE) { + Log.d(TAG, "StatusListener CastPlayer STATE_IDLE") + } else if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_BUFFERING) { + Log.d(TAG, "StatusListener CastPlayer BUFFERING") + } else if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_LOADING) { + Log.d(TAG, "StatusListener CastPlayer PLAYER_STATE_LOADING") + } else if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_PAUSED) { + Log.d(TAG, "StatusListener CastPlayer PLAYER_STATE_PAUSED") + } else if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_PLAYING) { + Log.d(TAG, "StatusListener CastPlayer PLAYER_STATE_PLAYING") + } else if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_UNKNOWN) { + Log.d(TAG, "StatusListener CastPlayer PLAYER_STATE_UNKNOWN") + } + + updateInternalStateAndNotifyIfChanged() + } + + override fun onMetadataUpdated() {} + override fun onQueueStatusUpdated() { + Log.d(TAG, "onQueueStatusUpdated") + updateTimelineAndNotifyIfChanged() + listeners.flushEvents() + } + + override fun onPreloadStatusUpdated() {} + override fun onSendingRemoteMediaRequest() {} + override fun onAdBreakStatusUpdated() {} + + // SessionManagerListener implementation. + override fun onSessionStarted(castSession: CastSession, s: String) { + Log.d(TAG, "StatusListener onSessionStarted") + setRemoteMediaClient(castSession.remoteMediaClient) + } + + override fun onSessionResumed(castSession: CastSession, b: Boolean) { + setRemoteMediaClient(castSession.remoteMediaClient) + } + + override fun onSessionEnded(castSession: CastSession, i: Int) { + Log.d(TAG, "StatusListener onSessionEnded") + setRemoteMediaClient(null) + } + + override fun onSessionSuspended(castSession: CastSession, i: Int) { + setRemoteMediaClient(null) + } + + override fun onSessionResumeFailed(castSession: CastSession, statusCode: Int) { + Log.e( + TAG, + "Session resume failed. Error code " + + statusCode) + } + + override fun onSessionStarting(castSession: CastSession) { + Log.d(TAG, "StatusListener onSessionStarting") + // Do nothing. + } + + override fun onSessionStartFailed(castSession: CastSession, statusCode: Int) { + Log.e( + TAG, + ("Session start failed. Error code " + + statusCode)) + } + + override fun onSessionEnding(castSession: CastSession) { + // Do nothing. + } + + override fun onSessionResuming(castSession: CastSession, s: String) { + // Do nothing. + } + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/CastTimeline.kt b/android/app/src/main/java/com/audiobookshelf/app/player/CastTimeline.kt new file mode 100644 index 0000000..a241547 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/CastTimeline.kt @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.audiobookshelf.app.player + +import android.net.Uri +import android.util.Log +import android.util.SparseArray +import android.util.SparseIntArray +import com.google.android.exoplayer2.C +import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.Timeline +import java.util.* + +/** A [Timeline] for Cast media queues. */ /* package */ +class CastTimeline(itemIds: IntArray, itemIdToData: SparseArray) : Timeline() { + /** Holds [Timeline] related data for a Cast media item. */ + class ItemData( + /** The duration of the item in microseconds, or [C.TIME_UNSET] if unknown. */ + val durationUs: Long, + /** + * The default start position of the item in microseconds, or [C.TIME_UNSET] if unknown. + */ + var defaultPositionUs: Long, isLive: Boolean) { + /** Whether the item is live content, or `false` if unknown. */ + val isLive: Boolean = isLive + + /** + * Returns a copy of this instance with the given values. + * + * @param durationUs The duration in microseconds, or [C.TIME_UNSET] if unknown. + * @param defaultPositionUs The default start position in microseconds, or [C.TIME_UNSET] + * if unknown. + * @param isLive Whether the item is live, or `false` if unknown. + */ + fun copyWithNewValues(durationUs: Long, defaultPositionUs: Long, isLive: Boolean): ItemData { + return if (durationUs == this.durationUs && defaultPositionUs == this.defaultPositionUs && isLive == this.isLive) { + this + } else ItemData(durationUs, defaultPositionUs, isLive) + } + + companion object { + /** Holds no media information. */ + val EMPTY = ItemData( /* durationUs= */ + C.TIME_UNSET, /* defaultPositionUs= */ + C.TIME_UNSET, /* isLive= */ + false) + } + + } + + private val idsToIndex: SparseIntArray + private val ids: IntArray + private val durationsUs: LongArray + private val defaultPositionsUs: LongArray + private val isLive: BooleanArray + + // Timeline implementation. + override fun getWindowCount(): Int { + return ids.size + } + + override fun getWindow(windowIndex: Int, window: Window, defaultPositionProjectionUs: Long): Window { + val durationUs = durationsUs[windowIndex] + val isDynamic = durationUs == C.TIME_UNSET + val mediaItem = MediaItem.Builder().setUri(Uri.EMPTY).setTag(ids[windowIndex]).build() + return window.set( /* uid= */ + ids[windowIndex], /* mediaItem= */ + mediaItem, /* manifest= */ + null, /* presentationStartTimeMs= */ + C.TIME_UNSET, /* windowStartTimeMs= */ + C.TIME_UNSET, /* elapsedRealtimeEpochOffsetMs= */ + C.TIME_UNSET, /* isSeekable= */ + !isDynamic, + isDynamic, + if (isLive[windowIndex]) mediaItem.liveConfiguration else null, + defaultPositionsUs[windowIndex], + durationUs, /* firstPeriodIndex= */ + windowIndex, /* lastPeriodIndex= */ + windowIndex, /* positionInFirstPeriodUs= */ + 0) + } + + override fun getPeriodCount(): Int { + return ids.size + } + + override fun getPeriod(periodIndex: Int, period: Period, setIds: Boolean): Period { + val id = ids[periodIndex] + return period.set(id, id, periodIndex, durationsUs[periodIndex], 0) + } + + override fun getIndexOfPeriod(uid: Any): Int { + return if (uid is Int) idsToIndex[uid, C.INDEX_UNSET] else C.INDEX_UNSET + } + + override fun getUidOfPeriod(periodIndex: Int): Int { + return ids[periodIndex] + } + + // equals and hashCode implementations. + override fun equals(other: Any?): Boolean { + if (this === other) { + return true + } else if (other !is CastTimeline) { + return false + } + val that = other + return (Arrays.equals(ids, that.ids) + && Arrays.equals(durationsUs, that.durationsUs) + && Arrays.equals(defaultPositionsUs, that.defaultPositionsUs) + && Arrays.equals(isLive, that.isLive)) + } + + override fun hashCode(): Int { + var result = Arrays.hashCode(ids) + result = 31 * result + Arrays.hashCode(durationsUs) + result = 31 * result + Arrays.hashCode(defaultPositionsUs) + result = 31 * result + Arrays.hashCode(isLive) + return result + } + + companion object { + /** [Timeline] for a cast queue that has no items. */ + val EMPTY_CAST_TIMELINE = CastTimeline(IntArray(0), SparseArray()) + } + + /** + * Creates a Cast timeline from the given data. + * + * @param itemIds The ids of the items in the timeline. + * @param itemIdToData Maps item ids to [ItemData]. + */ + init { + val itemCount = itemIds.size + idsToIndex = SparseIntArray(itemCount) + ids = itemIds.copyOf(itemCount) + durationsUs = LongArray(itemCount) + defaultPositionsUs = LongArray(itemCount) + isLive = BooleanArray(itemCount) + for (i in ids.indices) { + val id = ids[i] + idsToIndex.put(id, i) + val data = itemIdToData[id, ItemData.EMPTY] + durationsUs[i] = data.durationUs + defaultPositionsUs[i] = if (data.defaultPositionUs == C.TIME_UNSET) 0 else data.defaultPositionUs + isLive[i] = data.isLive + } + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/CastTimelineTracker.kt b/android/app/src/main/java/com/audiobookshelf/app/player/CastTimelineTracker.kt new file mode 100644 index 0000000..56d986e --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/CastTimelineTracker.kt @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.audiobookshelf.app.player + +import android.util.SparseArray +import com.google.android.exoplayer2.C +import com.google.android.gms.cast.MediaInfo +import com.google.android.gms.cast.framework.media.RemoteMediaClient + +/** + * Creates [CastTimelines][CastTimeline] from cast receiver app status updates. + * + * + * This class keeps track of the duration reported by the current item to fill any missing + * durations in the media queue items [See internal: b/65152553]. + */ +/* package */ +class CastTimelineTracker { + private val itemIdToData: SparseArray + + /** + * Returns a [CastTimeline] that represents the state of the given `remoteMediaClient`. + * + * + * Returned timelines may contain values obtained from `remoteMediaClient` in previous + * invocations of this method. + * + * @param remoteMediaClient The Cast media client. + * @return A [CastTimeline] that represents the given `remoteMediaClient` status. + */ + fun getCastTimeline(remoteMediaClient: RemoteMediaClient): CastTimeline { + val itemIds = remoteMediaClient.mediaQueue.itemIds + if (itemIds.size > 0) { + // Only remove unused items when there is something in the queue to avoid removing all entries + // if the remote media client clears the queue temporarily. See [Internal ref: b/128825216]. + removeUnusedItemDataEntries(itemIds) + } + + // TODO: Reset state when the app instance changes [Internal ref: b/129672468]. + val mediaStatus = remoteMediaClient.mediaStatus + ?: return CastTimeline.EMPTY_CAST_TIMELINE + val currentItemId = mediaStatus.currentItemId + updateItemData( + currentItemId, mediaStatus.mediaInfo, /* defaultPositionUs= */C.TIME_UNSET) + for (item in mediaStatus.queueItems) { + val defaultPositionUs = (item.startTime * C.MICROS_PER_SECOND).toLong() + updateItemData(item.itemId, item.media, defaultPositionUs) + } + return CastTimeline(itemIds, itemIdToData) + } + + private fun updateItemData(itemId: Int, mediaInfo: MediaInfo?, defaultPositionUs: Long) { + var defaultPositionUs = defaultPositionUs + val previousData = itemIdToData[itemId, CastTimeline.ItemData.EMPTY] + var durationUs = getStreamDurationUs(mediaInfo) + if (durationUs == C.TIME_UNSET) { + durationUs = previousData.durationUs + } + val isLive = if (mediaInfo == null) previousData.isLive else mediaInfo.streamType == MediaInfo.STREAM_TYPE_LIVE + if (defaultPositionUs == C.TIME_UNSET) { + defaultPositionUs = previousData.defaultPositionUs + } + itemIdToData.put(itemId, previousData.copyWithNewValues(durationUs, defaultPositionUs, isLive)) + } + + private fun getStreamDurationUs(mediaInfo: MediaInfo?): Long { + if (mediaInfo == null) { + return C.TIME_UNSET + } + val durationMs = mediaInfo.streamDuration + return if (durationMs != MediaInfo.UNKNOWN_DURATION) msToUs(durationMs) else C.TIME_UNSET + } + + fun msToUs(timeMs: Long): Long { + return if (timeMs == C.TIME_UNSET || timeMs == C.TIME_END_OF_SOURCE) timeMs else timeMs * 1000 + } + + private fun removeUnusedItemDataEntries(itemIds: IntArray) { + val scratchItemIds = HashSet( /* initialCapacity= */itemIds.size * 2) + for (id in itemIds) { + scratchItemIds.add(id) + } + var index = 0 + while (index < itemIdToData.size()) { + if (!scratchItemIds.contains(itemIdToData.keyAt(index))) { + itemIdToData.removeAt(index) + } else { + index++ + } + } + } + + init { + itemIdToData = SparseArray() + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/CastTrackSelection.kt b/android/app/src/main/java/com/audiobookshelf/app/player/CastTrackSelection.kt new file mode 100644 index 0000000..33f4d84 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/CastTrackSelection.kt @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.audiobookshelf.app.player + +import com.google.android.exoplayer2.C +import com.google.android.exoplayer2.Format +import com.google.android.exoplayer2.source.TrackGroup +import com.google.android.exoplayer2.trackselection.TrackSelection +import com.google.android.exoplayer2.util.Assertions + +/** + * [TrackSelection] that only selects the first track of the provided [TrackGroup]. + * + * + * This relies on [CastPlayer] track groups only having one track. + */ +/* package */ +internal class CastTrackSelection +/** @param trackGroup The [TrackGroup] from which the first track will only be selected. + */(private val trackGroup: TrackGroup) : TrackSelection { + override fun getType(): Int { + return TrackSelection.TYPE_UNSET + } + + override fun getTrackGroup(): TrackGroup { + return trackGroup + } + + override fun length(): Int { + return 1 + } + + override fun getFormat(index: Int): Format { + Assertions.checkArgument(index == 0) + return trackGroup.getFormat(0) + } + + override fun getIndexInTrackGroup(index: Int): Int { + return if (index == 0) 0 else C.INDEX_UNSET + } + + override fun indexOf(format: Format): Int { + return if (format === trackGroup.getFormat(0)) 0 else C.INDEX_UNSET + } + + override fun indexOf(indexInTrackGroup: Int): Int { + return if (indexInTrackGroup == 0) 0 else C.INDEX_UNSET + } + + // Object overrides. + override fun hashCode(): Int { + return System.identityHashCode(trackGroup) + } + + // Track groups are compared by identity not value, as distinct groups may have the same value. + override fun equals(obj: Any?): Boolean { + if (this === obj) { + return true + } + if (obj == null || javaClass != obj.javaClass) { + return false + } + val other = obj as CastTrackSelection + return trackGroup === other.trackGroup + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/CoverImageLoader.kt b/android/app/src/main/java/com/audiobookshelf/app/player/CoverImageLoader.kt new file mode 100644 index 0000000..5acd574 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/CoverImageLoader.kt @@ -0,0 +1,36 @@ +package com.audiobookshelf.app.player + +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.util.Log +import com.audiobookshelf.app.BuildConfig +import com.audiobookshelf.app.R +import com.bumptech.glide.Glide +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +private const val TAG = "CoverImageLoader" + +/** Loads [uri] as a bitmap via Glide, falling back to the app icon if the load fails. */ +suspend fun resolveUriAsBitmap(context: Context, uri: Uri): Bitmap? { + return withContext(Dispatchers.IO) { + try { + Glide.with(context) + .asBitmap() + .load(uri) + .placeholder(R.drawable.icon) + .error(R.drawable.icon) + .submit() + .get() + } catch (e: Exception) { + Log.e(TAG, "Failed to load cover bitmap for uri: $uri", e) + + Glide.with(context) + .asBitmap() + .load(Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)) + .submit() + .get() + } + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionCallback.kt b/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionCallback.kt new file mode 100644 index 0000000..c87e912 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionCallback.kt @@ -0,0 +1,286 @@ +package com.audiobookshelf.app.player + +import android.annotation.SuppressLint +import android.content.Intent +import android.os.* +import android.support.v4.media.session.MediaSessionCompat +import android.util.Log +import android.view.KeyEvent +import com.audiobookshelf.app.data.LibraryItemWrapper +import com.audiobookshelf.app.data.PodcastEpisode +import java.util.* +import kotlin.concurrent.schedule + +class MediaSessionCallback(var playerNotificationService:PlayerNotificationService) : MediaSessionCompat.Callback() { + var tag = "MediaSessionCallback" + + private var mediaButtonClickCount: Int = 0 + private var mediaButtonClickTimeout: Long = 1000 //ms + + override fun onPrepare() { + Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT") + playerNotificationService.mediaManager.getFirstItem()?.let { li -> + playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) { + if (it == null) { + Log.e(tag, "Failed to play library item") + } else { + val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate() + Handler(Looper.getMainLooper()).post { + playerNotificationService.preparePlayer(it,true, playbackRate) + } + } + } + } + } + + override fun onPlay() { + Log.d(tag, "ON PLAY MEDIA SESSION COMPAT") + playerNotificationService.play() + } + + override fun onPrepareFromSearch(query: String?, extras: Bundle?) { + Log.d(tag, "ON PREPARE FROM SEARCH $query") + super.onPrepareFromSearch(query, extras) + } + + override fun onPlayFromSearch(query: String?, extras: Bundle?) { + Log.d(tag, "ON PLAY FROM SEARCH $query") + playerNotificationService.mediaManager.getFromSearch(query)?.let { li -> + playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) { + if (it == null) { + Log.e(tag, "Failed to play library item") + } else { + val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate() + Handler(Looper.getMainLooper()).post { + playerNotificationService.preparePlayer(it, true, playbackRate) + } + } + } + } + } + + override fun onPause() { + Log.d(tag, "ON PAUSE MEDIA SESSION COMPAT") + playerNotificationService.pause() + } + + override fun onStop() { + playerNotificationService.pause() + } + + override fun onSkipToPrevious() { + playerNotificationService.skipToPrevious() + } + + override fun onSkipToNext() { + playerNotificationService.skipToNext() + } + + override fun onFastForward() { + playerNotificationService.jumpForward() + } + + override fun onRewind() { + playerNotificationService.jumpBackward() + } + + override fun onSeekTo(pos: Long) { + val currentTrackStartOffset = playerNotificationService.getCurrentTrackStartOffsetMs() + playerNotificationService.seekPlayer(currentTrackStartOffset + pos) + } + + private fun onChangeSpeed() { + // cycle to next speed, only contains preset android app options, as each increment needs it's own icon + // Rounding values in the event a non preset value (.5, 1, 1.2, 1.5, 2, 3) is selected in the phone app + val mediaManager = playerNotificationService.mediaManager + val newSpeed = when (mediaManager.getSavedPlaybackRate()) { + in 0.5f..0.7f -> 1.0f + in 0.8f..1.0f -> 1.2f + in 1.1f..1.2f -> 1.5f + in 1.3f..1.5f -> 2.0f + in 1.6f..2.0f -> 3.0f + in 2.1f..3.0f -> 0.5f + // anything set above 3 (can happen in the android app) will be reset to 1 + else -> 1.0f + } + mediaManager.setSavedPlaybackRate(newSpeed) + playerNotificationService.setPlaybackSpeed(newSpeed) + playerNotificationService.clientEventEmitter?.onPlaybackSpeedChanged(newSpeed) + } + + override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { + Log.d(tag, "ON PLAY FROM MEDIA ID $mediaId") + val libraryItemWrapper: LibraryItemWrapper? + var podcastEpisode: PodcastEpisode? = null + + if (mediaId.isNullOrEmpty()) { + libraryItemWrapper = playerNotificationService.mediaManager.getFirstItem() + } else { + val libraryItemWithEpisode = playerNotificationService.mediaManager.getPodcastWithEpisodeByEpisodeId(mediaId) + if (libraryItemWithEpisode != null) { + libraryItemWrapper = libraryItemWithEpisode.libraryItemWrapper + podcastEpisode = libraryItemWithEpisode.episode + } else { + libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId) + if (libraryItemWrapper == null) { + Log.e(tag, "onPlayFromMediaId: Media item not found $mediaId") + } + } + } + + libraryItemWrapper?.let { li -> + playerNotificationService.mediaManager.play(li, podcastEpisode, playerNotificationService.getPlayItemRequestPayload(false)) { + if (it == null) { + Log.e(tag, "Failed to play library item") + } else { + val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate() + Handler(Looper.getMainLooper()).post { + playerNotificationService.preparePlayer(it, true, playbackRate) + } + } + } + } + } + + override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean { + return handleCallMediaButton(mediaButtonEvent) + } + + private fun handleCallMediaButton(intent: Intent): Boolean { + Log.w(tag, "handleCallMediaButton $intent | ${intent.action}") + + if(Intent.ACTION_MEDIA_BUTTON == intent.action) { + val keyEvent = if (Build.VERSION.SDK_INT >= 33) { + intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT, KeyEvent::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT) + } + + Log.d(tag, "handleCallMediaButton keyEvent = $keyEvent | action ${keyEvent?.action}") + + // Widget button intent is only sending the action down event + if (keyEvent?.action == KeyEvent.ACTION_DOWN) { + Log.d(tag, "handleCallMediaButton: key action_down for ${keyEvent.keyCode}") + when (keyEvent.keyCode) { + KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> { + Log.d(tag, "handleCallMediaButton: Media Play/Pause") + + // TODO: Play/pause event sent from widget when app is closed. Currently the service gets destroyed before anything can happen +// if (playerNotificationService.currentPlaybackSession == null && DeviceManager.deviceData.lastPlaybackSession != null) { +// Log.i(tag, "No playback session but had one in the db") +// +// val connectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == DeviceManager.deviceData.lastPlaybackSession?.serverConnectionConfigId } +// connectionConfig?.let { +// Log.i(tag, "Setting playback session from db $it") +// DeviceManager.serverConnectionConfig = it +// +// playerNotificationService.currentPlaybackSession = DeviceManager.deviceData.lastPlaybackSession +// playerNotificationService.startNewPlaybackSession() +// return true +// } +// } + + if (playerNotificationService.mPlayer.isPlaying) { + if (0 == mediaButtonClickCount) playerNotificationService.pause() + handleMediaButtonClickCount() + } else { + if (0 == mediaButtonClickCount) { + playerNotificationService.play() + } + handleMediaButtonClickCount() + } + } + KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { + Log.d(tag, "handleCallMediaButton: Media Fast Forward") + playerNotificationService.jumpForward() + } + KeyEvent.KEYCODE_MEDIA_REWIND -> { + Log.d(tag, "handleCallMediaButton: Media Rewind") + playerNotificationService.jumpBackward() + } + } + } + + if (keyEvent?.action == KeyEvent.ACTION_UP) { + Log.d(tag, "handleCallMediaButton: key action_up for ${keyEvent.keyCode}") + when (keyEvent.keyCode) { + KeyEvent.KEYCODE_HEADSETHOOK -> { + Log.d(tag, "handleCallMediaButton: Headset Hook") + if (0 == mediaButtonClickCount) { + if (playerNotificationService.mPlayer.isPlaying) + playerNotificationService.pause() + else + playerNotificationService.play() + } + handleMediaButtonClickCount() + } + KeyEvent.KEYCODE_MEDIA_PLAY -> { + Log.d(tag, "handleCallMediaButton: Media Play") + if (0 == mediaButtonClickCount) { + playerNotificationService.play() + } + handleMediaButtonClickCount() + } + KeyEvent.KEYCODE_MEDIA_PAUSE -> { + Log.d(tag, "handleCallMediaButton: Media Pause") + if (0 == mediaButtonClickCount) playerNotificationService.pause() + handleMediaButtonClickCount() + } + KeyEvent.KEYCODE_MEDIA_NEXT -> { + playerNotificationService.jumpForward() + } + KeyEvent.KEYCODE_MEDIA_PREVIOUS -> { + playerNotificationService.jumpBackward() + } + KeyEvent.KEYCODE_MEDIA_STOP -> { + playerNotificationService.pause() + } + else -> { + Log.d(tag, "KeyCode:${keyEvent.keyCode}") + return false + } + } + } + } + return true + } + + private fun handleMediaButtonClickCount() { + mediaButtonClickCount++ + if (1 == mediaButtonClickCount) { + Timer().schedule(mediaButtonClickTimeout) { + mediaBtnHandler.sendEmptyMessage(mediaButtonClickCount) + mediaButtonClickCount = 0 + } + } + } + + + private val mediaBtnHandler : Handler = @SuppressLint("HandlerLeak") + object : Handler(){ + override fun handleMessage(msg: Message) { + super.handleMessage(msg) + if (2 == msg.what) { + playerNotificationService.jumpBackward() + playerNotificationService.play() + } + else if (msg.what >= 3) { + playerNotificationService.jumpForward() + playerNotificationService.play() + } + } + } + + override fun onCustomAction(action: String?, extras: Bundle?) { + super.onCustomAction(action, extras) + + when (action) { + CUSTOM_ACTION_JUMP_FORWARD -> onFastForward() + CUSTOM_ACTION_JUMP_BACKWARD -> onRewind() + CUSTOM_ACTION_SKIP_FORWARD -> onSkipToNext() + CUSTOM_ACTION_SKIP_BACKWARD -> onSkipToPrevious() + CUSTOM_ACTION_CHANGE_SPEED -> onChangeSpeed() + } + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionPlaybackPreparer.kt b/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionPlaybackPreparer.kt new file mode 100644 index 0000000..bc59d04 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionPlaybackPreparer.kt @@ -0,0 +1,93 @@ +package com.audiobookshelf.app.player + +import android.net.Uri +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.ResultReceiver +import android.support.v4.media.session.PlaybackStateCompat +import android.util.Log +import com.audiobookshelf.app.data.LibraryItemWrapper +import com.audiobookshelf.app.data.PodcastEpisode +import com.google.android.exoplayer2.Player +import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector + +class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificationService) : MediaSessionConnector.PlaybackPreparer { + var tag = "MediaSessionPlaybackPreparer" + + override fun onCommand(player: Player, command: String, extras: Bundle?, cb: ResultReceiver?): Boolean { + Log.d(tag, "ON COMMAND $command") + return false + } + + override fun getSupportedPrepareActions(): Long { + return PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID or + PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or + PlaybackStateCompat.ACTION_PREPARE_FROM_SEARCH or + PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH + } + + override fun onPrepare(playWhenReady: Boolean) { + Log.d(tag, "ON PREPARE $playWhenReady") + playerNotificationService.mediaManager.getFirstItem()?.let { li -> + playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) { + if (it == null) { + Log.e(tag, "Failed to play library item") + } else { + val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate() + Handler(Looper.getMainLooper()).post { + playerNotificationService.preparePlayer(it, playWhenReady, playbackRate) + } + } + } + } + } + + override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) { + Log.d(tag, "ON PREPARE FROM MEDIA ID $mediaId $playWhenReady") + + val libraryItemWrapper: LibraryItemWrapper? + var podcastEpisode: PodcastEpisode? = null + + val libraryItemWithEpisode = playerNotificationService.mediaManager.getPodcastWithEpisodeByEpisodeId(mediaId) + if (libraryItemWithEpisode != null) { + libraryItemWrapper = libraryItemWithEpisode.libraryItemWrapper + podcastEpisode = libraryItemWithEpisode.episode + } else { + libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId) + } + + libraryItemWrapper?.let { li -> + playerNotificationService.mediaManager.play(li, podcastEpisode, playerNotificationService.getPlayItemRequestPayload(false)) { + if (it == null) { + Log.e(tag, "Failed to play library item") + } else { + val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate() + Handler(Looper.getMainLooper()).post { + playerNotificationService.preparePlayer(it, playWhenReady, playbackRate) + } + } + } + } + } + + override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) { + Log.d(tag, "ON PREPARE FROM SEARCH $query") + playerNotificationService.mediaManager.getFromSearch(query)?.let { li -> + playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) { + if (it == null) { + Log.e(tag, "Failed to play library item") + } else { + val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate() + Handler(Looper.getMainLooper()).post { + playerNotificationService.preparePlayer(it, playWhenReady, playbackRate) + } + } + } + } + } + + override fun onPrepareFromUri(uri: Uri, playWhenReady: Boolean, extras: Bundle?) { + Log.d(tag, "ON PREPARE FROM URI $uri") + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerConstants.kt b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerConstants.kt new file mode 100644 index 0000000..35d787a --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerConstants.kt @@ -0,0 +1,21 @@ +package com.audiobookshelf.app.player + +const val CUSTOM_ACTION_JUMP_FORWARD = "com.audiobookshelf.customAction.jump_forward"; +const val CUSTOM_ACTION_JUMP_BACKWARD = "com.audiobookshelf.customAction.jump_backward"; +const val CUSTOM_ACTION_SKIP_FORWARD = "com.audiobookshelf.customAction.skip_forward"; +const val CUSTOM_ACTION_SKIP_BACKWARD = "com.audiobookshelf.customAction.skip_backward"; +const val CUSTOM_ACTION_CHANGE_SPEED = "com.audiobookshelf.customAction.change_speed"; + +const val PLAYMETHOD_DIRECTPLAY = 0 +const val PLAYMETHOD_DIRECTSTREAM = 1 +const val PLAYMETHOD_TRANSCODE = 2 +const val PLAYMETHOD_LOCAL = 3 + +// +// MEDIA BROWSER STUFF (ANDROID AUTO) +// +const val ANDROID_AUTO_PKG_NAME = "com.google.android.projection.gearhead" +const val ANDROID_AUTO_SIMULATOR_PKG_NAME = "com.google.android.autosimulator" +const val ANDROID_WEARABLE_PKG_NAME = "com.google.android.wearable.app" +const val ANDROID_GSEARCH_PKG_NAME = "com.google.android.googlequicksearchbox" +const val ANDROID_AUTOMOTIVE_PKG_NAME = "com.google.android.carassistant" diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerListener.kt b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerListener.kt new file mode 100644 index 0000000..8d28b63 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerListener.kt @@ -0,0 +1,174 @@ +package com.audiobookshelf.app.player + +import android.util.Log +import com.audiobookshelf.app.data.PlaybackSession +import com.audiobookshelf.app.data.PlayerState +import com.audiobookshelf.app.device.DeviceManager +import com.google.android.exoplayer2.PlaybackException +import com.google.android.exoplayer2.Player + +//const val PAUSE_LEN_BEFORE_RECHECK = 30000 // 30 seconds + +class PlayerListener(var playerNotificationService:PlayerNotificationService) : Player.Listener { + var tag = "PlayerListener" + + companion object { + var lastPauseTime: Long = 0 //ms + var lazyIsPlaying: Boolean = false + } + + override fun onPlayerError(error: PlaybackException) { + val errorMessage = error.message ?: "Unknown Error" + Log.e(tag, "onPlayerError $errorMessage") + playerNotificationService.handlePlayerPlaybackError(errorMessage) // If was direct playing session, fallback to transcode + } + + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int + ) { + if (reason == Player.DISCONTINUITY_REASON_SEEK) { + // If playing set seeking flag + Log.d(tag, "onPositionDiscontinuity: oldPosition=${oldPosition.positionMs}/${oldPosition.mediaItemIndex}, newPosition=${newPosition.positionMs}/${newPosition.mediaItemIndex}, isPlaying=${playerNotificationService.currentPlayer.isPlaying} reason=SEEK") + playerNotificationService.mediaProgressSyncer.seek() + lastPauseTime = 0 // When seeking while paused reset the auto-rewind + } else { + Log.d(tag, "onPositionDiscontinuity: oldPosition=${oldPosition.positionMs}/${oldPosition.mediaItemIndex}, newPosition=${newPosition.positionMs}/${newPosition.mediaItemIndex}, isPlaying=${playerNotificationService.currentPlayer.isPlaying}, reason=$reason") + } + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + Log.d(tag, "onIsPlayingChanged to $isPlaying | ${playerNotificationService.getMediaPlayer()} | playbackState=${playerNotificationService.currentPlayer.playbackState}") + + val player = playerNotificationService.currentPlayer + + // Goal of these 2 if statements and the lazyIsPlaying is to ignore this event when it is triggered by a seek + // When a seek occurs the player is paused and buffering, then plays again right afterwards. + if (!isPlaying && player.playbackState == Player.STATE_BUFFERING) { + Log.d(tag, "onIsPlayingChanged: Pause event when buffering is ignored") + return + } + if (lazyIsPlaying == isPlaying) { + Log.d(tag, "onIsPlayingChanged: Lazy is playing $lazyIsPlaying is already set to this so ignoring") + return + } + + lazyIsPlaying = isPlaying + + // Update widget + DeviceManager.widgetUpdater?.onPlayerChanged(playerNotificationService) + + if (isPlaying) { + Log.d(tag, "SeekBackTime: Player is playing") + if (lastPauseTime > 0 && DeviceManager.deviceData.deviceSettings?.disableAutoRewind != true) { + Log.d(tag, "SeekBackTime: playing started now set seek back time $lastPauseTime") + var seekBackTime = calcPauseSeekBackTime() + if (seekBackTime > 0) { + // Current chapter is used so that seek back does not go back to the previous chapter + val currentChapter = playerNotificationService.getCurrentBookChapter() + val minSeekBackTime = currentChapter?.startMs ?: 0 + + val currentTime = playerNotificationService.getCurrentTime() + val newTime = currentTime - seekBackTime + if (newTime < minSeekBackTime) { + seekBackTime = currentTime - minSeekBackTime + } + Log.d(tag, "SeekBackTime $seekBackTime") + } + + // TODO: this needs to be reworked so that the audio doesn't start playing before it checks for updated progress + // Check if playback session still exists or sync media progress if updated +// val pauseLength: Long = System.currentTimeMillis() - lastPauseTime +// if (pauseLength > PAUSE_LEN_BEFORE_RECHECK) { +// val shouldCarryOn = playerNotificationService.checkCurrentSessionProgress(seekBackTime) +// if (!shouldCarryOn) return +// } + + if (seekBackTime > 0L) { + playerNotificationService.seekBackward(seekBackTime) + } + } + } else { + Log.d(tag, "SeekBackTime: Player not playing set last pause time | playbackState=${player.playbackState}") + lastPauseTime = System.currentTimeMillis() + } + + // Start/stop progress sync interval + if (isPlaying) { + val playbackSession: PlaybackSession? = playerNotificationService.mediaProgressSyncer.currentPlaybackSession ?: playerNotificationService.currentPlaybackSession + playbackSession?.let { + // Handles auto-starting sleep timer and resetting sleep timer + playerNotificationService.sleepTimerManager.handleMediaPlayEvent(it.id) + + player.volume = 1F // Volume on sleep timer might have decreased this + + playerNotificationService.mediaProgressSyncer.play(it) + } + } else { + playerNotificationService.mediaProgressSyncer.pause { + Log.d(tag, "Media Progress Syncer paused and synced") + } + } + + playerNotificationService.clientEventEmitter?.onPlayingUpdate(isPlaying) + } + + override fun onEvents(player: Player, events: Player.Events) { + Log.d(tag, "onEvents ${playerNotificationService.getMediaPlayer()} | ${events.size()}") + + if (events.contains(Player.EVENT_POSITION_DISCONTINUITY)) { + Log.d(tag, "EVENT_POSITION_DISCONTINUITY") + } + + if (events.contains(Player.EVENT_IS_LOADING_CHANGED)) { + Log.d(tag, "EVENT_IS_LOADING_CHANGED : " + playerNotificationService.currentPlayer.isLoading) + } + + if (events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED)) { + Log.d(tag, "EVENT_PLAYBACK_STATE_CHANGED MediaPlayer = ${playerNotificationService.getMediaPlayer()}") + + if (playerNotificationService.currentPlayer.playbackState == Player.STATE_READY) { + Log.d(tag, "STATE_READY : " + playerNotificationService.currentPlayer.duration) + + if (lastPauseTime == 0L) { + lastPauseTime = -1 + } + playerNotificationService.sendClientMetadata(PlayerState.READY) + } + if (playerNotificationService.currentPlayer.playbackState == Player.STATE_BUFFERING) { + Log.d(tag, "STATE_BUFFERING : " + playerNotificationService.currentPlayer.currentPosition) + playerNotificationService.sendClientMetadata(PlayerState.BUFFERING) + } + if (playerNotificationService.currentPlayer.playbackState == Player.STATE_ENDED) { + Log.d(tag, "STATE_ENDED") + playerNotificationService.sendClientMetadata(PlayerState.ENDED) + + playerNotificationService.handlePlaybackEnded() + } + if (playerNotificationService.currentPlayer.playbackState == Player.STATE_IDLE) { + Log.d(tag, "STATE_IDLE") + playerNotificationService.sendClientMetadata(PlayerState.IDLE) + } + } + + if (events.contains(Player.EVENT_MEDIA_METADATA_CHANGED)) { + Log.d(tag, "EVENT_MEDIA_METADATA_CHANGED ${playerNotificationService.getMediaPlayer()}") + } + if (events.contains(Player.EVENT_PLAYLIST_METADATA_CHANGED)) { + Log.d(tag, "EVENT_PLAYLIST_METADATA_CHANGED ${playerNotificationService.getMediaPlayer()}") + } + } + + private fun calcPauseSeekBackTime() : Long { + if (lastPauseTime <= 0) return 0 + val time: Long = System.currentTimeMillis() - lastPauseTime + val seekback: Long + if (time < 10000) seekback = 0 // 10s or less = no seekback + else if (time < 60000) seekback = 3000 // 10s to 1m = jump back 3s + else if (time < 300000) seekback = 10000 // 1m to 5m = jump back 10s + else if (time < 1800000) seekback = 20000 // 5m to 30m = jump back 20s + else seekback = 29500 // 30m and up = jump back 30s + return seekback + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationListener.kt b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationListener.kt new file mode 100644 index 0000000..6ac0721 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationListener.kt @@ -0,0 +1,56 @@ +package com.audiobookshelf.app.player + +import android.app.Notification +import android.content.pm.ServiceInfo +import android.os.Build +import android.util.Log +import com.google.android.exoplayer2.ui.PlayerNotificationManager + +class PlayerNotificationListener(var playerNotificationService:PlayerNotificationService) : PlayerNotificationManager.NotificationListener { + var tag = "PlayerNotificationListener" + + companion object { + var isForegroundService = false + } + + override fun onNotificationPosted( + notificationId: Int, + notification: Notification, + onGoing: Boolean) { + + if (onGoing && !isForegroundService) { + // Start foreground service + Log.d(tag, "Notification Posted $notificationId - Start Foreground | $notification") + PlayerNotificationService.isClosed = false + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + playerNotificationService.startForeground(notificationId, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK) + } else { + playerNotificationService.startForeground(notificationId, notification) + } + isForegroundService = true + } else { + Log.d(tag, "Notification posted $notificationId, not starting foreground - onGoing=$onGoing | isForegroundService=$isForegroundService") + } + } + + override fun onNotificationCancelled( + notificationId: Int, + dismissedByUser: Boolean + ) { + if (dismissedByUser) { + Log.d(tag, "onNotificationCancelled dismissed by user") + playerNotificationService.stopSelf() + } else { + Log.d(tag, "onNotificationCancelled not dismissed by user") + + 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 + } + } + isForegroundService = false + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt new file mode 100644 index 0000000..86d4bdc --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt @@ -0,0 +1,2221 @@ +package com.audiobookshelf.app.player + +import android.annotation.SuppressLint +import android.app.* +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.ImageDecoder +import android.hardware.Sensor +import android.hardware.SensorManager +import android.net.* +import android.os.* +import android.provider.MediaStore +import android.provider.Settings +import android.support.v4.media.MediaBrowserCompat +import android.support.v4.media.MediaDescriptionCompat +import android.support.v4.media.MediaMetadataCompat +import android.support.v4.media.session.MediaControllerCompat +import android.support.v4.media.session.MediaSessionCompat +import androidx.media.VolumeProviderCompat +import android.media.AudioManager +import android.support.v4.media.session.PlaybackStateCompat +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import androidx.media.MediaBrowserServiceCompat +import androidx.media.utils.MediaConstants +import com.audiobookshelf.app.BuildConfig +import com.audiobookshelf.app.R +import com.audiobookshelf.app.data.* +import com.audiobookshelf.app.data.DeviceInfo +import com.audiobookshelf.app.device.DeviceManager +import com.audiobookshelf.app.managers.DbManager +import com.audiobookshelf.app.managers.SleepTimerManager +import com.audiobookshelf.app.media.MediaManager +import com.audiobookshelf.app.media.MediaProgressSyncer +import com.audiobookshelf.app.media.getUriToAbsIconDrawable +import com.audiobookshelf.app.media.getUriToDrawable +import com.audiobookshelf.app.plugins.AbsLogger +import com.audiobookshelf.app.server.ApiHandler +import com.google.android.exoplayer2.* +import com.google.android.exoplayer2.audio.AudioAttributes +import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector +import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.CustomActionProvider +import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator +import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory +import com.google.android.exoplayer2.extractor.mp3.Mp3Extractor +import com.google.android.exoplayer2.source.MediaSource +import com.google.android.exoplayer2.source.ProgressiveMediaSource +import com.google.android.exoplayer2.source.hls.HlsMediaSource +import com.google.android.exoplayer2.ui.PlayerNotificationManager +import com.google.android.exoplayer2.upstream.* +import java.util.* +import kotlin.concurrent.schedule +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking + +const val SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m +const val PLAYER_CAST = "cast-player" +const val PLAYER_EXO = "exo-player" + +class PlayerNotificationService : MediaBrowserServiceCompat() { + + companion object { + var isStarted = false + var isClosed = false + var isUnmeteredNetwork = false + var hasNetworkConnectivity = false // Not 100% reliable has internet + var isSwitchingPlayer = false // Used when switching between cast player and exoplayer + } + + private val tag = "PlayerNotificationServ" + + interface ClientEventEmitter { + fun onPlaybackSession(playbackSession: PlaybackSession) + fun onPlaybackClosed() + fun onPlayingUpdate(isPlaying: Boolean) + fun onMetadata(metadata: PlaybackMetadata) + fun onSleepTimerEnded(currentPosition: Long) + fun onSleepTimerSet(sleepTimeRemaining: Int, isAutoSleepTimer: Boolean) + fun onLocalMediaProgressUpdate(localMediaProgress: LocalMediaProgress) + fun onPlaybackFailed(errorMessage: String) + fun onMediaPlayerChanged(mediaPlayer: String) + fun onProgressSyncFailing() + fun onProgressSyncSuccess() + fun onNetworkMeteredChanged(isUnmetered: Boolean) + fun onMediaItemHistoryUpdated(mediaItemHistory: MediaItemHistory) + fun onPlaybackSpeedChanged(playbackSpeed: Float) + } + private val binder = LocalBinder() + + var clientEventEmitter: ClientEventEmitter? = null + + private lateinit var ctx: Context + private lateinit var mediaSessionConnector: MediaSessionConnector + private lateinit var playerNotificationManager: PlayerNotificationManager + lateinit var mediaSession: MediaSessionCompat + private var remoteVolumeProvider: VolumeProviderCompat? = null + private lateinit var transportControls: MediaControllerCompat.TransportControls + + lateinit var mediaManager: MediaManager + lateinit var apiHandler: ApiHandler + + lateinit var mPlayer: ExoPlayer + lateinit var currentPlayer: Player + var castPlayer: CastPlayer? = null + + lateinit var sleepTimerManager: SleepTimerManager + lateinit var mediaProgressSyncer: MediaProgressSyncer + + private var notificationId = 10 + private var channelId = "audiobookshelf_channel" + private var channelName = "Audiobookshelf Channel" + + var currentPlaybackSession: PlaybackSession? = null + private var initialPlaybackRate: Float? = null + + private val metadataScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + private var metadataArtJob: Job? = null + + private var isAndroidAuto = false + + // The following are used for the shake detection + private var isShakeSensorRegistered: Boolean = false + private var mSensorManager: SensorManager? = null + private var mAccelerometer: Sensor? = null + private var mShakeDetector: ShakeDetector? = null + private var shakeSensorUnregisterTask: TimerTask? = null + + // These are used to trigger reloading if + private var forceReloadingAndroidAuto: Boolean = false + private var firstLoadDone: Boolean = false + + fun isBrowseTreeInitialized(): Boolean { + return this::browseTree.isInitialized + } + + // Cache latest search so it wont trigger again when returning from series for example + private var cachedSearch: String = "" + private var cachedSearchResults: MutableList = mutableListOf() + + /* + Service related stuff + */ + override fun onBind(intent: Intent): IBinder? { + Log.d(tag, "onBind") + + // Android Auto Media Browser Service + if (SERVICE_INTERFACE == intent.action) { + Log.d(tag, "Is Media Browser Service") + return super.onBind(intent) + } + return binder + } + + inner class LocalBinder : Binder() { + // Return this instance of LocalService so clients can call public methods + fun getService(): PlayerNotificationService = this@PlayerNotificationService + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + isStarted = true + Log.d(tag, "onStartCommand $startId") + + return START_STICKY + } + + @Deprecated("Deprecated in Java") + override fun onStart(intent: Intent?, startId: Int) { + Log.d(tag, "onStart $startId") + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun createNotificationChannel(channelId: String, channelName: String): String { + val chan = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_LOW) + chan.lightColor = Color.DKGRAY + chan.lockscreenVisibility = Notification.VISIBILITY_PUBLIC + val service = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + service.createNotificationChannel(chan) + return channelId + } + + // detach player + override fun onDestroy() { + try { + val connectivityManager = + getSystemService(ConnectivityManager::class.java) as ConnectivityManager + connectivityManager.unregisterNetworkCallback(networkCallback) + } catch (error: Exception) { + Log.e(tag, "Error unregistering network listening callback $error") + } + + Log.d(tag, "onDestroy") + isStarted = false + isClosed = true + DeviceManager.widgetUpdater?.onPlayerChanged(this) + + playerNotificationManager.setPlayer(null) + mPlayer.release() + castPlayer?.release() + mediaSession.release() + mediaProgressSyncer.reset() + metadataScope.cancel() + + super.onDestroy() + } + + // removing service when user swipe out our app + override fun onTaskRemoved(rootIntent: Intent?) { + super.onTaskRemoved(rootIntent) + Log.d(tag, "onTaskRemoved") + + stopSelf() + } + + override fun onCreate() { + Log.d(tag, "onCreate") + super.onCreate() + ctx = this + + // Initialize Paper + DbManager.initialize(ctx) + + // Initialize widget + DeviceManager.initializeWidgetUpdater(ctx) + + // To listen for network change from metered to unmetered + val networkRequest = + NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) + .addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + .build() + val connectivityManager = + getSystemService(ConnectivityManager::class.java) as ConnectivityManager + connectivityManager.registerNetworkCallback(networkRequest, networkCallback) + + DbManager.initialize(ctx) + + // Initialize API + apiHandler = ApiHandler(ctx) + + // Initialize sleep timer + sleepTimerManager = SleepTimerManager(this) + + // Initialize Media Progress Syncer + mediaProgressSyncer = MediaProgressSyncer(this, apiHandler) + + // Initialize shake sensor + Log.d(tag, "onCreate Register sensor listener ${mAccelerometer?.isWakeUpSensor}") + initSensor() + + // Initialize media manager + mediaManager = MediaManager(apiHandler, ctx) + + channelId = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + createNotificationChannel(channelId, channelName) + } else "" + + val sessionActivityPendingIntent = + packageManager?.getLaunchIntentForPackage(packageName)?.let { sessionIntent -> + PendingIntent.getActivity(this, 0, sessionIntent, PendingIntent.FLAG_IMMUTABLE) + } + + mediaSession = + MediaSessionCompat(this, tag).apply { + setSessionActivity(sessionActivityPendingIntent) + isActive = true + } + + val mediaController = MediaControllerCompat(ctx, mediaSession.sessionToken) + + // This is for Media Browser + sessionToken = mediaSession.sessionToken + + val builder = PlayerNotificationManager.Builder(ctx, notificationId, channelId) + + builder.setMediaDescriptionAdapter(AbMediaDescriptionAdapter(mediaController, this)) + builder.setNotificationListener(PlayerNotificationListener(this)) + + playerNotificationManager = builder.build() + playerNotificationManager.setMediaSessionToken(mediaSession.sessionToken) + playerNotificationManager.setUsePlayPauseActions(true) + playerNotificationManager.setUseNextAction(false) + playerNotificationManager.setUsePreviousAction(false) + playerNotificationManager.setUseChronometer(false) + playerNotificationManager.setUseStopAction(false) + playerNotificationManager.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX) + playerNotificationManager.setUseFastForwardActionInCompactView(true) + playerNotificationManager.setUseRewindActionInCompactView(true) + playerNotificationManager.setSmallIcon(R.drawable.icon_monochrome) + + // Unknown action + playerNotificationManager.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE) + + transportControls = mediaController.transportControls + + mediaSessionConnector = MediaSessionConnector(mediaSession) + // Without this, the connector's default metadata provider rebuilds metadata from the + // player's own state on media item transitions/timeline changes, dropping the cover art + // bitmap that PlaybackSession.resolveCoverBitmapAsync resolves separately. + mediaSessionConnector.setMediaMetadataProvider { _ -> + currentPlaybackSession?.getMediaMetadataCompat(ctx) ?: MediaMetadataCompat.Builder().build() + } + val queueNavigator: TimelineQueueNavigator = + object : TimelineQueueNavigator(mediaSession) { + override fun getSupportedQueueNavigatorActions(player: Player): Long { + return PlaybackStateCompat.ACTION_PLAY_PAUSE or + PlaybackStateCompat.ACTION_PLAY or + PlaybackStateCompat.ACTION_PAUSE + } + + override fun getMediaDescription( + player: Player, + windowIndex: Int + ): MediaDescriptionCompat { + if (currentPlaybackSession == null) { + Log.e(tag, "Playback session is not set - returning blank MediaDescriptionCompat") + return MediaDescriptionCompat.Builder().build() + } + + val coverUri = currentPlaybackSession!!.getCoverUri(ctx) + + + var bitmap: Bitmap? = null + // Local covers get bitmap + // Note: In Android Auto for local cover images, setting the icon uri to a local path does not work (cover is blank) + // so we create and set the bitmap here instead of AbMediaDescriptionAdapter + if (currentPlaybackSession!!.localLibraryItem?.coverContentUrl != null) { + bitmap = + if (Build.VERSION.SDK_INT < 28) { + MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri) + } else { + val source: ImageDecoder.Source = + ImageDecoder.createSource(ctx.contentResolver, coverUri) + ImageDecoder.decodeBitmap(source) + } + } + + // Fix for local images crashing on Android 11 for specific devices + // https://stackoverflow.com/questions/64186578/android-11-mediastyle-notification-crash/64232958#64232958 + try { + ctx.grantUriPermission( + "com.android.systemui", + coverUri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } catch (error: Exception) { + Log.e(tag, "Grant uri permission error $error") + } + + val extra = Bundle() + extra.putString( + MediaMetadataCompat.METADATA_KEY_ARTIST, + currentPlaybackSession!!.displayAuthor + ) + + val mediaDescriptionBuilder = + MediaDescriptionCompat.Builder() + .setExtras(extra) + .setTitle(currentPlaybackSession!!.displayTitle) + + bitmap?.let { mediaDescriptionBuilder.setIconBitmap(it) } + ?: mediaDescriptionBuilder.setIconUri(coverUri) + + return mediaDescriptionBuilder.build() + } + } + + setMediaSessionConnectorPlaybackActions() + mediaSessionConnector.setQueueNavigator(queueNavigator) + mediaSessionConnector.setPlaybackPreparer(MediaSessionPlaybackPreparer(this)) + + mediaSession.setCallback(MediaSessionCallback(this)) + + initializeMPlayer() + currentPlayer = mPlayer + } + + private fun initializeMPlayer() { + val customLoadControl: LoadControl = + DefaultLoadControl.Builder() + .setBufferDurationsMs( + 1000 * 20, // 20s min buffer + 1000 * 45, // 45s max buffer + 1000 * 5, // 5s playback start + 1000 * 20 // 20s playback rebuffer + ) + .build() + + mPlayer = + ExoPlayer.Builder(this) + .setLoadControl(customLoadControl) + .setSeekBackIncrementMs(deviceSettings.jumpBackwardsTimeMs) + .setSeekForwardIncrementMs(deviceSettings.jumpForwardTimeMs) + .build() + mPlayer.setHandleAudioBecomingNoisy(true) + mPlayer.addListener(PlayerListener(this)) + val audioAttributes: AudioAttributes = + AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.AUDIO_CONTENT_TYPE_SPEECH) + .build() + mPlayer.setAudioAttributes(audioAttributes, true) + + // attach player to playerNotificationManager + playerNotificationManager.setPlayer(mPlayer) + + mediaSessionConnector.setPlayer(mPlayer) + } + + /* + User callable methods + */ + fun preparePlayer( + playbackSession: PlaybackSession, + playWhenReady: Boolean, + playbackRate: Float? + ) { + if (!isStarted) { + Log.i(tag, "preparePlayer: foreground service not started - Starting service --") + Intent(ctx, PlayerNotificationService::class.java).also { intent -> + ContextCompat.startForegroundService(ctx, intent) + } + } + + // TODO: When an item isFinished the currentTime should be reset to 0 + // will reset the time if currentTime is within 5s of duration (for android auto) + Log.d( + tag, + "Prepare Player Session Current Time=${playbackSession.currentTime}, Duration=${playbackSession.duration}" + ) + if (playbackSession.duration - playbackSession.currentTime < 5) { + Log.d(tag, "Prepare Player Session is finished, so restart it") + playbackSession.currentTime = 0.0 + } + + isClosed = false + + val mediaItems = playbackSession.getMediaItems(ctx) + val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f + initialPlaybackRate = playbackRate + + // Set actions on Android Auto like jump forward/backward + setMediaSessionConnectorCustomActions(playbackSession) + + playbackSession.mediaPlayer = getMediaPlayer() + + if (playbackSession.mediaPlayer == PLAYER_CAST && playbackSession.isLocal) { + Log.w(tag, "Cannot cast local media item - switching player") + currentPlaybackSession = null + switchToPlayer(false) + playbackSession.mediaPlayer = getMediaPlayer() + } + + if (playbackSession.mediaPlayer == PLAYER_CAST) { + // If cast-player is the first player to be used + mediaSessionConnector.setPlayer(castPlayer) + playerNotificationManager.setPlayer(castPlayer) + } + + currentPlaybackSession = playbackSession + DeviceManager.setLastPlaybackSession( + playbackSession + ) // Save playback session to use when app is closed + + metadataArtJob?.cancel() + metadataArtJob = + playbackSession.resolveCoverBitmapAsync(ctx, metadataScope) { + mediaSessionConnector.invalidateMediaSessionMetadata() + } + mediaSessionConnector.invalidateMediaSessionMetadata() + + AbsLogger.info("PlayerNotificationService", "preparePlayer: Started playback session for item ${currentPlaybackSession?.mediaItemId}. MediaPlayer ${currentPlaybackSession?.mediaPlayer}") + // Notify client + clientEventEmitter?.onPlaybackSession(playbackSession) + + // Update widget + DeviceManager.widgetUpdater?.onPlayerChanged(this) + + if (mediaItems.isEmpty()) { + Log.e(tag, "Invalid playback session no media items to play") + currentPlaybackSession = null + return + } + + if (mPlayer == currentPlayer) { + val mediaSource: MediaSource + + if (playbackSession.isLocal) { + AbsLogger.info("PlayerNotificationService", "preparePlayer: Playing local item ${currentPlaybackSession?.mediaItemId}.") + val dataSourceFactory = DefaultDataSource.Factory(ctx) + + val extractorsFactory = DefaultExtractorsFactory() + extractorsFactory.setConstantBitrateSeekingEnabled(true) + + if (DeviceManager.deviceData.deviceSettings?.enableMp3IndexSeeking == true) { + // @see + // https://exoplayer.dev/troubleshooting.html#why-is-seeking-inaccurate-in-some-mp3-files + extractorsFactory.setMp3ExtractorFlags(Mp3Extractor.FLAG_ENABLE_INDEX_SEEKING) + } + + mediaSource = + ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory) + .createMediaSource(mediaItems[0]) + } else if (!playbackSession.isHLS) { + AbsLogger.info("PlayerNotificationService", "preparePlayer: Direct playing item ${currentPlaybackSession?.mediaItemId}.") + val dataSourceFactory = DefaultHttpDataSource.Factory() + + val extractorsFactory = DefaultExtractorsFactory() + extractorsFactory.setConstantBitrateSeekingEnabled(true) + + if (DeviceManager.deviceData.deviceSettings?.enableMp3IndexSeeking == true) { + // @see + // https://exoplayer.dev/troubleshooting.html#why-is-seeking-inaccurate-in-some-mp3-files + extractorsFactory.setMp3ExtractorFlags(Mp3Extractor.FLAG_ENABLE_INDEX_SEEKING) + } + + dataSourceFactory.setUserAgent(channelId) + mediaSource = + ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory) + .createMediaSource(mediaItems[0]) + } else { + AbsLogger.info("PlayerNotificationService", "preparePlayer: Playing HLS stream of item ${currentPlaybackSession?.mediaItemId}.") + val dataSourceFactory = DefaultHttpDataSource.Factory() + dataSourceFactory.setUserAgent(channelId) + dataSourceFactory.setDefaultRequestProperties( + hashMapOf("Authorization" to "Bearer ${DeviceManager.token}") + ) + mediaSource = HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0]) + } + mPlayer.setMediaSource(mediaSource) + + // Add remaining media items if multi-track + if (mediaItems.size > 1) { + currentPlayer.addMediaItems(mediaItems.subList(1, mediaItems.size)) + Log.d(tag, "currentPlayer total media items ${currentPlayer.mediaItemCount}") + + val currentTrackIndex = playbackSession.getCurrentTrackIndex() + val currentTrackTime = playbackSession.getCurrentTrackTimeMs() + Log.d( + tag, + "currentPlayer current track index $currentTrackIndex & current track time $currentTrackTime" + ) + currentPlayer.seekTo(currentTrackIndex, currentTrackTime) + } else { + currentPlayer.seekTo(playbackSession.currentTimeMs) + } + + Log.d( + tag, + "Prepare complete for session ${currentPlaybackSession?.displayTitle} | ${currentPlayer.mediaItemCount}" + ) + currentPlayer.playWhenReady = playWhenReady + currentPlayer.setPlaybackSpeed(playbackRateToUse) + + currentPlayer.prepare() + } else if (castPlayer != null) { + val currentTrackIndex = playbackSession.getCurrentTrackIndex() + val currentTrackTime = playbackSession.getCurrentTrackTimeMs() + val mediaType = playbackSession.mediaType + Log.d(tag, "Loading cast player $currentTrackIndex $currentTrackTime $mediaType") + + castPlayer?.load( + mediaItems, + currentTrackIndex, + currentTrackTime, + playWhenReady, + playbackRateToUse, + mediaType + ) + } + } + + private fun setMediaSessionConnectorCustomActions(playbackSession: PlaybackSession) { + val mediaItems = playbackSession.getMediaItems(ctx) + val customActionProviders = + mutableListOf( + JumpBackwardCustomActionProvider(), + JumpForwardCustomActionProvider(), + ChangePlaybackSpeedCustomActionProvider() // Will be pushed to far left + ) + if (playbackSession.mediaPlayer != PLAYER_CAST && mediaItems.size > 1) { + customActionProviders.addAll( + listOf( + SkipBackwardCustomActionProvider(), + SkipForwardCustomActionProvider(), + ) + ) + } + mediaSessionConnector.setCustomActionProviders(*customActionProviders.toTypedArray()) + } + + fun setMediaSessionConnectorPlaybackActions() { + var playbackActions = + PlaybackStateCompat.ACTION_PLAY_PAUSE or + PlaybackStateCompat.ACTION_PLAY or + PlaybackStateCompat.ACTION_PAUSE or + PlaybackStateCompat.ACTION_FAST_FORWARD or + PlaybackStateCompat.ACTION_REWIND or + PlaybackStateCompat.ACTION_STOP + + if (deviceSettings.allowSeekingOnMediaControls) { + playbackActions = playbackActions or PlaybackStateCompat.ACTION_SEEK_TO + } + mediaSessionConnector.setEnabledPlaybackActions(playbackActions) + } + + fun handlePlayerPlaybackError(errorMessage: String) { + // On error and was attempting to direct play - fallback to transcode + currentPlaybackSession?.let { playbackSession -> + if (playbackSession.isDirectPlay) { + val playItemRequestPayload = getPlayItemRequestPayload(true) + Log.d(tag, "Fallback to transcode $playItemRequestPayload.mediaPlayer") + + val libraryItemId = playbackSession.libraryItemId ?: "" // Must be true since direct play + val episodeId = playbackSession.episodeId + mediaProgressSyncer.stop(false) { + apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) { + if (it == null) { // Play request failed + clientEventEmitter?.onPlaybackFailed(errorMessage) + closePlayback(true) + } else { + Handler(Looper.getMainLooper()).post { preparePlayer(it, true, null) } + } + } + } + } else { + clientEventEmitter?.onPlaybackFailed(errorMessage) + closePlayback(true) + } + } + } + + fun handlePlaybackEnded() { + Log.d(tag, "handlePlaybackEnded") + if (isAndroidAuto && currentPlaybackSession?.isPodcastEpisode == true) { + Log.d(tag, "Podcast playback ended on android auto") + val libraryItem = currentPlaybackSession?.libraryItem ?: return + + // Need to sync with server to set as finished + mediaProgressSyncer.finished { + // Need to reload media progress + mediaManager.loadServerUserMediaProgress { + val podcast = libraryItem.media as Podcast + val nextEpisode = podcast.getNextUnfinishedEpisode(libraryItem.id, mediaManager) + Log.d(tag, "handlePlaybackEnded nextEpisode=$nextEpisode") + nextEpisode?.let { podcastEpisode -> + mediaManager.play(libraryItem, podcastEpisode, getPlayItemRequestPayload(false)) { + if (it == null) { + Log.e(tag, "Failed to play library item") + } else { + val playbackRate = mediaManager.getSavedPlaybackRate() + Handler(Looper.getMainLooper()).post { preparePlayer(it, true, playbackRate) } + } + } + } + } + } + } + } + + fun startNewPlaybackSession() { + currentPlaybackSession?.let { playbackSession -> + Log.i(tag, "Starting new playback session for ${playbackSession.displayTitle}") + + val forceTranscode = playbackSession.isHLS // If already HLS then force + val playItemRequestPayload = getPlayItemRequestPayload(forceTranscode) + + val libraryItemId = playbackSession.libraryItemId ?: "" // Must be true since direct play + val episodeId = playbackSession.episodeId + mediaProgressSyncer.stop(false) { + apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) { + if (it == null) { + Log.e(tag, "Failed to start new playback session") + } else { + Log.d( + tag, + "New playback session response from server with session id ${it.id} for \"${it.displayTitle}\"" + ) + Handler(Looper.getMainLooper()).post { preparePlayer(it, true, null) } + } + } + } + } + } + + fun switchToPlayer(useCastPlayer: Boolean) { + val wasPlaying = currentPlayer.isPlaying + if (useCastPlayer) { + if (currentPlayer == castPlayer) { + Log.d(tag, "switchToPlayer: Already using Cast Player " + castPlayer?.deviceInfo) + return + } else { + Log.d(tag, "switchToPlayer: Switching to cast player from exo player stop exo player") + mPlayer.stop() + } + } else { + if (currentPlayer == mPlayer) { + Log.d(tag, "switchToPlayer: Already using Exo Player " + mPlayer.deviceInfo) + return + } else if (castPlayer != null) { + Log.d(tag, "switchToPlayer: Switching to exo player from cast player stop cast player") + castPlayer?.stop() + } + } + + if (currentPlaybackSession == null) { + Log.e(tag, "switchToPlayer: No Current playback session") + } else { + isSwitchingPlayer = true + } + + // Playback session in progress syncer is a copy that is up-to-date so replace current here with + // that + // TODO: bad design here implemented to prevent the session in MediaProgressSyncer from + // changing while syncing + if (mediaProgressSyncer.currentPlaybackSession != null) { + currentPlaybackSession = mediaProgressSyncer.currentPlaybackSession?.clone() + } + + currentPlayer = + if (useCastPlayer) { + Log.d(tag, "switchToPlayer: Using Cast Player " + castPlayer?.deviceInfo) + mediaSessionConnector.setPlayer(castPlayer) + playerNotificationManager.setPlayer(castPlayer) + setMediaSessionToCastVolume() + castPlayer as CastPlayer + } else { + Log.d(tag, "switchToPlayer: Using ExoPlayer") + mediaSessionConnector.setPlayer(mPlayer) + playerNotificationManager.setPlayer(mPlayer) + setMediaSessionToLocalVolume() + mPlayer + } + + clientEventEmitter?.onMediaPlayerChanged(getMediaPlayer()) + + currentPlaybackSession?.let { + Log.d(tag, "switchToPlayer: Starting new playback session ${it.displayTitle}") + if (wasPlaying) { // media is paused when switching players + clientEventEmitter?.onPlayingUpdate(false) + } + + // TODO: Start a new playback session here instead of using the existing + preparePlayer(it, false, null) + } + } + + private fun setMediaSessionToCastVolume() { + val currentVol = try { castPlayer?.getDeviceVolume() ?: 0 } catch (_: Exception) { 0 } + val provider = object : VolumeProviderCompat(VolumeProviderCompat.VOLUME_CONTROL_ABSOLUTE, 100, currentVol) { + override fun onSetVolumeTo(volume: Int) { + // Clamp, update UI immediately, then send to device + val clamped = volume.coerceIn(0, 100) + setCurrentVolume(clamped) + try { castPlayer?.setDeviceVolume(clamped) } catch (_: Exception) {} + } + + override fun onAdjustVolume(direction: Int) { + // Use Android-provided step (−1, 0, +1). Clamp, update UI immediately, then send. + val current = currentVolume + val target = (current + direction).coerceIn(0, 100) + setCurrentVolume(target) + try { castPlayer?.setDeviceVolume(target) } catch (_: Exception) {} + } + } + remoteVolumeProvider = provider + mediaSession.setPlaybackToRemote(provider) + } + + private fun setMediaSessionToLocalVolume() { + mediaSession.setPlaybackToLocal(AudioManager.STREAM_MUSIC) + remoteVolumeProvider = null + } + + fun getCurrentTrackStartOffsetMs(): Long { + return if (currentPlayer.mediaItemCount > 1) { + val windowIndex = currentPlayer.currentMediaItemIndex + val currentTrackStartOffset = currentPlaybackSession?.getTrackStartOffsetMs(windowIndex) ?: 0L + currentTrackStartOffset + } else { + 0 + } + } + + fun getCurrentTime(): Long { + return currentPlayer.currentPosition + getCurrentTrackStartOffsetMs() + } + + fun getCurrentTimeSeconds(): Double { + return getCurrentTime() / 1000.0 + } + + private fun getBufferedTime(): Long { + return if (currentPlayer.mediaItemCount > 1) { + val windowIndex = currentPlayer.currentMediaItemIndex + val currentTrackStartOffset = currentPlaybackSession?.getTrackStartOffsetMs(windowIndex) ?: 0L + currentPlayer.bufferedPosition + currentTrackStartOffset + } else { + currentPlayer.bufferedPosition + } + } + + fun getBufferedTimeSeconds(): Double { + return getBufferedTime() / 1000.0 + } + + fun getDuration(): Long { + return currentPlaybackSession?.totalDurationMs ?: 0L + } + + fun getCurrentPlaybackSessionCopy(): PlaybackSession? { + return currentPlaybackSession?.clone() + } + + fun getCurrentBookChapter(): BookChapter? { + return currentPlaybackSession?.getChapterForTime(this.getCurrentTime()) + } + + fun getEndTimeOfChapterOrTrack(): Long? { + return getCurrentBookChapter()?.endMs ?: currentPlaybackSession?.getCurrentTrackEndTime() + } + + private fun getNextBookChapter(): BookChapter? { + return currentPlaybackSession?.getNextChapterForTime(this.getCurrentTime()) + } + + fun getEndTimeOfNextChapterOrTrack(): Long? { + return getNextBookChapter()?.endMs ?: currentPlaybackSession?.getNextTrackEndTime() + } + + // Called from PlayerListener play event + // check with server if progress has updated since last play and sync progress update + fun checkCurrentSessionProgress(seekBackTime: Long): Boolean { + if (currentPlaybackSession == null) return true + + mediaProgressSyncer.currentPlaybackSession?.let { playbackSession -> + if (!DeviceManager.checkConnectivity(ctx)) { + return true // carry on + } + + if (playbackSession.isLocal) { + + // Make sure this connection config exists + val serverConnectionConfig = + DeviceManager.getServerConnectionConfig(playbackSession.serverConnectionConfigId) + if (serverConnectionConfig == null) { + Log.d( + tag, + "checkCurrentSessionProgress: Local library item server connection config is not saved ${playbackSession.serverConnectionConfigId}" + ) + return true // carry on + } + + // Local playback session check if server has updated media progress + Log.d( + tag, + "checkCurrentSessionProgress: Checking if local media progress was updated on server" + ) + apiHandler.getMediaProgress( + playbackSession.libraryItemId!!, + playbackSession.episodeId, + serverConnectionConfig + ) { mediaProgress -> + if (mediaProgress != null && + mediaProgress.lastUpdate > playbackSession.updatedAt && + mediaProgress.currentTime != playbackSession.currentTime + ) { + Log.d( + tag, + "checkCurrentSessionProgress: Media progress was updated since last play time updating from ${playbackSession.currentTime} to ${mediaProgress.currentTime}" + ) + mediaProgressSyncer.syncFromServerProgress(mediaProgress) + + // Update current playback session stored in PNS since MediaProgressSyncer version is a + // copy + mediaProgressSyncer.currentPlaybackSession?.let { updatedPlaybackSession -> + currentPlaybackSession = updatedPlaybackSession + } + + Handler(Looper.getMainLooper()).post { + seekPlayer(playbackSession.currentTimeMs) + // Should already be playing + currentPlayer.volume = 1F // Volume on sleep timer might have decreased this + currentPlaybackSession?.let { mediaProgressSyncer.play(it) } + clientEventEmitter?.onPlayingUpdate(true) + } + } else { + Handler(Looper.getMainLooper()).post { + if (seekBackTime > 0L) { + seekBackward(seekBackTime) + } + + // Should already be playing + currentPlayer.volume = 1F // Volume on sleep timer might have decreased this + mediaProgressSyncer.currentPlaybackSession?.let { playbackSession -> + mediaProgressSyncer.play(playbackSession) + } + clientEventEmitter?.onPlayingUpdate(true) + } + } + } + } else { + // Streaming from server so check if playback session still exists on server + Log.d( + tag, + "checkCurrentSessionProgress: Checking if playback session ${playbackSession.id} for server stream is still available" + ) + apiHandler.getPlaybackSession(playbackSession.id) { + if (it == null) { + Log.d( + tag, + "checkCurrentSessionProgress: Playback session does not exist on server - start new playback session" + ) + + Handler(Looper.getMainLooper()).post { + currentPlayer.pause() + startNewPlaybackSession() + } + } else { + Log.d(tag, "checkCurrentSessionProgress: Playback session still available on server") + Handler(Looper.getMainLooper()).post { + if (seekBackTime > 0L) { + seekBackward(seekBackTime) + } + + currentPlayer.volume = 1F // Volume on sleep timer might have decreased this + mediaProgressSyncer.currentPlaybackSession?.let { playbackSession -> + mediaProgressSyncer.play(playbackSession) + } + + clientEventEmitter?.onPlayingUpdate(true) + } + } + } + } + } + return false + } + + fun play() { + if (currentPlayer.isPlaying) { + Log.d(tag, "Already playing") + return + } + currentPlayer.volume = 1F + currentPlayer.play() + } + + fun pause() { + currentPlayer.pause() + } + + fun playPause(): Boolean { + return if (currentPlayer.isPlaying) { + pause() + false + } else { + play() + true + } + } + + fun seekPlayer(time: Long) { + var timeToSeek = time + Log.d(tag, "seekPlayer mediaCount = ${currentPlayer.mediaItemCount} | $timeToSeek") + if (timeToSeek < 0) { + Log.w(tag, "seekPlayer invalid time $timeToSeek - setting to 0") + timeToSeek = 0L + } else if (timeToSeek > getDuration()) { + Log.w(tag, "seekPlayer invalid time $timeToSeek - setting to MAX - 2000") + timeToSeek = getDuration() - 2000L + } + + if (currentPlayer.mediaItemCount > 1) { + currentPlaybackSession?.currentTime = timeToSeek / 1000.0 + val newWindowIndex = currentPlaybackSession?.getCurrentTrackIndex() ?: 0 + val newTimeOffset = currentPlaybackSession?.getCurrentTrackTimeMs() ?: 0 + Log.d(tag, "seekPlayer seekTo $newWindowIndex | $newTimeOffset") + currentPlayer.seekTo(newWindowIndex, newTimeOffset) + } else { + currentPlayer.seekTo(timeToSeek) + } + } + + fun skipToPrevious() { + currentPlayer.seekToPrevious() + } + + fun skipToNext() { + currentPlayer.seekToNext() + } + + fun jumpForward() { + seekForward(deviceSettings.jumpForwardTimeMs) + } + + fun jumpBackward() { + seekBackward(deviceSettings.jumpBackwardsTimeMs) + } + + fun seekForward(amount: Long) { + seekPlayer(getCurrentTime() + amount) + } + + fun seekBackward(amount: Long) { + seekPlayer(getCurrentTime() - amount) + } + + fun setPlaybackSpeed(speed: Float) { + mediaManager.userSettingsPlaybackRate = speed + currentPlayer.setPlaybackSpeed(speed) + + // Refresh Android Auto actions + mediaProgressSyncer.currentPlaybackSession?.let { setMediaSessionConnectorCustomActions(it) } + } + + fun closePlayback(calledOnError: Boolean? = false) { + Log.d(tag, "closePlayback") + val config = DeviceManager.serverConnectionConfig + + val isLocal = mediaProgressSyncer.currentIsLocal + val currentSessionId = mediaProgressSyncer.currentSessionId + if (mediaProgressSyncer.listeningTimerRunning) { + Log.i(tag, "About to close playback so stopping media progress syncer first") + + mediaProgressSyncer.stop( + calledOnError == false + ) { // If closing on error then do not sync progress (causes exception) + Log.d(tag, "Media Progress syncer stopped") + // If not local session then close on server + if (!isLocal && currentSessionId != "") { + apiHandler.closePlaybackSession(currentSessionId, config) { + Log.d(tag, "Closed playback session $currentSessionId") + } + } + } + } else { + // If not local session then close on server + if (!isLocal && currentSessionId != "") { + apiHandler.closePlaybackSession(currentSessionId, config) { + Log.d(tag, "Closed playback session $currentSessionId") + } + } + } + + try { + currentPlayer.stop() + currentPlayer.clearMediaItems() + } catch (e: Exception) { + Log.e(tag, "Exception clearing exoplayer $e") + } + + currentPlaybackSession = null + mediaProgressSyncer.reset() + clientEventEmitter?.onPlaybackClosed() + + PlayerListener.lastPauseTime = 0 + isClosed = true + DeviceManager.widgetUpdater?.onPlayerClosed() + stopForeground(Service.STOP_FOREGROUND_REMOVE) + stopSelf() + } + + fun sendClientMetadata(playerState: PlayerState) { + val duration = currentPlaybackSession?.getTotalDuration() ?: 0.0 + clientEventEmitter?.onMetadata(PlaybackMetadata(duration, getCurrentTimeSeconds(), playerState)) + } + + fun getMediaPlayer(): String { + return if (currentPlayer == castPlayer) PLAYER_CAST else PLAYER_EXO + } + + @SuppressLint("HardwareIds") + fun getDeviceInfo(): DeviceInfo { + /* EXAMPLE + manufacturer: Google + model: Pixel 6 + brand: google + sdkVersion: 32 + appVersion: 0.9.46-beta + */ + val deviceId = Settings.Secure.getString(ctx.contentResolver, Settings.Secure.ANDROID_ID) + return DeviceInfo( + deviceId, + Build.MANUFACTURER, + Build.MODEL, + Build.VERSION.SDK_INT, + BuildConfig.VERSION_NAME + ) + } + + private val deviceSettings + get() = DeviceManager.deviceData.deviceSettings ?: DeviceSettings.default() + + fun getPlayItemRequestPayload(forceTranscode: Boolean): PlayItemRequestPayload { + return PlayItemRequestPayload( + getMediaPlayer(), + !forceTranscode, + forceTranscode, + getDeviceInfo() + ) + } + + fun getContext(): Context { + return ctx + } + + fun alertSyncFailing() { + clientEventEmitter?.onProgressSyncFailing() + } + + fun alertSyncSuccess() { + clientEventEmitter?.onProgressSyncSuccess() + } + + // + // MEDIA BROWSER STUFF (ANDROID AUTO) + // + private val VALID_MEDIA_BROWSERS = + mutableListOf( + "com.audiobookshelf.app", + "com.audiobookshelf.app.debug", + 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 LIBRARIES_ROOT = "__LIBRARIES__" + private val RECENTLY_ROOT = "__RECENTLY__" + private val DOWNLOADS_ROOT = "__DOWNLOADS__" + private val CONTINUE_ROOT = "__CONTINUE__" + private lateinit var browseTree: BrowseTree + private val browseTreeInitListeners = mutableListOf<() -> Unit>() + + private fun waitForBrowseTree(cb: () -> Unit) + { + if (this::browseTree.isInitialized) + { + cb() + } + else + { + browseTreeInitListeners += cb + } + } + + private fun onBrowseTreeInitialized() + { + // Called after browseTree is assigned for the first time + browseTreeInitListeners.forEach { it.invoke() } + browseTreeInitListeners.clear() + } + + // Only allowing android auto or similar to access media browser service + // normal loading of audiobooks is handled in webview (not natively) + private fun isValid(packageName: String, uid: Int): Boolean { + Log.d(tag, "onGetRoot: Checking package $packageName with uid $uid") + if (!VALID_MEDIA_BROWSERS.contains(packageName)) { + Log.d(tag, "onGetRoot: package $packageName not valid for the media browser service") + return false + } + return true + } + + override fun onGetRoot( + clientPackageName: String, + clientUid: Int, + rootHints: Bundle? + ): BrowserRoot? { + // Verify that the specified package is allowed to access your content + return if (!isValid(clientPackageName, clientUid)) { + // No further calls will be made to other media browsing methods. + null + } else { + AbsLogger.info(tag, "onGetRoot: clientPackageName: $clientPackageName, clientUid: $clientUid") + isStarted = true + + // Reset cache if no longer connected to server or server changed + if (mediaManager.checkResetServerItems()) { + AbsLogger.info(tag, "onGetRoot: Reset Android Auto server items cache (${DeviceManager.serverConnectionConfigString})") + forceReloadingAndroidAuto = true + } + + isAndroidAuto = true + + val extras = Bundle() + extras.putBoolean(MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true) + extras.putInt( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE, + MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM + ) + extras.putInt( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE, + MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM + ) + + BrowserRoot(AUTO_MEDIA_ROOT, extras) + } + } + + override fun onLoadChildren( + parentMediaId: String, + result: Result> + ) { + AbsLogger.info(tag, "onLoadChildren: parentMediaId: $parentMediaId (${DeviceManager.serverConnectionConfigString})") + + result.detach() + + // Prevent crashing if app is restarted while browsing + if ((parentMediaId != DOWNLOADS_ROOT && parentMediaId != AUTO_MEDIA_ROOT) && !firstLoadDone) { + result.sendResult(null) + return + } + + if (parentMediaId == DOWNLOADS_ROOT) { // Load downloads + val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book") + val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast") + val localBrowseItems: MutableList = mutableListOf() + + localBooks.forEach { localLibraryItem -> + if (localLibraryItem.media.getAudioTracks().isNotEmpty()) { + val progress = DeviceManager.dbManager.getLocalMediaProgress(localLibraryItem.id) + val description = localLibraryItem.getMediaDescription(progress, ctx) + + localBrowseItems += + MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + } + } + + localPodcasts.forEach { localLibraryItem -> + val mediaDescription = localLibraryItem.getMediaDescription(null, ctx) + localBrowseItems += + MediaBrowserCompat.MediaItem( + mediaDescription, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + + result.sendResult(localBrowseItems) + } else if (parentMediaId == CONTINUE_ROOT) { + val localBrowseItems: MutableList = mutableListOf() + mediaManager.serverItemsInProgress.forEach { itemInProgress -> + val progress: MediaProgressWrapper? + val mediaDescription: MediaDescriptionCompat + if (itemInProgress.episode != null) { + if (itemInProgress.isLocal) { + progress = + DeviceManager.dbManager.getLocalMediaProgress( + "${itemInProgress.libraryItemWrapper.id}-${itemInProgress.episode.id}" + ) + } else { + progress = + mediaManager.serverUserMediaProgress.find { + it.libraryItemId == itemInProgress.libraryItemWrapper.id && + it.episodeId == itemInProgress.episode.id + } + + // to show download icon + val localLibraryItem = + DeviceManager.dbManager.getLocalLibraryItemByLId( + itemInProgress.libraryItemWrapper.id + ) + localLibraryItem?.let { lli -> + val localEpisode = + (lli.media as Podcast).episodes?.find { + it.serverEpisodeId == itemInProgress.episode.id + } + itemInProgress.episode.localEpisodeId = localEpisode?.id + } + } + mediaDescription = + itemInProgress.episode.getMediaDescription( + itemInProgress.libraryItemWrapper, + progress, + ctx + ) + } else { + if (itemInProgress.isLocal) { + progress = + DeviceManager.dbManager.getLocalMediaProgress( + itemInProgress.libraryItemWrapper.id + ) + } else { + progress = + mediaManager.serverUserMediaProgress.find { + it.libraryItemId == itemInProgress.libraryItemWrapper.id + } + + val localLibraryItem = + DeviceManager.dbManager.getLocalLibraryItemByLId( + itemInProgress.libraryItemWrapper.id + ) + (itemInProgress.libraryItemWrapper as LibraryItem).localLibraryItemId = + localLibraryItem?.id // To show downloaded icon + } + mediaDescription = itemInProgress.libraryItemWrapper.getMediaDescription(progress, ctx) + } + localBrowseItems += + MediaBrowserCompat.MediaItem( + mediaDescription, + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + } + result.sendResult(localBrowseItems) + } else if (parentMediaId == AUTO_MEDIA_ROOT) { + Log.d(tag, "Trying to initialize browseTree.") + if (!this::browseTree.isInitialized || forceReloadingAndroidAuto) { + forceReloadingAndroidAuto = false + AbsLogger.info(tag, "onLoadChildren: Loading Android Auto items") + mediaManager.loadAndroidAutoItems { + AbsLogger.info(tag, "onLoadChildren: Loaded Android Auto data, initializing browseTree") + + browseTree = + BrowseTree( + this, + mediaManager.serverItemsInProgress, + mediaManager.serverLibraries, + mediaManager.allLibraryPersonalizationsDone + ) + onBrowseTreeInitialized() + val children = + browseTree[parentMediaId]?.map { item -> + Log.d(tag, "Found top menu item: ${item.description.title}") + MediaBrowserCompat.MediaItem( + item.description, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + + result.sendResult(children as MutableList?) + firstLoadDone = true + if (mediaManager.serverLibraries.isNotEmpty()) { + AbsLogger.info(tag, "onLoadChildren: Android Auto fetching personalized data for all libraries") + mediaManager.populatePersonalizedDataForAllLibraries { + AbsLogger.info(tag, "onLoadChildren: Android Auto loaded personalized data for all libraries") + notifyChildrenChanged("/") + } + + AbsLogger.info(tag, "onLoadChildren: Android Auto fetching in progress items") + mediaManager.initializeInProgressItems { + AbsLogger.info(tag, "onLoadChildren: Android Auto loaded in progress items") + notifyChildrenChanged("/") + } + } + } + } else { + Log.d(tag, "Starting browseTree refresh") + browseTree = + BrowseTree( + this, + mediaManager.serverItemsInProgress, + mediaManager.serverLibraries, + mediaManager.allLibraryPersonalizationsDone + ) + onBrowseTreeInitialized() + val children = + browseTree[parentMediaId]?.map { item -> + Log.d(tag, "Found top menu item: ${item.description.title}") + MediaBrowserCompat.MediaItem( + item.description, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + + AbsLogger.info(tag, "onLoadChildren: Android auto data loaded") + result.sendResult(children as MutableList?) + } + } else if (parentMediaId == LIBRARIES_ROOT || parentMediaId == RECENTLY_ROOT) + { + Log.d(tag, "First load done: $firstLoadDone") + if (!firstLoadDone) + { + result.sendResult(null) + return + } + + if (!this::browseTree.isInitialized) + { + // ✅ good: detach and wait for init + result.detach() + waitForBrowseTree { + val children = browseTree[parentMediaId]?.map { item -> + Log.d(tag, "[MENU: $parentMediaId] Showing list item ${item.description.title}") + MediaBrowserCompat.MediaItem( + item.description, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + result.sendResult(children as MutableList?) + } + return + } + + // Already initialized: just return + val children = browseTree[parentMediaId]?.map { item -> + Log.d(tag, "[MENU: $parentMediaId] Showing list item ${item.description.title}") + MediaBrowserCompat.MediaItem( + item.description, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + result.sendResult(children as MutableList?) + } else if (mediaManager.getIsLibrary(parentMediaId)) { // Load library items for library + Log.d(tag, "Loading items for library $parentMediaId") + val selectedLibrary = mediaManager.getLibrary(parentMediaId) + if (selectedLibrary?.mediaType == "podcast") { // Podcasts are browseable + mediaManager.loadLibraryPodcasts(parentMediaId) { libraryItems -> + val children = + libraryItems?.map { libraryItem -> + val mediaDescription = libraryItem.getMediaDescription(null, ctx) + MediaBrowserCompat.MediaItem( + mediaDescription, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + result.sendResult(children as MutableList?) + } + } else { + val children = + mutableListOf( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Authors") + .setMediaId("__LIBRARY__${parentMediaId}__AUTHORS") + .setIconUri(getUriToAbsIconDrawable(ctx, "authors")) + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ), + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Series") + .setMediaId("__LIBRARY__${parentMediaId}__SERIES_LIST") + .setIconUri(getUriToAbsIconDrawable(ctx, "columns")) + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ), + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Collections") + .setMediaId("__LIBRARY__${parentMediaId}__COLLECTIONS") + .setIconUri( + getUriToDrawable( + ctx, + R.drawable.md_book_multiple_outline + ) + ) + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + if (mediaManager.getHasDiscovery(parentMediaId)) { + children.add( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Discovery") + .setMediaId("__LIBRARY__${parentMediaId}__DISCOVERY") + .setIconUri(getUriToDrawable(ctx, R.drawable.md_telescope)) + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + } + result.sendResult(children as MutableList?) + } + } else if (parentMediaId.startsWith(RECENTLY_ROOT)) { + Log.d(tag, "Browsing recently $parentMediaId") + val mediaIdParts = parentMediaId.split("__") + if (!mediaManager.getIsLibrary(mediaIdParts[2])) { + Log.d(tag, "${mediaIdParts[2]} is not library") + result.sendResult(null) + return + } + Log.d(tag, "Mediaparts: ${mediaIdParts.size} | $mediaIdParts") + if (mediaIdParts.size == 3) { + mediaManager.getLibraryRecentShelfs(mediaIdParts[2]) { availableShelfs -> + Log.d(tag, "Found ${availableShelfs.size} shelfs") + val children: MutableList = mutableListOf() + for (shelf in availableShelfs) { + if (shelf.type == "book") { + children.add( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Books") + .setMediaId("${parentMediaId}__BOOK") + .setIconUri( + getUriToDrawable( + ctx, + R.drawable.md_book_open_blank_variant_outline + ) + ) + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + } else if (shelf.type == "series") { + children.add( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Series") + .setMediaId("${parentMediaId}__SERIES") + .setIconUri(getUriToAbsIconDrawable(ctx, "columns")) + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + } else if (shelf.type == "episode") { + children.add( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Episodes") + .setMediaId("${parentMediaId}__EPISODE") + .setIconUri(getUriToAbsIconDrawable(ctx, "microphone_2")) + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + } else if (shelf.type == "podcast") { + children.add( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Podcast") + .setMediaId("${parentMediaId}__PODCAST") + .setIconUri(getUriToAbsIconDrawable(ctx, "podcast")) + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + } else if (shelf.type == "authors") { + children.add( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Authors") + .setMediaId("${parentMediaId}__AUTHORS") + .setIconUri(getUriToAbsIconDrawable(ctx, "authors")) + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + } + } + result.sendResult(children as MutableList?) + } + } else if (mediaIdParts.size == 4) { + mediaManager.getLibraryRecentShelfByType(mediaIdParts[2], mediaIdParts[3]) { shelf -> + if (shelf === null) { + result.sendResult(mutableListOf()) + } else { + if (shelf.type == "book") { + val children = + (shelf as LibraryShelfBookEntity).entities?.map { libraryItem -> + val progress = + mediaManager.serverUserMediaProgress.find { + it.libraryItemId == libraryItem.id + } + val localLibraryItem = + DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id) + libraryItem.localLibraryItemId = localLibraryItem?.id + val description = + libraryItem.getMediaDescription(progress, ctx, null, false) + MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + } + result.sendResult(children as MutableList?) + } else if (shelf.type == "episode") { + val episodesWithRecentEpisode = + (shelf as LibraryShelfEpisodeEntity).entities?.filter { libraryItem -> + libraryItem.recentEpisode !== null + } + val children = + episodesWithRecentEpisode?.map { libraryItem -> + val podcast = libraryItem.media as Podcast + val progress = + mediaManager.serverUserMediaProgress.find { + it.libraryItemId == libraryItem.libraryId && + it.episodeId == libraryItem.recentEpisode?.id + } + + // to show download icon + val localLibraryItem = + DeviceManager.dbManager.getLocalLibraryItemByLId( + libraryItem.recentEpisode!!.id + ) + localLibraryItem?.let { lli -> + val localEpisode = + (lli.media as Podcast).episodes?.find { + it.serverEpisodeId == libraryItem.recentEpisode.id + } + libraryItem.recentEpisode.localEpisodeId = localEpisode?.id + } + + val description = + libraryItem.recentEpisode.getMediaDescription( + libraryItem, + progress, + ctx + ) + MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + } + result.sendResult(children as MutableList?) + } else if (shelf.type == "podcast") { + val children = + (shelf as LibraryShelfPodcastEntity).entities?.map { libraryItem -> + val mediaDescription = libraryItem.getMediaDescription(null, ctx) + MediaBrowserCompat.MediaItem( + mediaDescription, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + result.sendResult(children as MutableList?) + } else if (shelf.type == "series") { + val children = + (shelf as LibraryShelfSeriesEntity).entities?.map { librarySeriesItem -> + val description = librarySeriesItem.getMediaDescription(null, ctx) + MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + result.sendResult(children as MutableList?) + } else if (shelf.type == "authors") { + val children = + (shelf as LibraryShelfAuthorEntity).entities?.map { authorItem -> + val description = authorItem.getMediaDescription(null, ctx) + MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + result.sendResult(children as MutableList?) + } else { + result.sendResult(mutableListOf()) + } + } + } + } + } else if (parentMediaId.startsWith("__LIBRARY__")) { + Log.d(tag, "Browsing library $parentMediaId") + val mediaIdParts = parentMediaId.split("__") + /* + MediaIdParts for Library + 1: LIBRARY + 2: mediaId for library + 3: Browsing style (AUTHORS, AUTHOR, AUTHOR_SERIES, SERIES_LIST, SERIES, COLLECTION, COLLECTIONS, DISCOVERY) + 4: + - Paging: SERIES_LIST, AUTHORS + - SeriesId: SERIES + - AuthorId: AUTHOR, AUTHOR_SERIES + - CollectionId: COLLECTIONS + 5: SeriesId: AUTHOR_SERIES + */ + if (!mediaManager.getIsLibrary(mediaIdParts[2])) { + Log.d(tag, "${mediaIdParts[2]} is not library") + result.sendResult(null) + return + } + Log.d(tag, "$mediaIdParts") + if (mediaIdParts[3] == "SERIES_LIST" && mediaIdParts.size == 5) { + Log.d(tag, "Loading series from library ${mediaIdParts[2]} with paging ${mediaIdParts[4]}") + mediaManager.loadLibrarySeriesWithAudio(mediaIdParts[2], mediaIdParts[4]) { seriesItems -> + Log.d(tag, "Received ${seriesItems.size} series") + + val seriesLetters = + seriesItems + .groupingBy { iwb -> + iwb.title.substring(0, mediaIdParts[4].length + 1).uppercase() + } + .eachCount() + if (seriesItems.size > + DeviceManager.deviceData.deviceSettings!! + .androidAutoBrowseLimitForGrouping && + seriesItems.size > 1 && + seriesLetters.size > 1 + ) { + val children = + seriesLetters.map { (seriesLetter, seriesCount) -> + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle(seriesLetter) + .setMediaId("${parentMediaId}${seriesLetter.last()}") + .setSubtitle("$seriesCount series") + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + result.sendResult(children as MutableList?) + } else { + val children = + seriesItems.map { seriesItem -> + val description = seriesItem.getMediaDescription(null, ctx) + MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + result.sendResult(children as MutableList?) + } + } + } else if (mediaIdParts[3] == "SERIES_LIST") { + Log.d(tag, "Loading series from library ${mediaIdParts[2]}") + mediaManager.loadLibrarySeriesWithAudio(mediaIdParts[2]) { seriesItems -> + Log.d(tag, "Received ${seriesItems.size} series") + if (seriesItems.size > + DeviceManager.deviceData.deviceSettings!! + .androidAutoBrowseLimitForGrouping && seriesItems.size > 1 + ) { + val seriesLetters = + seriesItems.groupingBy { iwb -> iwb.title.first().uppercaseChar() }.eachCount() + val children = + seriesLetters.map { (seriesLetter, seriesCount) -> + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle(seriesLetter.toString()) + .setSubtitle("$seriesCount series") + .setMediaId("${parentMediaId}__${seriesLetter}") + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + result.sendResult(children as MutableList?) + } else { + val children = + seriesItems.map { seriesItem -> + val description = seriesItem.getMediaDescription(null, ctx) + MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + result.sendResult(children as MutableList?) + } + } + } else if (mediaIdParts[3] == "SERIES") { + Log.d(tag, "Loading items for serie ${mediaIdParts[4]} from library ${mediaIdParts[2]}") + mediaManager.loadLibrarySeriesItemsWithAudio(mediaIdParts[2], mediaIdParts[4]) { + libraryItems -> + Log.d(tag, "Received ${libraryItems.size} library items") + var items = libraryItems + if (DeviceManager.deviceData.deviceSettings!!.androidAutoBrowseSeriesSequenceOrder === + AndroidAutoBrowseSeriesSequenceOrderSetting.DESC + ) { + items = libraryItems.reversed() + } + val children = + items.map { libraryItem -> + val progress = + mediaManager.serverUserMediaProgress.find { + it.libraryItemId == libraryItem.id + } + val localLibraryItem = + DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id) + libraryItem.localLibraryItemId = localLibraryItem?.id + val description = libraryItem.getMediaDescription(progress, ctx, null, true) + MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + } + result.sendResult(children as MutableList?) + } + } else if (mediaIdParts[3] == "AUTHORS" && mediaIdParts.size == 5) { + Log.d(tag, "Loading authors from library ${mediaIdParts[2]} with paging ${mediaIdParts[4]}") + mediaManager.loadAuthorsWithBooks(mediaIdParts[2], mediaIdParts[4]) { authorItems -> + Log.d(tag, "Received ${authorItems.size} authors") + + val authorLetters = + authorItems + .groupingBy { iwb -> + iwb.name.substring(0, mediaIdParts[4].length + 1).uppercase() + } + .eachCount() + if (authorItems.size > + DeviceManager.deviceData.deviceSettings!! + .androidAutoBrowseLimitForGrouping && + authorItems.size > 1 && + authorLetters.size > 1 + ) { + val children = + authorLetters.map { (authorLetter, authorCount) -> + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle(authorLetter) + .setMediaId("${parentMediaId}${authorLetter.last()}") + .setSubtitle("$authorCount authors") + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + result.sendResult(children as MutableList?) + } else { + val children = + authorItems.map { authorItem -> + val description = authorItem.getMediaDescription(null, ctx) + MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + result.sendResult(children as MutableList?) + } + } + } else if (mediaIdParts[3] == "AUTHORS") { + Log.d(tag, "Loading authors from library ${mediaIdParts[2]}") + mediaManager.loadAuthorsWithBooks(mediaIdParts[2]) { authorItems -> + Log.d(tag, "Received ${authorItems.size} authors") + if (authorItems.size > + DeviceManager.deviceData.deviceSettings!! + .androidAutoBrowseLimitForGrouping && authorItems.size > 1 + ) { + val authorLetters = + authorItems.groupingBy { iwb -> iwb.name.first().uppercaseChar() }.eachCount() + val children = + authorLetters.map { (authorLetter, authorCount) -> + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle(authorLetter.toString()) + .setSubtitle("$authorCount authors") + .setMediaId("${parentMediaId}__${authorLetter}") + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + result.sendResult(children as MutableList?) + } else { + val children = + authorItems.map { authorItem -> + val description = authorItem.getMediaDescription(null, ctx) + MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + result.sendResult(children as MutableList?) + } + } + } else if (mediaIdParts[3] == "AUTHOR") { + mediaManager.loadAuthorBooksWithAudio(mediaIdParts[2], mediaIdParts[4]) { libraryItems -> + val children = + libraryItems.map { libraryItem -> + val progress = + mediaManager.serverUserMediaProgress.find { + it.libraryItemId == libraryItem.id + } + val localLibraryItem = + DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id) + libraryItem.localLibraryItemId = localLibraryItem?.id + if (libraryItem.collapsedSeries != null) { + val description = + libraryItem.getMediaDescription(progress, ctx, mediaIdParts[4]) + MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } else { + val description = libraryItem.getMediaDescription(progress, ctx) + MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + } + } + result.sendResult(children as MutableList?) + } + } else if (mediaIdParts[3] == "AUTHOR_SERIES") { + mediaManager.loadAuthorSeriesBooksWithAudio( + mediaIdParts[2], + mediaIdParts[4], + mediaIdParts[5] + ) { libraryItems -> + var items = libraryItems + if (DeviceManager.deviceData.deviceSettings!!.androidAutoBrowseSeriesSequenceOrder === + AndroidAutoBrowseSeriesSequenceOrderSetting.DESC + ) { + items = libraryItems.reversed() + } + val children = + items.map { libraryItem -> + val progress = + mediaManager.serverUserMediaProgress.find { + it.libraryItemId == libraryItem.id + } + val localLibraryItem = + DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id) + libraryItem.localLibraryItemId = localLibraryItem?.id + val description = libraryItem.getMediaDescription(progress, ctx, null, true) + if (libraryItem.collapsedSeries != null) { + MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } else { + MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + } + } + result.sendResult(children as MutableList?) + } + } else if (mediaIdParts[3] == "COLLECTIONS") { + Log.d(tag, "Loading collections from library ${mediaIdParts[2]}") + mediaManager.loadLibraryCollectionsWithAudio(mediaIdParts[2]) { collectionItems -> + Log.d(tag, "Received ${collectionItems.size} collections") + val children = + collectionItems.map { collectionItem -> + val description = collectionItem.getMediaDescription(null, ctx) + MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + result.sendResult(children as MutableList?) + } + } else if (mediaIdParts[3] == "COLLECTION") { + Log.d(tag, "Loading collection ${mediaIdParts[4]} books from library ${mediaIdParts[2]}") + mediaManager.loadLibraryCollectionBooksWithAudio(mediaIdParts[2], mediaIdParts[4]) { + libraryItems -> + Log.d(tag, "Received ${libraryItems.size} collections") + val children = + libraryItems.map { libraryItem -> + val progress = + mediaManager.serverUserMediaProgress.find { + it.libraryItemId == libraryItem.id + } + val localLibraryItem = + DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id) + libraryItem.localLibraryItemId = localLibraryItem?.id + val description = libraryItem.getMediaDescription(progress, ctx) + MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + } + result.sendResult(children as MutableList?) + } + } else if (mediaIdParts[3] == "DISCOVERY") { + Log.d(tag, "Loading discovery from library ${mediaIdParts[2]}") + mediaManager.loadLibraryDiscoveryBooksWithAudio(mediaIdParts[2]) { libraryItems -> + Log.d(tag, "Received ${libraryItems.size} libraryItems for discovery") + val children = + libraryItems.map { libraryItem -> + val progress = + mediaManager.serverUserMediaProgress.find { + it.libraryItemId == libraryItem.id + } + val localLibraryItem = + DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id) + libraryItem.localLibraryItemId = localLibraryItem?.id + val description = libraryItem.getMediaDescription(progress, ctx) + MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + } + result.sendResult(children as MutableList?) + } + } else { + result.sendResult(null) + } + } else { + Log.d(tag, "Loading podcast episodes for podcast $parentMediaId") + mediaManager.loadPodcastEpisodeMediaBrowserItems(parentMediaId, ctx) { result.sendResult(it) } + } + } + + override fun onSearch( + query: String, + extras: Bundle?, + result: Result> + ) { + result.detach() + if (cachedSearch != query) { + Log.d(tag, "Search bundle: $extras") + var foundBooks: MutableList = mutableListOf() + var foundPodcasts: MutableList = mutableListOf() + var foundSeries: MutableList = mutableListOf() + var foundAuthors: MutableList = mutableListOf() + + mediaManager.serverLibraries.forEach { serverLibrary -> + runBlocking { + // Skip searching library if it doesn't have any audio files + if (serverLibrary.stats?.numAudioFiles == 0) return@runBlocking + val searchResult = mediaManager.doSearch(serverLibrary.id, query) + for (resultData in searchResult.entries.iterator()) { + when (resultData.key) { + "book" -> foundBooks.addAll(resultData.value) + "series" -> foundSeries.addAll(resultData.value) + "authors" -> foundAuthors.addAll(resultData.value) + "podcast" -> foundPodcasts.addAll(resultData.value) + } + } + } + } + foundBooks.addAll(foundSeries) + foundBooks.addAll(foundAuthors) + cachedSearchResults = foundBooks + } + result.sendResult(cachedSearchResults) + cachedSearch = query + Log.d(tag, "onSearch: Done") + } + + // + // SHAKE SENSOR + // + private fun initSensor() { + // ShakeDetector initialization + mSensorManager = getSystemService(SENSOR_SERVICE) as SensorManager + mAccelerometer = mSensorManager!!.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) + + mShakeDetector = ShakeDetector() + mShakeDetector!!.setOnShakeListener( + object : ShakeDetector.OnShakeListener { + override fun onShake(count: Int) { + Log.d(tag, "PHONE SHAKE! $count") + sleepTimerManager.handleShake() + } + } + ) + } + + // Shake sensor used for sleep timer + fun registerSensor() { + if (isShakeSensorRegistered) { + Log.i(tag, "Shake sensor already registered") + return + } + shakeSensorUnregisterTask?.cancel() + + Log.d(tag, "Registering shake SENSOR ${mAccelerometer?.isWakeUpSensor}") + val success = + mSensorManager!!.registerListener( + mShakeDetector, + mAccelerometer, + SensorManager.SENSOR_DELAY_UI + ) + if (success) isShakeSensorRegistered = true + } + + fun unregisterSensor() { + if (!isShakeSensorRegistered) return + + // Unregister shake sensor after wake up expiration + shakeSensorUnregisterTask?.cancel() + shakeSensorUnregisterTask = + Timer("ShakeUnregisterTimer", false).schedule(SLEEP_TIMER_WAKE_UP_EXPIRATION) { + Handler(Looper.getMainLooper()).post { + Log.d(tag, "wake time expired: Unregistering shake sensor") + mSensorManager!!.unregisterListener(mShakeDetector) + isShakeSensorRegistered = false + } + } + } + + private val networkCallback = + object : ConnectivityManager.NetworkCallback() { + // Network capabilities have changed for the network + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities + ) { + super.onCapabilitiesChanged(network, networkCapabilities) + + isUnmeteredNetwork = + networkCapabilities.hasCapability( + NetworkCapabilities.NET_CAPABILITY_NOT_METERED + ) + hasNetworkConnectivity = + networkCapabilities.hasCapability( + NetworkCapabilities.NET_CAPABILITY_VALIDATED + ) && + networkCapabilities.hasCapability( + NetworkCapabilities.NET_CAPABILITY_INTERNET + ) + Log.i( + tag, + "Network capabilities changed. hasNetworkConnectivity=$hasNetworkConnectivity | isUnmeteredNetwork=$isUnmeteredNetwork" + ) + clientEventEmitter?.onNetworkMeteredChanged(isUnmeteredNetwork) + if (hasNetworkConnectivity) { + // Force android auto loading if libraries are empty. + // Lack of network connectivity is most likely reason for libraries being empty + if (isBrowseTreeInitialized() && + firstLoadDone && + mediaManager.serverLibraries.isEmpty() + ) { + forceReloadingAndroidAuto = true + notifyChildrenChanged("/") + } + } + } + } + + inner class JumpBackwardCustomActionProvider : CustomActionProvider { + override fun onCustomAction(player: Player, action: String, extras: Bundle?) { + /* + This does not appear to ever get called. Instead, MediaSessionCallback.onCustomAction() is + responsible to reacting to a custom action. + */ + } + + override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? { + return PlaybackStateCompat.CustomAction.Builder( + CUSTOM_ACTION_JUMP_BACKWARD, + getContext().getString(R.string.action_jump_backward), + R.drawable.exo_icon_rewind + ) + .build() + } + } + + inner class JumpForwardCustomActionProvider : CustomActionProvider { + override fun onCustomAction(player: Player, action: String, extras: Bundle?) { + /* + This does not appear to ever get called. Instead, MediaSessionCallback.onCustomAction() is + responsible to reacting to a custom action. + */ + } + + override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? { + return PlaybackStateCompat.CustomAction.Builder( + CUSTOM_ACTION_JUMP_FORWARD, + getContext().getString(R.string.action_jump_forward), + R.drawable.exo_icon_fastforward + ) + .build() + } + } + + inner class SkipForwardCustomActionProvider : CustomActionProvider { + override fun onCustomAction(player: Player, action: String, extras: Bundle?) { + /* + This does not appear to ever get called. Instead, MediaSessionCallback.onCustomAction() is + responsible to reacting to a custom action. + */ + } + + override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? { + return PlaybackStateCompat.CustomAction.Builder( + CUSTOM_ACTION_SKIP_FORWARD, + getContext().getString(R.string.action_skip_forward), + R.drawable.skip_next_24 + ) + .build() + } + } + + inner class SkipBackwardCustomActionProvider : CustomActionProvider { + override fun onCustomAction(player: Player, action: String, extras: Bundle?) { + /* + This does not appear to ever get called. Instead, MediaSessionCallback.onCustomAction() is + responsible to reacting to a custom action. + */ + } + + override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? { + return PlaybackStateCompat.CustomAction.Builder( + CUSTOM_ACTION_SKIP_BACKWARD, + getContext().getString(R.string.action_skip_backward), + R.drawable.skip_previous_24 + ) + .build() + } + } + + inner class ChangePlaybackSpeedCustomActionProvider : 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? { + val playbackRate = mediaManager.getSavedPlaybackRate() + + // Rounding values in the event a non preset value (.5, 1, 1.2, 1.5, 2, 3) is selected in the + // phone app + val drawable: Int = + when (playbackRate) { + in 0.5f..0.7f -> R.drawable.ic_play_speed_0_5x + in 0.8f..1.0f -> R.drawable.ic_play_speed_1_0x + in 1.1f..1.3f -> R.drawable.ic_play_speed_1_2x + in 1.4f..1.7f -> R.drawable.ic_play_speed_1_5x + in 1.8f..2.4f -> R.drawable.ic_play_speed_2_0x + in 2.5f..3.0f -> R.drawable.ic_play_speed_3_0x + // anything set above 3 will be show the 3x to save from creating 100 icons + else -> R.drawable.ic_play_speed_3_0x + } + val customActionExtras = Bundle() + customActionExtras.putFloat("speed", playbackRate) + return PlaybackStateCompat.CustomAction.Builder( + CUSTOM_ACTION_CHANGE_SPEED, + getContext().getString(R.string.action_change_speed), + drawable + ) + .setExtras(customActionExtras) + .build() + } + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/ShakeDetector.kt b/android/app/src/main/java/com/audiobookshelf/app/player/ShakeDetector.kt new file mode 100644 index 0000000..d25206d --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/ShakeDetector.kt @@ -0,0 +1,70 @@ +package com.audiobookshelf.app.player + +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import com.audiobookshelf.app.device.DeviceManager +import com.audiobookshelf.app.plugins.AbsLogger +import kotlin.math.sqrt + +class ShakeDetector : SensorEventListener { + private var mListener: OnShakeListener? = null + private var mShakeTimestamp: Long = 0 + private var mShakeCount = 0 + fun setOnShakeListener(listener: OnShakeListener?) { + mListener = listener + } + + interface OnShakeListener { + fun onShake(count: Int) + } + + override fun onAccuracyChanged( + sensor: Sensor, + accuracy: Int + ) { // ignore + } + + override fun onSensorChanged(event: SensorEvent) { + if (mListener != null) { + val x = event.values[0] + val y = event.values[1] + val z = event.values[2] + val gX = x / SensorManager.GRAVITY_EARTH + val gY = y / SensorManager.GRAVITY_EARTH + val gZ = z / SensorManager.GRAVITY_EARTH + // gForce will be close to 1 when there is no movement. + val gForce: Float = sqrt(gX * gX + gY * gY + gZ * gZ) + val shakeThreshold = DeviceManager.deviceData.deviceSettings?.getShakeThresholdGravity() ?: 1.6f + if (gForce > shakeThreshold) { + val now = System.currentTimeMillis() + // ignore shake events too close to each other (500ms) + if (mShakeTimestamp + SHAKE_SLOP_TIME_MS > now) { + return + } + // reset the shake count after 3 seconds of no shakes + if (mShakeTimestamp + SHAKE_COUNT_RESET_TIME_MS < now) { + mShakeCount = 0 + } + AbsLogger.info("ShakeDetector", "Device shake above threshold ($gForce > $shakeThreshold)") + mShakeTimestamp = now + mShakeCount++ + mListener!!.onShake(mShakeCount) + } + } + } + + companion object { + /* + * The gForce that is necessary to register as shake. + * Must be greater than 1G (one earth gravity unit). + * You can install "G-Force", by Blake La Pierre + * from the Google Play Store and run it to see how + * many G's it takes to register a shake + */ +// var SHAKE_THRESHOLD_GRAVITY = 1.5f // orig 2.7f + private const val SHAKE_SLOP_TIME_MS = 500 + private const val SHAKE_COUNT_RESET_TIME_MS = 3000 + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt new file mode 100644 index 0000000..6e76637 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt @@ -0,0 +1,461 @@ +package com.audiobookshelf.app.plugins + +import android.os.Handler +import android.os.Looper +import android.util.Log +import com.audiobookshelf.app.MainActivity +import com.audiobookshelf.app.data.* +import com.audiobookshelf.app.device.DeviceManager +import com.audiobookshelf.app.media.MediaEventManager +import com.audiobookshelf.app.player.CastManager +import com.audiobookshelf.app.player.PlayerListener +import com.audiobookshelf.app.player.PlayerNotificationService +import com.audiobookshelf.app.server.ApiHandler +import com.fasterxml.jackson.core.json.JsonReadFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.getcapacitor.* +import com.getcapacitor.annotation.CapacitorPlugin +import com.google.android.gms.cast.CastDevice +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability +import org.json.JSONObject + +@CapacitorPlugin(name = "AbsAudioPlayer") +class AbsAudioPlayer : Plugin() { + private val tag = "AbsAudioPlayer" + private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) + + private lateinit var mainActivity: MainActivity + private lateinit var apiHandler:ApiHandler + var castManager:CastManager? = null + + lateinit var playerNotificationService: PlayerNotificationService + + private var isCastAvailable:Boolean = false + + // Track foreground state to avoid flooding WebView with events while backgrounded + private var isInForeground: Boolean = true + + override fun load() { + mainActivity = (activity as MainActivity) + apiHandler = ApiHandler(mainActivity) + + try { + initCastManager() + } catch(e:Exception) { + Log.e(tag, "initCastManager exception ${e.printStackTrace()}") + } + + val foregroundServiceReady : () -> Unit = { + playerNotificationService = mainActivity.foregroundService + + playerNotificationService.clientEventEmitter = (object : PlayerNotificationService.ClientEventEmitter { + override fun onPlaybackSession(playbackSession: PlaybackSession) { + notifyListeners("onPlaybackSession", JSObject(jacksonMapper.writeValueAsString(playbackSession))) + } + + override fun onPlaybackClosed() { + emit("onPlaybackClosed", true) + } + + override fun onPlayingUpdate(isPlaying: Boolean) { + emit("onPlayingUpdate", isPlaying) + } + + override fun onMetadata(metadata: PlaybackMetadata) { + // Skip frequent metadata updates when app is backgrounded to prevent event queue buildup + if (!isInForeground) return + notifyListeners("onMetadata", JSObject(jacksonMapper.writeValueAsString(metadata))) + } + + override fun onSleepTimerEnded(currentPosition: Long) { + emit("onSleepTimerEnded", currentPosition) + } + + override fun onSleepTimerSet(sleepTimeRemaining: Int, isAutoSleepTimer:Boolean) { + // Skip sleep timer updates when app is backgrounded to prevent event queue buildup + if (!isInForeground) return + val ret = JSObject() + ret.put("value", sleepTimeRemaining) + ret.put("isAuto", isAutoSleepTimer) + notifyListeners("onSleepTimerSet", ret) + } + + override fun onLocalMediaProgressUpdate(localMediaProgress: LocalMediaProgress) { + // Skip progress updates when app is backgrounded to prevent event queue buildup + if (!isInForeground) return + notifyListeners("onLocalMediaProgressUpdate", JSObject(jacksonMapper.writeValueAsString(localMediaProgress))) + } + + override fun onPlaybackFailed(errorMessage: String) { + emit("onPlaybackFailed", errorMessage) + } + + override fun onMediaPlayerChanged(mediaPlayer:String) { + emit("onMediaPlayerChanged", mediaPlayer) + } + + override fun onProgressSyncFailing() { + emit("onProgressSyncFailing", "") + } + + override fun onProgressSyncSuccess() { + emit("onProgressSyncSuccess", "") + } + + override fun onNetworkMeteredChanged(isUnmetered:Boolean) { + emit("onNetworkMeteredChanged", isUnmetered) + } + + override fun onMediaItemHistoryUpdated(mediaItemHistory:MediaItemHistory) { + notifyListeners("onMediaItemHistoryUpdated", JSObject(jacksonMapper.writeValueAsString(mediaItemHistory))) + } + + override fun onPlaybackSpeedChanged(playbackSpeed:Float) { + emit("onPlaybackSpeedChanged", playbackSpeed) + } + }) + + MediaEventManager.clientEventEmitter = playerNotificationService.clientEventEmitter + } + mainActivity.pluginCallback = foregroundServiceReady + } + + fun emit(evtName: String, value: Any) { + val ret = JSObject() + ret.put("value", value) + notifyListeners(evtName, ret) + } + + override fun handleOnDestroy() { + castManager?.detach() + super.handleOnDestroy() + } + + override fun handleOnPause() { + super.handleOnPause() + isInForeground = false + } + + override fun handleOnResume() { + super.handleOnResume() + isInForeground = true + + // Send current state to UI after resume to sync up (with small delay to let WebView fully resume) + if (::playerNotificationService.isInitialized && playerNotificationService.currentPlaybackSession != null) { + Handler(Looper.getMainLooper()).postDelayed({ + playerNotificationService.sendClientMetadata(PlayerState.READY) + playerNotificationService.sleepTimerManager.sendCurrentSleepTimerState() + playerNotificationService.mediaProgressSyncer.currentLocalMediaProgress?.let { + playerNotificationService.clientEventEmitter?.onLocalMediaProgressUpdate(it) + } + }, 100) + } + } + + private fun initCastManager() { + val googleApi = GoogleApiAvailability.getInstance() + val statusCode = googleApi.isGooglePlayServicesAvailable(mainActivity) + + if (statusCode != ConnectionResult.SUCCESS) { + if (statusCode == ConnectionResult.SERVICE_MISSING) { + Log.w(tag, "initCastManager: Google Api Missing") + } else if (statusCode == ConnectionResult.SERVICE_DISABLED) { + Log.w(tag, "initCastManager: Google Api Disabled") + } else if (statusCode == ConnectionResult.SERVICE_INVALID) { + Log.w(tag, "initCastManager: Google Api Invalid") + } else if (statusCode == ConnectionResult.SERVICE_UPDATING) { + Log.w(tag, "initCastManager: Google Api Updating") + } else if (statusCode == ConnectionResult.SERVICE_VERSION_UPDATE_REQUIRED) { + Log.w(tag, "initCastManager: Google Api Update Required") + } + return + } + + val connListener = object: CastManager.ChromecastListener() { + override fun onReceiverAvailableUpdate(available: Boolean) { + Log.d(tag, "ChromecastListener: CAST Receiver Update Available $available") + isCastAvailable = available + emit("onCastAvailableUpdate", available) + } + + override fun onSessionRejoin(jsonSession: JSONObject?) { + Log.d(tag, "ChromecastListener: CAST onSessionRejoin") + } + + override fun onMediaLoaded(jsonMedia: JSONObject?) { + Log.d(tag, "ChromecastListener: CAST onMediaLoaded") + } + + override fun onMediaUpdate(jsonMedia: JSONObject?) { + Log.d(tag, "ChromecastListener: CAST onMediaUpdate") + } + + override fun onSessionUpdate(jsonSession: JSONObject?) { + Log.d(tag, "ChromecastListener: CAST onSessionUpdate") + } + + override fun onSessionEnd(jsonSession: JSONObject?) { + Log.d(tag, "ChromecastListener: CAST onSessionEnd") + } + + override fun onMessageReceived(p0: CastDevice, p1: String, p2: String) { + Log.d(tag, "ChromecastListener: CAST onMessageReceived") + } + } + + castManager = CastManager(mainActivity) + castManager?.startRouteScan(connListener) + } + + @PluginMethod + fun prepareLibraryItem(call: PluginCall) { + val libraryItemId = call.getString("libraryItemId", "").toString() + val episodeId = call.getString("episodeId", "").toString() + val playWhenReady = call.getBoolean("playWhenReady") == true + val playbackRate = call.getFloat("playbackRate",1f) ?: 1f + val startTimeOverride = call.getDouble("startTime") + + AbsLogger.info("AbsAudioPlayer", "prepareLibraryItem: lid=$libraryItemId, startTimeOverride=$startTimeOverride, playbackRate=$playbackRate") + + if (libraryItemId.isEmpty()) { + Log.e(tag, "Invalid call to play library item no library item id") + return call.resolve(JSObject("{\"error\":\"Invalid request\"}")) + } + + if (libraryItemId.startsWith("local")) { // Play local media item + DeviceManager.dbManager.getLocalLibraryItem(libraryItemId)?.let { + var episode: PodcastEpisode? = null + if (episodeId.isNotEmpty()) { + val podcastMedia = it.media as Podcast + episode = podcastMedia.episodes?.find { ep -> ep.id == episodeId } + if (episode == null) { + Log.e(tag, "prepareLibraryItem: Podcast episode not found $episodeId") + return call.resolve(JSObject("{\"error\":\"Podcast episode not found\"}")) + } + } + if (!it.hasTracks(mainActivity, episode)) { + return call.resolve(JSObject("{\"error\":\"No audio files found on device. Download book again to fix.\"}")) + } + + Handler(Looper.getMainLooper()).post { + Log.d(tag, "prepareLibraryItem: Preparing Local Media item ${jacksonMapper.writeValueAsString(it)}") + val playbackSession = it.getPlaybackSession(episode, playerNotificationService.getDeviceInfo()) + if (startTimeOverride != null) { + Log.d(tag, "prepareLibraryItem: Using start time override $startTimeOverride") + playbackSession.currentTime = startTimeOverride + } + + if (playerNotificationService.mediaProgressSyncer.listeningTimerRunning) { // If progress syncing then first stop before preparing next + playerNotificationService.mediaProgressSyncer.stop { + Log.d(tag, "Media progress syncer was already syncing - stopped") + PlayerListener.lazyIsPlaying = false + + Handler(Looper.getMainLooper()).post { // TODO: This was needed again which is probably a design a flaw + playerNotificationService.preparePlayer( + playbackSession, + playWhenReady, + playbackRate + ) + } + } + } else { + playerNotificationService.mediaProgressSyncer.reset() + playerNotificationService.preparePlayer(playbackSession, playWhenReady, playbackRate) + } + } + return call.resolve(JSObject()) + } + } else { // Play library item from server + val playItemRequestPayload = playerNotificationService.getPlayItemRequestPayload(false) + Handler(Looper.getMainLooper()).post { + playerNotificationService.mediaProgressSyncer.stop { + apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) { + if (it == null) { + call.resolve(JSObject("{\"error\":\"Server play request failed\"}")) + } else { + if (startTimeOverride != null) { + Log.d(tag, "prepareLibraryItem: Using start time override $startTimeOverride") + it.currentTime = startTimeOverride + } + + Handler(Looper.getMainLooper()).post { + Log.d(tag, "Preparing Player playback session ${jacksonMapper.writeValueAsString(it)}") + PlayerListener.lazyIsPlaying = false + playerNotificationService.preparePlayer(it, playWhenReady, playbackRate) + } + call.resolve(JSObject(jacksonMapper.writeValueAsString(it))) + } + } + } + } + } + } + + @PluginMethod + fun getCurrentTime(call: PluginCall) { + Handler(Looper.getMainLooper()).post { + val currentTime = playerNotificationService.getCurrentTimeSeconds() + val bufferedTime = playerNotificationService.getBufferedTimeSeconds() + val ret = JSObject() + ret.put("value", currentTime) + ret.put("bufferedTime", bufferedTime) + call.resolve(ret) + } + } + + @PluginMethod + fun pausePlayer(call: PluginCall) { + Handler(Looper.getMainLooper()).post { + playerNotificationService.pause() + call.resolve() + } + } + + @PluginMethod + fun playPlayer(call: PluginCall) { + Handler(Looper.getMainLooper()).post { + playerNotificationService.play() + call.resolve() + } + } + + @PluginMethod + fun playPause(call: PluginCall) { + Handler(Looper.getMainLooper()).post { + val playing = playerNotificationService.playPause() + call.resolve(JSObject("{\"playing\":$playing}")) + } + } + + @PluginMethod + fun seek(call: PluginCall) { + val time: Double = call.getDouble("value", 0.0) ?: 0.0 // Value in seconds, fractional + Log.d(tag, "seek action to $time") + Handler(Looper.getMainLooper()).post { + playerNotificationService.seekPlayer((time * 1000L).toLong()) + call.resolve() + } + } + + @PluginMethod + fun seekForward(call: PluginCall) { + val amount:Int = call.getInt("value", 0) ?: 0 + Handler(Looper.getMainLooper()).post { + playerNotificationService.seekForward(amount * 1000L) // convert to ms + call.resolve() + } + } + + @PluginMethod + fun seekBackward(call: PluginCall) { + val amount:Int = call.getInt("value", 0) ?: 0 // Value in seconds + Handler(Looper.getMainLooper()).post { + playerNotificationService.seekBackward(amount * 1000L) // convert to ms + call.resolve() + } + } + + @PluginMethod + fun setPlaybackSpeed(call: PluginCall) { + val playbackSpeed:Float = call.getFloat("value", 1.0f) ?: 1.0f + + Handler(Looper.getMainLooper()).post { + playerNotificationService.setPlaybackSpeed(playbackSpeed) + call.resolve() + } + } + + @PluginMethod + fun closePlayback(call: PluginCall) { + Handler(Looper.getMainLooper()).post { + playerNotificationService.closePlayback() + call.resolve() + } + } + + @PluginMethod + fun setSleepTimer(call: PluginCall) { + val time:Long = call.getString("time", "360000")!!.toLong() + val isChapterTime:Boolean = call.getBoolean("isChapterTime", false) == true + + Handler(Looper.getMainLooper()).post { + val playbackSession: PlaybackSession? = playerNotificationService.mediaProgressSyncer.currentPlaybackSession ?: playerNotificationService.currentPlaybackSession + val success:Boolean = playerNotificationService.sleepTimerManager.setManualSleepTimer(playbackSession?.id ?: "", time, isChapterTime) + val ret = JSObject() + ret.put("success", success) + call.resolve(ret) + } + } + + @PluginMethod + fun getSleepTimerTime(call: PluginCall) { + val time = playerNotificationService.sleepTimerManager.getSleepTimerTime() + val ret = JSObject() + ret.put("value", time) + call.resolve(ret) + } + + @PluginMethod + fun increaseSleepTime(call: PluginCall) { + val time:Long = call.getString("time", "300000")!!.toLong() + + Handler(Looper.getMainLooper()).post { + playerNotificationService.sleepTimerManager.increaseSleepTime(time) + val ret = JSObject() + ret.put("success", true) + call.resolve() + } + } + + @PluginMethod + fun decreaseSleepTime(call: PluginCall) { + val time:Long = call.getString("time", "300000")!!.toLong() + + Handler(Looper.getMainLooper()).post { + playerNotificationService.sleepTimerManager.decreaseSleepTime(time) + val ret = JSObject() + ret.put("success", true) + call.resolve() + } + } + + @PluginMethod + fun cancelSleepTimer(call: PluginCall) { + Handler(Looper.getMainLooper()).post { + playerNotificationService.sleepTimerManager.cancelSleepTimer() + } + call.resolve() + } + + @PluginMethod + fun requestSession(call: PluginCall) { + // Need to make sure the player service has been started + Log.d(tag, "CAST REQUEST SESSION PLUGIN") + call.resolve() + if (castManager == null) { + Log.e(tag, "Cast Manager not initialized") + return + } + castManager?.requestSession(playerNotificationService, object : CastManager.RequestSessionCallback() { + override fun onError(errorCode: Int) { + Log.e(tag, "CAST REQUEST SESSION CALLBACK ERROR $errorCode") + } + + override fun onCancel() { + Log.d(tag, "CAST REQUEST SESSION ON CANCEL") + } + + override fun onJoin(jsonSession: JSONObject?) { + Log.d(tag, "CAST REQUEST SESSION ON JOIN") + } + }) + } + + @PluginMethod + fun getIsCastAvailable(call: PluginCall) { + val jsobj = JSObject() + jsobj.put("value", isCastAvailable) + call.resolve(jsobj) + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt new file mode 100644 index 0000000..0964d47 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt @@ -0,0 +1,591 @@ +package com.audiobookshelf.app.plugins + +import android.os.Handler +import android.os.Looper +import android.util.Log +import com.audiobookshelf.app.MainActivity +import com.audiobookshelf.app.data.* +import com.audiobookshelf.app.device.DeviceManager +import com.audiobookshelf.app.media.MediaEventManager +import com.audiobookshelf.app.server.ApiHandler +import com.audiobookshelf.app.managers.SecureStorage +import com.fasterxml.jackson.core.json.JsonReadFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.getcapacitor.* +import com.getcapacitor.annotation.CapacitorPlugin +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch + +@CapacitorPlugin(name = "AbsDatabase") +class AbsDatabase : Plugin() { + val tag = "AbsDatabase" + private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) + + private lateinit var mainActivity: MainActivity + private lateinit var apiHandler: ApiHandler + private lateinit var secureStorage: SecureStorage + + data class LocalMediaProgressPayload(val value:List) + data class LocalLibraryItemsPayload(val value:List) + data class LocalFoldersPayload(val value:List) + data class ServerConnConfigPayload(val id:String?, val index:Int, val name:String?, val userId:String, val username:String, var version:String, val token:String, val refreshToken:String?, val address:String?, val customHeaders:Map?) + + override fun load() { + mainActivity = (activity as MainActivity) + apiHandler = ApiHandler(mainActivity) + ApiHandler.absDatabaseNotifyListeners = ::notifyListeners + + secureStorage = SecureStorage(mainActivity) + + DeviceManager.dbManager.cleanLocalMediaProgress() + DeviceManager.dbManager.cleanLocalLibraryItems(mainActivity) + DeviceManager.dbManager.cleanLogs() + } + + @PluginMethod + fun getDeviceData(call:PluginCall) { + GlobalScope.launch(Dispatchers.IO) { + val deviceData = DeviceManager.dbManager.getDeviceData() + call.resolve(JSObject(jacksonMapper.writeValueAsString(deviceData))) + } + } + + @PluginMethod + fun getLocalFolders(call:PluginCall) { + GlobalScope.launch(Dispatchers.IO) { + val folders = DeviceManager.dbManager.getAllLocalFolders() + call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalFoldersPayload(folders)))) + } + } + + @PluginMethod + fun getLocalFolder(call:PluginCall) { + val folderId = call.getString("folderId", "").toString() + GlobalScope.launch(Dispatchers.IO) { + DeviceManager.dbManager.getLocalFolder(folderId)?.let { + val folderObj = jacksonMapper.writeValueAsString(it) + call.resolve(JSObject(folderObj)) + } ?: call.resolve() + } + } + + @PluginMethod + fun getLocalLibraryItem(call:PluginCall) { + val id = call.getString("id", "").toString() + + GlobalScope.launch(Dispatchers.IO) { + val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(id) + if (localLibraryItem == null) { + call.resolve() + } else { + call.resolve(JSObject(jacksonMapper.writeValueAsString(localLibraryItem))) + } + } + } + + @PluginMethod + fun getLocalLibraryItemByLId(call:PluginCall) { + val libraryItemId = call.getString("libraryItemId", "").toString() + GlobalScope.launch(Dispatchers.IO) { + val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItemId) + if (localLibraryItem == null) { + call.resolve() + } else { + call.resolve(JSObject(jacksonMapper.writeValueAsString(localLibraryItem))) + } + } + } + + @PluginMethod + fun getLocalLibraryItems(call:PluginCall) { + val mediaType = call.getString("mediaType", "").toString() + + GlobalScope.launch(Dispatchers.IO) { + val localLibraryItems = DeviceManager.dbManager.getLocalLibraryItems(mediaType) + call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalLibraryItemsPayload(localLibraryItems)))) + } + } + + @PluginMethod + fun getLocalLibraryItemsInFolder(call:PluginCall) { + val folderId = call.getString("folderId", "").toString() + GlobalScope.launch(Dispatchers.IO) { + val localLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(folderId) + call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalLibraryItemsPayload(localLibraryItems)))) + } + } + + @PluginMethod + fun setCurrentServerConnectionConfig(call:PluginCall) { + Log.d(tag, "setCurrentServerConnectionConfig called") + val serverConfigPayload = jacksonMapper.readValue(call.data.toString()) + var serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConfigPayload.id } + + val userId = serverConfigPayload.userId + val username = serverConfigPayload.username + val serverVersion = serverConfigPayload.version + val accessToken = serverConfigPayload.token + val refreshToken = serverConfigPayload.refreshToken // Refresh only sent after login or refresh + + GlobalScope.launch(Dispatchers.IO) { + if (serverConnectionConfig == null) { // New Server Connection + val serverAddress = call.getString("address", "").toString() + + // Create new server connection config + val sscId = DeviceManager.getBase64Id("$serverAddress@$username") + val sscIndex = DeviceManager.deviceData.serverConnectionConfigs.size + + // Store refresh token securely if provided + val hasRefreshToken = if (!refreshToken.isNullOrEmpty()) { + secureStorage.storeRefreshToken(sscId, refreshToken) + } else { + false + } + Log.d(tag, "Refresh token secured = $hasRefreshToken") + + serverConnectionConfig = ServerConnectionConfig(sscId, sscIndex, "$serverAddress ($username)", serverAddress, serverVersion, userId, username, accessToken, serverConfigPayload.customHeaders) + + // Add and save + DeviceManager.deviceData.serverConnectionConfigs.add(serverConnectionConfig!!) + DeviceManager.deviceData.lastServerConnectionConfigId = serverConnectionConfig?.id + DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData) + } else { + var shouldSave = false + if (serverConnectionConfig?.username != username || serverConnectionConfig?.token != accessToken || serverConnectionConfig?.version != serverVersion) { + serverConnectionConfig?.userId = userId + serverConnectionConfig?.username = username + serverConnectionConfig?.name = "${serverConnectionConfig?.address} (${serverConnectionConfig?.username})" + serverConnectionConfig?.version = serverVersion + serverConnectionConfig?.token = accessToken + shouldSave = true + } + + // Update refresh token if provided + if (!refreshToken.isNullOrEmpty()) { + val stored = secureStorage.storeRefreshToken(serverConnectionConfig!!.id, refreshToken) + Log.d(tag, "Refresh token secured = $stored") + } + + // Set last connection config + if (DeviceManager.deviceData.lastServerConnectionConfigId != serverConfigPayload.id) { + DeviceManager.deviceData.lastServerConnectionConfigId = serverConfigPayload.id + shouldSave = true + } + + if (shouldSave) DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData) + } + + DeviceManager.serverConnectionConfig = serverConnectionConfig + call.resolve(JSObject(jacksonMapper.writeValueAsString(DeviceManager.serverConnectionConfig))) + } + } + + @PluginMethod + fun removeServerConnectionConfig(call:PluginCall) { + GlobalScope.launch(Dispatchers.IO) { + val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString() + + // Remove refresh token if it exists + secureStorage.removeRefreshToken(serverConnectionConfigId) + + DeviceManager.deviceData.serverConnectionConfigs = DeviceManager.deviceData.serverConnectionConfigs.filter { it.id != serverConnectionConfigId } as MutableList + if (DeviceManager.deviceData.lastServerConnectionConfigId == serverConnectionConfigId) { + DeviceManager.deviceData.lastServerConnectionConfigId = null + } + DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData) + if (DeviceManager.serverConnectionConfig?.id == serverConnectionConfigId) { + DeviceManager.serverConnectionConfig = null + } + call.resolve() + } + } + + @PluginMethod + fun getRefreshToken(call:PluginCall) { + val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString() + + GlobalScope.launch(Dispatchers.IO) { + val refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId) + if (refreshToken != null) { + val result = JSObject() + result.put("refreshToken", refreshToken) + call.resolve(result) + } else { + call.resolve() + } + } + } + + @PluginMethod + fun clearRefreshToken(call:PluginCall) { + val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString() + + val refreshToken = secureStorage.removeRefreshToken(serverConnectionConfigId) + val result = JSObject() + result.put("success", refreshToken) + call.resolve(result) + } + + @PluginMethod + fun getAccessToken(call:PluginCall) { + val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString() + val serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConnectionConfigId } + val token = serverConnectionConfig?.token ?: "" + val ret = JSObject() + ret.put("token", token) + call.resolve(ret) + } + + @PluginMethod + fun logout(call:PluginCall) { + GlobalScope.launch(Dispatchers.IO) { + DeviceManager.serverConnectionConfig = null + DeviceManager.deviceData.lastServerConnectionConfigId = null + DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData) + call.resolve() + } + } + + @PluginMethod + fun getAllLocalMediaProgress(call:PluginCall) { + GlobalScope.launch(Dispatchers.IO) { + val localMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress() + call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalMediaProgressPayload(localMediaProgress)))) + } + } + + @PluginMethod + fun getLocalMediaProgressForServerItem(call:PluginCall) { + val libraryItemId = call.getString("libraryItemId", "").toString() + var episodeId:String? = call.getString("episodeId", "").toString() + if (episodeId == "") episodeId = null + + GlobalScope.launch(Dispatchers.IO) { + val allLocalMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress() + val localMediaProgress = allLocalMediaProgress.find { libraryItemId == it.libraryItemId && (episodeId == null || it.episodeId == episodeId) } + + if (localMediaProgress == null) { + call.resolve() + } else { + call.resolve(JSObject(jacksonMapper.writeValueAsString(localMediaProgress))) + } + } + } + + @PluginMethod + fun removeLocalMediaProgress(call:PluginCall) { + val localMediaProgressId = call.getString("localMediaProgressId", "").toString() + DeviceManager.dbManager.removeLocalMediaProgress(localMediaProgressId) + call.resolve() + } + + @PluginMethod + fun syncLocalSessionsWithServer(call:PluginCall) { + if (DeviceManager.serverConnectionConfig == null) { + AbsLogger.error("AbsDatabase", "syncLocalSessionsWithServer: not connected to server") + return call.resolve() + } + + apiHandler.syncLocalMediaProgressForUser { + val savedSessions = DeviceManager.dbManager.getPlaybackSessions().filter { it.serverConnectionConfigId == DeviceManager.serverConnectionConfigId } + + if (savedSessions.isNotEmpty()) { + apiHandler.sendSyncLocalSessions(savedSessions) { success, errorMsg -> + if (!success) { + call.resolve(JSObject("{\"error\":\"$errorMsg\"}")) + } else { + AbsLogger.info("AbsDatabase", "syncLocalSessionsWithServer: Finished sending local playback sessions to server. Removing ${savedSessions.size} saved sessions.") + // Remove all local sessions + savedSessions.forEach { + DeviceManager.dbManager.removePlaybackSession(it.id) + } + call.resolve() + } + } + } else { + AbsLogger.info("AbsDatabase", "syncLocalSessionsWithServer: No saved local playback sessions to send to server.") + call.resolve() + } + } + } + + // Updates received via web socket + // This function doesn't need to sync with the server also because this data is coming from the server + // If sending the localMediaProgressId then update existing media progress + // If sending localLibraryItemId then save new local media progress + @PluginMethod + fun syncServerMediaProgressWithLocalMediaProgress(call:PluginCall) { + val serverMediaProgress = call.getObject("mediaProgress").toString() + val localLibraryItemId = call.getString("localLibraryItemId", "").toString() + var localEpisodeId:String? = call.getString("localEpisodeId", "").toString() + if (localEpisodeId.isNullOrEmpty()) localEpisodeId = null + var localMediaProgressId = call.getString("localMediaProgressId") ?: "" + + val mediaProgress = jacksonMapper.readValue(serverMediaProgress) + + if (localMediaProgressId == "") { + val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId) + if (localLibraryItem != null) { + localMediaProgressId = if (localEpisodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId" + + val localMediaProgress = LocalMediaProgress( + id = localMediaProgressId, + localLibraryItemId = localLibraryItemId, + localEpisodeId = localEpisodeId, + duration = mediaProgress.duration, + progress = mediaProgress.progress, + currentTime = mediaProgress.currentTime, + isFinished = mediaProgress.isFinished, + ebookLocation = mediaProgress.ebookLocation, + ebookProgress = mediaProgress.ebookProgress, + lastUpdate = mediaProgress.lastUpdate, + startedAt = mediaProgress.startedAt, + finishedAt = mediaProgress.finishedAt, + serverConnectionConfigId = localLibraryItem.serverConnectionConfigId, + serverAddress = localLibraryItem.serverAddress, + serverUserId = localLibraryItem.serverUserId, + libraryItemId = localLibraryItem.libraryItemId, + episodeId = mediaProgress.episodeId) + + Log.d(tag, "syncServerMediaProgressWithLocalMediaProgress: Saving new local media progress $localMediaProgress") + DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress) + call.resolve(JSObject(jacksonMapper.writeValueAsString(localMediaProgress))) + } else { + Log.e(tag, "syncServerMediaProgressWithLocalMediaProgress: Local library item not found") + } + } else { + Log.d(tag, "syncServerMediaProgressWithLocalMediaProgress $localMediaProgressId") + val localMediaProgress = DeviceManager.dbManager.getLocalMediaProgress(localMediaProgressId) + + if (localMediaProgress == null) { + Log.w(tag, "syncServerMediaProgressWithLocalMediaProgress Local media progress not found $localMediaProgressId") + call.resolve() + } else { + MediaEventManager.syncEvent(mediaProgress, "Received from webhook event") + + localMediaProgress.updateFromServerMediaProgress(mediaProgress) + DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress) + call.resolve(JSObject(jacksonMapper.writeValueAsString(localMediaProgress))) + } + } + } + + @PluginMethod + fun updateLocalMediaProgressFinished(call:PluginCall) { + val localLibraryItemId = call.getString("localLibraryItemId", "").toString() + var localEpisodeId:String? = call.getString("localEpisodeId", "").toString() + if (localEpisodeId.isNullOrEmpty()) localEpisodeId = null + + val localMediaProgressId = if (localEpisodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId" + val isFinished = call.getBoolean("isFinished", false) == true + + Log.d(tag, "updateLocalMediaProgressFinished $localMediaProgressId | Is Finished:$isFinished") + var localMediaProgress = DeviceManager.dbManager.getLocalMediaProgress(localMediaProgressId) + + if (localMediaProgress == null) { // Create new local media progress if does not exist + Log.d(tag, "updateLocalMediaProgressFinished Local Media Progress not found $localMediaProgressId - Creating new") + val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId) + + if (localLibraryItem == null) { + return call.resolve(JSObject("{\"error\":\"Library Item not found\"}")) + } + if (localLibraryItem.mediaType != "podcast" && !localEpisodeId.isNullOrEmpty()) { + return call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}")) + } + + val duration: Double + var podcastEpisode: PodcastEpisode? = null + if (!localEpisodeId.isNullOrEmpty()) { + val podcast = localLibraryItem.media as Podcast + podcastEpisode = podcast.episodes?.find { episode -> + episode.id == localEpisodeId + } + if (podcastEpisode == null) { + return call.resolve(JSObject("{\"error\":\"Podcast episode not found\"}")) + } + duration = podcastEpisode.duration ?: 0.0 + } else { + val book = localLibraryItem.media as Book + duration = book.duration ?: 0.0 + } + + val currentTime = System.currentTimeMillis() + localMediaProgress = LocalMediaProgress( + id = localMediaProgressId, + localLibraryItemId = localLibraryItemId, + localEpisodeId = localEpisodeId, + duration = duration, + progress = if (isFinished) 1.0 else 0.0, + currentTime = 0.0, + isFinished = isFinished, + ebookLocation = null, + ebookProgress = null, + lastUpdate = currentTime, + startedAt = if (isFinished) currentTime else 0L, + finishedAt = if (isFinished) currentTime else null, + serverConnectionConfigId = localLibraryItem.serverConnectionConfigId, + serverAddress = localLibraryItem.serverAddress, + serverUserId = localLibraryItem.serverUserId, + libraryItemId = localLibraryItem.libraryItemId, + episodeId = podcastEpisode?.serverEpisodeId) + } else { + localMediaProgress.updateIsFinished(isFinished) + } + + // Save local media progress locally + DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress) + + val lmpstring = jacksonMapper.writeValueAsString(localMediaProgress) + Log.d(tag, "updateLocalMediaProgressFinished: Local Media Progress String $lmpstring") + + // Send update to server media progress is linked to a server and user is logged into that server + localMediaProgress.serverConnectionConfigId?.let { configId -> + if (DeviceManager.serverConnectionConfigId == configId) { + val libraryItemId = localMediaProgress.libraryItemId ?: "" + val episodeId = localMediaProgress.episodeId ?: "" + val updatePayload = JSObject() + updatePayload.put("isFinished", isFinished) + apiHandler.updateMediaProgress(libraryItemId,episodeId,updatePayload) { + Log.d(tag, "updateLocalMediaProgressFinished: Updated media progress isFinished on server") + val jsobj = JSObject() + jsobj.put("local", true) + jsobj.put("server", true) + jsobj.put("localMediaProgress", JSObject(lmpstring)) + call.resolve(jsobj) + } + } + } + if (localMediaProgress.serverConnectionConfigId == null || DeviceManager.serverConnectionConfigId != localMediaProgress.serverConnectionConfigId) { + val jsobj = JSObject() + jsobj.put("local", true) + jsobj.put("server", false) + jsobj.put("localMediaProgress", JSObject(lmpstring)) + call.resolve(jsobj) + } + } + + @PluginMethod + fun updateLocalEbookProgress(call:PluginCall) { + val localLibraryItemId = call.getString("localLibraryItemId", "").toString() + val ebookLocation = call.getString("ebookLocation", "").toString() + val ebookProgress = call.getDouble("ebookProgress") ?: 0.0 + + val localMediaProgressId = localLibraryItemId + var localMediaProgress = DeviceManager.dbManager.getLocalMediaProgress(localMediaProgressId) + + if (localMediaProgress == null) { + Log.d(tag, "updateLocalEbookProgress Local Media Progress not found $localMediaProgressId - Creating new") + val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId) + ?: return call.resolve(JSObject("{\"error\":\"Library Item not found\"}")) + + val book = localLibraryItem.media as Book + + localMediaProgress = LocalMediaProgress( + id = localMediaProgressId, + localLibraryItemId = localLibraryItemId, + localEpisodeId = null, + duration = book.duration ?: 0.0, + progress = 0.0, + currentTime = 0.0, + isFinished = false, + ebookLocation = ebookLocation, + ebookProgress = ebookProgress, + lastUpdate = System.currentTimeMillis(), + startedAt = 0L, + finishedAt = null, + serverConnectionConfigId = localLibraryItem.serverConnectionConfigId, + serverAddress = localLibraryItem.serverAddress, + serverUserId = localLibraryItem.serverUserId, + libraryItemId = localLibraryItem.libraryItemId, + episodeId = null) + } else { + localMediaProgress.updateEbookProgress(ebookLocation, ebookProgress) + } + + // Save local media progress locally + DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress) + + val lmpstring = jacksonMapper.writeValueAsString(localMediaProgress) + Log.d(tag, "updateLocalEbookProgress: Local Media Progress String $lmpstring") + + val jsobj = JSObject() + jsobj.put("localMediaProgress", JSObject(lmpstring)) + call.resolve(jsobj) + } + + @PluginMethod + fun updateLocalTrackOrder(call:PluginCall) { + val localLibraryItemId = call.getString("localLibraryItemId", "") ?: "" + val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId) + if (localLibraryItem == null) { + call.resolve() + return + } + + val audioTracks = localLibraryItem.media.getAudioTracks() as MutableList + + val tracks:JSArray = call.getArray("tracks") ?: JSArray() + Log.d(tag, "updateLocalTrackOrder $tracks") + + var index = 1 + var hasUpdates = false + for (i in 0 until tracks.length()) { + val track = tracks.getJSONObject(i) + val localFileId = track.getString("localFileId") + + val existingTrack = audioTracks.find{ it.localFileId == localFileId } + if (existingTrack != null) { + Log.d(tag, "Found existing track ${existingTrack.localFileId} that has index ${existingTrack.index} should be index $index") + if (existingTrack.index != index) hasUpdates = true + existingTrack.index = index++ + } else { + Log.e(tag, "Audio track with local file id not found") + } + } + + if (hasUpdates) { + Log.d(tag, "Save library item track orders") + localLibraryItem.media.setAudioTracks(audioTracks) + DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem) + call.resolve(JSObject(jacksonMapper.writeValueAsString(localLibraryItem))) + } else { + Log.d(tag, "No tracks need to be updated") + call.resolve() + } + } + + @PluginMethod + fun updateDeviceSettings(call:PluginCall) { // Returns device data + Log.d(tag, "updateDeviceSettings called") + val newDeviceSettings = jacksonMapper.readValue(call.data.toString()) + + Handler(Looper.getMainLooper()).post { + DeviceManager.deviceData.deviceSettings = newDeviceSettings + DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData) + + // Updates playback actions for media notification (handles media control seek locking setting) + if (mainActivity.isPlayerNotificationServiceInitialized()) { + mainActivity.foregroundService.setMediaSessionConnectorPlaybackActions() + } + + call.resolve(JSObject(jacksonMapper.writeValueAsString(DeviceManager.deviceData))) + } + } + + @PluginMethod + fun getMediaItemHistory(call:PluginCall) { // Returns device data + Log.d(tag, "getMediaItemHistory called") + val mediaId = call.getString("mediaId") ?: "" + + GlobalScope.launch(Dispatchers.IO) { + val mediaItemHistory = DeviceManager.dbManager.getMediaItemHistory(mediaId) + if (mediaItemHistory == null) { + call.resolve() + } else { + call.resolve(JSObject(jacksonMapper.writeValueAsString(mediaItemHistory))) + } + } + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt new file mode 100644 index 0000000..cd678a1 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt @@ -0,0 +1,272 @@ +package com.audiobookshelf.app.plugins + +import android.os.Environment +import com.audiobookshelf.app.MainActivity +import com.audiobookshelf.app.data.* +import com.audiobookshelf.app.device.DeviceManager +import com.audiobookshelf.app.models.DownloadItem +import com.audiobookshelf.app.models.DownloadItemPart +import com.audiobookshelf.app.server.ApiHandler +import com.audiobookshelf.app.managers.DownloadItemManager +import com.audiobookshelf.app.services.DownloadServiceHost +import com.fasterxml.jackson.core.json.JsonReadFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.getcapacitor.JSObject +import com.getcapacitor.Plugin +import com.getcapacitor.PluginCall +import com.getcapacitor.PluginMethod +import com.getcapacitor.annotation.CapacitorPlugin +import java.io.File + +@CapacitorPlugin(name = "AbsDownloader") +class AbsDownloader : Plugin() { + private val tag = "AbsDownloader" + private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) + + lateinit var mainActivity: MainActivity + lateinit var apiHandler: ApiHandler + lateinit var downloadItemManager: DownloadItemManager + + private val clientEventEmitter = (object : DownloadItemManager.DownloadEventEmitter { + override fun onDownloadItem(downloadItem:DownloadItem) { + notifyListeners("onDownloadItem", JSObject(jacksonMapper.writeValueAsString(downloadItem))) + } + override fun onDownloadItemPartUpdate(downloadItemPart:DownloadItemPart) { + notifyListeners("onDownloadItemPartUpdate", JSObject(jacksonMapper.writeValueAsString(downloadItemPart))) + } + override fun onDownloadItemComplete(jsobj:JSObject) { + notifyListeners("onItemDownloadComplete", jsobj) + } + override fun onQueueChanged(hasWork: Boolean) { + notifyListeners("onQueueChanged", JSObject().put("hasWork", hasWork)) + } + }) + + override fun load() { + mainActivity = (activity as MainActivity) + apiHandler = ApiHandler(mainActivity) + downloadItemManager = DownloadServiceHost.ensure(mainActivity) + DownloadServiceHost.attachBridge(mainActivity, clientEventEmitter) + } + + override fun handleOnDestroy() { + DownloadServiceHost.detachBridge() + super.handleOnDestroy() + } + + @PluginMethod + fun setDownloadNotificationStrings(call: PluginCall) { + DownloadServiceHost.setNotificationStrings( + mainActivity, + call.getString("preparing") ?: "Preparing downloads", + call.getString("downloadingFile") ?: "Downloading {0}", + call.getString("waitingForStorage") ?: "Waiting for available storage", + call.getString("downloads") ?: "Downloads", + call.getString("cancel") ?: "Cancel") + call.resolve() + } + + /** Replays restored queue items when the frontend subscribes to download events. */ + @PluginMethod(returnType = PluginMethod.RETURN_NONE) + override fun addListener(call: PluginCall) { + super.addListener(call) + if (call.getString("eventName") == "onDownloadItem" && ::downloadItemManager.isInitialized) { + downloadItemManager.downloadItemQueue.forEach { item -> + notifyListeners("onDownloadItem", JSObject(jacksonMapper.writeValueAsString(item))) + } + } + } + + @PluginMethod + fun downloadLibraryItem(call: PluginCall) { + val libraryItemId = call.data.getString("libraryItemId").toString() + var episodeId = call.data.getString("episodeId").toString() + if (episodeId == "null") episodeId = "" + var localFolderId = call.data.getString("localFolderId", "").toString() + AbsLogger.info(tag, "Requested download for item $libraryItemId${if (episodeId.isEmpty()) "" else " / episode $episodeId"}") + + val downloadId = if (episodeId.isEmpty()) libraryItemId else "$libraryItemId-$episodeId" + DownloadServiceHost.retryExisting(mainActivity, downloadId) { result -> + when (result) { + DownloadServiceHost.ExistingDownloadResult.RETRIED -> call.resolve() + DownloadServiceHost.ExistingDownloadResult.ACTIVE -> { + AbsLogger.info(tag, "Download already active for $downloadId") + call.resolve(JSObject("{\"error\":\"Download already started for this media entity\"}")) + } + DownloadServiceHost.ExistingDownloadResult.SERVICE_START_FAILED -> + call.resolve(JSObject("{\"error\":\"Unable to start the Android download service\"}")) + DownloadServiceHost.ExistingDownloadResult.NOT_FOUND -> { + apiHandler.getLibraryItemWithProgress(libraryItemId, episodeId) { libraryItem -> + if (libraryItem == null) { + call.resolve(JSObject("{\"error\":\"Server request failed\"}")) + } else { + AbsLogger.info(tag, "Preparing download for ${libraryItem.id}") + + if (localFolderId == "") localFolderId = "internal-${libraryItem.mediaType}" + var localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId) + if (localFolder == null && localFolderId.startsWith("internal-")) { + AbsLogger.info(tag, "Creating internal download folder $localFolderId") + localFolder = LocalFolder(localFolderId, "Internal App Storage", "", "", "", "internal", libraryItem.mediaType) + DeviceManager.dbManager.saveLocalFolder(localFolder) + } + + if (localFolder == null) { + call.resolve(JSObject("{\"error\":\"Local Folder Not Found\"}")) + } else if (episodeId.isNotEmpty() && libraryItem.mediaType != "podcast") { + call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}")) + } else if (episodeId.isNotEmpty()) { + val podcast = libraryItem.media as Podcast + val episode = podcast.episodes?.find { it.id == episodeId } + if (episode == null) { + call.resolve(JSObject("{\"error\":\"Invalid podcast episode not found\"}")) + } else { + startLibraryItemDownload(libraryItem, localFolder, episode) { error -> resolveDownloadCall(call, error) } + } + } else { + startLibraryItemDownload(libraryItem, localFolder, null) { error -> resolveDownloadCall(call, error) } + } + } + } + } + } + } + } + + private fun resolveDownloadCall(call: PluginCall, error: String?) { + if (error == null) call.resolve() + else call.resolve(JSObject().put("error", error)) + } + + // Item filenames could be the same if they are in sub-folders, this will make them unique + private fun getFilenameFromRelPath(relPath: String): String { + var cleanedRelPath = relPath.replace("\\", "_").replace("/", "_") + cleanedRelPath = cleanStringForFileSystem(cleanedRelPath) + return if (cleanedRelPath.startsWith("_")) cleanedRelPath.substring(1) else cleanedRelPath + } + + // Replace characters that cant be used in the file system + // Reserved characters: ?:\"*|/\\<> + private fun cleanStringForFileSystem(str:String):String { + val reservedCharacters = listOf("?", "\"", "*", "|", "/", "\\", "<", ">") + var newTitle = str + newTitle = newTitle.replace(":", " -") // Special case replace : with - + + reservedCharacters.forEach { + newTitle = newTitle.replace(it, "") + } + return newTitle + } + + private fun startLibraryItemDownload( + libraryItem: LibraryItem, + localFolder: LocalFolder, + episode: PodcastEpisode?, + callback: (String?) -> Unit + ) { + val isInternal = localFolder.id.startsWith("internal-") + + val finalInternalFolderPath = "${mainActivity.filesDir}/downloads/${libraryItem.id}" + val tempFolderPath = + if (isInternal) { + "${mainActivity.filesDir}/download-staging/${libraryItem.id}" + } else { + "${mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: mainActivity.filesDir}/download-staging/${libraryItem.id}" + } + + + if (libraryItem.mediaType == "book") { + val bookTitle = cleanStringForFileSystem(libraryItem.media.metadata.title) + val bookAuthor = cleanStringForFileSystem(libraryItem.media.metadata.getAuthorDisplayName()) + + val tracks = libraryItem.media.getAudioTracks() + AbsLogger.info(tag, "Queueing library item download with ${tracks.size} files") + val itemSubfolder = "$bookAuthor/$bookTitle" + val itemFolderPath = if (isInternal) finalInternalFolderPath else "${localFolder.absolutePath}/$itemSubfolder" + val downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null, libraryItem.userMediaProgress,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, itemSubfolder, libraryItem.media, mutableListOf()) + + val book = libraryItem.media as Book + book.ebookFile?.let { ebookFile -> + val fileSize = ebookFile.metadata?.size ?: 0 + val serverPath = "/api/items/${libraryItem.id}/file/${ebookFile.ino}/download" + val destinationFilename = getFilenameFromRelPath(ebookFile.metadata?.relPath ?: "") + val finalDestinationFile = File("$itemFolderPath/$destinationFilename") + val destinationFile = File("$tempFolderPath/$destinationFilename.part") + + val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,ebookFile,null,null) + downloadItem.downloadItemParts.add(downloadItemPart) + } + + // Create download item part for each audio track + val audioFiles = (libraryItem.media as Book).audioFiles ?: mutableListOf() + tracks.forEach { audioTrack -> + val fileSize = audioTrack.metadata?.size ?: 0 + + // TODO: Currently file ino is only stored on AudioFile. This should be updated server side to be in FileMetadata or on the AudioTrack + val audioFileIno = audioFiles.find { it.metadata.path == audioTrack.metadata?.path }?.ino + + val serverPath = "/api/items/${libraryItem.id}/file/${audioFileIno}/download" + val destinationFilename = getFilenameFromRelPath(audioTrack.relPath) + + val finalDestinationFile = File("$itemFolderPath/$destinationFilename") + val destinationFile = File("$tempFolderPath/$destinationFilename.part") + + val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,audioTrack,null) + downloadItem.downloadItemParts.add(downloadItemPart) + } + + if (downloadItem.downloadItemParts.isNotEmpty()) { + // Add cover download item + if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) { + val coverLibraryFile = libraryItem.libraryFiles?.find { it.metadata.path == libraryItem.media.coverPath } + val coverFileSize = coverLibraryFile?.metadata?.size ?: 0 + + val serverPath = "/api/items/${libraryItem.id}/cover" + val destinationFilename = "cover-${libraryItem.id}.jpg" + val destinationFile = File("$tempFolderPath/$destinationFilename.part") + val finalDestinationFile = File("$itemFolderPath/$destinationFilename") + + val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, coverFileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,null,null) + downloadItem.downloadItemParts.add(downloadItemPart) + } + + DownloadServiceHost.enqueue(mainActivity, downloadItem, callback) + } else callback("No downloadable files found") + } else { + // Podcast episode download + val podcastTitle = cleanStringForFileSystem(libraryItem.media.metadata.title) + + val audioTrack = episode?.audioTrack + val audioFileIno = episode?.audioFile?.ino + val fileSize = audioTrack?.metadata?.size ?: 0 + + AbsLogger.info(tag, "Queueing podcast episode download") + val itemFolderPath = if (isInternal) finalInternalFolderPath else "${localFolder.absolutePath}/$podcastTitle" + val downloadItemId = "${libraryItem.id}-${episode?.id}" + val downloadItem = DownloadItem(downloadItemId, libraryItem.id, episode?.id, libraryItem.userMediaProgress, DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, podcastTitle, podcastTitle, libraryItem.media, mutableListOf()) + + var serverPath = "/api/items/${libraryItem.id}/file/${audioFileIno}/download" + var destinationFilename = getFilenameFromRelPath(audioTrack?.relPath ?: "") + + var destinationFile = File("$tempFolderPath/$destinationFilename.part") + var finalDestinationFile = File("$itemFolderPath/$destinationFilename") + var downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,fileSize, destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,audioTrack,episode) + downloadItem.downloadItemParts.add(downloadItemPart) + + if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) { + val coverLibraryFile = libraryItem.libraryFiles?.find { it.metadata.path == libraryItem.media.coverPath } + val coverFileSize = coverLibraryFile?.metadata?.size ?: 0 + + serverPath = "/api/items/${libraryItem.id}/cover" + destinationFilename = "cover.jpg" + + destinationFile = File("$tempFolderPath/$destinationFilename.part") + finalDestinationFile = File("$itemFolderPath/$destinationFilename") + + downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null,null) + downloadItem.downloadItemParts.add(downloadItemPart) + } + + DownloadServiceHost.enqueue(mainActivity, downloadItem, callback) + } + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt new file mode 100644 index 0000000..307c6c0 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt @@ -0,0 +1,328 @@ +package com.audiobookshelf.app.plugins + +import android.app.AlertDialog +import android.content.Context +import android.net.Uri +import android.os.Build +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.documentfile.provider.DocumentFile +import com.anggrayudi.storage.SimpleStorage +import com.anggrayudi.storage.callback.FolderPickerCallback +import com.anggrayudi.storage.callback.StorageAccessCallback +import com.anggrayudi.storage.file.* +import com.audiobookshelf.app.MainActivity +import com.audiobookshelf.app.data.LocalFolder +import com.audiobookshelf.app.device.DeviceManager +import com.fasterxml.jackson.core.json.JsonReadFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.getcapacitor.* +import com.getcapacitor.annotation.CapacitorPlugin +import java.io.File + +@CapacitorPlugin(name = "AbsFileSystem") +class AbsFileSystem : Plugin() { + private val TAG = "AbsFileSystem" + private val tag = "AbsFileSystem" + private var jacksonMapper = + jacksonObjectMapper() + .enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) + + lateinit var mainActivity: MainActivity + + override fun load() { + mainActivity = (activity as MainActivity) + + mainActivity.storage.storageAccessCallback = + object : StorageAccessCallback { + override fun onRootPathNotSelected( + requestCode: Int, + rootPath: String, + uri: Uri, + selectedStorageType: StorageType, + expectedStorageType: StorageType + ) { + Log.d(TAG, "STORAGE ACCESS CALLBACK") + } + + override fun onCanceledByUser(requestCode: Int) { + Log.d(TAG, "STORAGE ACCESS CALLBACK") + } + + override fun onExpectedStorageNotSelected( + requestCode: Int, + selectedFolder: DocumentFile, + selectedStorageType: StorageType, + expectedBasePath: String, + expectedStorageType: StorageType + ) { + Log.d(TAG, "STORAGE ACCESS CALLBACK") + } + + override fun onStoragePermissionDenied(requestCode: Int) { + Log.d(TAG, "STORAGE ACCESS CALLBACK") + } + + override fun onRootPathPermissionGranted(requestCode: Int, root: DocumentFile) { + Log.d(TAG, "STORAGE ACCESS CALLBACK") + } + } + } + + @PluginMethod + fun setFolderPickerStrings(call: PluginCall) { + mainActivity.getSharedPreferences(FOLDER_PICKER_PREFERENCES, Context.MODE_PRIVATE) + .edit() + .putString(KEY_WRITE_ACCESS_REQUIRED, call.getString("writeAccessRequired")) + .putString(KEY_ALLOW, call.getString("allow")) + .putString(KEY_CANCEL, call.getString("cancel")) + .putString(KEY_ACCESS_DENIED, call.getString("accessDenied")) + .putString(KEY_PERMISSION_DENIED, call.getString("permissionDenied")) + .apply() + call.resolve() + } + + @PluginMethod + fun selectFolder(call: PluginCall) { + val mediaType = call.data.getString("mediaType", "book").toString() + val REQUEST_CODE_SELECT_FOLDER = 6 + val REQUEST_CODE_SDCARD_ACCESS = 7 + + mainActivity.storage.folderPickerCallback = + object : FolderPickerCallback { + override fun onFolderSelected(requestCode: Int, folder: DocumentFile) { + Log.d(TAG, "ON FOLDER SELECTED ${folder.uri} ${folder.name}") + val absolutePath = folder.getAbsolutePath(activity) + val storageType = folder.getStorageType(activity) + val basePath = folder.getBasePath(activity) + val folderId = + android.util.Base64.encodeToString( + folder.id.toByteArray(), + android.util.Base64.DEFAULT + ) + + val localFolder = + LocalFolder( + folderId, + folder.name ?: "", + folder.uri.toString(), + basePath, + absolutePath, + storageType.toString(), + mediaType + ) + + DeviceManager.dbManager.saveLocalFolder(localFolder) + call.resolve(JSObject(jacksonMapper.writeValueAsString(localFolder))) + } + + override fun onStorageAccessDenied( + requestCode: Int, + folder: DocumentFile?, + storageType: StorageType, + storageId: String + ) { + Log.e(tag, "Storage Access Denied ${folder?.getAbsolutePath(mainActivity)}") + + val jsobj = JSObject() + if (requestCode == REQUEST_CODE_SELECT_FOLDER) { + + val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity) + builder.setMessage(folderPickerString(KEY_WRITE_ACCESS_REQUIRED, DEFAULT_WRITE_ACCESS_REQUIRED)) + builder.setNegativeButton(folderPickerString(KEY_CANCEL, DEFAULT_CANCEL)) { _, _ -> + run { + jsobj.put("error", folderPickerString(KEY_ACCESS_DENIED, DEFAULT_ACCESS_DENIED)) + call.resolve(jsobj) + } + } + builder.setPositiveButton(folderPickerString(KEY_ALLOW, DEFAULT_ALLOW)) { _, _ -> + mainActivity.storageHelper.requestStorageAccess( + REQUEST_CODE_SDCARD_ACCESS, + initialPath = FileFullPath(mainActivity, storageId, "") + ) + } + builder.show() + } else { + Log.d(TAG, "STORAGE ACCESS DENIED $requestCode") + jsobj.put("error", folderPickerString(KEY_ACCESS_DENIED, DEFAULT_ACCESS_DENIED)) + call.resolve(jsobj) + } + } + + override fun onStoragePermissionDenied(requestCode: Int) { + Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode") + val jsobj = JSObject() + jsobj.put("error", folderPickerString(KEY_PERMISSION_DENIED, DEFAULT_PERMISSION_DENIED)) + call.resolve(jsobj) + } + } + + mainActivity.storage.openFolderPicker(REQUEST_CODE_SELECT_FOLDER) + } + + @RequiresApi(Build.VERSION_CODES.R) + @PluginMethod + fun requestStoragePermission(call: PluginCall) { + Log.d(TAG, "Request Storage Permissions") + mainActivity.storageHelper.requestStorageAccess() + call.resolve() + } + + @PluginMethod + fun checkStoragePermission(call: PluginCall) { + val res: Boolean + if (Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.P) { + res = SimpleStorage.hasStoragePermission(context) + Log.d(TAG, "checkStoragePermission: Check Storage Access $res") + } else { + Log.d(TAG, "checkStoragePermission: Has permission on Android 10 or up") + res = true + } + + val jsobj = JSObject() + jsobj.put("value", res) + call.resolve(jsobj) + } + + @PluginMethod + fun checkFolderPermissions(call: PluginCall) { + val folderUrl = call.data.getString("folderUrl", "").toString() + Log.d(TAG, "Check Folder Permissions for $folderUrl") + + val hasAccess = SimpleStorage.hasStorageAccess(context, folderUrl, true) + + val jsobj = JSObject() + jsobj.put("value", hasAccess) + call.resolve(jsobj) + } + + @PluginMethod + fun getSDKVersion(call: PluginCall) { + val jsObject = JSObject() + jsObject.put("version", Build.VERSION.SDK_INT) + call.resolve(jsObject) + } + + @PluginMethod + fun removeFolder(call: PluginCall) { + val folderId = call.data.getString("folderId", "").toString() + DeviceManager.dbManager.removeLocalFolder(folderId) + call.resolve() + } + + @PluginMethod + fun removeLocalLibraryItem(call: PluginCall) { + val localLibraryItemId = call.data.getString("localLibraryItemId", "").toString() + DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId) + call.resolve() + } + + @PluginMethod + fun deleteItem(call: PluginCall) { + val localLibraryItemId = call.data.getString("id", "").toString() + val absolutePath = call.data.getString("absolutePath", "").toString() + val contentUrl = call.data.getString("contentUrl", "").toString() + Log.d(tag, "deleteItem $absolutePath | $contentUrl") + + // Check if should delete subfolder + val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId) + + val success: Boolean + + // If internal library item use File to delete + if (localLibraryItem?.folderId?.startsWith("internal-") == true) { + Log.d(tag, "Deleting internal library item at absolutePath $absolutePath") + val file = File(absolutePath) + success = + if (file.exists()) { + file.deleteRecursively() + } else { + true + } + } else { + var subfolderPathToDelete = "" + localLibraryItem?.folderId?.let { folderId -> + val folder = DeviceManager.dbManager.getLocalFolder(folderId) + folder?.absolutePath?.let { folderPath -> + val splitAbsolutePath = absolutePath.split("/") + val fullSubDir = + splitAbsolutePath.subList(0, splitAbsolutePath.size - 1).joinToString("/") + if (fullSubDir != folderPath) { + val subdirHasAnItem = + DeviceManager.dbManager.getLocalLibraryItems().any { _localLibraryItem -> + if (_localLibraryItem.id == localLibraryItemId) { + false + } else { + _localLibraryItem.absolutePath.startsWith(fullSubDir) + } + } + subfolderPathToDelete = if (subdirHasAnItem) "" else fullSubDir + } + } + } + + val docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl)) + if (docfile?.exists() == true) { + success = docfile.delete() == true + } else { + Log.d(tag, "Folder $contentUrl doesn't exist") + success = true + } + + if (subfolderPathToDelete != "") { + Log.d(tag, "Deleting empty subfolder at $subfolderPathToDelete") + val docfilesub = DocumentFileCompat.fromFullPath(mainActivity, subfolderPathToDelete) + docfilesub?.delete() + } + } + + if (success) { + DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId) + } + call.resolve(JSObject("{\"success\":$success}")) + } + + @PluginMethod + fun deleteTrackFromItem(call: PluginCall) { + val localLibraryItemId = call.data.getString("id", "").toString() + val trackLocalFileId = call.data.getString("trackLocalFileId", "").toString() + val contentUrl = call.data.getString("trackContentUrl", "").toString() + Log.d(tag, "deleteTrackFromItem $contentUrl") + + val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId) + if (localLibraryItem == null) { + Log.e(tag, "deleteTrackFromItem: LLI does not exist $localLibraryItemId") + return call.resolve(JSObject("{\"success\":false}")) + } + + val docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl)) + val success = docfile?.delete() == true + if (success) { + localLibraryItem.media.removeAudioTrack(trackLocalFileId) + localLibraryItem.removeLocalFile(trackLocalFileId) + DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem) + call.resolve(JSObject(jacksonMapper.writeValueAsString(localLibraryItem))) + } else { + call.resolve(JSObject("{\"success\":false}")) + } + } + + private fun folderPickerString(key: String, defaultValue: String): String = + mainActivity.getSharedPreferences(FOLDER_PICKER_PREFERENCES, Context.MODE_PRIVATE) + .getString(key, defaultValue) ?: defaultValue + + private companion object { + const val FOLDER_PICKER_PREFERENCES = "folder_picker" + const val KEY_WRITE_ACCESS_REQUIRED = "write_access_required" + const val KEY_ALLOW = "allow" + const val KEY_CANCEL = "cancel" + const val KEY_ACCESS_DENIED = "access_denied" + const val KEY_PERMISSION_DENIED = "permission_denied" + const val DEFAULT_WRITE_ACCESS_REQUIRED = + "You do not have write access to this folder. Would you like to grant access?" + const val DEFAULT_ALLOW = "Allow" + const val DEFAULT_CANCEL = "Cancel" + const val DEFAULT_ACCESS_DENIED = "Access denied" + const val DEFAULT_PERMISSION_DENIED = "Permission denied" + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsLogger.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsLogger.kt new file mode 100644 index 0000000..ababfb5 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsLogger.kt @@ -0,0 +1,80 @@ +package com.audiobookshelf.app.plugins + +import android.util.Log +import com.audiobookshelf.app.device.DeviceManager +import com.fasterxml.jackson.core.json.JsonReadFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.getcapacitor.JSObject +import com.getcapacitor.Plugin +import com.getcapacitor.PluginCall +import com.getcapacitor.PluginMethod +import com.getcapacitor.annotation.CapacitorPlugin +import java.util.UUID + +data class AbsLog( + var id:String, + var tag:String, + var level:String, + var message:String, + var timestamp:Long +) + +data class AbsLogList(val value:List) + +@CapacitorPlugin(name = "AbsLogger") +class AbsLogger : Plugin() { + private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) + + override fun load() { + onLogEmitter = { log:AbsLog -> + notifyListeners("onLog", JSObject(jacksonMapper.writeValueAsString(log))) + } + info("AbsLogger", "load: AbsLogger plugin initialized") + } + + companion object { + var onLogEmitter:((log:AbsLog) -> Unit)? = null + + fun log(level:String, tag:String, message:String) { + val absLog = AbsLog(id = UUID.randomUUID().toString(), tag, level, message, timestamp = System.currentTimeMillis()) + DeviceManager.dbManager.saveLog(absLog) + onLogEmitter?.let { it(absLog) } + } + fun info(tag:String, message:String) { + Log.i("AbsLogger", message) + log("info", tag, message) + } + fun error(tag:String, message:String) { + Log.e("AbsLogger", message) + log("error", tag, message) + } + } + + @PluginMethod + fun info(call: PluginCall) { + val msg = call.getString("message") ?: return call.reject("No message") + val tag = call.getString("tag") ?: "" + info(tag, msg) + call.resolve() + } + + @PluginMethod + fun error(call: PluginCall) { + val msg = call.getString("message") ?: return call.reject("No message") + val tag = call.getString("tag") ?: "" + error(tag, msg) + call.resolve() + } + + @PluginMethod + fun getAllLogs(call: PluginCall) { + val absLogs = DeviceManager.dbManager.getAllLogs() + call.resolve(JSObject(jacksonMapper.writeValueAsString(AbsLogList(absLogs)))) + } + + @PluginMethod + fun clearLogs(call: PluginCall) { + DeviceManager.dbManager.removeAllLogs() + call.resolve() + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt new file mode 100644 index 0000000..2ad61a7 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt @@ -0,0 +1,840 @@ +package com.audiobookshelf.app.server + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import android.provider.Settings +import android.util.Base64 +import android.util.Log +import com.audiobookshelf.app.data.* +import com.audiobookshelf.app.device.DeviceManager +import com.audiobookshelf.app.media.MediaEventManager +import com.audiobookshelf.app.media.MediaProgressSyncData +import com.audiobookshelf.app.media.SyncResult +import com.audiobookshelf.app.models.User +import com.audiobookshelf.app.BuildConfig +import com.audiobookshelf.app.plugins.AbsLogger +import com.audiobookshelf.app.managers.SecureStorage +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.core.json.JsonReadFeature +import com.fasterxml.jackson.databind.node.ObjectNode +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.getcapacitor.JSArray +import com.getcapacitor.JSObject +import okhttp3.* +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.internal.EMPTY_REQUEST +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import java.io.IOException +import java.util.concurrent.TimeUnit + +class ApiHandler(var ctx:Context) { + val tag = "ApiHandler" + + companion object { + // For sending data back to the Webview frontend + lateinit var absDatabaseNotifyListeners:(String, JSObject) -> Unit + + fun checkAbsDatabaseNotifyListenersInitted():Boolean { + return ::absDatabaseNotifyListeners.isInitialized + } + } + + private var defaultClient = OkHttpClient() + private var pingClient = OkHttpClient.Builder().callTimeout(3, TimeUnit.SECONDS).build() + private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) + private var secureStorage = SecureStorage(ctx) + + @JsonIgnoreProperties(ignoreUnknown = true) + data class LocalSessionSyncResult(val id:String, val success:Boolean, val progressSynced:Boolean?, val error:String?) + data class LocalSessionsSyncResponsePayload(val results:List) + + private fun getRequest(endpoint:String, httpClient:OkHttpClient?, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) { + val address = config?.address ?: DeviceManager.serverAddress + val token = config?.token ?: DeviceManager.token + + try { + val request = Request.Builder() + .url("${address}$endpoint").addHeader("Authorization", "Bearer $token") + .build() + makeRequest(request, httpClient, cb) + } catch(e: Exception) { + e.printStackTrace() + val jsobj = JSObject() + jsobj.put("error", "Request failed: ${e.message}") + cb(jsobj) + } + } + + private fun postRequest(endpoint:String, payload: JSObject?, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) { + val address = config?.address ?: DeviceManager.serverAddress + val token = config?.token ?: DeviceManager.token + val mediaType = "application/json; charset=utf-8".toMediaType() + val requestBody = payload?.toString()?.toRequestBody(mediaType) ?: EMPTY_REQUEST + val requestUrl = "${address}$endpoint" + Log.d(tag, "postRequest to $requestUrl") + try { + val request = Request.Builder().post(requestBody) + .url(requestUrl).addHeader("Authorization", "Bearer ${token}") + .build() + makeRequest(request, null, cb) + } catch(e: Exception) { + e.printStackTrace() + val jsobj = JSObject() + jsobj.put("error", "Request failed: ${e.message}") + cb(jsobj) + } + } + + private fun patchRequest(endpoint:String, payload: JSObject, cb: (JSObject) -> Unit) { + val mediaType = "application/json; charset=utf-8".toMediaType() + val requestBody = payload.toString().toRequestBody(mediaType) + try { + val request = Request.Builder().patch(requestBody) + .url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}") + .build() + makeRequest(request, null, cb) + } catch(e: Exception) { + e.printStackTrace() + val jsobj = JSObject() + jsobj.put("error", "Request failed: ${e.message}") + cb(jsobj) + } + } + + private fun makeRequest(request:Request, httpClient:OkHttpClient?, cb: (JSObject) -> Unit) { + val client = httpClient ?: defaultClient + + client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + Log.d(tag, "FAILURE TO CONNECT") + e.printStackTrace() + + val jsobj = JSObject() + jsobj.put("error", "Failed to connect") + cb(jsobj) + } + + override fun onResponse(call: Call, response: Response) { + response.use { + if (it.code == 401) { + // Handle 401 Unauthorized by attempting token refresh + AbsLogger.info(tag, "makeRequest: 401 Unauthorized for request to \"${request.url}\" - attempt token refresh") + handleTokenRefresh(request, httpClient, cb) + return + } + + if (!it.isSuccessful) { + val jsobj = JSObject() + jsobj.put("error", "Unexpected code $response") + cb(jsobj) + return + } + + val bodyString = it.body!!.string() + if (bodyString == "OK") { + cb(JSObject()) + } else { + try { + var jsonObj = JSObject() + if (bodyString.startsWith("[")) { + val array = JSArray(bodyString) + jsonObj.put("value", array) + } else { + jsonObj = JSObject(bodyString) + } + cb(jsonObj) + } catch(je:JSONException) { + Log.e(tag, "Invalid JSON response ${je.localizedMessage} from body $bodyString") + val jsobj = JSObject() + jsobj.put("error", "Invalid response body") + cb(jsobj) + } + } + } + } + }) + } + + /** + * Handles token refresh when a 401 Unauthorized response is received + * This function will: + * 1. Get the refresh token from secure storage for the current server connection + * 2. Make a request to /auth/refresh endpoint with the refresh token + * 3. Update the stored tokens with the new access token + * 4. Retry the original request with the new access token + * 5. If refresh fails, fail the request ([refreshAuthTokens] owns clearing the session) + * + * @param originalRequest The original request that failed with 401 + * @param httpClient The HTTP client to use for the request + * @param callback The callback to return the response + */ + private fun handleTokenRefresh(originalRequest: Request, httpClient: OkHttpClient?, callback: (JSObject) -> Unit) { + val serverConnectionConfigId = DeviceManager.serverConnectionConfigId + refreshAuthTokens(serverConnectionConfigId, httpClient) { result -> + if (result is RefreshResult.Success) { + retryOriginalRequest(originalRequest, result.accessToken, httpClient, callback) + } else { + callback(JSObject().put("error", "Authentication failed - login again")) + } + } + } + + sealed interface RefreshResult { + data class Success(val accessToken: String) : RefreshResult + + /** The server rejected the refresh token, so the session has already been cleared. */ + data object Rejected : RefreshResult + + /** The refresh could not be completed. Credentials are untouched and the caller may retry. */ + data object Transient : RefreshResult + } + + /** Refreshes tokens for a specific saved server, including downloads queued while another server is active. */ + fun refreshAuthTokens( + serverConnectionConfigId: String, + httpClient: OkHttpClient? = null, + onResult: (RefreshResult) -> Unit + ) { + val config = DeviceManager.getServerConnectionConfig(serverConnectionConfigId) + val refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId) + if (config == null || refreshToken.isNullOrEmpty()) { + AbsLogger.error(tag, "No refresh token or server configuration for $serverConnectionConfigId") + handleRefreshRejected(serverConnectionConfigId) + onResult(RefreshResult.Rejected) + return + } + val request = try { + Request.Builder() + .url("${config.address}/auth/refresh") + .addHeader("x-refresh-token", refreshToken) + .addHeader("Content-Type", "application/json") + .post(EMPTY_REQUEST) + .build() + } catch (e: Exception) { + AbsLogger.error(tag, "Could not create refresh request for ${config.name}: ${e.message}") + onResult(RefreshResult.Transient) + return + } + (httpClient ?: defaultClient).newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + AbsLogger.error(tag, "Token refresh failed for ${config.name}: ${e.message}") + onResult(RefreshResult.Transient) + } + + override fun onResponse(call: Call, response: Response) { + response.use { + if (!it.isSuccessful) { + AbsLogger.error(tag, "Token refresh returned ${it.code} for ${config.name}") + if (it.code != 401 && it.code != 403) { + onResult(RefreshResult.Transient) + return + } + handleRefreshRejected(serverConnectionConfigId) + onResult(RefreshResult.Rejected) + return + } + try { + val user = JSONObject(it.body!!.string()).optJSONObject("user") + val accessToken = user?.optString("accessToken").orEmpty() + if (accessToken.isEmpty()) { + AbsLogger.error(tag, "Refresh response had no access token for ${config.name}") + onResult(RefreshResult.Transient) + return + } + updateTokens(accessToken, user?.optString("refreshToken").orEmpty().ifEmpty { refreshToken }, serverConnectionConfigId) + onResult(RefreshResult.Success(accessToken)) + } catch (e: Exception) { + AbsLogger.error(tag, "Could not parse refresh response for ${config.name}: ${e.message}") + onResult(RefreshResult.Transient) + } + } + } + }) + } + + /** + * Clears only the server that rejected the refresh token; queued downloads can target a non-active server. + * + * Only call this when the server explicitly rejected the refresh token. Transient failures should not log the user out + */ + private fun handleRefreshRejected(serverConnectionConfigId: String) { + // Must not throw: callers still have to report the refresh result to an in-flight request or download. + try { + secureStorage.removeRefreshToken(serverConnectionConfigId) + if (DeviceManager.serverConnectionConfigId != serverConnectionConfigId) return + DeviceManager.serverConnectionConfig = null + DeviceManager.deviceData.lastServerConnectionConfigId = null + DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData) + if (checkAbsDatabaseNotifyListenersInitted()) { + absDatabaseNotifyListeners( + "onTokenRefreshFailure", + JSObject().put("error", "Token refresh failed").put("serverConnectionConfigId", serverConnectionConfigId)) + } + } catch (e: Exception) { + AbsLogger.error(tag, "Could not clear session for $serverConnectionConfigId: ${e.message}") + } + } + + /** + * Updates the stored tokens with new access and refresh tokens + * + * @param newAccessToken The new access token + * @param newRefreshToken The new refresh token (or existing one if not provided) + */ + private fun updateTokens(newAccessToken: String, newRefreshToken: String, serverConnectionConfigId: String) { + try { + // Update the refresh token in secure storage if it's new + if (newRefreshToken != secureStorage.getRefreshToken(serverConnectionConfigId)) { + secureStorage.storeRefreshToken(serverConnectionConfigId, newRefreshToken) + Log.d(tag, "updateTokens: Updated refresh token in secure storage") + } + + // The refreshed connection may be queued in the downloader rather than currently active. + DeviceManager.getServerConnectionConfig(serverConnectionConfigId)?.let { config -> + config.token = newAccessToken + DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData) + Log.d(tag, "updateTokens: Updated access token in server connection config") + } + + // Send access token to Webview frontend + if (DeviceManager.serverConnectionConfigId == serverConnectionConfigId && checkAbsDatabaseNotifyListenersInitted()) { + val tokenJsObject = JSObject() + tokenJsObject.put("accessToken", newAccessToken) + absDatabaseNotifyListeners("onTokenRefresh", tokenJsObject) + } else { + // Can happen if Webview is never run + Log.i(tag, "AbsDatabaseNotifyListeners is not initialized so cannot send new access token") + } + AbsLogger.info(tag, "updateTokens: Successfully refreshed auth tokens for server ${DeviceManager.serverConnectionConfigString}") + } catch (e: Exception) { + Log.e(tag, "updateTokens: Failed to update tokens", e) + AbsLogger.error(tag, "updateTokens: Failed to refresh auth tokens for server ${DeviceManager.serverConnectionConfigString} (error: ${e.message})") + } + } + + /** + * Retries the original request with the new access token + * + * @param originalRequest The original request to retry + * @param newAccessToken The new access token to use + * @param httpClient The HTTP client to use + * @param callback The callback to return the response + */ + private fun retryOriginalRequest(originalRequest: Request, newAccessToken: String, httpClient: OkHttpClient?, callback: (JSObject) -> Unit) { + try { + // Create a new request with the updated authorization header + val newRequest = originalRequest.newBuilder() + .removeHeader("Authorization") + .addHeader("Authorization", "Bearer $newAccessToken") + .build() + + Log.d(tag, "retryOriginalRequest: Retrying request to ${newRequest.url}") + + // Make the retry request + val client = httpClient ?: defaultClient + client.newCall(newRequest).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + Log.e(tag, "retryOriginalRequest: Failed to retry request", e) + AbsLogger.error(tag, "retryOriginalRequest: Failed to retry request after token refresh for server ${DeviceManager.serverConnectionConfigString} (error: ${e.message})") + val errorObj = JSObject() + errorObj.put("error", "Failed to retry request after token refresh") + callback(errorObj) + } + + override fun onResponse(call: Call, response: Response) { + response.use { + if (!it.isSuccessful) { + Log.e(tag, "retryOriginalRequest: Retry request failed with status ${it.code}") + AbsLogger.error(tag, "retryOriginalRequest: Retry request failed with status ${it.code} for server ${DeviceManager.serverConnectionConfigString}") + val errorObj = JSObject() + errorObj.put("error", "Retry request failed with status ${it.code}") + callback(errorObj) + return + } + + val bodyString = it.body!!.string() + if (bodyString == "OK") { + callback(JSObject()) + } else { + try { + var jsonObj = JSObject() + if (bodyString.startsWith("[")) { + val array = JSArray(bodyString) + jsonObj.put("value", array) + } else { + jsonObj = JSObject(bodyString) + } + callback(jsonObj) + } catch(je:JSONException) { + Log.e(tag, "retryOriginalRequest: Invalid JSON response ${je.localizedMessage} from body $bodyString") + val errorObj = JSObject() + errorObj.put("error", "Invalid response body") + callback(errorObj) + } + } + } + } + }) + + } catch (e: Exception) { + Log.e(tag, "retryOriginalRequest: Unexpected error during retry", e) + AbsLogger.error(tag, "retryOriginalRequest: Unexpected error during retry for server ${DeviceManager.serverConnectionConfigString}") + val errorObj = JSObject() + errorObj.put("error", "Failed to retry request") + callback(errorObj) + } + } + + fun getCurrentUser(cb: (User?) -> Unit) { + getRequest("/api/me", null, null) { + if (it.has("error")) { + Log.e(tag, it.getString("error") ?: "getCurrentUser Failed") + cb(null) + } else { + val user = jacksonMapper.readValue(it.toString()) + cb(user) + } + } + } + + fun getLibraries(cb: (List) -> Unit) { + val mapper = jacksonMapper + getRequest("/api/libraries?include=stats", null,null) { + val libraries = mutableListOf() + + var array = JSONArray() + if (it.has("libraries")) { // TODO: Server 2.2.9 changed to this + array = it.getJSONArray("libraries") + } else if (it.has("value")) { + array = it.getJSONArray("value") + } + + for (i in 0 until array.length()) { + libraries.add(mapper.readValue(array.get(i).toString())) + } + cb(libraries) + } + } + + fun getLibraryPersonalized(libraryItemId:String, cb: (List?) -> Unit) { + getRequest("/api/libraries/$libraryItemId/personalized", null, null) { + if (it.has("error")) { + Log.e(tag, it.getString("error") ?: "getLibraryStats Failed") + cb(null) + } else { + val items = mutableListOf() + val array = it.getJSONArray("value") + for (i in 0 until array.length()) { + val item = jacksonMapper.readValue(array.get(i).toString()) + items.add(item) + } + cb(items) + } + } + } + + fun getLibraryItem(libraryItemId:String, cb: (LibraryItem?) -> Unit) { + getRequest("/api/items/$libraryItemId?expanded=1", null, null) { + if (it.has("error")) { + Log.e(tag, it.getString("error") ?: "getLibraryItem Failed") + cb(null) + } else { + val libraryItem = jacksonMapper.readValue(it.toString()) + cb(libraryItem) + } + } + } + + fun getLibraryItemWithProgress(libraryItemId:String, episodeId:String?, cb: (LibraryItem?) -> Unit) { + var requestUrl = "/api/items/$libraryItemId?expanded=1&include=progress" + if (!episodeId.isNullOrEmpty()) requestUrl += "&episode=$episodeId" + getRequest(requestUrl, null, null) { + if (it.has("error")) { + Log.e(tag, it.getString("error") ?: "getLibraryItemWithProgress Failed") + cb(null) + } else { + val libraryItem = jacksonMapper.readValue(it.toString()) + cb(libraryItem) + } + } + } + + fun getLibraryItems(libraryId:String, cb: (List) -> Unit) { + getRequest("/api/libraries/$libraryId/items?limit=100&minified=1", null, null) { + val items = mutableListOf() + if (it.has("results")) { + val array = it.getJSONArray("results") + for (i in 0 until array.length()) { + val item = jacksonMapper.readValue(array.get(i).toString()) + items.add(item) + } + } + cb(items) + } + } + + fun getLibrarySeries(libraryId:String, cb: (List) -> Unit) { + Log.d(tag, "Getting series") + getRequest("/api/libraries/$libraryId/series?minified=1&sort=name&limit=10000", null, null) { + val items = mutableListOf() + if (it.has("results")) { + val array = it.getJSONArray("results") + for (i in 0 until array.length()) { + val item = jacksonMapper.readValue(array.get(i).toString()) + items.add(item) + } + } + cb(items) + } + } + + fun getLibrarySeriesItems(libraryId:String, seriesId:String, cb: (List) -> Unit) { + Log.d(tag, "Getting items for series") + val seriesIdBase64 = Base64.encodeToString(seriesId.toByteArray(), Base64.DEFAULT) + getRequest("/api/libraries/$libraryId/items?minified=1&sort=media.metadata.title&filter=series.${seriesIdBase64}&limit=1000", null, null) { + val items = mutableListOf() + if (it.has("results")) { + val array = it.getJSONArray("results") + for (i in 0 until array.length()) { + val item = jacksonMapper.readValue(array.get(i).toString()) + items.add(item) + } + } + cb(items) + } + } + + fun getLibraryAuthors(libraryId:String, cb: (List) -> Unit) { + Log.d(tag, "Getting series") + getRequest("/api/libraries/$libraryId/authors", null, null) { + val items = mutableListOf() + if (it.has("authors")) { + val array = it.getJSONArray("authors") + for (i in 0 until array.length()) { + val item = jacksonMapper.readValue(array.get(i).toString()) + items.add(item) + } + }else{ + Log.e(tag, "No results") + } + cb(items) + } + } + + fun getLibraryItemsFromAuthor(libraryId:String, authorId:String, cb: (List) -> Unit) { + Log.d(tag, "Getting author items") + val authorIdBase64 = Base64.encodeToString(authorId.toByteArray(), Base64.DEFAULT) + getRequest("/api/libraries/$libraryId/items?limit=1000&minified=1&filter=authors.${authorIdBase64}&sort=media.metadata.title&collapseseries=1", null, null) { + val items = mutableListOf() + if (it.has("results")) { + val array = it.getJSONArray("results") + for (i in 0 until array.length()) { + val item = jacksonMapper.readValue(array.get(i).toString()) + if (item.collapsedSeries != null) { + item.collapsedSeries?.libraryId = libraryId + } + items.add(item) + } + }else{ + Log.e(tag, "No results") + } + cb(items) + } + } + + fun getLibraryCollections(libraryId:String, cb: (List) -> Unit) { + Log.d(tag, "Getting collections") + getRequest("/api/libraries/$libraryId/collections?minified=1&sort=name&limit=1000", null, null) { + val items = mutableListOf() + if (it.has("results")) { + val array = it.getJSONArray("results") + for (i in 0 until array.length()) { + val item = jacksonMapper.readValue(array.get(i).toString()) + items.add(item) + } + } + cb(items) + } + } + + fun getSearchResults(libraryId:String, queryString:String, cb: (LibraryItemSearchResultType?) -> Unit) { + Log.d(tag, "Doing search for library $libraryId") + getRequest("/api/libraries/$libraryId/search?q=$queryString", null, null) { + if (it.has("error")) { + Log.e(tag, it.getString("error") ?: "getSearchResults Failed") + cb(null) + } else { + val librarySearchResults = jacksonMapper.readValue(it.toString()) + cb(librarySearchResults) + } + } + } + + fun getAllItemsInProgress(cb: (List) -> Unit) { + getRequest("/api/me/items-in-progress", null, null) { + val items = mutableListOf() + if (it.has("libraryItems")) { + val array = it.getJSONArray("libraryItems") + for (i in 0 until array.length()) { + val jsobj = array.get(i) as JSONObject + val itemInProgress = ItemInProgress.makeFromServerObject(jsobj, jacksonMapper) + items.add(itemInProgress) + } + } + cb(items) + } + } + + fun playLibraryItem(libraryItemId:String, episodeId:String?, playItemRequestPayload:PlayItemRequestPayload, cb: (PlaybackSession?) -> Unit) { + val payload = JSObject(jacksonMapper.writeValueAsString(playItemRequestPayload)) + + val endpoint = if (episodeId.isNullOrEmpty()) "/api/items/$libraryItemId/play" else "/api/items/$libraryItemId/play/$episodeId" + postRequest(endpoint, payload, null) { + if (it.has("error")) { + Log.e(tag, it.getString("error") ?: "Play Library Item Failed") + cb(null) + } else { + it.put("serverConnectionConfigId", DeviceManager.serverConnectionConfig?.id) + it.put("serverAddress", DeviceManager.serverAddress) + val playbackSession = jacksonMapper.readValue(it.toString()) + cb(playbackSession) + } + } + } + + fun sendProgressSync(sessionId:String, syncData: MediaProgressSyncData, cb: (Boolean, String?) -> Unit) { + val payload = JSObject(jacksonMapper.writeValueAsString(syncData)) + + postRequest("/api/session/$sessionId/sync", payload, null) { + if (!it.getString("error").isNullOrEmpty()) { + cb(false, it.getString("error")) + } else { + cb(true, null) + } + } + } + + private fun createPartialPlaybackSession(playbackSession: PlaybackSession): ObjectNode { + val json = jacksonMapper.createObjectNode() + json.put("id", playbackSession.id) + json.put("userId", playbackSession.userId) + json.put("libraryItemId", playbackSession.libraryItemId) + json.put("episodeId", playbackSession.episodeId) + json.put("mediaType", playbackSession.mediaType) + json.put("displayTitle", playbackSession.displayTitle) + json.put("displayAuthor", playbackSession.displayAuthor) + json.put("duration", playbackSession.duration) + json.put("playMethod", playbackSession.playMethod) + json.put("startedAt", playbackSession.startedAt) + json.put("updatedAt", playbackSession.updatedAt) + json.put("timeListening", playbackSession.timeListening) + json.put("currentTime", playbackSession.currentTime) + json.put("mediaPlayer", playbackSession.mediaPlayer) + return json + } + + fun sendLocalProgressSync(playbackSession:PlaybackSession, cb: (Boolean, String?) -> Unit) { + val partialSession = createPartialPlaybackSession(playbackSession) + partialSession.set("deviceInfo", jacksonMapper.valueToTree(playbackSession.deviceInfo)) + postRequest("/api/session/local", JSObject(partialSession.toString()), null) { + if (!it.getString("error").isNullOrEmpty()) { + cb(false, it.getString("error")) + } else { + cb(true, null) + } + } + } + + fun updateMediaProgress(libraryItemId:String,episodeId:String?,updatePayload:JSObject, cb: () -> Unit) { + Log.d(tag, "updateMediaProgress $libraryItemId $episodeId $updatePayload") + val endpoint = if(episodeId.isNullOrEmpty()) "/api/me/progress/$libraryItemId" else "/api/me/progress/$libraryItemId/$episodeId" + patchRequest(endpoint,updatePayload) { + Log.d(tag, "updateMediaProgress patched progress") + cb() + } + } + + fun getMediaProgress(libraryItemId:String, episodeId:String?, serverConnectionConfig:ServerConnectionConfig?, cb: (MediaProgress?) -> Unit) { + val endpoint = if(episodeId.isNullOrEmpty()) "/api/me/progress/$libraryItemId" else "/api/me/progress/$libraryItemId/$episodeId" + + // TODO: Using ping client here allows for shorter timeout (3 seconds), maybe rename or make diff client for requests requiring quicker response + getRequest(endpoint, pingClient, serverConnectionConfig) { + if (it.has("error")) { + Log.e(tag, "getMediaProgress: Failed to get progress") + cb(null) + } else { + val progress = jacksonMapper.readValue(it.toString()) + cb(progress) + } + } + } + + fun getPlaybackSession(playbackSessionId:String, cb: (PlaybackSession?) -> Unit) { + Log.d(tag, "getPlaybackSession for $playbackSessionId for server ${DeviceManager.serverAddress}") + val endpoint = "/api/session/$playbackSessionId" + getRequest(endpoint, null, null) { + val err = it.getString("error") + if (!err.isNullOrEmpty()) { + cb(null) + } else { + cb(jacksonMapper.readValue(it.toString())) + } + } + } + + fun pingServer(config:ServerConnectionConfig, cb: (Boolean) -> Unit) { + Log.d(tag, "pingServer: Pinging ${config.address}") + getRequest("/ping", pingClient, config) { + val success = it.getString("success") + if (success.isNullOrEmpty()) { + Log.d(tag, "pingServer: Ping ${config.address} Failed") + cb(false) + } else { + Log.d(tag, "pingServer: Ping ${config.address} Successful") + cb(true) + } + } + } + + fun closePlaybackSession(playbackSessionId:String, config:ServerConnectionConfig?, cb: (Boolean) -> Unit) { + Log.d(tag, "closePlaybackSession: playbackSessionId=$playbackSessionId") + postRequest("/api/session/$playbackSessionId/close", null, config) { + cb(true) + } + } + + fun authorize(config:ServerConnectionConfig, cb: (MutableList?) -> 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 = mutableListOf() + val user = it.getJSObject("user") + val mediaProgress = user?.getJSONArray("mediaProgress") ?: JSONArray() + for (i in 0 until mediaProgress.length()) { + val mediaProg = jacksonMapper.readValue(mediaProgress.getJSONObject(i).toString()) + mediaProgressList.add(mediaProg) + } + Log.d(tag, "authorize: Authorize ${config.address} Successful") + cb(mediaProgressList) + } + } + } + + fun sendSyncLocalSessions(playbackSessions:List, cb: (Boolean, String?) -> Unit) { + @SuppressLint("HardwareIds") + val deviceId = Settings.Secure.getString(ctx.contentResolver, Settings.Secure.ANDROID_ID) + val deviceInfo = DeviceInfo(deviceId, Build.MANUFACTURER, Build.MODEL, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME) + + val json = jacksonMapper.createObjectNode(); + json.putArray("sessions").addAll(playbackSessions.map(::createPartialPlaybackSession)) + json.set("deviceInfo", jacksonMapper.valueToTree(deviceInfo)) + AbsLogger.info("ApiHandler", "sendSyncLocalSessions: Sending ${playbackSessions.size} saved local playback sessions to server (${DeviceManager.serverConnectionConfigName})") + + postRequest("/api/session/local-all", JSObject(json.toString()), null) { + if (!it.getString("error").isNullOrEmpty()) { + AbsLogger.error("ApiHandler", "sendSyncLocalSessions: Failed to sync local sessions. (${it.getString("error")})") + cb(false, it.getString("error")) + } else { + val response = jacksonMapper.readValue(it.toString()) + response.results.forEach { localSessionSyncResult -> + Log.d(tag, "Synced session result ${localSessionSyncResult.id}|${localSessionSyncResult.progressSynced}|${localSessionSyncResult.success}") + + playbackSessions.find { ps -> ps.id == localSessionSyncResult.id }?.let { session -> + if (localSessionSyncResult.progressSynced == true) { + val syncResult = SyncResult(true, true, "Progress synced on server") + MediaEventManager.saveEvent(session, syncResult) + + AbsLogger.info("ApiHandler", "sendSyncLocalSessions: Synced session \"${session.displayTitle}\" with server, server progress was updated for item ${session.mediaItemId}") + } else if (!localSessionSyncResult.success) { + AbsLogger.error("ApiHandler", "sendSyncLocalSessions: Failed to sync session \"${session.displayTitle}\" with server. Error: ${localSessionSyncResult.error}") + } else { + AbsLogger.info("ApiHandler", "sendSyncLocalSessions: Synced session \"${session.displayTitle}\" with server. Server progress was up-to-date for item ${session.mediaItemId}") + } + } + } + cb(true, null) + } + } + } + + fun syncLocalMediaProgressForUser(cb: () -> Unit) { + AbsLogger.info("ApiHandler", "[ApiHandler] syncLocalMediaProgressForUser: Server connection ${DeviceManager.serverConnectionConfigName}") + + // Get all local media progress for this server + val allLocalMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress().filter { it.serverConnectionConfigId == DeviceManager.serverConnectionConfigId } + if (allLocalMediaProgress.isEmpty()) { + AbsLogger.info("ApiHandler", "[ApiHandler] syncLocalMediaProgressForUser: No local media progress to sync") + return cb() + } + + AbsLogger.info("ApiHandler", "syncLocalMediaProgressForUser: Found ${allLocalMediaProgress.size} local media progress") + + getCurrentUser { user -> + if (user == null) { + AbsLogger.error("ApiHandler", "syncLocalMediaProgressForUser: Failed to load user from server (${DeviceManager.serverConnectionConfigName})") + } else { + var numLocalMediaProgressUptToDate = 0 + var numLocalMediaProgressUpdated = 0 + + // Compare server user progress with local progress + user.mediaProgress.forEach { mediaProgress -> + // Get matching local media progress + allLocalMediaProgress.find { it.isMatch(mediaProgress) }?.let { localMediaProgress -> + if (mediaProgress.lastUpdate > localMediaProgress.lastUpdate) { + val updateLogs = mutableListOf() + if (mediaProgress.progress != localMediaProgress.progress) { + updateLogs.add("Updated progress from ${localMediaProgress.progress} to ${mediaProgress.progress}") + } + if (mediaProgress.currentTime != localMediaProgress.currentTime) { + updateLogs.add("Updated currentTime from ${localMediaProgress.currentTime} to ${mediaProgress.currentTime}") + } + if (mediaProgress.isFinished != localMediaProgress.isFinished) { + updateLogs.add("Updated isFinished from ${localMediaProgress.isFinished} to ${mediaProgress.isFinished}") + } + if (mediaProgress.ebookProgress != localMediaProgress.ebookProgress) { + updateLogs.add("Updated ebookProgress from ${localMediaProgress.isFinished} to ${mediaProgress.isFinished}") + } + if (updateLogs.isNotEmpty()) { + AbsLogger.info("ApiHandler", "syncLocalMediaProgressForUser: Server progress for item \"${mediaProgress.mediaItemId}\" is more recent than local (server lastUpdate=${mediaProgress.lastUpdate}, local lastUpdate=${localMediaProgress.lastUpdate}). ${updateLogs.joinToString()}") + } + + localMediaProgress.updateFromServerMediaProgress(mediaProgress) + + // Only report sync if progress changed + if (updateLogs.isNotEmpty()) { + MediaEventManager.syncEvent(mediaProgress, "Sync on server connection") + } + DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress) + numLocalMediaProgressUpdated++ + } else if (localMediaProgress.lastUpdate > mediaProgress.lastUpdate && localMediaProgress.ebookLocation != null && localMediaProgress.ebookLocation != mediaProgress.ebookLocation) { + // Patch ebook progress to server + AbsLogger.info("ApiHandler", "syncLocalMediaProgressForUser: Local progress for ebook item \"${mediaProgress.mediaItemId}\" is more recent than server progress. Local progress last updated ${localMediaProgress.lastUpdate}, server progress last updated ${mediaProgress.lastUpdate}. Sending server request to update ebook progress from ${mediaProgress.ebookProgress} to ${localMediaProgress.ebookProgress}") + val endpoint = "/api/me/progress/${localMediaProgress.libraryItemId}" + val updatePayload = JSObject() + updatePayload.put("ebookLocation", localMediaProgress.ebookLocation) + updatePayload.put("ebookProgress", localMediaProgress.ebookProgress) + updatePayload.put("lastUpdate", localMediaProgress.lastUpdate) + patchRequest(endpoint,updatePayload) { + AbsLogger.info("ApiHandler", "syncLocalMediaProgressForUser: Successfully updated server ebook progress for item item \"${mediaProgress.mediaItemId}\"") + } + } else { + numLocalMediaProgressUptToDate++ + } + } + } + + AbsLogger.info("ApiHandler", "syncLocalMediaProgressForUser: Finishing syncing local media progress with server. $numLocalMediaProgressUptToDate up-to-date, $numLocalMediaProgressUpdated updated") + } + cb() + } + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt new file mode 100644 index 0000000..3ee79ea --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt @@ -0,0 +1,102 @@ +package com.audiobookshelf.app.services + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import com.audiobookshelf.app.R +import com.audiobookshelf.app.models.DownloadItemPart + +/** Android-owned foreground lifecycle for transfers that must outlive the WebView and Activity. */ +class DownloadService : Service() { + override fun onCreate() { + super.onCreate() + createChannel() + startForegroundWithType(DownloadServiceHost.notificationStrings(this).preparing) + DownloadServiceHost.attachService(this) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_CANCEL -> DownloadServiceHost.cancelAll(this) + else -> { + startForegroundWithType(DownloadServiceHost.notificationStrings(this).preparing) + DownloadServiceHost.startWork(this) + } + } + return START_STICKY + } + + override fun onDestroy() { + DownloadServiceHost.detachService(this) + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + fun onPartUpdate(part: DownloadItemPart) { + val strings = DownloadServiceHost.notificationStrings(this) + val text = + if (part.waitingForSpace) strings.waitingForStorage + else strings.downloadingFile.replace("{0}", part.filename) + val progress = part.progress.coerceIn(0L, 100L).toInt() + val notification = notification(text, progress, part.fileSize > 0L) + (getSystemService(NOTIFICATION_SERVICE) as NotificationManager).notify(NOTIFICATION_ID, notification) + } + + fun onQueueChanged(hasWork: Boolean) { + if (!hasWork) { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + } + + private fun startForegroundWithType(text: String) { + val notification = notification(text) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else { + startForeground(NOTIFICATION_ID, notification) + } + } + + private fun notification(text: String, progress: Int = 0, determinate: Boolean = false): Notification { + val cancelIntent = PendingIntent.getService( + this, 1, Intent(this, DownloadService::class.java).setAction(ACTION_CANCEL), pendingIntentFlags()) + return NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.icon) + .setContentTitle(DownloadServiceHost.notificationStrings(this).downloads) + .setContentText(text) + .setOnlyAlertOnce(true) + .setOngoing(true) + .setProgress(100, progress, !determinate) + .addAction(0, DownloadServiceHost.notificationStrings(this).cancel, cancelIntent) + .build() + } + + private fun createChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + DownloadServiceHost.notificationStrings(this).downloads, + NotificationManager.IMPORTANCE_LOW)) + } + + private fun pendingIntentFlags(): Int = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + + companion object { + private const val CHANNEL_ID = "downloads" + private const val NOTIFICATION_ID = 11 + private const val ACTION_CANCEL = "com.audiobookshelf.app.download.CANCEL" + fun intent(context: Context) = Intent(context, DownloadService::class.java) + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/services/DownloadServiceHost.kt b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadServiceHost.kt new file mode 100644 index 0000000..1bf1f8c --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadServiceHost.kt @@ -0,0 +1,213 @@ +package com.audiobookshelf.app.services + +import android.content.Context +import androidx.core.content.ContextCompat +import com.audiobookshelf.app.device.FolderScanner +import com.audiobookshelf.app.managers.DbManager +import com.audiobookshelf.app.managers.DownloadItemManager +import com.audiobookshelf.app.managers.IncompleteDownloadCleanup +import com.audiobookshelf.app.models.DownloadItem +import com.audiobookshelf.app.plugins.AbsLogger +import com.getcapacitor.JSObject +import java.util.Collections +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +/** Shared process owner used by the foreground service and the Capacitor bridge. */ +object DownloadServiceHost { + enum class ExistingDownloadResult { NOT_FOUND, ACTIVE, RETRIED, SERVICE_START_FAILED } + + data class NotificationStrings( + val preparing: String, + val downloadingFile: String, + val waitingForStorage: String, + val downloads: String, + val cancel: String + ) + + private var manager: DownloadItemManager? = null + private var bridgeEmitter: DownloadItemManager.DownloadEventEmitter = NoopEmitter + @Volatile private var service: DownloadService? = null + @Volatile private var bridgeReady = false + private val deferredCompletions = Collections.synchronizedList(mutableListOf()) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var restoreJob: Job? = null + + @Synchronized + fun ensure(context: Context): DownloadItemManager { + if (manager == null) { + val appContext = context.applicationContext + DbManager.initialize(appContext) + manager = DownloadItemManager(FolderScanner(appContext), appContext, ForwardingEmitter) + restoreJob = scope.launch { + IncompleteDownloadCleanup.cleanupExpired(appContext) + manager!!.restoreQueue() + onRestoreComplete(appContext) + } + } + return manager!! + } + + /** Attaches the frontend after restored queue items have been emitted. */ + @Synchronized + fun attachBridge(context: Context, emitter: DownloadItemManager.DownloadEventEmitter) { + bridgeReady = false + bridgeEmitter = emitter + ensure(context).setEventEmitter(ForwardingEmitter) + bridgeReady = true + val completions = synchronized(deferredCompletions) { + deferredCompletions.toList().also { deferredCompletions.clear() } + } + completions.forEach(bridgeEmitter::onDownloadItemComplete) + } + + @Synchronized + fun detachBridge() { + bridgeReady = false + bridgeEmitter = NoopEmitter + } + + fun enqueue(context: Context, item: DownloadItem, callback: (String?) -> Unit) { + val queue = ensure(context) + scope.launch { + restoreJob?.join() + queue.addDownloadItem(item) + if (startService(context)) callback(null) + else callback("Unable to start the Android download service") + } + } + + fun retryExisting( + context: Context, + downloadItemId: String, + callback: (ExistingDownloadResult) -> Unit + ) { + val queue = ensure(context) + scope.launch { + restoreJob?.join() + val existing = queue.downloadItemQueue.find { it.id == downloadItemId } + if (existing == null) { + callback(ExistingDownloadResult.NOT_FOUND) + } else if (!queue.retryDownloadItem(downloadItemId)) { + callback(ExistingDownloadResult.ACTIVE) + } else if (startService(context)) { + callback(ExistingDownloadResult.RETRIED) + } else { + callback(ExistingDownloadResult.SERVICE_START_FAILED) + } + } + } + + fun cancelAll(context: Context) { + val queue = ensure(context) + scope.launch { + restoreJob?.join() + queue.cancelAll() + } + } + + fun setNotificationStrings( + context: Context, + preparing: String, + downloadingFile: String, + waitingForStorage: String, + downloads: String, + cancel: String + ) { + context.getSharedPreferences(NOTIFICATION_PREFERENCES, Context.MODE_PRIVATE) + .edit() + .putString(KEY_PREPARING, preparing) + .putString(KEY_DOWNLOADING_FILE, downloadingFile) + .putString(KEY_WAITING_FOR_STORAGE, waitingForStorage) + .putString(KEY_DOWNLOADS, downloads) + .putString(KEY_CANCEL, cancel) + .apply() + } + + fun notificationStrings(context: Context): NotificationStrings { + val preferences = context.getSharedPreferences(NOTIFICATION_PREFERENCES, Context.MODE_PRIVATE) + return NotificationStrings( + preferences.getString(KEY_PREPARING, DEFAULT_PREPARING) ?: DEFAULT_PREPARING, + preferences.getString(KEY_DOWNLOADING_FILE, DEFAULT_DOWNLOADING_FILE) + ?: DEFAULT_DOWNLOADING_FILE, + preferences.getString(KEY_WAITING_FOR_STORAGE, DEFAULT_WAITING_FOR_STORAGE) + ?: DEFAULT_WAITING_FOR_STORAGE, + preferences.getString(KEY_DOWNLOADS, DEFAULT_DOWNLOADS) ?: DEFAULT_DOWNLOADS, + preferences.getString(KEY_CANCEL, DEFAULT_CANCEL) ?: DEFAULT_CANCEL) + } + + fun attachService(downloadService: DownloadService) { + synchronized(this) { service = downloadService } + startWork(downloadService) + } + + @Synchronized + fun detachService(downloadService: DownloadService) { + if (service === downloadService) service = null + } + + fun startWork(context: Context) { + val queue = ensure(context) + scope.launch { + restoreJob?.join() + queue.resumeWork() + } + } + + private fun onRestoreComplete(context: Context) { + val attachedService = synchronized(this) { service } + if (attachedService != null) { + manager?.resumeWork() + } else if (bridgeReady && manager?.hasWork() == true) { + startService(context) + } + } + + private fun startService(context: Context): Boolean { + return try { + ContextCompat.startForegroundService(context, DownloadService.intent(context)) + true + } catch (e: RuntimeException) { + AbsLogger.error(TAG, "Could not start download foreground service: ${e.message}") + false + } + } + + private object ForwardingEmitter : DownloadItemManager.DownloadEventEmitter { + override fun onDownloadItem(downloadItem: DownloadItem) { bridgeEmitter.onDownloadItem(downloadItem) } + override fun onDownloadItemPartUpdate(downloadItemPart: com.audiobookshelf.app.models.DownloadItemPart) { + if (bridgeReady) bridgeEmitter.onDownloadItemPartUpdate(downloadItemPart) + service?.onPartUpdate(downloadItemPart) + } + override fun onDownloadItemComplete(jsobj: JSObject) { + if (bridgeReady) bridgeEmitter.onDownloadItemComplete(jsobj) else deferredCompletions.add(jsobj) + } + override fun onQueueChanged(hasWork: Boolean) { + bridgeEmitter.onQueueChanged(hasWork) + service?.onQueueChanged(hasWork) + } + } + + private object NoopEmitter : DownloadItemManager.DownloadEventEmitter { + override fun onDownloadItem(downloadItem: DownloadItem) = Unit + override fun onDownloadItemPartUpdate(downloadItemPart: com.audiobookshelf.app.models.DownloadItemPart) = Unit + override fun onDownloadItemComplete(jsobj: JSObject) = Unit + override fun onQueueChanged(hasWork: Boolean) = Unit + } + + private const val NOTIFICATION_PREFERENCES = "download_notifications" + private const val KEY_PREPARING = "preparing" + private const val KEY_DOWNLOADING_FILE = "downloading_file" + private const val KEY_WAITING_FOR_STORAGE = "waiting_for_storage" + private const val KEY_DOWNLOADS = "downloads" + private const val KEY_CANCEL = "cancel" + private const val DEFAULT_PREPARING = "Preparing downloads" + private const val DEFAULT_DOWNLOADING_FILE = "Downloading {0}" + private const val DEFAULT_WAITING_FOR_STORAGE = "Waiting for available storage" + private const val DEFAULT_DOWNLOADS = "Downloads" + private const val DEFAULT_CANCEL = "Cancel" + private const val TAG = "DownloadServiceHost" +} diff --git a/android/app/src/main/res/drawable-anydpi-v24/exo_icon_downloaddone.xml b/android/app/src/main/res/drawable-anydpi-v24/exo_icon_downloaddone.xml new file mode 100644 index 0000000..0c3acf8 --- /dev/null +++ b/android/app/src/main/res/drawable-anydpi-v24/exo_icon_downloaddone.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/android/app/src/main/res/drawable-anydpi-v24/exo_icon_localaudio.xml b/android/app/src/main/res/drawable-anydpi-v24/exo_icon_localaudio.xml new file mode 100644 index 0000000..87354c3 --- /dev/null +++ b/android/app/src/main/res/drawable-anydpi-v24/exo_icon_localaudio.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/app/src/main/res/drawable-anydpi/exo_icon_fastforward.xml b/android/app/src/main/res/drawable-anydpi/exo_icon_fastforward.xml new file mode 100644 index 0000000..c9238b3 --- /dev/null +++ b/android/app/src/main/res/drawable-anydpi/exo_icon_fastforward.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/app/src/main/res/drawable-anydpi/exo_icon_rewind.xml b/android/app/src/main/res/drawable-anydpi/exo_icon_rewind.xml new file mode 100644 index 0000000..9e70127 --- /dev/null +++ b/android/app/src/main/res/drawable-anydpi/exo_icon_rewind.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/app/src/main/res/drawable-anydpi/ic_play_speed_0_5x.xml b/android/app/src/main/res/drawable-anydpi/ic_play_speed_0_5x.xml new file mode 100644 index 0000000..dc91ae5 --- /dev/null +++ b/android/app/src/main/res/drawable-anydpi/ic_play_speed_0_5x.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable-anydpi/ic_play_speed_1_0x.xml b/android/app/src/main/res/drawable-anydpi/ic_play_speed_1_0x.xml new file mode 100644 index 0000000..e0c261c --- /dev/null +++ b/android/app/src/main/res/drawable-anydpi/ic_play_speed_1_0x.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable-anydpi/ic_play_speed_1_2x.xml b/android/app/src/main/res/drawable-anydpi/ic_play_speed_1_2x.xml new file mode 100644 index 0000000..6590aab --- /dev/null +++ b/android/app/src/main/res/drawable-anydpi/ic_play_speed_1_2x.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable-anydpi/ic_play_speed_1_5x.xml b/android/app/src/main/res/drawable-anydpi/ic_play_speed_1_5x.xml new file mode 100644 index 0000000..1ed9793 --- /dev/null +++ b/android/app/src/main/res/drawable-anydpi/ic_play_speed_1_5x.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable-anydpi/ic_play_speed_2_0x.xml b/android/app/src/main/res/drawable-anydpi/ic_play_speed_2_0x.xml new file mode 100644 index 0000000..54ff98a --- /dev/null +++ b/android/app/src/main/res/drawable-anydpi/ic_play_speed_2_0x.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable-anydpi/ic_play_speed_3_0x.xml b/android/app/src/main/res/drawable-anydpi/ic_play_speed_3_0x.xml new file mode 100644 index 0000000..6d834aa --- /dev/null +++ b/android/app/src/main/res/drawable-anydpi/ic_play_speed_3_0x.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable-anydpi/icon_library_folder.xml b/android/app/src/main/res/drawable-anydpi/icon_library_folder.xml new file mode 100644 index 0000000..2eb0c3c --- /dev/null +++ b/android/app/src/main/res/drawable-anydpi/icon_library_folder.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/app/src/main/res/drawable-anydpi/md_clock_outline.xml b/android/app/src/main/res/drawable-anydpi/md_clock_outline.xml new file mode 100644 index 0000000..6fa1e71 --- /dev/null +++ b/android/app/src/main/res/drawable-anydpi/md_clock_outline.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/android/app/src/main/res/drawable-anydpi/skip_next_24.xml b/android/app/src/main/res/drawable-anydpi/skip_next_24.xml new file mode 100644 index 0000000..8c8a748 --- /dev/null +++ b/android/app/src/main/res/drawable-anydpi/skip_next_24.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/app/src/main/res/drawable-anydpi/skip_previous_24.xml b/android/app/src/main/res/drawable-anydpi/skip_previous_24.xml new file mode 100644 index 0000000..ee35a17 --- /dev/null +++ b/android/app/src/main/res/drawable-anydpi/skip_previous_24.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/app/src/main/res/drawable-hdpi/exo_icon_downloaddone.png b/android/app/src/main/res/drawable-hdpi/exo_icon_downloaddone.png new file mode 100644 index 0000000000000000000000000000000000000000..f7b92c79289af7ad6643fb2c4702f60063efa1d9 GIT binary patch literal 215 zcmeAS@N?(olHy`uVBq!ia0vp^Dj>|k1|%Oc%$NbB=6Sj}hE&{od;K79g8~n0fLnnG z5QQahJ}=-hd-yQ{O1;BDZx_-7(R!jzVo4|^uJFqVZdZBAU9 zByyRjxt-lpqB}PG*d(FgJ=z}c&M-Dq%e-dQb2Xj0Z2gL5i~qJI-88lod41{hf(r8w z%w1nC3z7^tMeLt7O=`U8@z0v|jnkC?<}QcnH&{<3t1qgO<>C!3n<}^cdAQS(Dxgal NJYD@<);T3K0RWK)QY-)f literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-hdpi/exo_icon_fastforward.png b/android/app/src/main/res/drawable-hdpi/exo_icon_fastforward.png new file mode 100644 index 0000000000000000000000000000000000000000..be2f1f7e57d33ed95d52cd1bba85a07ec585ec84 GIT binary patch literal 462 zcmV;<0WtoGP)xXwnh`Pr8;}iPgRnuoL9#*Fpx!_=SCP7ojAddI9{S(ufjcaH zNS17hB9Q<<8?;XQ z=KDd)1e!l&>aa7l|L{;=+@C(6KGgaMpok18>@*peZ+#4qTefqCn?o7vz|GPr;cc^D z<3u=nX;QbskNaVI^)W+~4)8i#F^`}t0VAKSeNAKvFj#9%-Pu~twO6y|PDG~0C;mFd z9KS&Vho6oB(YPkh)x-ij5N&L0z<2V2Fg9>i{A&Y!12XZj_}HS)QUCw|07*qoM6N<$ Eg5IIS=>Px# literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-hdpi/exo_icon_localaudio.png b/android/app/src/main/res/drawable-hdpi/exo_icon_localaudio.png new file mode 100644 index 0000000000000000000000000000000000000000..81972efc6afbfda0f35e75f15111d0dfc7c27a56 GIT binary patch literal 250 zcmeAS@N?(olHy`uVBq!ia0vp^Dj>|k1|%Oc%$NbB4tlychE&{2PLNp=^ zwbDjLTNxKF3xih+0@(#$E?~C!(85@F!6E&3AbYP>#udlKd{Q&{m~KsGIP$vjhwYVn z-vjJwWm&R97(dnqWd%f8KeO#L{Z$`j>DF*%!osc1QvW7%W;|WM-F(*ZKYMR;Lz)(k z7CY}QfhMaLj5FU^I%u=|DqMEt+@jNNH{pduzzVJlyZ$M$URe5mo`Z`=;}hl=H;*ug yGIMXRc*D5GMLa=5gP~L=gTpPc#Gq>)1H+m?-t8S1k1hv#hQZU-&t;ucLK6U!bzGDH literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-hdpi/exo_icon_rewind.png b/android/app/src/main/res/drawable-hdpi/exo_icon_rewind.png new file mode 100644 index 0000000000000000000000000000000000000000..893b7ff5bc73b8c64144feb68451c83945603f10 GIT binary patch literal 444 zcmV;t0Ymi>G z^#|qYYo>0`IXGKSzX?UDOARs*wK9WROD&nkQh}^^F@uMlke0)ilHfXmgWo{C1BfG_ zNngWYBqM-;xDOEboG%OkVZdZSV?iM11H{lxLnx3@ybL!1I=lBRT63zdo8Zm