From 66eaf0530c85e8342121acb377a914bc12453be3 Mon Sep 17 00:00:00 2001 From: haturatu Date: Sun, 3 Nov 2024 02:35:31 +0900 Subject: [PATCH] update --- app/build.gradle.kts | 155 +- app/proguard-rules.pro | 16 +- .../1.json | 0 .../10.json | 0 .../11.json | 0 .../12.json | 0 .../13.json | 0 .../14.json | 0 .../15.json | 0 .../16.json | 0 .../17.json | 0 .../18.json | 0 .../19.json | 0 .../2.json | 0 .../20.json | 0 .../21.json | 0 .../22.json | 0 .../23.json | 12 +- .../24.json | 678 ++++++++ .../25.json | 684 ++++++++ .../26.json | 733 +++++++++ .../27.json | 740 +++++++++ .../28.json | 752 +++++++++ .../29.json | 759 +++++++++ .../3.json | 0 .../30.json | 765 +++++++++ .../4.json | 0 .../5.json | 0 .../6.json | 0 .../7.json | 0 .../8.json | 0 .../9.json | 0 app/src/main/AndroidManifest.xml | 167 +- app/src/main/assets/logback.xml | 18 + app/src/main/assets/lottie/play_pause.json | 1 + .../kotlin/app/vimusic/android/Database.kt | 1130 +++++++++++++ .../app/vimusic/android/MainApplication.kt | 558 +++++++ .../vimusic/android}/models/Album.kt | 6 +- .../vimusic/android}/models/Artist.kt | 4 +- .../vimusic/android}/models/Event.kt | 2 +- .../vimusic/android/models/EventWithSong.kt | 16 + .../vimusic/android}/models/Format.kt | 2 +- .../vimusic/android}/models/Info.kt | 2 +- .../vimusic/android}/models/Lyrics.kt | 7 +- .../kotlin/app/vimusic/android/models/Mood.kt | 23 + .../vimusic/android/models/PipedSession.kt | 26 + .../vimusic/android}/models/Playlist.kt | 5 +- .../vimusic/android/models/PlaylistPreview.kt | 19 + .../android}/models/PlaylistWithSongs.kt | 2 +- .../android}/models/QueuedMediaItem.kt | 2 +- .../vimusic/android}/models/SearchQuery.kt | 2 +- .../kotlin/app/vimusic/android/models/Song.kt | 25 + .../vimusic/android}/models/SongAlbumMap.kt | 2 +- .../vimusic/android}/models/SongArtistMap.kt | 2 +- .../android}/models/SongPlaylistMap.kt | 2 +- .../android}/models/SongWithContentLength.kt | 2 +- .../android}/models/SortedSongPlaylistMap.kt | 2 +- .../app/vimusic/android/models/ui/UiMedia.kt | 20 + .../preferences/AppearancePreferences.kt | 44 + .../android/preferences/DataPreferences.kt | 55 + .../android/preferences/OldPreferences.kt | 22 + .../android/preferences/OrderPreferences.kt | 22 + .../android/preferences/PlayerPreferences.kt | 119 ++ .../android/preferences/UIStatePreferences.kt | 41 + .../vimusic/android/service/BitmapProvider.kt | 100 ++ .../android/service/PlaybackExceptions.kt | 37 + .../service/PlayerMediaBrowserService.kt | 371 +++++ .../vimusic/android/service/PlayerService.kt | 1435 +++++++++++++++++ .../android/service/PrecacheService.kt | 318 ++++ .../android/service/ServiceNotifications.kt | 181 +++ .../android/ui/components/BottomSheet.kt | 302 ++++ .../android/ui/components/FadingRow.kt | 51 + .../app/vimusic/android/ui/components/Menu.kt | 115 ++ .../android/ui/components/MusicBars.kt | 81 + .../vimusic/android/ui/components/SeekBar.kt | 488 ++++++ .../android}/ui/components/ShimmerHost.kt | 34 +- .../ui/components/themed/Attribution.kt | 100 ++ .../ui/components/themed/BigIconButton.kt | 46 + .../android/ui/components/themed/Dialog.kt | 424 +++++ .../ui/components/themed/DialogTextButton.kt | 29 +- .../android/ui/components/themed/Divider.kt | 72 + .../themed/FloatingActionsContainer.kt | 166 ++ .../android/ui/components/themed/Header.kt | 92 ++ .../ui/components/themed/IconButton.kt | 99 ++ .../themed/LayoutWithAdaptiveThumbnail.kt | 67 + .../ui/components/themed/MediaItemMenu.kt | 821 ++++++++++ .../android}/ui/components/themed/Menu.kt | 55 +- .../ui/components/themed/NavigationRail.kt | 348 ++++ .../ui/components/themed/PlaylistInfo.kt | 75 + .../ui/components/themed/PrimaryButton.kt | 16 +- .../ui/components/themed/ProgressIndicator.kt | 49 + .../ui/components/themed/ReorderHandle.kt | 28 + .../android}/ui/components/themed/Scaffold.kt | 42 +- .../ui/components/themed/SecondaryButton.kt | 8 +- .../components/themed/SecondaryTextButton.kt | 16 +- .../android/ui/components/themed/Slider.kt | 33 + .../android}/ui/components/themed/Switch.kt | 15 +- .../android/ui/components/themed/TextField.kt | 140 ++ .../ui/components/themed/TextPlaceholder.kt | 27 +- .../ui/components/themed/TextToggle.kt | 68 + .../app/vimusic/android/ui/items/AlbumItem.kt | 139 ++ .../vimusic/android/ui/items/ArtistItem.kt | 131 ++ .../vimusic/android/ui/items/ItemContainer.kt | 56 + .../vimusic/android/ui/items/PlaylistItem.kt | 256 +++ .../app/vimusic/android/ui/items/SongItem.kt | 295 ++++ .../app/vimusic/android/ui/items/VideoItem.kt | 144 ++ .../android/ui/modifiers/FadingEdge.kt | 46 + .../android/ui/modifiers/PinchToggle.kt | 73 + .../vimusic/android/ui/modifiers/Pressable.kt | 31 + .../vimusic/android/ui/modifiers/Swiping.kt | 246 +++ .../app/vimusic/android/ui/screens/Routes.kt | 121 ++ .../android/ui/screens/album/AlbumScreen.kt | 246 +++ .../android/ui/screens/album/AlbumSongs.kt | 150 ++ .../ui/screens/artist/ArtistLocalSongs.kt | 104 +- .../ui/screens/artist/ArtistOverview.kt | 300 ++++ .../android/ui/screens/artist/ArtistScreen.kt | 343 ++++ .../builtinplaylist/BuiltInPlaylistScreen.kt | 53 + .../builtinplaylist/BuiltInPlaylistSongs.kt | 239 +++ .../android}/ui/screens/home/HomeAlbums.kt | 92 +- .../android}/ui/screens/home/HomeArtists.kt | 104 +- .../android/ui/screens/home/HomeDiscovery.kt | 401 +++++ .../android/ui/screens/home/HomeLocalSongs.kt | 157 ++ .../android/ui/screens/home/HomePlaylists.kt | 270 ++++ .../ui/screens/home/HomeQuickPicks.kt} | 241 +-- .../android/ui/screens/home/HomeScreen.kt | 140 ++ .../android/ui/screens/home/HomeSongs.kt | 384 +++++ .../localplaylist/LocalPlaylistScreen.kt | 89 + .../localplaylist/LocalPlaylistSongs.kt | 359 +++++ .../android/ui/screens/mood/MoodList.kt | 188 +++ .../android/ui/screens/mood/MoodScreen.kt | 42 + .../android/ui/screens/mood/MoreAlbumsList.kt | 127 ++ .../ui/screens/mood/MoreAlbumsScreen.kt | 44 + .../android/ui/screens/mood/MoreMoodsList.kt | 141 ++ .../ui/screens/mood/MoreMoodsScreen.kt | 49 + .../pipedplaylist/PipedPlaylistScreen.kt | 55 + .../pipedplaylist/PipedPlaylistSongList.kt | 176 ++ .../screens/player/AnimatedPlayPauseIcon.kt | 127 ++ .../android/ui/screens/player/Controls.kt | 455 ++++++ .../android/ui/screens/player/Lyrics.kt | 711 ++++++++ .../android/ui/screens/player/LyricsDialog.kt | 132 ++ .../ui/screens/player/PlaybackError.kt | 83 + .../android/ui/screens/player/Player.kt | 621 +++++++ .../android/ui/screens/player/Queue.kt | 585 +++++++ .../ui/screens/player/StatsForNerds.kt | 225 +++ .../android/ui/screens/player/Thumbnail.kt | 263 +++ .../ui/screens/playlist/PlaylistScreen.kt | 50 + .../ui/screens/playlist/PlaylistSongList.kt | 261 +++ .../ui/screens/search/LocalSongSearch.kt | 87 +- .../android/ui/screens/search/OnlineSearch.kt | 319 ++++ .../ui/screens/search/SearchScreen.kt | 57 +- .../ui/screens/searchresult/ItemsPage.kt | 148 ++ .../searchresult/SearchResultScreen.kt | 278 ++++ .../android/ui/screens/settings/About.kt | 292 ++++ .../ui/screens/settings/AppearanceSettings.kt | 270 ++++ .../ui/screens/settings/CacheSettings.kt | 114 ++ .../ui/screens/settings/DatabaseSettings.kt | 176 ++ .../android/ui/screens/settings/LogsScreen.kt | 243 +++ .../ui/screens/settings/OtherSettings.kt | 338 ++++ .../ui/screens/settings/PlayerSettings.kt | 209 +++ .../ui/screens/settings/SettingsScreen.kt | 378 +++++ .../ui/screens/settings/SyncSettings.kt | 298 ++++ .../vimusic/android/utils/ActionReceiver.kt | 96 ++ .../app/vimusic/android/utils/BitmapState.kt | 25 + .../kotlin/app/vimusic/android/utils/Cache.kt | 39 + .../app/vimusic/android/utils/CacheState.kt | 162 ++ .../app/vimusic/android/utils/Context.kt | 132 ++ .../app/vimusic/android/utils/Coroutines.kt | 8 + .../app/vimusic/android/utils/Credentials.kt | 85 + .../app/vimusic/android/utils/Cursor.kt | 186 +++ .../vimusic/android}/utils/DrawScope.kt | 23 +- .../app/vimusic/android/utils/ExoPlayer.kt | 83 + .../android}/utils/InvincibleService.kt | 69 +- .../app/vimusic/android/utils/Language.kt | 39 + .../app/vimusic/android/utils/Logcat.kt | 164 ++ .../app/vimusic/android/utils/MonetCompat.kt | 32 + .../app/vimusic/android/utils/Nothing.kt | 133 ++ .../kotlin/app/vimusic/android/utils/PIP.kt | 216 +++ .../app/vimusic/android/utils/Player.kt | 121 ++ .../app/vimusic/android/utils/PlayerState.kt | 156 ++ .../vimusic/android/utils/ScrollingInfo.kt | 83 + .../vimusic/android/utils/SmoothScroll.kt} | 18 +- .../android/utils/SnapLayoutInfoProvider.kt | 88 + .../android/utils/SynchronizedLyrics.kt | 44 + .../app/vimusic/android/utils/SystemBars.kt | 28 + .../app/vimusic/android/utils/TextStyle.kt | 41 + .../vimusic/android}/utils/TimerJob.kt | 14 +- .../kotlin/app/vimusic/android/utils/Utils.kt | 219 +++ .../vimusic/android/utils/YouTubeRadio.kt} | 13 +- .../kotlin/it/vfsfitvnm/vimusic/Database.kt | 684 -------- .../it/vfsfitvnm/vimusic/MainActivity.kt | 491 ------ .../it/vfsfitvnm/vimusic/MainApplication.kt | 35 - .../vimusic/enums/BuiltInPlaylist.kt | 6 - .../vimusic/enums/CoilDiskCacheSize.kt | 18 - .../vimusic/enums/ColorPaletteMode.kt | 7 - .../vimusic/enums/ColorPaletteName.kt | 7 - .../enums/ExoPlayerDiskCacheMaxSize.kt | 22 - .../vimusic/enums/ThumbnailRoundness.kt | 25 - .../vimusic/models/PlaylistPreview.kt | 10 - .../it/vfsfitvnm/vimusic/models/Song.kt | 36 - .../vimusic/service/BitmapProvider.kt | 83 - .../vimusic/service/PlaybackExceptions.kt | 11 - .../service/PlayerMediaBrowserService.kt | 301 ---- .../vimusic/service/PlayerService.kt | 1008 ------------ .../vimusic/ui/components/BottomSheet.kt | 301 ---- .../vfsfitvnm/vimusic/ui/components/Menu.kt | 75 - .../vimusic/ui/components/MusicBars.kt | 149 -- .../vimusic/ui/components/SeekBar.kt | 143 -- .../vimusic/ui/components/themed/Dialog.kt | 334 ---- .../themed/FloatingActionsContainer.kt | 169 -- .../vimusic/ui/components/themed/Header.kt | 99 -- .../ui/components/themed/IconButton.kt | 62 - .../themed/LayoutWithAdaptiveThumbnail.kt | 70 - .../ui/components/themed/MediaItemMenu.kt | 735 --------- .../ui/components/themed/NavigationRail.kt | 170 -- .../vfsfitvnm/vimusic/ui/items/AlbumItem.kt | 155 -- .../vfsfitvnm/vimusic/ui/items/ArtistItem.kt | 145 -- .../vimusic/ui/items/ItemContainer.kt | 68 - .../vimusic/ui/items/PlaylistItem.kt | 274 ---- .../it/vfsfitvnm/vimusic/ui/items/SongItem.kt | 218 --- .../vfsfitvnm/vimusic/ui/items/VideoItem.kt | 149 -- .../it/vfsfitvnm/vimusic/ui/screens/Routes.kt | 47 - .../vimusic/ui/screens/album/AlbumScreen.kt | 236 --- .../vimusic/ui/screens/album/AlbumSongs.kt | 172 -- .../ui/screens/artist/ArtistOverview.kt | 349 ---- .../vimusic/ui/screens/artist/ArtistScreen.kt | 373 ----- .../builtinplaylist/BuiltInPlaylistScreen.kt | 54 - .../builtinplaylist/BuiltInPlaylistSongs.kt | 170 -- .../vimusic/ui/screens/home/HomePlaylists.kt | 210 --- .../vimusic/ui/screens/home/HomeScreen.kt | 162 -- .../vimusic/ui/screens/home/HomeSongs.kt | 194 --- .../localplaylist/LocalPlaylistScreen.kt | 45 - .../localplaylist/LocalPlaylistSongs.kt | 298 ---- .../vimusic/ui/screens/player/Controls.kt | 278 ---- .../vimusic/ui/screens/player/Lyrics.kt | 404 ----- .../ui/screens/player/PlaybackError.kt | 75 - .../vimusic/ui/screens/player/Player.kt | 413 ----- .../vimusic/ui/screens/player/Queue.kt | 388 ----- .../ui/screens/player/StatsForNerds.kt | 210 --- .../vimusic/ui/screens/player/Thumbnail.kt | 173 -- .../ui/screens/playlist/PlaylistScreen.kt | 41 - .../ui/screens/playlist/PlaylistSongList.kt | 246 --- .../vimusic/ui/screens/search/OnlineSearch.kt | 323 ---- .../ui/screens/searchresult/ItemsPage.kt | 130 -- .../searchresult/SearchResultScreen.kt | 322 ---- .../vimusic/ui/screens/settings/About.kt | 77 - .../ui/screens/settings/AppearanceSettings.kt | 131 -- .../ui/screens/settings/CacheSettings.kt | 119 -- .../ui/screens/settings/DatabaseSettings.kt | 153 -- .../ui/screens/settings/OtherSettings.kt | 182 --- .../ui/screens/settings/PlayerSettings.kt | 128 -- .../ui/screens/settings/SettingsScreen.kt | 252 --- .../vimusic/ui/styling/Appearance.kt | 39 - .../vimusic/ui/styling/ColorPalette.kt | 177 -- .../vimusic/ui/styling/Dimensions.kt | 38 - .../vimusic/ui/styling/Typography.kt | 90 -- .../vfsfitvnm/vimusic/utils/Configuration.kt | 11 - .../it/vfsfitvnm/vimusic/utils/Context.kt | 40 - .../it/vfsfitvnm/vimusic/utils/FadingEdge.kt | 24 - .../utils/LazyGridSnapLayoutInfoProvider.kt | 72 - .../it/vfsfitvnm/vimusic/utils/Player.kt | 99 -- .../it/vfsfitvnm/vimusic/utils/PlayerState.kt | 77 - .../it/vfsfitvnm/vimusic/utils/Preferences.kt | 115 -- .../it/vfsfitvnm/vimusic/utils/RingBuffer.kt | 11 - .../vfsfitvnm/vimusic/utils/ScrollingInfo.kt | 93 -- .../vimusic/utils/SynchronizedLyrics.kt | 30 - .../it/vfsfitvnm/vimusic/utils/TextStyle.kt | 35 - .../it/vfsfitvnm/vimusic/utils/Utils.kt | 120 -- app/src/main/res/drawable/add.xml | 29 +- app/src/main/res/drawable/airplane.xml | 7 +- app/src/main/res/drawable/alarm.xml | 19 +- app/src/main/res/drawable/alert_circle.xml | 7 +- app/src/main/res/drawable/app_icon.xml | 24 +- app/src/main/res/drawable/arrow_down.xml | 20 - app/src/main/res/drawable/arrow_forward.xml | 29 +- app/src/main/res/drawable/arrow_up.xml | 29 +- app/src/main/res/drawable/bookmark.xml | 7 +- .../main/res/drawable/bookmark_outline.xml | 15 +- app/src/main/res/drawable/bug_outline.xml | 30 + app/src/main/res/drawable/calendar.xml | 13 +- app/src/main/res/drawable/checkmark.xml | 13 - app/src/main/res/drawable/chevron_back.xml | 15 +- app/src/main/res/drawable/chevron_down.xml | 15 +- app/src/main/res/drawable/chevron_forward.xml | 15 +- app/src/main/res/drawable/chevron_up.xml | 19 +- app/src/main/res/drawable/close.xml | 7 +- app/src/main/res/drawable/color_palette.xml | 7 +- app/src/main/res/drawable/delete.xml | 5 + app/src/main/res/drawable/disc.xml | 13 +- app/src/main/res/drawable/download.xml | 13 +- .../main/res/drawable/ellipsis_horizontal.xml | 19 +- .../main/res/drawable/ellipsis_vertical.xml | 15 - app/src/main/res/drawable/enqueue.xml | 13 +- app/src/main/res/drawable/equalizer.xml | 19 +- app/src/main/res/drawable/expand.xml | 14 + app/src/main/res/drawable/explicit.xml | 11 + app/src/main/res/drawable/film.xml | 9 +- app/src/main/res/drawable/globe.xml | 57 +- app/src/main/res/drawable/heart.xml | 7 +- app/src/main/res/drawable/heart_dislike.xml | 15 - app/src/main/res/drawable/heart_outline.xml | 13 +- app/src/main/res/drawable/help_outline.xml | 17 + app/src/main/res/drawable/history.xml | 11 + .../res/drawable/ic_banner_foreground.xml | 110 +- .../res/drawable/ic_launcher_foreground.xml | 31 +- .../res/drawable/ic_launcher_monochrome.xml | 21 + app/src/main/res/drawable/infinite.xml | 25 +- app/src/main/res/drawable/information.xml | 33 +- .../drawable/information_circle_outline.xml | 31 + app/src/main/res/drawable/library.xml | 37 +- app/src/main/res/drawable/link.xml | 27 - app/src/main/res/drawable/medical.xml | 7 +- app/src/main/res/drawable/musical_notes.xml | 7 +- app/src/main/res/drawable/notifications.xml | 12 - app/src/main/res/drawable/pause.xml | 13 +- app/src/main/res/drawable/pencil.xml | 29 +- app/src/main/res/drawable/person.xml | 13 +- app/src/main/res/drawable/play.xml | 7 +- app/src/main/res/drawable/play_skip_back.xml | 7 +- .../main/res/drawable/play_skip_forward.xml | 7 +- app/src/main/res/drawable/playlist.xml | 81 +- app/src/main/res/drawable/radio.xml | 43 +- .../res/drawable/remove_circle_outline.xml | 11 + app/src/main/res/drawable/reorder.xml | 29 +- app/src/main/res/drawable/search.xml | 7 +- app/src/main/res/drawable/server.xml | 25 +- app/src/main/res/drawable/settings.xml | 11 + app/src/main/res/drawable/shapes.xml | 13 +- app/src/main/res/drawable/share_social.xml | 7 +- app/src/main/res/drawable/shuffle.xml | 71 +- app/src/main/res/drawable/sort.xml | 15 - app/src/main/res/drawable/sparkles.xml | 19 +- app/src/main/res/drawable/speed.xml | 11 + app/src/main/res/drawable/star.xml | 7 +- app/src/main/res/drawable/sync.xml | 43 +- app/src/main/res/drawable/text.xml | 13 +- app/src/main/res/drawable/time.xml | 7 +- app/src/main/res/drawable/trash.xml | 7 +- app/src/main/res/drawable/trending.xml | 29 +- app/src/main/res/drawable/trending_up.xml | 12 + app/src/main/res/drawable/volume_up.xml | 12 + app/src/main/res/drawable/warning_outline.xml | 26 + .../main/res/mipmap-anydpi-v26/ic_banner.xml | 2 +- .../res/mipmap-anydpi-v26/ic_launcher.xml | 2 +- .../mipmap-anydpi-v26/ic_launcher_round.xml | 2 +- app/src/main/res/mipmap-hdpi/ic_launcher.png | Bin 1746 -> 0 bytes app/src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 900 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 3742 -> 0 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2104 bytes app/src/main/res/mipmap-mdpi/ic_launcher.png | Bin 1243 -> 0 bytes app/src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 684 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 2392 -> 0 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1316 bytes app/src/main/res/mipmap-xhdpi/ic_banner.png | Bin 3784 -> 0 bytes app/src/main/res/mipmap-xhdpi/ic_banner.webp | Bin 0 -> 2332 bytes app/src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 2438 -> 0 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1142 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 5308 -> 0 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 2850 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 3752 -> 0 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 1600 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 8353 -> 0 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 4120 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 5323 -> 0 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 1968 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 12378 -> 0 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 5468 bytes app/src/main/res/resources.properties | 1 + app/src/main/res/values-b+sr+Cyrl/strings.xml | 423 +++++ app/src/main/res/values-b+sr+Latn/strings.xml | 423 +++++ app/src/main/res/values-de/strings.xml | 411 +++++ app/src/main/res/values-es/strings.xml | 422 +++++ app/src/main/res/values-et/strings.xml | 427 +++++ app/src/main/res/values-in/strings.xml | 387 +++++ app/src/main/res/values-ja/strings.xml | 409 +++++ app/src/main/res/values-night-v29/themes.xml | 15 + app/src/main/res/values-night/themes.xml | 6 +- app/src/main/res/values-nl/strings.xml | 433 +++++ app/src/main/res/values-ru/strings.xml | 430 +++++ app/src/main/res/values-tr/strings.xml | 407 +++++ app/src/main/res/values-zh/strings.xml | 427 +++++ app/src/main/res/values/colors.xml | 3 +- app/src/main/res/values/strings.xml | 433 +++++ app/src/main/res/values/themes.xml | 4 +- .../res/xml/allowed_media_browser_callers.xml | 27 + app/vendor/KetchumSDK_Community_20240307.jar | Bin 0 -> 25437 bytes build.gradle.kts | 30 - compose-persist/.gitignore | 1 - compose-persist/build.gradle.kts | 46 - .../it/vfsfitvnm/compose/persist/Persist.kt | 26 - .../vfsfitvnm/compose/persist/PersistMap.kt | 3 - .../compose/persist/PersistMapCleanup.kt | 19 - .../compose/persist/PersistMapOwner.kt | 5 - .../it/vfsfitvnm/compose/persist/Utils.kt | 16 - compose-reordering/.gitignore | 1 - compose-reordering/build.gradle.kts | 47 - .../src/main/AndroidManifest.xml | 2 - .../compose/reordering/AnimatablesPool.kt | 38 - .../reordering/AnimateItemPlacement.kt | 10 - .../reordering/ReorderingLazyColumn.kt | 40 - .../compose/reordering/ReorderingLazyList.kt | 293 ---- .../compose/reordering/ReorderingState.kt | 225 --- compose-routing/.gitignore | 1 - compose-routing/build.gradle.kts | 48 - compose-routing/src/main/AndroidManifest.xml | 2 - .../vfsfitvnm/compose/routing/GlobalRoute.kt | 14 - .../it/vfsfitvnm/compose/routing/Route.kt | 79 - .../vfsfitvnm/compose/routing/RouteHandler.kt | 98 -- .../compose/routing/RouteHandlerScope.kt | 35 - .../vfsfitvnm/compose/routing/Transitions.kt | 46 - compose/persist/build.gradle.kts | 34 + .../persist}/src/main/AndroidManifest.xml | 0 .../app/vimusic/compose/persist/Persist.kt | 33 + .../app/vimusic/compose/persist/PersistMap.kt | 16 + .../compose/persist/PersistMapCleanup.kt | 30 + compose/preferences/build.gradle.kts | 37 + .../preferences/src/main/AndroidManifest.xml | 4 + .../compose/preferences/PreferencesHolders.kt | 176 ++ compose/reordering/build.gradle.kts | 32 + .../reordering/src/main/AndroidManifest.xml | 2 + .../compose/reordering/AnimatablesPool.kt | 30 + .../reordering/AnimateItemPlacement.kt | 13 + .../compose/reordering/DraggedItem.kt | 34 +- .../vimusic}/compose/reordering/Reorder.kt | 22 +- .../compose/reordering/ReorderingState.kt | 222 +++ compose/routing/build.gradle.kts | 35 + compose/routing/src/main/AndroidManifest.xml | 2 + .../vimusic/compose/routing/GlobalRoute.kt | 57 + .../app/vimusic/compose/routing/RootRouter.kt | 195 +++ .../app/vimusic/compose/routing/Route.kt | 113 ++ .../compose/routing/RouteHandlerScope.kt | 18 + .../vimusic/compose/routing/Transitions.kt | 34 + core/data/build.gradle.kts | 32 + core/data/src/main/AndroidManifest.xml | 4 + .../vimusic/core/data}/enums/AlbumSortBy.kt | 2 +- .../vimusic/core/data}/enums/ArtistSortBy.kt | 2 +- .../core/data/enums/BuiltInPlaylist.kt | 8 + .../core/data/enums/CoilDiskCacheSize.kt | 13 + .../core/data/enums/ExoPlayerDiskCacheSize.kt | 17 + .../core/data}/enums/PlaylistSortBy.kt | 2 +- .../vimusic/core/data}/enums/SongSortBy.kt | 2 +- .../app/vimusic/core/data}/enums/SortOrder.kt | 2 +- .../app/vimusic/core/data/utils/Bytes.kt | 3 + .../vimusic/core/data/utils/CallValidator.kt | 141 ++ .../app/vimusic/core/data/utils/RingBuffer.kt | 54 + .../app/vimusic/core/data/utils/Versions.kt | 22 + core/material-compat/build.gradle.kts | 34 + .../android/material/color/DynamicColors.kt | 9 + core/ui/build.gradle.kts | 48 + core/ui/src/main/AndroidManifest.xml | 4 + .../kotlin/app/vimusic/core/ui/Appearance.kt | 121 ++ .../app/vimusic/core/ui/ColorPalette.kt | 269 +++ .../kotlin/app/vimusic/core/ui/Dimensions.kt | 46 + .../main/kotlin/app/vimusic/core/ui/Enums.kt | 34 + .../main/kotlin/app/vimusic/core/ui/Hsl.kt | 40 + .../main/kotlin/app/vimusic/core/ui/Ripple.kt | 49 + .../kotlin/app/vimusic/core/ui/Shimmer.kt | 29 + .../kotlin/app/vimusic/core/ui/Typography.kt | 180 +++ .../kotlin/app/vimusic/core/ui/utils/Audio.kt | 46 + .../app/vimusic/core/ui/utils/Bundle.kt | 294 ++++ .../vimusic/core/ui/utils/Configuration.kt | 59 + .../kotlin/app/vimusic/core/ui/utils/Dp.kt | 6 + .../app/vimusic/core/ui/utils/Pixels.kt | 29 + .../kotlin/app/vimusic/core/ui/utils/Saver.kt | 32 + core/ui/src/main/res/font/poppins_w300.ttf | Bin 0 -> 159848 bytes core/ui/src/main/res/font/poppins_w400.ttf | Bin 0 -> 158240 bytes core/ui/src/main/res/font/poppins_w500.ttf | Bin 0 -> 156520 bytes core/ui/src/main/res/font/poppins_w600.ttf | Bin 0 -> 155232 bytes core/ui/src/main/res/font/poppins_w700.ttf | Bin 0 -> 153944 bytes core/ui/src/main/res/values/font_certs.xml | 17 + detekt.yml | 126 ++ gradle.properties | 5 +- gradle/libs.versions.toml | 88 + gradle/wrapper/gradle-wrapper.jar | Bin 59203 -> 43453 bytes gradle/wrapper/gradle-wrapper.properties | 7 +- gradlew | 282 ++-- gradlew.bat | 35 +- innertube/.gitignore | 1 - .../it/vfsfitvnm/innertube/Innertube.kt | 203 --- .../it/vfsfitvnm/innertube/models/Context.kt | 53 - .../innertube/models/GridRenderer.kt | 13 - .../models/MusicTwoRowItemRenderer.kt | 11 - .../innertube/models/NavigationEndpoint.kt | 203 --- .../it/vfsfitvnm/innertube/models/Runs.kt | 31 - .../vfsfitvnm/innertube/models/Thumbnail.kt | 21 - .../innertube/models/ThumbnailRenderer.kt | 22 - .../innertube/models/TwoColResults.kt | 14 - .../models/bodies/ContinuationBody.kt | 10 - .../innertube/models/bodies/PlayerBody.kt | 11 - .../vfsfitvnm/innertube/requests/AlbumPage.kt | 36 - .../innertube/requests/ArtistPage.kt | 106 -- .../vfsfitvnm/innertube/requests/ItemsPage.kt | 97 -- .../it/vfsfitvnm/innertube/requests/Player.kt | 67 - .../innertube/requests/PlaylistPage.kt | 108 -- .../it/vfsfitvnm/innertube/requests/Queue.kt | 29 - .../innertube/requests/SearchPage.kt | 62 - .../innertube/requests/SearchSuggestions.kt | 29 - .../utils/FromMusicTwoRowItemRenderer.kt | 76 - .../utils/FromPlaylistPanelVideoRenderer.kt | 35 - .../it/vfsfitvnm/innertube/utils/Utils.kt | 50 - innertube/src/test/kotlin/Test.kt | 10 - ktor-client-brotli/.gitignore | 1 - ktor-client-brotli/build.gradle.kts | 14 +- .../compression/{brotli.kt => Brotli.kt} | 2 +- .../plugins/compression/BrotliEncoder.kt | 27 +- kugou/.gitignore | 1 - .../main/kotlin/it/vfsfitvnm/kugou/KuGou.kt | 213 --- .../main/kotlin/it/vfsfitvnm/kugou/Result.kt | 10 - kugou/src/test/kotlin/Test.kt | 11 - providers/common/build.gradle.kts | 19 + .../app/vimusic/providers/utils/Coroutines.kt | 6 + .../vimusic/providers/utils/Serializers.kt | 35 + {kugou => providers/github}/build.gradle.kts | 18 +- .../app/vimusic/providers/github/GitHub.kt | 55 + .../providers/github/models/Reactions.kt | 26 + .../providers/github/models/Release.kt | 71 + .../providers/github/models/SimpleUser.kt | 43 + .../providers/github/requests/Releases.kt | 18 + providers/innertube/build.gradle.kts | 32 + .../vimusic/providers/innertube/Innertube.kt | 358 ++++ .../innertube/JavaScriptChallenge.kt | 27 + .../providers/innertube/models/Badge.kt | 18 + .../innertube/models/BrowseResponse.kt | 20 +- .../innertube/models/ButtonRenderer.kt | 7 +- .../providers/innertube/models/Context.kt | 171 ++ .../innertube/models/Continuation.kt | 2 +- .../innertube/models/ContinuationResponse.kt | 6 +- .../innertube/models/GetQueueResponse.kt | 6 +- .../innertube/models/GridRenderer.kt | 25 + .../models/MusicCarouselShelfRenderer.kt | 7 +- .../models/MusicNavigationButtonRenderer.kt | 29 + .../models/MusicResponsiveListItemRenderer.kt | 3 +- .../innertube/models/MusicShelfRenderer.kt | 19 +- .../models/MusicTwoRowItemRenderer.kt | 26 + .../innertube/models/NavigationEndpoint.kt | 77 + .../innertube/models/NextResponse.kt | 6 +- .../innertube/models/PlayerResponse.kt | 21 +- .../models/PlaylistPanelVideoRenderer.kt | 3 +- .../providers/innertube/models/Runs.kt | 52 + .../innertube/models/SearchResponse.kt | 5 +- .../models/SearchSuggestionsResponse.kt | 4 +- .../innertube/models/SectionListRenderer.kt | 19 +- .../providers}/innertube/models/Tabs.kt | 12 +- .../providers/innertube/models/Thumbnail.kt | 16 + .../innertube/models/ThumbnailRenderer.kt | 42 + .../innertube/models/bodies/BrowseBody.kt | 4 +- .../models/bodies/ContinuationBody.kt | 10 + .../innertube/models/bodies/NextBody.kt | 4 +- .../innertube/models/bodies/PlayerBody.kt | 25 + .../innertube/models/bodies/QueueBody.kt | 6 +- .../innertube/models/bodies/SearchBody.kt | 4 +- .../models/bodies/SearchSuggestionsBody.kt | 4 +- .../providers/innertube/requests/AlbumPage.kt | 35 + .../innertube/requests/ArtistPage.kt | 134 ++ .../providers/innertube/requests/Browse.kt | 109 ++ .../innertube/requests/DiscoverPage.kt | 108 ++ .../providers/innertube/requests/ItemsPage.kt | 93 ++ .../providers}/innertube/requests/Lyrics.kt | 27 +- .../providers}/innertube/requests/NextPage.kt | 54 +- .../providers/innertube/requests/Player.kt | 101 ++ .../innertube/requests/PlaylistPage.kt | 179 ++ .../providers/innertube/requests/Queue.kt | 29 + .../innertube/requests/RelatedPage.kt | 52 +- .../innertube/requests/SearchPage.kt | 69 + .../innertube/requests/SearchSuggestions.kt | 32 + .../FromMusicResponsiveListItemRenderer.kt | 26 +- .../utils/FromMusicShelfRendererContent.kt | 75 +- .../utils/FromMusicTwoRowItemRenderer.kt | 71 + .../utils/FromPlaylistPanelVideoRenderer.kt | 35 + .../providers/innertube/utils/Utils.kt | 38 + providers/kugou/build.gradle.kts | 25 + .../app/vimusic/providers/kugou/KuGou.kt | 183 +++ .../kugou/models/DownloadLyricsResponse.kt | 2 +- .../kugou/models/SearchLyricsResponse.kt | 2 +- .../kugou/models/SearchSongResponse.kt | 4 +- .../lrclib}/build.gradle.kts | 21 +- .../app/vimusic/providers/lrclib/LrcLib.kt | 218 +++ .../vimusic/providers/lrclib/models/Track.kt | 23 + providers/piped/build.gradle.kts | 26 + .../app/vimusic/providers/piped/Piped.kt | 169 ++ .../providers/piped/models/Instance.kt | 30 + .../providers/piped/models/PlaylistPreview.kt | 60 + .../providers/piped/models/Serializers.kt | 43 + .../vimusic/providers/piped/models/Session.kt | 13 + providers/sponsorblock/build.gradle.kts | 24 + .../providers/sponsorblock/SponsorBlock.kt | 34 + .../providers/sponsorblock/models/Action.kt | 22 + .../providers/sponsorblock/models/Category.kt | 34 + .../providers/sponsorblock/models/Segment.kt | 19 + .../sponsorblock/requests/Segments.kt | 26 + providers/translate/build.gradle.kts | 30 + .../vimusic/providers/translate/Translate.kt | 125 ++ .../providers/translate/models/Language.kt | 122 ++ .../providers/translate/requests/Translate.kt | 34 + settings.gradle.kts | 79 +- 594 files changed, 41330 insertions(+), 17829 deletions(-) rename app/schemas/{it.vfsfitvnm.vimusic.DatabaseInitializer => app.vimusic.android.DatabaseInitializer}/1.json (100%) rename app/schemas/{it.vfsfitvnm.vimusic.DatabaseInitializer => app.vimusic.android.DatabaseInitializer}/10.json (100%) rename app/schemas/{it.vfsfitvnm.vimusic.DatabaseInitializer => app.vimusic.android.DatabaseInitializer}/11.json (100%) rename app/schemas/{it.vfsfitvnm.vimusic.DatabaseInitializer => app.vimusic.android.DatabaseInitializer}/12.json (100%) rename app/schemas/{it.vfsfitvnm.vimusic.DatabaseInitializer => app.vimusic.android.DatabaseInitializer}/13.json (100%) rename app/schemas/{it.vfsfitvnm.vimusic.DatabaseInitializer => app.vimusic.android.DatabaseInitializer}/14.json (100%) rename app/schemas/{it.vfsfitvnm.vimusic.DatabaseInitializer => app.vimusic.android.DatabaseInitializer}/15.json (100%) rename app/schemas/{it.vfsfitvnm.vimusic.DatabaseInitializer => app.vimusic.android.DatabaseInitializer}/16.json (100%) rename app/schemas/{it.vfsfitvnm.vimusic.DatabaseInitializer => app.vimusic.android.DatabaseInitializer}/17.json (100%) rename app/schemas/{it.vfsfitvnm.vimusic.DatabaseInitializer => app.vimusic.android.DatabaseInitializer}/18.json (100%) rename app/schemas/{it.vfsfitvnm.vimusic.DatabaseInitializer => app.vimusic.android.DatabaseInitializer}/19.json (100%) rename app/schemas/{it.vfsfitvnm.vimusic.DatabaseInitializer => app.vimusic.android.DatabaseInitializer}/2.json (100%) rename app/schemas/{it.vfsfitvnm.vimusic.DatabaseInitializer => app.vimusic.android.DatabaseInitializer}/20.json (100%) rename app/schemas/{it.vfsfitvnm.vimusic.DatabaseInitializer => app.vimusic.android.DatabaseInitializer}/21.json (100%) rename app/schemas/{it.vfsfitvnm.vimusic.DatabaseInitializer => app.vimusic.android.DatabaseInitializer}/22.json (100%) rename app/schemas/{it.vfsfitvnm.vimusic.DatabaseInitializer => app.vimusic.android.DatabaseInitializer}/23.json (98%) create mode 100644 app/schemas/app.vimusic.android.DatabaseInitializer/24.json create mode 100644 app/schemas/app.vimusic.android.DatabaseInitializer/25.json create mode 100644 app/schemas/app.vimusic.android.DatabaseInitializer/26.json create mode 100644 app/schemas/app.vimusic.android.DatabaseInitializer/27.json create mode 100644 app/schemas/app.vimusic.android.DatabaseInitializer/28.json create mode 100644 app/schemas/app.vimusic.android.DatabaseInitializer/29.json rename app/schemas/{it.vfsfitvnm.vimusic.DatabaseInitializer => app.vimusic.android.DatabaseInitializer}/3.json (100%) create mode 100644 app/schemas/app.vimusic.android.DatabaseInitializer/30.json rename app/schemas/{it.vfsfitvnm.vimusic.DatabaseInitializer => app.vimusic.android.DatabaseInitializer}/4.json (100%) rename app/schemas/{it.vfsfitvnm.vimusic.DatabaseInitializer => app.vimusic.android.DatabaseInitializer}/5.json (100%) rename app/schemas/{it.vfsfitvnm.vimusic.DatabaseInitializer => app.vimusic.android.DatabaseInitializer}/6.json (100%) rename app/schemas/{it.vfsfitvnm.vimusic.DatabaseInitializer => app.vimusic.android.DatabaseInitializer}/7.json (100%) rename app/schemas/{it.vfsfitvnm.vimusic.DatabaseInitializer => app.vimusic.android.DatabaseInitializer}/8.json (100%) rename app/schemas/{it.vfsfitvnm.vimusic.DatabaseInitializer => app.vimusic.android.DatabaseInitializer}/9.json (100%) create mode 100644 app/src/main/assets/logback.xml create mode 100644 app/src/main/assets/lottie/play_pause.json create mode 100644 app/src/main/kotlin/app/vimusic/android/Database.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/MainApplication.kt rename app/src/main/kotlin/{it/vfsfitvnm/vimusic => app/vimusic/android}/models/Album.kt (72%) rename app/src/main/kotlin/{it/vfsfitvnm/vimusic => app/vimusic/android}/models/Artist.kt (79%) rename app/src/main/kotlin/{it/vfsfitvnm/vimusic => app/vimusic/android}/models/Event.kt (94%) create mode 100644 app/src/main/kotlin/app/vimusic/android/models/EventWithSong.kt rename app/src/main/kotlin/{it/vfsfitvnm/vimusic => app/vimusic/android}/models/Format.kt (94%) rename app/src/main/kotlin/{it/vfsfitvnm/vimusic => app/vimusic/android}/models/Info.kt (63%) rename app/src/main/kotlin/{it/vfsfitvnm/vimusic => app/vimusic/android}/models/Lyrics.kt (76%) create mode 100644 app/src/main/kotlin/app/vimusic/android/models/Mood.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/models/PipedSession.kt rename app/src/main/kotlin/{it/vfsfitvnm/vimusic => app/vimusic/android}/models/Playlist.kt (68%) create mode 100644 app/src/main/kotlin/app/vimusic/android/models/PlaylistPreview.kt rename app/src/main/kotlin/{it/vfsfitvnm/vimusic => app/vimusic/android}/models/PlaylistWithSongs.kt (93%) rename app/src/main/kotlin/{it/vfsfitvnm/vimusic => app/vimusic/android}/models/QueuedMediaItem.kt (91%) rename app/src/main/kotlin/{it/vfsfitvnm/vimusic => app/vimusic/android}/models/SearchQuery.kt (90%) create mode 100644 app/src/main/kotlin/app/vimusic/android/models/Song.kt rename app/src/main/kotlin/{it/vfsfitvnm/vimusic => app/vimusic/android}/models/SongAlbumMap.kt (95%) rename app/src/main/kotlin/{it/vfsfitvnm/vimusic => app/vimusic/android}/models/SongArtistMap.kt (95%) rename app/src/main/kotlin/{it/vfsfitvnm/vimusic => app/vimusic/android}/models/SongPlaylistMap.kt (95%) rename app/src/main/kotlin/{it/vfsfitvnm/vimusic => app/vimusic/android}/models/SongWithContentLength.kt (83%) rename app/src/main/kotlin/{it/vfsfitvnm/vimusic => app/vimusic/android}/models/SortedSongPlaylistMap.kt (90%) create mode 100644 app/src/main/kotlin/app/vimusic/android/models/ui/UiMedia.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/preferences/AppearancePreferences.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/preferences/DataPreferences.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/preferences/OldPreferences.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/preferences/OrderPreferences.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/preferences/PlayerPreferences.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/preferences/UIStatePreferences.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/service/BitmapProvider.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/service/PlaybackExceptions.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/service/PlayerMediaBrowserService.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/service/PlayerService.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/service/PrecacheService.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/service/ServiceNotifications.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/components/BottomSheet.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/components/FadingRow.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/components/Menu.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/components/MusicBars.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/components/SeekBar.kt rename app/src/main/kotlin/{it/vfsfitvnm/vimusic => app/vimusic/android}/ui/components/ShimmerHost.kt (58%) create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/components/themed/Attribution.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/components/themed/BigIconButton.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/components/themed/Dialog.kt rename app/src/main/kotlin/{it/vfsfitvnm/vimusic => app/vimusic/android}/ui/components/themed/DialogTextButton.kt (61%) create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/components/themed/Divider.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/components/themed/FloatingActionsContainer.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/components/themed/Header.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/components/themed/IconButton.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/components/themed/LayoutWithAdaptiveThumbnail.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/components/themed/MediaItemMenu.kt rename app/src/main/kotlin/{it/vfsfitvnm/vimusic => app/vimusic/android}/ui/components/themed/Menu.kt (67%) create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/components/themed/NavigationRail.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/components/themed/PlaylistInfo.kt rename app/src/main/kotlin/{it/vfsfitvnm/vimusic => app/vimusic/android}/ui/components/themed/PrimaryButton.kt (75%) create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/components/themed/ProgressIndicator.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/components/themed/ReorderHandle.kt rename app/src/main/kotlin/{it/vfsfitvnm/vimusic => app/vimusic/android}/ui/components/themed/Scaffold.kt (54%) rename app/src/main/kotlin/{it/vfsfitvnm/vimusic => app/vimusic/android}/ui/components/themed/SecondaryButton.kt (87%) rename app/src/main/kotlin/{it/vfsfitvnm/vimusic => app/vimusic/android}/ui/components/themed/SecondaryTextButton.kt (67%) create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/components/themed/Slider.kt rename app/src/main/kotlin/{it/vfsfitvnm/vimusic => app/vimusic/android}/ui/components/themed/Switch.kt (85%) create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/components/themed/TextField.kt rename app/src/main/kotlin/{it/vfsfitvnm/vimusic => app/vimusic/android}/ui/components/themed/TextPlaceholder.kt (53%) create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/components/themed/TextToggle.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/items/AlbumItem.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/items/ArtistItem.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/items/ItemContainer.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/items/PlaylistItem.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/items/SongItem.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/items/VideoItem.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/modifiers/FadingEdge.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/modifiers/PinchToggle.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/modifiers/Pressable.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/modifiers/Swiping.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/Routes.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/album/AlbumScreen.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/album/AlbumSongs.kt rename app/src/main/kotlin/{it/vfsfitvnm/vimusic => app/vimusic/android}/ui/screens/artist/ArtistLocalSongs.kt (55%) create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/artist/ArtistOverview.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/artist/ArtistScreen.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/builtinplaylist/BuiltInPlaylistScreen.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/builtinplaylist/BuiltInPlaylistSongs.kt rename app/src/main/kotlin/{it/vfsfitvnm/vimusic => app/vimusic/android}/ui/screens/home/HomeAlbums.kt (50%) rename app/src/main/kotlin/{it/vfsfitvnm/vimusic => app/vimusic/android}/ui/screens/home/HomeArtists.kt (50%) create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/home/HomeDiscovery.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/home/HomeLocalSongs.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/home/HomePlaylists.kt rename app/src/main/kotlin/{it/vfsfitvnm/vimusic/ui/screens/home/QuickPicks.kt => app/vimusic/android/ui/screens/home/HomeQuickPicks.kt} (64%) create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/home/HomeScreen.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/home/HomeSongs.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/localplaylist/LocalPlaylistScreen.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/localplaylist/LocalPlaylistSongs.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/mood/MoodList.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/mood/MoodScreen.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/mood/MoreAlbumsList.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/mood/MoreAlbumsScreen.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/mood/MoreMoodsList.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/mood/MoreMoodsScreen.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/pipedplaylist/PipedPlaylistScreen.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/pipedplaylist/PipedPlaylistSongList.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/player/AnimatedPlayPauseIcon.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/player/Controls.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/player/Lyrics.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/player/LyricsDialog.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/player/PlaybackError.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/player/Player.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/player/Queue.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/player/StatsForNerds.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/player/Thumbnail.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/playlist/PlaylistScreen.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/playlist/PlaylistSongList.kt rename app/src/main/kotlin/{it/vfsfitvnm/vimusic => app/vimusic/android}/ui/screens/search/LocalSongSearch.kt (64%) create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/search/OnlineSearch.kt rename app/src/main/kotlin/{it/vfsfitvnm/vimusic => app/vimusic/android}/ui/screens/search/SearchScreen.kt (66%) create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/searchresult/ItemsPage.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/searchresult/SearchResultScreen.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/settings/About.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/settings/AppearanceSettings.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/settings/CacheSettings.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/settings/DatabaseSettings.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/settings/LogsScreen.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/settings/OtherSettings.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/settings/PlayerSettings.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/settings/SettingsScreen.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/ui/screens/settings/SyncSettings.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/utils/ActionReceiver.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/utils/BitmapState.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/utils/Cache.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/utils/CacheState.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/utils/Context.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/utils/Coroutines.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/utils/Credentials.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/utils/Cursor.kt rename app/src/main/kotlin/{it/vfsfitvnm/vimusic => app/vimusic/android}/utils/DrawScope.kt (55%) create mode 100644 app/src/main/kotlin/app/vimusic/android/utils/ExoPlayer.kt rename app/src/main/kotlin/{it/vfsfitvnm/vimusic => app/vimusic/android}/utils/InvincibleService.kt (64%) create mode 100644 app/src/main/kotlin/app/vimusic/android/utils/Language.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/utils/Logcat.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/utils/MonetCompat.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/utils/Nothing.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/utils/PIP.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/utils/Player.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/utils/PlayerState.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/utils/ScrollingInfo.kt rename app/src/main/kotlin/{it/vfsfitvnm/vimusic/utils/SmoothScrollToTop.kt => app/vimusic/android/utils/SmoothScroll.kt} (61%) create mode 100644 app/src/main/kotlin/app/vimusic/android/utils/SnapLayoutInfoProvider.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/utils/SynchronizedLyrics.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/utils/SystemBars.kt create mode 100644 app/src/main/kotlin/app/vimusic/android/utils/TextStyle.kt rename app/src/main/kotlin/{it/vfsfitvnm/vimusic => app/vimusic/android}/utils/TimerJob.kt (70%) create mode 100644 app/src/main/kotlin/app/vimusic/android/utils/Utils.kt rename app/src/main/kotlin/{it/vfsfitvnm/vimusic/utils/YoutubeRadio.kt => app/vimusic/android/utils/YouTubeRadio.kt} (83%) delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/MainActivity.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/MainApplication.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/BuiltInPlaylist.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/CoilDiskCacheSize.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/ColorPaletteMode.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/ColorPaletteName.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/ExoPlayerDiskCacheMaxSize.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/ThumbnailRoundness.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/models/PlaylistPreview.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Song.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/service/BitmapProvider.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/service/PlaybackExceptions.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/service/PlayerMediaBrowserService.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/service/PlayerService.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/BottomSheet.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/Menu.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/MusicBars.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/SeekBar.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Dialog.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/FloatingActionsContainer.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Header.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/IconButton.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/LayoutWithAdaptiveThumbnail.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/MediaItemMenu.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/NavigationRail.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/AlbumItem.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/ArtistItem.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/ItemContainer.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/PlaylistItem.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/SongItem.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/VideoItem.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/Routes.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/album/AlbumScreen.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/album/AlbumSongs.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistOverview.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistScreen.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/builtinplaylist/BuiltInPlaylistScreen.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/builtinplaylist/BuiltInPlaylistSongs.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomePlaylists.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeScreen.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeSongs.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/localplaylist/LocalPlaylistScreen.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/localplaylist/LocalPlaylistSongs.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Controls.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Lyrics.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/PlaybackError.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Player.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Queue.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/StatsForNerds.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Thumbnail.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/playlist/PlaylistScreen.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/playlist/PlaylistSongList.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/OnlineSearch.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/ItemsPage.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/SearchResultScreen.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/About.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/AppearanceSettings.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/CacheSettings.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/DatabaseSettings.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/OtherSettings.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/PlayerSettings.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/SettingsScreen.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/Appearance.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/ColorPalette.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/Dimensions.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/Typography.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Configuration.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Context.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/FadingEdge.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/LazyGridSnapLayoutInfoProvider.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Player.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/PlayerState.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Preferences.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/RingBuffer.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/ScrollingInfo.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/SynchronizedLyrics.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/TextStyle.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Utils.kt delete mode 100644 app/src/main/res/drawable/arrow_down.xml create mode 100644 app/src/main/res/drawable/bug_outline.xml delete mode 100644 app/src/main/res/drawable/checkmark.xml create mode 100644 app/src/main/res/drawable/delete.xml delete mode 100644 app/src/main/res/drawable/ellipsis_vertical.xml create mode 100644 app/src/main/res/drawable/expand.xml create mode 100644 app/src/main/res/drawable/explicit.xml delete mode 100644 app/src/main/res/drawable/heart_dislike.xml create mode 100644 app/src/main/res/drawable/help_outline.xml create mode 100644 app/src/main/res/drawable/history.xml create mode 100644 app/src/main/res/drawable/ic_launcher_monochrome.xml create mode 100644 app/src/main/res/drawable/information_circle_outline.xml delete mode 100644 app/src/main/res/drawable/link.xml delete mode 100644 app/src/main/res/drawable/notifications.xml create mode 100644 app/src/main/res/drawable/remove_circle_outline.xml create mode 100644 app/src/main/res/drawable/settings.xml delete mode 100644 app/src/main/res/drawable/sort.xml create mode 100644 app/src/main/res/drawable/speed.xml create mode 100644 app/src/main/res/drawable/trending_up.xml create mode 100644 app/src/main/res/drawable/volume_up.xml create mode 100644 app/src/main/res/drawable/warning_outline.xml delete mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.webp delete mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.webp delete mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.webp delete mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.webp delete mode 100644 app/src/main/res/mipmap-xhdpi/ic_banner.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_banner.webp delete mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.webp delete mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp delete mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.webp delete mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp delete mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp delete mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/resources.properties create mode 100644 app/src/main/res/values-b+sr+Cyrl/strings.xml create mode 100644 app/src/main/res/values-b+sr+Latn/strings.xml create mode 100644 app/src/main/res/values-de/strings.xml create mode 100644 app/src/main/res/values-es/strings.xml create mode 100644 app/src/main/res/values-et/strings.xml create mode 100644 app/src/main/res/values-in/strings.xml create mode 100644 app/src/main/res/values-ja/strings.xml create mode 100644 app/src/main/res/values-night-v29/themes.xml create mode 100644 app/src/main/res/values-nl/strings.xml create mode 100644 app/src/main/res/values-ru/strings.xml create mode 100644 app/src/main/res/values-tr/strings.xml create mode 100644 app/src/main/res/values-zh/strings.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/xml/allowed_media_browser_callers.xml create mode 100644 app/vendor/KetchumSDK_Community_20240307.jar delete mode 100644 build.gradle.kts delete mode 100644 compose-persist/.gitignore delete mode 100644 compose-persist/build.gradle.kts delete mode 100644 compose-persist/src/main/kotlin/it/vfsfitvnm/compose/persist/Persist.kt delete mode 100644 compose-persist/src/main/kotlin/it/vfsfitvnm/compose/persist/PersistMap.kt delete mode 100644 compose-persist/src/main/kotlin/it/vfsfitvnm/compose/persist/PersistMapCleanup.kt delete mode 100644 compose-persist/src/main/kotlin/it/vfsfitvnm/compose/persist/PersistMapOwner.kt delete mode 100644 compose-persist/src/main/kotlin/it/vfsfitvnm/compose/persist/Utils.kt delete mode 100644 compose-reordering/.gitignore delete mode 100644 compose-reordering/build.gradle.kts delete mode 100644 compose-reordering/src/main/AndroidManifest.xml delete mode 100644 compose-reordering/src/main/kotlin/it/vfsfitvnm/compose/reordering/AnimatablesPool.kt delete mode 100644 compose-reordering/src/main/kotlin/it/vfsfitvnm/compose/reordering/AnimateItemPlacement.kt delete mode 100644 compose-reordering/src/main/kotlin/it/vfsfitvnm/compose/reordering/ReorderingLazyColumn.kt delete mode 100644 compose-reordering/src/main/kotlin/it/vfsfitvnm/compose/reordering/ReorderingLazyList.kt delete mode 100644 compose-reordering/src/main/kotlin/it/vfsfitvnm/compose/reordering/ReorderingState.kt delete mode 100644 compose-routing/.gitignore delete mode 100644 compose-routing/build.gradle.kts delete mode 100644 compose-routing/src/main/AndroidManifest.xml delete mode 100644 compose-routing/src/main/kotlin/it/vfsfitvnm/compose/routing/GlobalRoute.kt delete mode 100644 compose-routing/src/main/kotlin/it/vfsfitvnm/compose/routing/Route.kt delete mode 100644 compose-routing/src/main/kotlin/it/vfsfitvnm/compose/routing/RouteHandler.kt delete mode 100644 compose-routing/src/main/kotlin/it/vfsfitvnm/compose/routing/RouteHandlerScope.kt delete mode 100644 compose-routing/src/main/kotlin/it/vfsfitvnm/compose/routing/Transitions.kt create mode 100644 compose/persist/build.gradle.kts rename {compose-persist => compose/persist}/src/main/AndroidManifest.xml (100%) create mode 100644 compose/persist/src/main/kotlin/app/vimusic/compose/persist/Persist.kt create mode 100644 compose/persist/src/main/kotlin/app/vimusic/compose/persist/PersistMap.kt create mode 100644 compose/persist/src/main/kotlin/app/vimusic/compose/persist/PersistMapCleanup.kt create mode 100644 compose/preferences/build.gradle.kts create mode 100644 compose/preferences/src/main/AndroidManifest.xml create mode 100644 compose/preferences/src/main/kotlin/app/vimusic/compose/preferences/PreferencesHolders.kt create mode 100644 compose/reordering/build.gradle.kts create mode 100644 compose/reordering/src/main/AndroidManifest.xml create mode 100644 compose/reordering/src/main/kotlin/app/vimusic/compose/reordering/AnimatablesPool.kt create mode 100644 compose/reordering/src/main/kotlin/app/vimusic/compose/reordering/AnimateItemPlacement.kt rename {compose-reordering/src/main/kotlin/it/vfsfitvnm => compose/reordering/src/main/kotlin/app/vimusic}/compose/reordering/DraggedItem.kt (51%) rename {compose-reordering/src/main/kotlin/it/vfsfitvnm => compose/reordering/src/main/kotlin/app/vimusic}/compose/reordering/Reorder.kt (63%) create mode 100644 compose/reordering/src/main/kotlin/app/vimusic/compose/reordering/ReorderingState.kt create mode 100644 compose/routing/build.gradle.kts create mode 100644 compose/routing/src/main/AndroidManifest.xml create mode 100644 compose/routing/src/main/kotlin/app/vimusic/compose/routing/GlobalRoute.kt create mode 100644 compose/routing/src/main/kotlin/app/vimusic/compose/routing/RootRouter.kt create mode 100644 compose/routing/src/main/kotlin/app/vimusic/compose/routing/Route.kt create mode 100644 compose/routing/src/main/kotlin/app/vimusic/compose/routing/RouteHandlerScope.kt create mode 100644 compose/routing/src/main/kotlin/app/vimusic/compose/routing/Transitions.kt create mode 100644 core/data/build.gradle.kts create mode 100644 core/data/src/main/AndroidManifest.xml rename {app/src/main/kotlin/it/vfsfitvnm/vimusic => core/data/src/main/kotlin/app/vimusic/core/data}/enums/AlbumSortBy.kt (63%) rename {app/src/main/kotlin/it/vfsfitvnm/vimusic => core/data/src/main/kotlin/app/vimusic/core/data}/enums/ArtistSortBy.kt (59%) create mode 100644 core/data/src/main/kotlin/app/vimusic/core/data/enums/BuiltInPlaylist.kt create mode 100644 core/data/src/main/kotlin/app/vimusic/core/data/enums/CoilDiskCacheSize.kt create mode 100644 core/data/src/main/kotlin/app/vimusic/core/data/enums/ExoPlayerDiskCacheSize.kt rename {app/src/main/kotlin/it/vfsfitvnm/vimusic => core/data/src/main/kotlin/app/vimusic/core/data}/enums/PlaylistSortBy.kt (66%) rename {app/src/main/kotlin/it/vfsfitvnm/vimusic => core/data/src/main/kotlin/app/vimusic/core/data}/enums/SongSortBy.kt (64%) rename {app/src/main/kotlin/it/vfsfitvnm/vimusic => core/data/src/main/kotlin/app/vimusic/core/data}/enums/SortOrder.kt (82%) create mode 100644 core/data/src/main/kotlin/app/vimusic/core/data/utils/Bytes.kt create mode 100644 core/data/src/main/kotlin/app/vimusic/core/data/utils/CallValidator.kt create mode 100644 core/data/src/main/kotlin/app/vimusic/core/data/utils/RingBuffer.kt create mode 100644 core/data/src/main/kotlin/app/vimusic/core/data/utils/Versions.kt create mode 100644 core/material-compat/build.gradle.kts create mode 100644 core/material-compat/src/main/kotlin/com/google/android/material/color/DynamicColors.kt create mode 100644 core/ui/build.gradle.kts create mode 100644 core/ui/src/main/AndroidManifest.xml create mode 100644 core/ui/src/main/kotlin/app/vimusic/core/ui/Appearance.kt create mode 100644 core/ui/src/main/kotlin/app/vimusic/core/ui/ColorPalette.kt create mode 100644 core/ui/src/main/kotlin/app/vimusic/core/ui/Dimensions.kt create mode 100644 core/ui/src/main/kotlin/app/vimusic/core/ui/Enums.kt create mode 100644 core/ui/src/main/kotlin/app/vimusic/core/ui/Hsl.kt create mode 100644 core/ui/src/main/kotlin/app/vimusic/core/ui/Ripple.kt create mode 100644 core/ui/src/main/kotlin/app/vimusic/core/ui/Shimmer.kt create mode 100644 core/ui/src/main/kotlin/app/vimusic/core/ui/Typography.kt create mode 100644 core/ui/src/main/kotlin/app/vimusic/core/ui/utils/Audio.kt create mode 100644 core/ui/src/main/kotlin/app/vimusic/core/ui/utils/Bundle.kt create mode 100644 core/ui/src/main/kotlin/app/vimusic/core/ui/utils/Configuration.kt create mode 100644 core/ui/src/main/kotlin/app/vimusic/core/ui/utils/Dp.kt create mode 100644 core/ui/src/main/kotlin/app/vimusic/core/ui/utils/Pixels.kt create mode 100644 core/ui/src/main/kotlin/app/vimusic/core/ui/utils/Saver.kt create mode 100644 core/ui/src/main/res/font/poppins_w300.ttf create mode 100644 core/ui/src/main/res/font/poppins_w400.ttf create mode 100644 core/ui/src/main/res/font/poppins_w500.ttf create mode 100644 core/ui/src/main/res/font/poppins_w600.ttf create mode 100644 core/ui/src/main/res/font/poppins_w700.ttf create mode 100644 core/ui/src/main/res/values/font_certs.xml create mode 100644 detekt.yml create mode 100644 gradle/libs.versions.toml mode change 100644 => 100755 gradlew delete mode 100644 innertube/.gitignore delete mode 100644 innertube/src/main/kotlin/it/vfsfitvnm/innertube/Innertube.kt delete mode 100644 innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/Context.kt delete mode 100644 innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/GridRenderer.kt delete mode 100644 innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/MusicTwoRowItemRenderer.kt delete mode 100644 innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/NavigationEndpoint.kt delete mode 100644 innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/Runs.kt delete mode 100644 innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/Thumbnail.kt delete mode 100644 innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/ThumbnailRenderer.kt delete mode 100644 innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/TwoColResults.kt delete mode 100644 innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/bodies/ContinuationBody.kt delete mode 100644 innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/bodies/PlayerBody.kt delete mode 100644 innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/AlbumPage.kt delete mode 100644 innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/ArtistPage.kt delete mode 100644 innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/ItemsPage.kt delete mode 100644 innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/Player.kt delete mode 100644 innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/PlaylistPage.kt delete mode 100644 innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/Queue.kt delete mode 100644 innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/SearchPage.kt delete mode 100644 innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/SearchSuggestions.kt delete mode 100644 innertube/src/main/kotlin/it/vfsfitvnm/innertube/utils/FromMusicTwoRowItemRenderer.kt delete mode 100644 innertube/src/main/kotlin/it/vfsfitvnm/innertube/utils/FromPlaylistPanelVideoRenderer.kt delete mode 100644 innertube/src/main/kotlin/it/vfsfitvnm/innertube/utils/Utils.kt delete mode 100644 innertube/src/test/kotlin/Test.kt delete mode 100644 ktor-client-brotli/.gitignore rename ktor-client-brotli/src/main/kotlin/io/ktor/client/plugins/compression/{brotli.kt => Brotli.kt} (59%) delete mode 100644 kugou/.gitignore delete mode 100644 kugou/src/main/kotlin/it/vfsfitvnm/kugou/KuGou.kt delete mode 100644 kugou/src/main/kotlin/it/vfsfitvnm/kugou/Result.kt delete mode 100644 kugou/src/test/kotlin/Test.kt create mode 100644 providers/common/build.gradle.kts create mode 100644 providers/common/src/main/kotlin/app/vimusic/providers/utils/Coroutines.kt create mode 100644 providers/common/src/main/kotlin/app/vimusic/providers/utils/Serializers.kt rename {kugou => providers/github}/build.gradle.kts (58%) create mode 100644 providers/github/src/main/kotlin/app/vimusic/providers/github/GitHub.kt create mode 100644 providers/github/src/main/kotlin/app/vimusic/providers/github/models/Reactions.kt create mode 100644 providers/github/src/main/kotlin/app/vimusic/providers/github/models/Release.kt create mode 100644 providers/github/src/main/kotlin/app/vimusic/providers/github/models/SimpleUser.kt create mode 100644 providers/github/src/main/kotlin/app/vimusic/providers/github/requests/Releases.kt create mode 100644 providers/innertube/build.gradle.kts create mode 100644 providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/Innertube.kt create mode 100644 providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/JavaScriptChallenge.kt create mode 100644 providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/Badge.kt rename {innertube/src/main/kotlin/it/vfsfitvnm => providers/innertube/src/main/kotlin/app/vimusic/providers}/innertube/models/BrowseResponse.kt (76%) rename {innertube/src/main/kotlin/it/vfsfitvnm => providers/innertube/src/main/kotlin/app/vimusic/providers}/innertube/models/ButtonRenderer.kt (50%) create mode 100644 providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/Context.kt rename {innertube/src/main/kotlin/it/vfsfitvnm => providers/innertube/src/main/kotlin/app/vimusic/providers}/innertube/models/Continuation.kt (89%) rename {innertube/src/main/kotlin/it/vfsfitvnm => providers/innertube/src/main/kotlin/app/vimusic/providers}/innertube/models/ContinuationResponse.kt (77%) rename {innertube/src/main/kotlin/it/vfsfitvnm => providers/innertube/src/main/kotlin/app/vimusic/providers}/innertube/models/GetQueueResponse.kt (60%) create mode 100644 providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/GridRenderer.kt rename {innertube/src/main/kotlin/it/vfsfitvnm => providers/innertube/src/main/kotlin/app/vimusic/providers}/innertube/models/MusicCarouselShelfRenderer.kt (82%) create mode 100644 providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/MusicNavigationButtonRenderer.kt rename {innertube/src/main/kotlin/it/vfsfitvnm => providers/innertube/src/main/kotlin/app/vimusic/providers}/innertube/models/MusicResponsiveListItemRenderer.kt (90%) rename {innertube/src/main/kotlin/it/vfsfitvnm => providers/innertube/src/main/kotlin/app/vimusic/providers}/innertube/models/MusicShelfRenderer.kt (71%) create mode 100644 providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/MusicTwoRowItemRenderer.kt create mode 100644 providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/NavigationEndpoint.kt rename {innertube/src/main/kotlin/it/vfsfitvnm => providers/innertube/src/main/kotlin/app/vimusic/providers}/innertube/models/NextResponse.kt (96%) rename {innertube/src/main/kotlin/it/vfsfitvnm => providers/innertube/src/main/kotlin/app/vimusic/providers}/innertube/models/PlayerResponse.kt (63%) rename {innertube/src/main/kotlin/it/vfsfitvnm => providers/innertube/src/main/kotlin/app/vimusic/providers}/innertube/models/PlaylistPanelVideoRenderer.kt (81%) create mode 100644 providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/Runs.kt rename {innertube/src/main/kotlin/it/vfsfitvnm => providers/innertube/src/main/kotlin/app/vimusic/providers}/innertube/models/SearchResponse.kt (59%) rename {innertube/src/main/kotlin/it/vfsfitvnm => providers/innertube/src/main/kotlin/app/vimusic/providers}/innertube/models/SearchSuggestionsResponse.kt (85%) rename {innertube/src/main/kotlin/it/vfsfitvnm => providers/innertube/src/main/kotlin/app/vimusic/providers}/innertube/models/SectionListRenderer.kt (62%) rename {innertube/src/main/kotlin/it/vfsfitvnm => providers/innertube/src/main/kotlin/app/vimusic/providers}/innertube/models/Tabs.kt (58%) create mode 100644 providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/Thumbnail.kt create mode 100644 providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/ThumbnailRenderer.kt rename {innertube/src/main/kotlin/it/vfsfitvnm => providers/innertube/src/main/kotlin/app/vimusic/providers}/innertube/models/bodies/BrowseBody.kt (63%) create mode 100644 providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/bodies/ContinuationBody.kt rename {innertube/src/main/kotlin/it/vfsfitvnm => providers/innertube/src/main/kotlin/app/vimusic/providers}/innertube/models/bodies/NextBody.kt (86%) create mode 100644 providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/bodies/PlayerBody.kt rename {innertube/src/main/kotlin/it/vfsfitvnm => providers/innertube/src/main/kotlin/app/vimusic/providers}/innertube/models/bodies/QueueBody.kt (54%) rename {innertube/src/main/kotlin/it/vfsfitvnm => providers/innertube/src/main/kotlin/app/vimusic/providers}/innertube/models/bodies/SearchBody.kt (61%) rename {innertube/src/main/kotlin/it/vfsfitvnm => providers/innertube/src/main/kotlin/app/vimusic/providers}/innertube/models/bodies/SearchSuggestionsBody.kt (60%) create mode 100644 providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/AlbumPage.kt create mode 100644 providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/ArtistPage.kt create mode 100644 providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/Browse.kt create mode 100644 providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/DiscoverPage.kt create mode 100644 providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/ItemsPage.kt rename {innertube/src/main/kotlin/it/vfsfitvnm => providers/innertube/src/main/kotlin/app/vimusic/providers}/innertube/requests/Lyrics.kt (50%) rename {innertube/src/main/kotlin/it/vfsfitvnm => providers/innertube/src/main/kotlin/app/vimusic/providers}/innertube/requests/NextPage.kt (54%) create mode 100644 providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/Player.kt create mode 100644 providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/PlaylistPage.kt create mode 100644 providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/Queue.kt rename {innertube/src/main/kotlin/it/vfsfitvnm => providers/innertube/src/main/kotlin/app/vimusic/providers}/innertube/requests/RelatedPage.kt (51%) create mode 100644 providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/SearchPage.kt create mode 100644 providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/SearchSuggestions.kt rename {innertube/src/main/kotlin/it/vfsfitvnm => providers/innertube/src/main/kotlin/app/vimusic/providers}/innertube/utils/FromMusicResponsiveListItemRenderer.kt (57%) rename {innertube/src/main/kotlin/it/vfsfitvnm => providers/innertube/src/main/kotlin/app/vimusic/providers}/innertube/utils/FromMusicShelfRendererContent.kt (72%) create mode 100644 providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/utils/FromMusicTwoRowItemRenderer.kt create mode 100644 providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/utils/FromPlaylistPanelVideoRenderer.kt create mode 100644 providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/utils/Utils.kt create mode 100644 providers/kugou/build.gradle.kts create mode 100644 providers/kugou/src/main/kotlin/app/vimusic/providers/kugou/KuGou.kt rename {kugou/src/main/kotlin/it/vfsfitvnm => providers/kugou/src/main/kotlin/app/vimusic/providers}/kugou/models/DownloadLyricsResponse.kt (74%) rename {kugou/src/main/kotlin/it/vfsfitvnm => providers/kugou/src/main/kotlin/app/vimusic/providers}/kugou/models/SearchLyricsResponse.kt (88%) rename {kugou/src/main/kotlin/it/vfsfitvnm => providers/kugou/src/main/kotlin/app/vimusic/providers}/kugou/models/SearchSongResponse.kt (80%) rename {innertube => providers/lrclib}/build.gradle.kts (51%) create mode 100644 providers/lrclib/src/main/kotlin/app/vimusic/providers/lrclib/LrcLib.kt create mode 100644 providers/lrclib/src/main/kotlin/app/vimusic/providers/lrclib/models/Track.kt create mode 100644 providers/piped/build.gradle.kts create mode 100644 providers/piped/src/main/kotlin/app/vimusic/providers/piped/Piped.kt create mode 100644 providers/piped/src/main/kotlin/app/vimusic/providers/piped/models/Instance.kt create mode 100644 providers/piped/src/main/kotlin/app/vimusic/providers/piped/models/PlaylistPreview.kt create mode 100644 providers/piped/src/main/kotlin/app/vimusic/providers/piped/models/Serializers.kt create mode 100644 providers/piped/src/main/kotlin/app/vimusic/providers/piped/models/Session.kt create mode 100644 providers/sponsorblock/build.gradle.kts create mode 100644 providers/sponsorblock/src/main/kotlin/app/vimusic/providers/sponsorblock/SponsorBlock.kt create mode 100644 providers/sponsorblock/src/main/kotlin/app/vimusic/providers/sponsorblock/models/Action.kt create mode 100644 providers/sponsorblock/src/main/kotlin/app/vimusic/providers/sponsorblock/models/Category.kt create mode 100644 providers/sponsorblock/src/main/kotlin/app/vimusic/providers/sponsorblock/models/Segment.kt create mode 100644 providers/sponsorblock/src/main/kotlin/app/vimusic/providers/sponsorblock/requests/Segments.kt create mode 100644 providers/translate/build.gradle.kts create mode 100644 providers/translate/src/main/kotlin/app/vimusic/providers/translate/Translate.kt create mode 100644 providers/translate/src/main/kotlin/app/vimusic/providers/translate/models/Language.kt create mode 100644 providers/translate/src/main/kotlin/app/vimusic/providers/translate/requests/Translate.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9b0628b..7a7054b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,18 +1,29 @@ +import org.jetbrains.kotlin.compose.compiler.gradle.ComposeFeatureFlag + plugins { - id("com.android.application") - kotlin("android") - kotlin("kapt") + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.parcelize) + alias(libs.plugins.ksp) } android { - compileSdk = 33 + val appId = "${project.group}.android" + + namespace = appId + compileSdk = 35 defaultConfig { - applicationId = "it.vfsfitvnm.vimusic" + applicationId = appId + minSdk = 21 - targetSdk = 33 - versionCode = 20 - versionName = "0.5.4" + targetSdk = 35 + + versionCode = System.getenv("ANDROID_VERSION_CODE")?.toIntOrNull() ?: 12 + versionName = project.version.toString() + + multiDexEnabled = true } splits { @@ -22,75 +33,147 @@ android { } } - namespace = "it.vfsfitvnm.vimusic" + signingConfigs { + create("ci") { + storeFile = System.getenv("ANDROID_NIGHTLY_KEYSTORE")?.let { file(it) } + storePassword = System.getenv("ANDROID_NIGHTLY_KEYSTORE_PASSWORD") + keyAlias = System.getenv("ANDROID_NIGHTLY_KEYSTORE_ALIAS") + keyPassword = System.getenv("ANDROID_NIGHTLY_KEYSTORE_PASSWORD") + } + } buildTypes { debug { applicationIdSuffix = ".debug" - manifestPlaceholders["appName"] = "Debug" + versionNameSuffix = "-DEBUG" + manifestPlaceholders["appName"] = "ViMusic Debug" } release { + versionNameSuffix = "-RELEASE" isMinifyEnabled = true isShrinkResources = true manifestPlaceholders["appName"] = "ViMusic" - signingConfig = signingConfigs.getByName("debug") - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) } - } - sourceSets.all { - kotlin.srcDir("src/$name/kotlin") + create("nightly") { + initWith(getByName("release")) + matchingFallbacks += "release" + + applicationIdSuffix = ".nightly" + versionNameSuffix = "-NIGHTLY" + manifestPlaceholders["appName"] = "ViMusic Nightly" + signingConfig = signingConfigs.findByName("ci") + } } buildFeatures { - compose = true + buildConfig = true } compileOptions { isCoreLibraryDesugaringEnabled = true - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 } - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() + kotlinOptions { + freeCompilerArgs = freeCompilerArgs + listOf("-Xcontext-receivers") } - kotlinOptions { - freeCompilerArgs += "-Xcontext-receivers" - jvmTarget = "1.8" + packaging { + resources.excludes.add("META-INF/**/*") + } + + androidResources { + @Suppress("UnstableApiUsage") + generateLocaleConfig = true } } -kapt { - arguments { - arg("room.schemaLocation", "$projectDir/schemas") +kotlin { + jvmToolchain(libs.versions.jvm.get().toInt()) + + task("testClasses") +} + +ksp { + arg("room.schemaLocation", "$projectDir/schemas") +} + +composeCompiler { + featureFlags = setOf( + ComposeFeatureFlag.StrongSkipping, + ComposeFeatureFlag.OptimizeNonSkippingGroups + ) + + if (project.findProperty("enableComposeCompilerReports") == "true") { + val dest = layout.buildDirectory.dir("compose_metrics") + metricsDestination = dest + reportsDestination = dest } } dependencies { - implementation(projects.composePersist) - implementation(projects.composeRouting) - implementation(projects.composeReordering) + coreLibraryDesugaring(libs.desugaring) + implementation(projects.compose.persist) + implementation(projects.compose.preferences) + implementation(projects.compose.routing) + implementation(projects.compose.reordering) + + implementation(fileTree(projectDir.resolve("vendor"))) + + implementation(platform(libs.compose.bom)) implementation(libs.compose.activity) implementation(libs.compose.foundation) implementation(libs.compose.ui) implementation(libs.compose.ui.util) - implementation(libs.compose.ripple) implementation(libs.compose.shimmer) - implementation(libs.compose.coil) + implementation(libs.compose.lottie) + implementation(libs.compose.material3) + + implementation(libs.coil.compose) + implementation(libs.coil.ktor) implementation(libs.palette) + implementation(libs.monet) + runtimeOnly(projects.core.materialCompat) implementation(libs.exoplayer) + implementation(libs.exoplayer.workmanager) + implementation(libs.media3.session) + implementation(libs.media) - implementation(libs.room) - kapt(libs.room.compiler) + implementation(libs.workmanager) + implementation(libs.workmanager.ktx) - implementation(projects.innertube) - implementation(projects.kugou) + implementation(libs.credentials) + implementation(libs.credentials.play) - coreLibraryDesugaring(libs.desugaring) + implementation(libs.kotlin.coroutines) + implementation(libs.kotlin.immutable) + implementation(libs.kotlin.datetime) + + implementation(libs.room) + ksp(libs.room.compiler) + + implementation(libs.log4j) + implementation(libs.slf4j) + implementation(libs.logback) + + implementation(projects.providers.github) + implementation(projects.providers.innertube) + implementation(projects.providers.kugou) + implementation(projects.providers.lrclib) + implementation(projects.providers.piped) + implementation(projects.providers.sponsorblock) + implementation(projects.providers.translate) + implementation(projects.core.data) + implementation(projects.core.ui) + + detektPlugins(libs.detekt.compose) + detektPlugins(libs.detekt.formatting) } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 8c15294..bd987ed 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,5 +1,6 @@ -keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation +#noinspection ShrinkerUnresolvedReference -if @kotlinx.serialization.Serializable class ** -keepclassmembers class <1> { static <1>$Companion Companion; @@ -9,6 +10,7 @@ static **$* *; } -keepclassmembers class <2>$<3> { + #noinspection ShrinkerUnresolvedReference kotlinx.serialization.KSerializer serializer(...); } @@ -17,9 +19,15 @@ } -keepclassmembers class <1> { public static <1> INSTANCE; + #noinspection ShrinkerUnresolvedReference kotlinx.serialization.KSerializer serializer(...); } +-if class androidx.credentials.CredentialManager +-keep class androidx.credentials.playservices.** { + *; +} + -keepattributes RuntimeVisibleAnnotations,AnnotationDefault -dontwarn org.bouncycastle.jsse.BCSSLParameters @@ -31,4 +39,10 @@ -dontwarn org.openjsse.javax.net.ssl.SSLParameters -dontwarn org.openjsse.javax.net.ssl.SSLSocket -dontwarn org.openjsse.net.ssl.OpenJSSE --dontwarn org.slf4j.impl.StaticLoggerBinder \ No newline at end of file +-dontwarn org.slf4j.impl.StaticLoggerBinder + +# Rhino +-keep class org.mozilla.javascript.** { *; } +-keep class org.mozilla.classfile.ClassFileWriter +-dontwarn org.mozilla.javascript.JavaToJSONConverters +-dontwarn org.mozilla.javascript.tools.** diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/1.json b/app/schemas/app.vimusic.android.DatabaseInitializer/1.json similarity index 100% rename from app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/1.json rename to app/schemas/app.vimusic.android.DatabaseInitializer/1.json diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/10.json b/app/schemas/app.vimusic.android.DatabaseInitializer/10.json similarity index 100% rename from app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/10.json rename to app/schemas/app.vimusic.android.DatabaseInitializer/10.json diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/11.json b/app/schemas/app.vimusic.android.DatabaseInitializer/11.json similarity index 100% rename from app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/11.json rename to app/schemas/app.vimusic.android.DatabaseInitializer/11.json diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/12.json b/app/schemas/app.vimusic.android.DatabaseInitializer/12.json similarity index 100% rename from app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/12.json rename to app/schemas/app.vimusic.android.DatabaseInitializer/12.json diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/13.json b/app/schemas/app.vimusic.android.DatabaseInitializer/13.json similarity index 100% rename from app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/13.json rename to app/schemas/app.vimusic.android.DatabaseInitializer/13.json diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/14.json b/app/schemas/app.vimusic.android.DatabaseInitializer/14.json similarity index 100% rename from app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/14.json rename to app/schemas/app.vimusic.android.DatabaseInitializer/14.json diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/15.json b/app/schemas/app.vimusic.android.DatabaseInitializer/15.json similarity index 100% rename from app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/15.json rename to app/schemas/app.vimusic.android.DatabaseInitializer/15.json diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/16.json b/app/schemas/app.vimusic.android.DatabaseInitializer/16.json similarity index 100% rename from app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/16.json rename to app/schemas/app.vimusic.android.DatabaseInitializer/16.json diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/17.json b/app/schemas/app.vimusic.android.DatabaseInitializer/17.json similarity index 100% rename from app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/17.json rename to app/schemas/app.vimusic.android.DatabaseInitializer/17.json diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/18.json b/app/schemas/app.vimusic.android.DatabaseInitializer/18.json similarity index 100% rename from app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/18.json rename to app/schemas/app.vimusic.android.DatabaseInitializer/18.json diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/19.json b/app/schemas/app.vimusic.android.DatabaseInitializer/19.json similarity index 100% rename from app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/19.json rename to app/schemas/app.vimusic.android.DatabaseInitializer/19.json diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/2.json b/app/schemas/app.vimusic.android.DatabaseInitializer/2.json similarity index 100% rename from app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/2.json rename to app/schemas/app.vimusic.android.DatabaseInitializer/2.json diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/20.json b/app/schemas/app.vimusic.android.DatabaseInitializer/20.json similarity index 100% rename from app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/20.json rename to app/schemas/app.vimusic.android.DatabaseInitializer/20.json diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/21.json b/app/schemas/app.vimusic.android.DatabaseInitializer/21.json similarity index 100% rename from app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/21.json rename to app/schemas/app.vimusic.android.DatabaseInitializer/21.json diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/22.json b/app/schemas/app.vimusic.android.DatabaseInitializer/22.json similarity index 100% rename from app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/22.json rename to app/schemas/app.vimusic.android.DatabaseInitializer/22.json diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/23.json b/app/schemas/app.vimusic.android.DatabaseInitializer/23.json similarity index 98% rename from app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/23.json rename to app/schemas/app.vimusic.android.DatabaseInitializer/23.json index 7cc0ae4..f264666 100644 --- a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/23.json +++ b/app/schemas/app.vimusic.android.DatabaseInitializer/23.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 23, - "identityHash": "205c24811149a247279bcbfdc2d6c396", + "identityHash": "7f599a26d50b2917fe68a176f414b0f2", "entities": [ { "tableName": "Song", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT, `thumbnailUrl` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT, `thumbnailUrl` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessBoost` REAL, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", @@ -49,6 +49,12 @@ "columnName": "totalPlayTimeMs", "affinity": "INTEGER", "notNull": true + }, + { + "fieldPath": "loudnessBoost", + "columnName": "loudnessBoost", + "affinity": "REAL", + "notNull": false } ], "primaryKey": { @@ -666,7 +672,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '205c24811149a247279bcbfdc2d6c396')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7f599a26d50b2917fe68a176f414b0f2')" ] } } \ No newline at end of file diff --git a/app/schemas/app.vimusic.android.DatabaseInitializer/24.json b/app/schemas/app.vimusic.android.DatabaseInitializer/24.json new file mode 100644 index 0000000..a021728 --- /dev/null +++ b/app/schemas/app.vimusic.android.DatabaseInitializer/24.json @@ -0,0 +1,678 @@ +{ + "formatVersion": 1, + "database": { + "version": 24, + "identityHash": "7f599a26d50b2917fe68a176f414b0f2", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT, `thumbnailUrl` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessBoost` REAL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistsText", + "columnName": "artistsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessBoost", + "columnName": "loudnessBoost", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongPlaylistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongPlaylistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongPlaylistMap_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `thumbnailUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongArtistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_SongArtistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongArtistMap_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorsText", + "columnName": "authorsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareUrl", + "columnName": "shareUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongAlbumMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_SongAlbumMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongAlbumMap_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "QueuedMediaItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaItem", + "columnName": "mediaItem", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Event", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playTime", + "columnName": "playTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Event_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Event_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `fixed` TEXT, `synced` TEXT, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fixed", + "columnName": "fixed", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [ + { + "viewName": "SortedSongPlaylistMap", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7f599a26d50b2917fe68a176f414b0f2')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/app.vimusic.android.DatabaseInitializer/25.json b/app/schemas/app.vimusic.android.DatabaseInitializer/25.json new file mode 100644 index 0000000..cf20268 --- /dev/null +++ b/app/schemas/app.vimusic.android.DatabaseInitializer/25.json @@ -0,0 +1,684 @@ +{ + "formatVersion": 1, + "database": { + "version": 25, + "identityHash": "35bed92752541c2739a932832debd361", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT, `thumbnailUrl` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessBoost` REAL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistsText", + "columnName": "artistsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessBoost", + "columnName": "loudnessBoost", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongPlaylistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongPlaylistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongPlaylistMap_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `thumbnailUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongArtistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_SongArtistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongArtistMap_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorsText", + "columnName": "authorsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareUrl", + "columnName": "shareUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongAlbumMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_SongAlbumMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongAlbumMap_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "QueuedMediaItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaItem", + "columnName": "mediaItem", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Event", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playTime", + "columnName": "playTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Event_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Event_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `fixed` TEXT, `synced` TEXT, `startTime` INTEGER, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fixed", + "columnName": "fixed", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startTime", + "columnName": "startTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [ + { + "viewName": "SortedSongPlaylistMap", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '35bed92752541c2739a932832debd361')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/app.vimusic.android.DatabaseInitializer/26.json b/app/schemas/app.vimusic.android.DatabaseInitializer/26.json new file mode 100644 index 0000000..3e6a59b --- /dev/null +++ b/app/schemas/app.vimusic.android.DatabaseInitializer/26.json @@ -0,0 +1,733 @@ +{ + "formatVersion": 1, + "database": { + "version": 26, + "identityHash": "722e6d30eeb0cd89a3ad80e3eb83d0f1", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT, `thumbnailUrl` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessBoost` REAL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistsText", + "columnName": "artistsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessBoost", + "columnName": "loudnessBoost", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongPlaylistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongPlaylistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongPlaylistMap_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `thumbnailUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongArtistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_SongArtistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongArtistMap_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorsText", + "columnName": "authorsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareUrl", + "columnName": "shareUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongAlbumMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_SongAlbumMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongAlbumMap_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "QueuedMediaItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaItem", + "columnName": "mediaItem", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Event", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playTime", + "columnName": "playTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Event_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Event_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `fixed` TEXT, `synced` TEXT, `startTime` INTEGER, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fixed", + "columnName": "fixed", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startTime", + "columnName": "startTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "PipedSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `apiBaseUrl` TEXT NOT NULL, `token` TEXT NOT NULL, `username` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "apiBaseUrl", + "columnName": "apiBaseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_PipedSession_apiBaseUrl_username", + "unique": true, + "columnNames": [ + "apiBaseUrl", + "username" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PipedSession_apiBaseUrl_username` ON `${TABLE_NAME}` (`apiBaseUrl`, `username`)" + } + ], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "SortedSongPlaylistMap", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '722e6d30eeb0cd89a3ad80e3eb83d0f1')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/app.vimusic.android.DatabaseInitializer/27.json b/app/schemas/app.vimusic.android.DatabaseInitializer/27.json new file mode 100644 index 0000000..c9ee1e2 --- /dev/null +++ b/app/schemas/app.vimusic.android.DatabaseInitializer/27.json @@ -0,0 +1,740 @@ +{ + "formatVersion": 1, + "database": { + "version": 27, + "identityHash": "ed8f47508639d4245327fdcde0cfa553", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT, `thumbnailUrl` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessBoost` REAL, `blacklisted` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistsText", + "columnName": "artistsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessBoost", + "columnName": "loudnessBoost", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "blacklisted", + "columnName": "blacklisted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongPlaylistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongPlaylistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongPlaylistMap_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `thumbnailUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongArtistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_SongArtistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongArtistMap_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorsText", + "columnName": "authorsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareUrl", + "columnName": "shareUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongAlbumMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_SongAlbumMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongAlbumMap_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "QueuedMediaItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaItem", + "columnName": "mediaItem", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Event", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playTime", + "columnName": "playTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Event_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Event_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `fixed` TEXT, `synced` TEXT, `startTime` INTEGER, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fixed", + "columnName": "fixed", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startTime", + "columnName": "startTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "PipedSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `apiBaseUrl` TEXT NOT NULL, `token` TEXT NOT NULL, `username` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "apiBaseUrl", + "columnName": "apiBaseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_PipedSession_apiBaseUrl_username", + "unique": true, + "columnNames": [ + "apiBaseUrl", + "username" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PipedSession_apiBaseUrl_username` ON `${TABLE_NAME}` (`apiBaseUrl`, `username`)" + } + ], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "SortedSongPlaylistMap", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ed8f47508639d4245327fdcde0cfa553')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/app.vimusic.android.DatabaseInitializer/28.json b/app/schemas/app.vimusic.android.DatabaseInitializer/28.json new file mode 100644 index 0000000..221890c --- /dev/null +++ b/app/schemas/app.vimusic.android.DatabaseInitializer/28.json @@ -0,0 +1,752 @@ +{ + "formatVersion": 1, + "database": { + "version": 28, + "identityHash": "0423cc07b12ce198d7fe19d7f2f1ad08", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT, `thumbnailUrl` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessBoost` REAL, `blacklisted` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistsText", + "columnName": "artistsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessBoost", + "columnName": "loudnessBoost", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "blacklisted", + "columnName": "blacklisted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongPlaylistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongPlaylistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongPlaylistMap_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `thumbnailUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongArtistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_SongArtistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongArtistMap_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `description` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, `otherInfo` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorsText", + "columnName": "authorsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareUrl", + "columnName": "shareUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "otherInfo", + "columnName": "otherInfo", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongAlbumMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_SongAlbumMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongAlbumMap_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "QueuedMediaItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaItem", + "columnName": "mediaItem", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Event", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playTime", + "columnName": "playTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Event_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Event_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `fixed` TEXT, `synced` TEXT, `startTime` INTEGER, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fixed", + "columnName": "fixed", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startTime", + "columnName": "startTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "PipedSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `apiBaseUrl` TEXT NOT NULL, `token` TEXT NOT NULL, `username` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "apiBaseUrl", + "columnName": "apiBaseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_PipedSession_apiBaseUrl_username", + "unique": true, + "columnNames": [ + "apiBaseUrl", + "username" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PipedSession_apiBaseUrl_username` ON `${TABLE_NAME}` (`apiBaseUrl`, `username`)" + } + ], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "SortedSongPlaylistMap", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0423cc07b12ce198d7fe19d7f2f1ad08')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/app.vimusic.android.DatabaseInitializer/29.json b/app/schemas/app.vimusic.android.DatabaseInitializer/29.json new file mode 100644 index 0000000..88987aa --- /dev/null +++ b/app/schemas/app.vimusic.android.DatabaseInitializer/29.json @@ -0,0 +1,759 @@ +{ + "formatVersion": 1, + "database": { + "version": 29, + "identityHash": "04b6c6febb4bfbe7b6ea3a8a9cfea351", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT, `thumbnailUrl` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessBoost` REAL, `blacklisted` INTEGER NOT NULL DEFAULT false, `explicit` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistsText", + "columnName": "artistsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessBoost", + "columnName": "loudnessBoost", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "blacklisted", + "columnName": "blacklisted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "explicit", + "columnName": "explicit", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongPlaylistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongPlaylistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongPlaylistMap_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `thumbnailUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongArtistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_SongArtistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongArtistMap_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `description` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, `otherInfo` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorsText", + "columnName": "authorsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareUrl", + "columnName": "shareUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "otherInfo", + "columnName": "otherInfo", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongAlbumMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_SongAlbumMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongAlbumMap_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "QueuedMediaItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaItem", + "columnName": "mediaItem", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Event", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playTime", + "columnName": "playTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Event_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Event_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `fixed` TEXT, `synced` TEXT, `startTime` INTEGER, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fixed", + "columnName": "fixed", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startTime", + "columnName": "startTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "PipedSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `apiBaseUrl` TEXT NOT NULL, `token` TEXT NOT NULL, `username` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "apiBaseUrl", + "columnName": "apiBaseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_PipedSession_apiBaseUrl_username", + "unique": true, + "columnNames": [ + "apiBaseUrl", + "username" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PipedSession_apiBaseUrl_username` ON `${TABLE_NAME}` (`apiBaseUrl`, `username`)" + } + ], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "SortedSongPlaylistMap", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '04b6c6febb4bfbe7b6ea3a8a9cfea351')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/3.json b/app/schemas/app.vimusic.android.DatabaseInitializer/3.json similarity index 100% rename from app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/3.json rename to app/schemas/app.vimusic.android.DatabaseInitializer/3.json diff --git a/app/schemas/app.vimusic.android.DatabaseInitializer/30.json b/app/schemas/app.vimusic.android.DatabaseInitializer/30.json new file mode 100644 index 0000000..47ea6d1 --- /dev/null +++ b/app/schemas/app.vimusic.android.DatabaseInitializer/30.json @@ -0,0 +1,765 @@ +{ + "formatVersion": 1, + "database": { + "version": 30, + "identityHash": "17e6383ea8e4d6c4774898994501f11a", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT, `thumbnailUrl` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessBoost` REAL, `blacklisted` INTEGER NOT NULL DEFAULT false, `explicit` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistsText", + "columnName": "artistsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessBoost", + "columnName": "loudnessBoost", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "blacklisted", + "columnName": "blacklisted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "explicit", + "columnName": "explicit", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongPlaylistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongPlaylistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongPlaylistMap_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT, `thumbnail` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnail", + "columnName": "thumbnail", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `thumbnailUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongArtistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_SongArtistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongArtistMap_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `description` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, `otherInfo` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorsText", + "columnName": "authorsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareUrl", + "columnName": "shareUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "otherInfo", + "columnName": "otherInfo", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongAlbumMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_SongAlbumMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongAlbumMap_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "QueuedMediaItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaItem", + "columnName": "mediaItem", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Event", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playTime", + "columnName": "playTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Event_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Event_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `fixed` TEXT, `synced` TEXT, `startTime` INTEGER, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fixed", + "columnName": "fixed", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startTime", + "columnName": "startTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "PipedSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `apiBaseUrl` TEXT NOT NULL, `token` TEXT NOT NULL, `username` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "apiBaseUrl", + "columnName": "apiBaseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_PipedSession_apiBaseUrl_username", + "unique": true, + "columnNames": [ + "apiBaseUrl", + "username" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PipedSession_apiBaseUrl_username` ON `${TABLE_NAME}` (`apiBaseUrl`, `username`)" + } + ], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "SortedSongPlaylistMap", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '17e6383ea8e4d6c4774898994501f11a')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/4.json b/app/schemas/app.vimusic.android.DatabaseInitializer/4.json similarity index 100% rename from app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/4.json rename to app/schemas/app.vimusic.android.DatabaseInitializer/4.json diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/5.json b/app/schemas/app.vimusic.android.DatabaseInitializer/5.json similarity index 100% rename from app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/5.json rename to app/schemas/app.vimusic.android.DatabaseInitializer/5.json diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/6.json b/app/schemas/app.vimusic.android.DatabaseInitializer/6.json similarity index 100% rename from app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/6.json rename to app/schemas/app.vimusic.android.DatabaseInitializer/6.json diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/7.json b/app/schemas/app.vimusic.android.DatabaseInitializer/7.json similarity index 100% rename from app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/7.json rename to app/schemas/app.vimusic.android.DatabaseInitializer/7.json diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/8.json b/app/schemas/app.vimusic.android.DatabaseInitializer/8.json similarity index 100% rename from app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/8.json rename to app/schemas/app.vimusic.android.DatabaseInitializer/8.json diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/9.json b/app/schemas/app.vimusic.android.DatabaseInitializer/9.json similarity index 100% rename from app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/9.json rename to app/schemas/app.vimusic.android.DatabaseInitializer/9.json diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e0dd9d9..3ad9018 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,19 +1,45 @@ - + + + + + - + + + + + + + + + + + + + + + + + android:theme="@style/Theme.ViMusic.NoActionBar" + tools:ignore="UnusedAttribute"> + android:windowSoftInputMode="adjustResize" + android:supportsPictureInPicture="true" + android:configChanges="colorMode|density|fontScale|keyboard|keyboardHidden|layoutDirection|locale|mcc|mnc|navigation|orientation|screenLayout|screenSize|smallestScreenSize|touchscreen|uiMode"> + + + + + + + + + + + @@ -50,43 +91,79 @@ + android:scheme="https" + tools:ignore="IntentFilterUniqueDataAttributes" /> + + android:scheme="https" + tools:ignore="IntentFilterUniqueDataAttributes" /> + + android:scheme="https" + tools:ignore="IntentFilterUniqueDataAttributes" /> + + android:scheme="https" + tools:ignore="IntentFilterUniqueDataAttributes" /> + + android:scheme="https" + tools:ignore="IntentFilterUniqueDataAttributes" /> + + android:scheme="https" + tools:ignore="IntentFilterUniqueDataAttributes" /> + + android:scheme="https" + tools:ignore="IntentFilterUniqueDataAttributes" /> + + android:scheme="https" + tools:ignore="IntentFilterUniqueDataAttributes" /> + + + + android:scheme="https" + tools:ignore="IntentFilterUniqueDataAttributes" /> + + android:scheme="https" + tools:ignore="IntentFilterUniqueDataAttributes" /> + + + + + + + + + + + @@ -94,13 +171,35 @@ android:name=".service.PlayerService" android:exported="false" android:foregroundServiceType="mediaPlayback"> + + + + + - + + + + + + + + tools:ignore="ExportedService"> + + + + + - + + @@ -108,11 +207,39 @@ android:name=".service.PlayerService$NotificationDismissReceiver" android:exported="false" /> - + - + + + + + + + + + + + + + + diff --git a/app/src/main/assets/logback.xml b/app/src/main/assets/logback.xml new file mode 100644 index 0000000..c46036a --- /dev/null +++ b/app/src/main/assets/logback.xml @@ -0,0 +1,18 @@ + + + + %logger{12} + + + [%-20thread] %msg + + + + + + + diff --git a/app/src/main/assets/lottie/play_pause.json b/app/src/main/assets/lottie/play_pause.json new file mode 100644 index 0000000..4f85f93 --- /dev/null +++ b/app/src/main/assets/lottie/play_pause.json @@ -0,0 +1 @@ +{"v":"5.9.6","fr":60,"ip":20,"op":40,"w":300,"h":300,"nm":"[Lottie] Media Controls - Pause / Play","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":2,"ty":3,"nm":"Scale (Import Fix)","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[151.25,150,0],"ix":2,"l":2},"a":{"a":0,"k":[60,60,0],"ix":1,"l":2},"s":{"a":0,"k":[62,62,100],"ix":6,"l":2}},"ao":0,"ip":0,"op":41,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Pause To Play","parent":2,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.2,"y":1},"o":{"x":0.4,"y":0},"t":0,"s":[89.453,60,0],"to":[-5.333,0,0],"ti":[0,0,0]},{"i":{"x":0.2,"y":1},"o":{"x":0.4,"y":0},"t":20,"s":[57.453,60,0],"to":[0,0,0],"ti":[-5.333,0,0]},{"t":35,"s":[89.453,60,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.2,"y":1},"o":{"x":0.4,"y":0},"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-149.48,-140.991],[-149.48,140.944],[-69.479,140.958],[-69.454,-140.957]],"c":true}]},{"i":{"x":0.2,"y":1},"o":{"x":0.4,"y":0},"t":20,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-91.093,-158.798],[-91.093,158.798],[19.636,89.817],[19.661,-89.801]],"c":true}]},{"t":35,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-149.48,-140.991],[-149.48,140.944],[-69.479,140.958],[-69.454,-140.957]],"c":true}]}],"ix":2},"nm":"Path","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Left","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.2,"y":1},"o":{"x":0.4,"y":0},"t":0,"s":[{"i":[[0,0],[-42.935,0],[0,26.683],[0,0]],"o":[[0,0],[0,-40.986],[-34.12,0],[0,0]],"v":[[9.475,140.958],[90.498,140.986],[90.555,-141.059],[9.5,-140.941]],"c":true}]},{"i":{"x":0.2,"y":1},"o":{"x":0.4,"y":0},"t":20,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[13.519,93.716],[159.525,0.238],[159.531,0.13],[13.066,-94.002]],"c":true}]},{"t":35,"s":[{"i":[[0,0],[-42.935,0],[0,26.683],[0,0]],"o":[[0,0],[0,-40.986],[-34.12,0],[0,0]],"v":[[9.475,140.958],[90.498,140.986],[90.555,-141.059],[9.5,-140.941]],"c":true}]}],"ix":2},"nm":"Path","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Right","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":41,"st":0,"ct":1,"bm":0}],"markers":[{"tm":0,"cm":"Pause to Play","dr":0},{"tm":20,"cm":"Play To Pause","dr":0}]} \ No newline at end of file diff --git a/app/src/main/kotlin/app/vimusic/android/Database.kt b/app/src/main/kotlin/app/vimusic/android/Database.kt new file mode 100644 index 0000000..0506620 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/Database.kt @@ -0,0 +1,1130 @@ +package app.vimusic.android + +import android.content.ContentValues +import android.database.SQLException +import android.database.sqlite.SQLiteDatabase.CONFLICT_IGNORE +import android.os.Parcel +import androidx.annotation.OptIn +import androidx.core.database.getFloatOrNull +import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi +import androidx.room.AutoMigration +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.DeleteColumn +import androidx.room.DeleteTable +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.RawQuery +import androidx.room.RenameColumn +import androidx.room.RenameTable +import androidx.room.RewriteQueriesToDropUnusedColumns +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.Transaction +import androidx.room.TypeConverter +import androidx.room.TypeConverters +import androidx.room.Update +import androidx.room.Upsert +import androidx.room.migration.AutoMigrationSpec +import androidx.room.migration.Migration +import androidx.sqlite.db.SimpleSQLiteQuery +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteQuery +import app.vimusic.android.models.Album +import app.vimusic.android.models.Artist +import app.vimusic.android.models.Event +import app.vimusic.android.models.EventWithSong +import app.vimusic.android.models.Format +import app.vimusic.android.models.Info +import app.vimusic.android.models.Lyrics +import app.vimusic.android.models.PipedSession +import app.vimusic.android.models.Playlist +import app.vimusic.android.models.PlaylistPreview +import app.vimusic.android.models.PlaylistWithSongs +import app.vimusic.android.models.QueuedMediaItem +import app.vimusic.android.models.SearchQuery +import app.vimusic.android.models.Song +import app.vimusic.android.models.SongAlbumMap +import app.vimusic.android.models.SongArtistMap +import app.vimusic.android.models.SongPlaylistMap +import app.vimusic.android.models.SongWithContentLength +import app.vimusic.android.models.SortedSongPlaylistMap +import app.vimusic.android.service.LOCAL_KEY_PREFIX +import app.vimusic.core.data.enums.AlbumSortBy +import app.vimusic.core.data.enums.ArtistSortBy +import app.vimusic.core.data.enums.PlaylistSortBy +import app.vimusic.core.data.enums.SongSortBy +import app.vimusic.core.data.enums.SortOrder +import app.vimusic.core.ui.utils.songBundle +import io.ktor.http.Url +import kotlinx.coroutines.flow.Flow + +@Dao +@Suppress("TooManyFunctions") +interface Database { + companion object : Database by DatabaseInitializer.instance.database + + @Transaction + @Query("SELECT * FROM Song WHERE id NOT LIKE '$LOCAL_KEY_PREFIX%' ORDER BY ROWID ASC") + @RewriteQueriesToDropUnusedColumns + fun songsByRowIdAsc(): Flow> + + @Transaction + @Query("SELECT * FROM Song WHERE id NOT LIKE '$LOCAL_KEY_PREFIX%' ORDER BY ROWID DESC") + @RewriteQueriesToDropUnusedColumns + fun songsByRowIdDesc(): Flow> + + @Transaction + @Query("SELECT * FROM Song WHERE id NOT LIKE '$LOCAL_KEY_PREFIX%' ORDER BY title COLLATE NOCASE ASC") + @RewriteQueriesToDropUnusedColumns + fun songsByTitleAsc(): Flow> + + @Transaction + @Query("SELECT * FROM Song WHERE id NOT LIKE '$LOCAL_KEY_PREFIX%' ORDER BY title COLLATE NOCASE DESC") + @RewriteQueriesToDropUnusedColumns + fun songsByTitleDesc(): Flow> + + @Transaction + @Query( + """ + SELECT * FROM Song + WHERE id NOT LIKE '$LOCAL_KEY_PREFIX%' + ORDER BY totalPlayTimeMs ASC + """ + ) + @RewriteQueriesToDropUnusedColumns + fun songsByPlayTimeAsc(): Flow> + + @Transaction + @Query( + """ + SELECT * FROM Song + WHERE id NOT LIKE '$LOCAL_KEY_PREFIX%' + ORDER BY totalPlayTimeMs DESC + LIMIT :limit + """ + ) + @RewriteQueriesToDropUnusedColumns + fun songsByPlayTimeDesc(limit: Int = -1): Flow> + + @Transaction + @Query("SELECT * FROM Song WHERE id LIKE '$LOCAL_KEY_PREFIX%' ORDER BY ROWID ASC") + @RewriteQueriesToDropUnusedColumns + fun localSongsByRowIdAsc(): Flow> + + @Transaction + @Query("SELECT * FROM Song WHERE id LIKE '$LOCAL_KEY_PREFIX%' ORDER BY ROWID DESC") + @RewriteQueriesToDropUnusedColumns + fun localSongsByRowIdDesc(): Flow> + + @Transaction + @Query("SELECT * FROM Song WHERE id LIKE '$LOCAL_KEY_PREFIX%' ORDER BY title COLLATE NOCASE ASC") + @RewriteQueriesToDropUnusedColumns + fun localSongsByTitleAsc(): Flow> + + @Transaction + @Query("SELECT * FROM Song WHERE id LIKE '$LOCAL_KEY_PREFIX%' ORDER BY title COLLATE NOCASE DESC") + @RewriteQueriesToDropUnusedColumns + fun localSongsByTitleDesc(): Flow> + + @Transaction + @Query("SELECT * FROM Song WHERE id LIKE '$LOCAL_KEY_PREFIX%' ORDER BY totalPlayTimeMs ASC") + @RewriteQueriesToDropUnusedColumns + fun localSongsByPlayTimeAsc(): Flow> + + @Transaction + @Query("SELECT * FROM Song WHERE id LIKE '$LOCAL_KEY_PREFIX%' ORDER BY totalPlayTimeMs DESC") + @RewriteQueriesToDropUnusedColumns + fun localSongsByPlayTimeDesc(): Flow> + + @Suppress("CyclomaticComplexMethod") + fun songs(sortBy: SongSortBy, sortOrder: SortOrder, isLocal: Boolean = false) = when (sortBy) { + SongSortBy.PlayTime -> when (sortOrder) { + SortOrder.Ascending -> if (isLocal) localSongsByPlayTimeAsc() else songsByPlayTimeAsc() + SortOrder.Descending -> if (isLocal) localSongsByPlayTimeDesc() else songsByPlayTimeDesc() + } + + SongSortBy.Title -> when (sortOrder) { + SortOrder.Ascending -> if (isLocal) localSongsByTitleAsc() else songsByTitleAsc() + SortOrder.Descending -> if (isLocal) localSongsByTitleDesc() else songsByTitleDesc() + } + + SongSortBy.DateAdded -> when (sortOrder) { + SortOrder.Ascending -> if (isLocal) localSongsByRowIdAsc() else songsByRowIdAsc() + SortOrder.Descending -> if (isLocal) localSongsByRowIdDesc() else songsByRowIdDesc() + } + } + + @Transaction + @Query("SELECT * FROM Song WHERE likedAt IS NOT NULL ORDER BY totalPlayTimeMs ASC") + fun favoritesByPlayTimeAsc(): Flow> + + @Transaction + @Query("SELECT * FROM Song WHERE likedAt IS NOT NULL ORDER BY totalPlayTimeMs DESC") + fun favoritesByPlayTimeDesc(): Flow> + + @Transaction + @Query("SELECT * FROM Song WHERE likedAt IS NOT NULL ORDER BY likedAt ASC") + fun favoritesByLikedAtAsc(): Flow> + + @Transaction + @Query("SELECT * FROM Song WHERE likedAt IS NOT NULL ORDER BY likedAt DESC") + fun favoritesByLikedAtDesc(): Flow> + + @Transaction + @Query("SELECT * FROM Song WHERE likedAt IS NOT NULL ORDER BY title COLLATE NOCASE ASC") + fun favoritesByTitleAsc(): Flow> + + @Transaction + @Query("SELECT * FROM Song WHERE likedAt IS NOT NULL ORDER BY title COLLATE NOCASE DESC") + fun favoritesByTitleDesc(): Flow> + + fun favorites( + sortBy: SongSortBy = SongSortBy.DateAdded, + sortOrder: SortOrder = SortOrder.Descending + ) = when (sortBy) { + SongSortBy.PlayTime -> when (sortOrder) { + SortOrder.Ascending -> favoritesByPlayTimeAsc() + SortOrder.Descending -> favoritesByPlayTimeDesc() + } + + SongSortBy.Title -> when (sortOrder) { + SortOrder.Ascending -> favoritesByTitleAsc() + SortOrder.Descending -> favoritesByTitleDesc() + } + + SongSortBy.DateAdded -> when (sortOrder) { + SortOrder.Ascending -> favoritesByLikedAtAsc() + SortOrder.Descending -> favoritesByLikedAtDesc() + } + } + + @Query("SELECT * FROM QueuedMediaItem") + fun queue(): List + + @Transaction + @Query( + """ + SELECT Song.* FROM Event + JOIN Song ON Song.id = Event.songId + WHERE Event.ROWID in ( + SELECT max(Event.ROWID) + FROM Event + GROUP BY songId + ) + ORDER BY timestamp DESC + LIMIT :size + """ + ) + @RewriteQueriesToDropUnusedColumns + fun history(size: Int = 100): Flow> + + @Query("DELETE FROM QueuedMediaItem") + fun clearQueue() + + @Query("SELECT * FROM SearchQuery WHERE `query` LIKE :query ORDER BY id DESC") + fun queries(query: String): Flow> + + @Query("SELECT COUNT (*) FROM SearchQuery") + fun queriesCount(): Flow + + @Query("DELETE FROM SearchQuery") + fun clearQueries() + + @Query("SELECT * FROM Song WHERE id = :id") + fun song(id: String): Flow + + @Query("SELECT likedAt FROM Song WHERE id = :songId") + fun likedAt(songId: String): Flow + + @Query("UPDATE Song SET likedAt = :likedAt WHERE id = :songId") + fun like(songId: String, likedAt: Long?): Int + + @Query("UPDATE Song SET durationText = :durationText WHERE id = :songId") + fun updateDurationText(songId: String, durationText: String): Int + + @Query("SELECT * FROM Lyrics WHERE songId = :songId") + fun lyrics(songId: String): Flow + + @Query("SELECT * FROM Artist WHERE id = :id") + fun artist(id: String): Flow + + @Query("SELECT * FROM Artist WHERE bookmarkedAt IS NOT NULL ORDER BY name COLLATE NOCASE DESC") + fun artistsByNameDesc(): Flow> + + @Query("SELECT * FROM Artist WHERE bookmarkedAt IS NOT NULL ORDER BY name COLLATE NOCASE ASC") + fun artistsByNameAsc(): Flow> + + @Query("SELECT * FROM Artist WHERE bookmarkedAt IS NOT NULL ORDER BY bookmarkedAt DESC") + fun artistsByRowIdDesc(): Flow> + + @Query("SELECT * FROM Artist WHERE bookmarkedAt IS NOT NULL ORDER BY bookmarkedAt ASC") + fun artistsByRowIdAsc(): Flow> + + fun artists(sortBy: ArtistSortBy, sortOrder: SortOrder) = when (sortBy) { + ArtistSortBy.Name -> when (sortOrder) { + SortOrder.Ascending -> artistsByNameAsc() + SortOrder.Descending -> artistsByNameDesc() + } + + ArtistSortBy.DateAdded -> when (sortOrder) { + SortOrder.Ascending -> artistsByRowIdAsc() + SortOrder.Descending -> artistsByRowIdDesc() + } + } + + @Query("SELECT * FROM Album WHERE id = :id") + fun album(id: String): Flow + + @Transaction + @Query( + """ + SELECT * FROM Song + JOIN SongAlbumMap ON Song.id = SongAlbumMap.songId + WHERE SongAlbumMap.albumId = :albumId AND + position IS NOT NULL + ORDER BY position + """ + ) + @RewriteQueriesToDropUnusedColumns + fun albumSongs(albumId: String): Flow> + + @Query("SELECT * FROM Album WHERE bookmarkedAt IS NOT NULL ORDER BY title COLLATE NOCASE ASC") + fun albumsByTitleAsc(): Flow> + + // authorsText as fallback for when YouTube showed the year in the artist field + @Query("SELECT * FROM Album WHERE bookmarkedAt IS NOT NULL ORDER BY year ASC, authorsText COLLATE NOCASE ASC") + fun albumsByYearAsc(): Flow> + + @Query("SELECT * FROM Album WHERE bookmarkedAt IS NOT NULL ORDER BY bookmarkedAt ASC") + fun albumsByRowIdAsc(): Flow> + + @Query("SELECT * FROM Album WHERE bookmarkedAt IS NOT NULL ORDER BY title COLLATE NOCASE DESC") + fun albumsByTitleDesc(): Flow> + + @Query("SELECT * FROM Album WHERE bookmarkedAt IS NOT NULL ORDER BY year DESC, authorsText COLLATE NOCASE DESC") + fun albumsByYearDesc(): Flow> + + @Query("SELECT * FROM Album WHERE bookmarkedAt IS NOT NULL ORDER BY bookmarkedAt DESC") + fun albumsByRowIdDesc(): Flow> + + fun albums(sortBy: AlbumSortBy, sortOrder: SortOrder) = when (sortBy) { + AlbumSortBy.Title -> when (sortOrder) { + SortOrder.Ascending -> albumsByTitleAsc() + SortOrder.Descending -> albumsByTitleDesc() + } + + AlbumSortBy.Year -> when (sortOrder) { + SortOrder.Ascending -> albumsByYearAsc() + SortOrder.Descending -> albumsByYearDesc() + } + + AlbumSortBy.DateAdded -> when (sortOrder) { + SortOrder.Ascending -> albumsByRowIdAsc() + SortOrder.Descending -> albumsByRowIdDesc() + } + } + + @Query("UPDATE Song SET totalPlayTimeMs = totalPlayTimeMs + :addition WHERE id = :id") + fun incrementTotalPlayTimeMs(id: String, addition: Long) + + @Query("SELECT * FROM PipedSession") + fun pipedSessions(): Flow> + + @Query("SELECT * FROM Playlist WHERE id = :id") + fun playlist(id: Long): Flow + + // TODO: apparently this is an edge-case now? + @RewriteQueriesToDropUnusedColumns + @Transaction + @Query( + """ + SELECT * FROM SortedSongPlaylistMap + INNER JOIN Song on Song.id = SortedSongPlaylistMap.songId + WHERE playlistId = :id + ORDER BY SortedSongPlaylistMap.position + """ + ) + fun playlistSongs(id: Long): Flow?> + + @Transaction + @Query("SELECT * FROM Playlist WHERE id = :id") + fun playlistWithSongs(id: Long): Flow + + @Transaction + @Query( + """ + SELECT id, name, (SELECT COUNT(*) FROM SongPlaylistMap WHERE playlistId = id) as songCount, thumbnail FROM Playlist + ORDER BY name COLLATE NOCASE ASC + """ + ) + fun playlistPreviewsByNameAsc(): Flow> + + @Transaction + @Query( + """ + SELECT id, name, (SELECT COUNT(*) FROM SongPlaylistMap WHERE playlistId = id) as songCount, thumbnail FROM Playlist + ORDER BY ROWID ASC + """ + ) + fun playlistPreviewsByDateAddedAsc(): Flow> + + @Transaction + @Query( + """ + SELECT id, name, (SELECT COUNT(*) FROM SongPlaylistMap WHERE playlistId = id) as songCount, thumbnail FROM Playlist + ORDER BY songCount ASC + """ + ) + fun playlistPreviewsByDateSongCountAsc(): Flow> + + @Transaction + @Query( + """ + SELECT id, name, (SELECT COUNT(*) FROM SongPlaylistMap WHERE playlistId = id) as songCount, thumbnail FROM Playlist + ORDER BY name COLLATE NOCASE DESC + """ + ) + fun playlistPreviewsByNameDesc(): Flow> + + @Transaction + @Query( + """ + SELECT id, name, (SELECT COUNT(*) FROM SongPlaylistMap WHERE playlistId = id) as songCount, thumbnail FROM Playlist + ORDER BY ROWID DESC + """ + ) + fun playlistPreviewsByDateAddedDesc(): Flow> + + @Transaction + @Query( + """ + SELECT id, name, (SELECT COUNT(*) FROM SongPlaylistMap WHERE playlistId = id) as songCount, thumbnail FROM Playlist + ORDER BY songCount DESC + """ + ) + fun playlistPreviewsByDateSongCountDesc(): Flow> + + fun playlistPreviews( + sortBy: PlaylistSortBy, + sortOrder: SortOrder + ) = when (sortBy) { + PlaylistSortBy.Name -> when (sortOrder) { + SortOrder.Ascending -> playlistPreviewsByNameAsc() + SortOrder.Descending -> playlistPreviewsByNameDesc() + } + + PlaylistSortBy.SongCount -> when (sortOrder) { + SortOrder.Ascending -> playlistPreviewsByDateSongCountAsc() + SortOrder.Descending -> playlistPreviewsByDateSongCountDesc() + } + + PlaylistSortBy.DateAdded -> when (sortOrder) { + SortOrder.Ascending -> playlistPreviewsByDateAddedAsc() + SortOrder.Descending -> playlistPreviewsByDateAddedDesc() + } + } + + @Query( + """ + SELECT thumbnailUrl FROM Song + JOIN SongPlaylistMap ON id = songId + WHERE playlistId = :id + ORDER BY position + LIMIT 4 + """ + ) + fun playlistThumbnailUrls(id: Long): Flow> + + @Transaction + @Query( + """ + SELECT * FROM Song + JOIN SongArtistMap ON Song.id = SongArtistMap.songId + WHERE SongArtistMap.artistId = :artistId AND + totalPlayTimeMs > 0 + ORDER BY Song.ROWID DESC + """ + ) + @RewriteQueriesToDropUnusedColumns + fun artistSongs(artistId: String): Flow> + + @Query("SELECT * FROM Format WHERE songId = :songId") + fun format(songId: String): Flow + + @Transaction + @Query( + """ + SELECT Song.*, contentLength FROM Song + JOIN Format ON id = songId + WHERE contentLength IS NOT NULL + ORDER BY Song.totalPlayTimeMs ASC + """ + ) + fun songsWithContentLengthByPlayTimeAsc(): Flow> + + @Transaction + @Query( + """ + SELECT Song.*, contentLength FROM Song + JOIN Format ON id = songId + WHERE contentLength IS NOT NULL + ORDER BY Song.totalPlayTimeMs DESC + """ + ) + fun songsWithContentLengthByPlayTimeDesc(): Flow> + + @Transaction + @Query( + """ + SELECT Song.*, contentLength FROM Song + JOIN Format ON id = songId + WHERE contentLength IS NOT NULL + ORDER BY Song.ROWID ASC + """ + ) + fun songsWithContentLengthByRowIdAsc(): Flow> + + @Transaction + @Query( + """ + SELECT Song.*, contentLength FROM Song + JOIN Format ON id = songId + WHERE contentLength IS NOT NULL + ORDER BY Song.ROWID DESC + """ + ) + fun songsWithContentLengthByRowIdDesc(): Flow> + + @Transaction + @Query( + """ + SELECT Song.*, contentLength FROM Song + JOIN Format ON id = songId + WHERE contentLength IS NOT NULL + ORDER BY Song.title COLLATE NOCASE ASC + """ + ) + fun songsWithContentLengthByTitleAsc(): Flow> + + @Transaction + @Query( + """ + SELECT Song.*, contentLength FROM Song + JOIN Format ON id = songId + WHERE contentLength IS NOT NULL + ORDER BY Song.title COLLATE NOCASE DESC + """ + ) + fun songsWithContentLengthByTitleDesc(): Flow> + + fun songsWithContentLength( + sortBy: SongSortBy = SongSortBy.DateAdded, + sortOrder: SortOrder = SortOrder.Descending + ) = when (sortBy) { + SongSortBy.PlayTime -> when (sortOrder) { + SortOrder.Ascending -> songsWithContentLengthByPlayTimeAsc() + SortOrder.Descending -> songsWithContentLengthByPlayTimeDesc() + } + + SongSortBy.Title -> when (sortOrder) { + SortOrder.Ascending -> songsWithContentLengthByTitleAsc() + SortOrder.Descending -> songsWithContentLengthByTitleDesc() + } + + SongSortBy.DateAdded -> when (sortOrder) { + SortOrder.Ascending -> songsWithContentLengthByRowIdAsc() + SortOrder.Descending -> songsWithContentLengthByRowIdDesc() + } + } + + @Query("SELECT id FROM Song WHERE blacklisted") + suspend fun blacklistedIds(): List + + @Query("SELECT blacklisted FROM Song WHERE id = :songId") + fun blacklisted(songId: String): Flow + + @Query("SELECT COUNT (*) FROM Song where blacklisted") + fun blacklistLength(): Flow + + @Transaction + @Query("UPDATE Song SET blacklisted = NOT blacklisted WHERE blacklisted") + fun resetBlacklist() + + @Transaction + @Query("UPDATE Song SET blacklisted = NOT blacklisted WHERE id = :songId") + fun toggleBlacklist(songId: String) + + suspend fun filterBlacklistedSongs(songs: List): List { + val blacklistedIds = blacklistedIds() + return songs.filter { it.mediaId !in blacklistedIds } + } + + @Transaction + @Query( + """ + UPDATE SongPlaylistMap SET position = + CASE + WHEN position < :fromPosition THEN position + 1 + WHEN position > :fromPosition THEN position - 1 + ELSE :toPosition + END + WHERE playlistId = :playlistId AND position BETWEEN MIN(:fromPosition,:toPosition) and MAX(:fromPosition,:toPosition) + """ + ) + fun move(playlistId: Long, fromPosition: Int, toPosition: Int) + + @Query("DELETE FROM SongPlaylistMap WHERE playlistId = :id") + fun clearPlaylist(id: Long) + + @Query("DELETE FROM SongAlbumMap WHERE albumId = :id") + fun clearAlbum(id: String) + + @Query("SELECT loudnessDb FROM Format WHERE songId = :songId") + fun loudnessDb(songId: String): Flow + + @Query("SELECT Song.loudnessBoost FROM Song WHERE id = :songId") + fun loudnessBoost(songId: String): Flow + + @Query("UPDATE Song SET loudnessBoost = :loudnessBoost WHERE id = :songId") + fun setLoudnessBoost(songId: String, loudnessBoost: Float?) + + @Query("SELECT * FROM Song WHERE title LIKE :query OR artistsText LIKE :query") + fun search(query: String): Flow> + + @Query("SELECT albumId AS id, NULL AS name FROM SongAlbumMap WHERE songId = :songId") + suspend fun songAlbumInfo(songId: String): Info + + @Query("SELECT id, name FROM Artist LEFT JOIN SongArtistMap ON id = artistId WHERE songId = :songId") + suspend fun songArtistInfo(songId: String): List + + @Transaction + @Query( + """ + SELECT Song.* FROM Event + JOIN Song ON Song.id = songId + WHERE Song.id NOT LIKE '$LOCAL_KEY_PREFIX%' + GROUP BY songId + ORDER BY SUM(playTime) + DESC LIMIT :limit + """ + ) + @RewriteQueriesToDropUnusedColumns + fun trending(limit: Int = 3): Flow> + + @Transaction + @Query( + """ + SELECT Song.* FROM Event + JOIN Song ON Song.id = songId + WHERE (:now - Event.timestamp) <= :period AND + Song.id NOT LIKE '$LOCAL_KEY_PREFIX%' + GROUP BY songId + ORDER BY SUM(playTime) DESC + LIMIT :limit + """ + ) + @RewriteQueriesToDropUnusedColumns + fun trending( + limit: Int = 3, + now: Long = System.currentTimeMillis(), + period: Long + ): Flow> + + @Transaction + @Query("SELECT * FROM Event ORDER BY timestamp DESC") + fun events(): Flow> + + @Query("SELECT COUNT (*) FROM Event") + fun eventsCount(): Flow + + @Query("DELETE FROM Event") + fun clearEvents() + + @Query("DELETE FROM Event WHERE songId = :songId") + fun clearEventsFor(songId: String) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + @Throws(SQLException::class) + fun insert(event: Event) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(format: Format) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(searchQuery: SearchQuery) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(playlist: Playlist): Long + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(songPlaylistMap: SongPlaylistMap): Long + + @Insert(onConflict = OnConflictStrategy.ABORT) + fun insert(songArtistMap: SongArtistMap): Long + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(song: Song): Long + + @Insert(onConflict = OnConflictStrategy.ABORT) + fun insert(queuedMediaItems: List) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insertSongPlaylistMaps(songPlaylistMaps: List) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(album: Album, songAlbumMap: SongAlbumMap) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(artists: List, songArtistMaps: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(pipedSession: PipedSession) + + @Transaction + fun insert(mediaItem: MediaItem, block: (Song) -> Song = { it }) { + val extras = mediaItem.mediaMetadata.extras?.songBundle + val song = Song( + id = mediaItem.mediaId, + title = mediaItem.mediaMetadata.title?.toString().orEmpty(), + artistsText = mediaItem.mediaMetadata.artist?.toString(), + durationText = extras?.durationText, + thumbnailUrl = mediaItem.mediaMetadata.artworkUri?.toString(), + explicit = extras?.explicit == true + ).let(block).also { song -> + if (insert(song) == -1L) return + } + + extras?.albumId?.let { albumId -> + insert( + Album(id = albumId, title = mediaItem.mediaMetadata.albumTitle?.toString()), + SongAlbumMap(songId = song.id, albumId = albumId, position = null) + ) + } + + extras?.artistNames?.let { artistNames -> + extras.artistIds?.let { artistIds -> + if (artistNames.size == artistIds.size) insert( + artistNames.mapIndexed { index, artistName -> + Artist( + id = artistIds[index], + name = artistName + ) + }, + artistIds.map { artistId -> + SongArtistMap( + songId = song.id, + artistId = artistId + ) + } + ) + } + } + } + + @Update + fun update(artist: Artist) + + @Update + fun update(album: Album) + + @Update + fun update(playlist: Playlist) + + @Upsert + fun upsert(lyrics: Lyrics) + + @Upsert + fun upsert(album: Album, songAlbumMaps: List) + + @Upsert + fun upsert(artist: Artist) + + @Delete + fun delete(song: Song) + + @Delete + fun delete(searchQuery: SearchQuery) + + @Delete + fun delete(playlist: Playlist) + + @Delete + fun delete(songPlaylistMap: SongPlaylistMap) + + @Delete + fun delete(pipedSession: PipedSession) + + @RawQuery + fun raw(supportSQLiteQuery: SupportSQLiteQuery): Int + + fun checkpoint() { + raw(SimpleSQLiteQuery("PRAGMA wal_checkpoint(FULL)")) + } +} + +@androidx.room.Database( + entities = [ + Song::class, + SongPlaylistMap::class, + Playlist::class, + Artist::class, + SongArtistMap::class, + Album::class, + SongAlbumMap::class, + SearchQuery::class, + QueuedMediaItem::class, + Format::class, + Event::class, + Lyrics::class, + PipedSession::class + ], + views = [SortedSongPlaylistMap::class], + version = 30, + exportSchema = true, + autoMigrations = [ + AutoMigration(from = 1, to = 2), + AutoMigration(from = 2, to = 3), + AutoMigration(from = 3, to = 4, spec = DatabaseInitializer.From3To4Migration::class), + AutoMigration(from = 4, to = 5), + AutoMigration(from = 5, to = 6), + AutoMigration(from = 6, to = 7), + AutoMigration(from = 7, to = 8, spec = DatabaseInitializer.From7To8Migration::class), + AutoMigration(from = 9, to = 10), + AutoMigration(from = 11, to = 12, spec = DatabaseInitializer.From11To12Migration::class), + AutoMigration(from = 12, to = 13), + AutoMigration(from = 13, to = 14), + AutoMigration(from = 15, to = 16), + AutoMigration(from = 16, to = 17), + AutoMigration(from = 17, to = 18), + AutoMigration(from = 18, to = 19), + AutoMigration(from = 19, to = 20), + AutoMigration(from = 20, to = 21, spec = DatabaseInitializer.From20To21Migration::class), + AutoMigration(from = 21, to = 22, spec = DatabaseInitializer.From21To22Migration::class), + AutoMigration(from = 23, to = 24), + AutoMigration(from = 24, to = 25), + AutoMigration(from = 25, to = 26), + AutoMigration(from = 26, to = 27), + AutoMigration(from = 27, to = 28), + AutoMigration(from = 28, to = 29), + AutoMigration(from = 29, to = 30) + ] +) +@TypeConverters(Converters::class) +abstract class DatabaseInitializer protected constructor() : RoomDatabase() { + abstract val database: Database + + companion object { + @Volatile + lateinit var instance: DatabaseInitializer + + private fun buildDatabase() = Room + .databaseBuilder( + context = Dependencies.application.applicationContext, + klass = DatabaseInitializer::class.java, + name = "data.db" + ) + .addMigrations( + From8To9Migration(), + From10To11Migration(), + From14To15Migration(), + From22To23Migration(), + From23To24Migration() + ) + .build() + + operator fun invoke() { + if (!::instance.isInitialized) reload() + } + + fun reload() = synchronized(this) { + instance = buildDatabase() + } + } + + @DeleteTable.Entries(DeleteTable(tableName = "QueuedMediaItem")) + class From3To4Migration : AutoMigrationSpec + + @RenameColumn.Entries(RenameColumn("Song", "albumInfoId", "albumId")) + class From7To8Migration : AutoMigrationSpec + + class From8To9Migration : Migration(8, 9) { + override fun migrate(db: SupportSQLiteDatabase) { + db.query( + SimpleSQLiteQuery( + query = "SELECT DISTINCT browseId, text, Info.id FROM Info JOIN Song ON Info.id = Song.albumId;" + ) + ).use { cursor -> + val albumValues = ContentValues(2) + while (cursor.moveToNext()) { + albumValues.put("id", cursor.getString(0)) + albumValues.put("title", cursor.getString(1)) + db.insert("Album", CONFLICT_IGNORE, albumValues) + + db.execSQL( + "UPDATE Song SET albumId = '${cursor.getString(0)}' WHERE albumId = ${ + cursor.getLong( + 2 + ) + }" + ) + } + } + + db.query( + SimpleSQLiteQuery( + query = """ + SELECT GROUP_CONCAT(text, ''), SongWithAuthors.songId FROM Info + JOIN SongWithAuthors ON Info.id = SongWithAuthors.authorInfoId + GROUP BY songId; + """.trimIndent() + ) + ).use { cursor -> + val songValues = ContentValues(1) + while (cursor.moveToNext()) { + songValues.put("artistsText", cursor.getString(0)) + db.update( + table = "Song", + conflictAlgorithm = CONFLICT_IGNORE, + values = songValues, + whereClause = "id = ?", + whereArgs = arrayOf(cursor.getString(1)) + ) + } + } + + db.query( + SimpleSQLiteQuery( + query = """ + SELECT browseId, text, Info.id FROM Info + JOIN SongWithAuthors ON Info.id = SongWithAuthors.authorInfoId + WHERE browseId NOT NULL; + """.trimIndent() + ) + ).use { cursor -> + val artistValues = ContentValues(2) + while (cursor.moveToNext()) { + artistValues.put("id", cursor.getString(0)) + artistValues.put("name", cursor.getString(1)) + db.insert("Artist", CONFLICT_IGNORE, artistValues) + + db.execSQL( + "UPDATE SongWithAuthors SET authorInfoId = '${cursor.getString(0)}' WHERE authorInfoId = ${ + cursor.getLong(2) + }" + ) + } + } + + db.execSQL("INSERT INTO SongArtistMap(songId, artistId) SELECT songId, authorInfoId FROM SongWithAuthors") + + db.execSQL("DROP TABLE Info;") + db.execSQL("DROP TABLE SongWithAuthors;") + } + } + + class From10To11Migration : Migration(10, 11) { + override fun migrate(db: SupportSQLiteDatabase) { + db.query(SimpleSQLiteQuery("SELECT id, albumId FROM Song;")).use { cursor -> + val songAlbumMapValues = ContentValues(2) + while (cursor.moveToNext()) { + songAlbumMapValues.put("songId", cursor.getString(0)) + songAlbumMapValues.put("albumId", cursor.getString(1)) + db.insert("SongAlbumMap", CONFLICT_IGNORE, songAlbumMapValues) + } + } + + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `Song_new` ( + `id` TEXT NOT NULL, + `title` TEXT NOT NULL, + `artistsText` TEXT, + `durationText` TEXT NOT NULL, + `thumbnailUrl` TEXT, `lyrics` TEXT, + `likedAt` INTEGER, + `totalPlayTimeMs` INTEGER NOT NULL, + `loudnessDb` REAL, + `contentLength` INTEGER, + PRIMARY KEY(`id`) + ) + """.trimIndent() + ) + + db.execSQL( + """ + INSERT INTO Song_new(id, title, artistsText, durationText, thumbnailUrl, lyrics, + likedAt, totalPlayTimeMs, loudnessDb, contentLength) SELECT id, title, artistsText, + durationText, thumbnailUrl, lyrics, likedAt, totalPlayTimeMs, loudnessDb, contentLength + FROM Song; + """.trimIndent() + ) + db.execSQL("DROP TABLE Song;") + db.execSQL("ALTER TABLE Song_new RENAME TO Song;") + } + } + + @RenameTable("SongInPlaylist", "SongPlaylistMap") + @RenameTable("SortedSongInPlaylist", "SortedSongPlaylistMap") + class From11To12Migration : AutoMigrationSpec + + class From14To15Migration : Migration(14, 15) { + override fun migrate(db: SupportSQLiteDatabase) { + db.query(SimpleSQLiteQuery("SELECT id, loudnessDb, contentLength FROM Song;")) + .use { cursor -> + val formatValues = ContentValues(3) + while (cursor.moveToNext()) { + formatValues.put("songId", cursor.getString(0)) + formatValues.put("loudnessDb", cursor.getFloatOrNull(1)) + formatValues.put("contentLength", cursor.getFloatOrNull(2)) + db.insert("Format", CONFLICT_IGNORE, formatValues) + } + } + + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `Song_new` ( + `id` TEXT NOT NULL, + `title` TEXT NOT NULL, + `artistsText` TEXT, + `durationText` TEXT NOT NULL, + `thumbnailUrl` TEXT, + `lyrics` TEXT, + `likedAt` INTEGER, + `totalPlayTimeMs` INTEGER NOT NULL, + PRIMARY KEY(`id`) + ) + """.trimIndent() + ) + + db.execSQL( + """ + INSERT INTO Song_new(id, title, artistsText, durationText, thumbnailUrl, lyrics, likedAt, totalPlayTimeMs) + SELECT id, title, artistsText, durationText, thumbnailUrl, lyrics, likedAt, totalPlayTimeMs + FROM Song; + """.trimIndent() + ) + db.execSQL("DROP TABLE Song;") + db.execSQL("ALTER TABLE Song_new RENAME TO Song;") + } + } + + @DeleteColumn.Entries( + DeleteColumn("Artist", "shuffleVideoId"), + DeleteColumn("Artist", "shufflePlaylistId"), + DeleteColumn("Artist", "radioVideoId"), + DeleteColumn("Artist", "radioPlaylistId") + ) + class From20To21Migration : AutoMigrationSpec + + @DeleteColumn.Entries(DeleteColumn("Artist", "info")) + class From21To22Migration : AutoMigrationSpec + + class From22To23Migration : Migration(22, 23) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS Lyrics ( + `songId` TEXT NOT NULL, + `fixed` TEXT, + `synced` TEXT, + PRIMARY KEY(`songId`), + FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + + db.query(SimpleSQLiteQuery("SELECT id, lyrics, synchronizedLyrics FROM Song;")) + .use { cursor -> + val lyricsValues = ContentValues(3) + while (cursor.moveToNext()) { + lyricsValues.put("songId", cursor.getString(0)) + lyricsValues.put("fixed", cursor.getString(1)) + lyricsValues.put("synced", cursor.getString(2)) + db.insert("Lyrics", CONFLICT_IGNORE, lyricsValues) + } + } + + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS Song_new ( + `id` TEXT NOT NULL, + `title` TEXT NOT NULL, + `artistsText` TEXT, + `durationText` TEXT, + `thumbnailUrl` TEXT, + `likedAt` INTEGER, + `totalPlayTimeMs` INTEGER NOT NULL, + PRIMARY KEY(`id`) + ) + """.trimIndent() + ) + db.execSQL( + """ + INSERT INTO Song_new(id, title, artistsText, durationText, thumbnailUrl, likedAt, totalPlayTimeMs) + SELECT id, title, artistsText, durationText, thumbnailUrl, likedAt, totalPlayTimeMs + FROM Song; + """.trimIndent() + ) + db.execSQL("DROP TABLE Song;") + db.execSQL("ALTER TABLE Song_new RENAME TO Song;") + } + } + + class From23To24Migration : Migration(23, 24) { + override fun migrate(db: SupportSQLiteDatabase) = + db.execSQL("ALTER TABLE Song ADD COLUMN loudnessBoost REAL") + } +} + +@Suppress("unused") +@TypeConverters +object Converters { + @TypeConverter + @OptIn(UnstableApi::class) + fun mediaItemFromByteArray(value: ByteArray?): MediaItem? = value?.let { byteArray -> + runCatching { + val parcel = Parcel.obtain() + parcel.unmarshall(byteArray, 0, byteArray.size) + parcel.setDataPosition(0) + val bundle = parcel.readBundle(MediaItem::class.java.classLoader) + parcel.recycle() + + bundle?.let(MediaItem::fromBundle) + }.getOrNull() + } + + @TypeConverter + @OptIn(UnstableApi::class) + fun mediaItemToByteArray(mediaItem: MediaItem?): ByteArray? = mediaItem?.toBundle()?.let { + val parcel = Parcel.obtain() + parcel.writeBundle(it) + val bytes = parcel.marshall() + parcel.recycle() + + bytes + } + + @TypeConverter + fun urlToString(url: Url) = url.toString() + + @TypeConverter + fun stringToUrl(string: String) = Url(string) +} + +@Suppress("UnusedReceiverParameter") +val Database.internal: RoomDatabase + get() = DatabaseInitializer.instance + +fun query(block: () -> Unit) = DatabaseInitializer.instance.queryExecutor.execute(block) + +fun transaction(block: () -> Unit) = with(DatabaseInitializer.instance) { + transactionExecutor.execute { + runInTransaction(block) + } +} + +val RoomDatabase.path: String? + get() = openHelper.writableDatabase.path diff --git a/app/src/main/kotlin/app/vimusic/android/MainApplication.kt b/app/src/main/kotlin/app/vimusic/android/MainApplication.kt new file mode 100644 index 0000000..1fa460e --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/MainApplication.kt @@ -0,0 +1,558 @@ +package app.vimusic.android + +import android.app.Application +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.net.Uri +import android.os.Bundle +import android.os.IBinder +import android.os.StrictMode +import android.os.StrictMode.VmPolicy +import android.provider.MediaStore +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.BoxWithConstraintsScope +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.add +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.isImeVisible +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalRippleConfiguration +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.coerceAtLeast +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.core.view.WindowCompat +import androidx.credentials.CredentialManager +import androidx.lifecycle.ViewModel +import androidx.lifecycle.lifecycleScope +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.work.Configuration +import app.vimusic.android.preferences.AppearancePreferences +import app.vimusic.android.preferences.DataPreferences +import app.vimusic.android.service.PlayerService +import app.vimusic.android.service.ServiceNotifications +import app.vimusic.android.service.downloadState +import app.vimusic.android.ui.components.BottomSheetMenu +import app.vimusic.android.ui.components.rememberBottomSheetState +import app.vimusic.android.ui.components.themed.LinearProgressIndicator +import app.vimusic.android.ui.screens.albumRoute +import app.vimusic.android.ui.screens.artistRoute +import app.vimusic.android.ui.screens.home.HomeScreen +import app.vimusic.android.ui.screens.player.Player +import app.vimusic.android.ui.screens.player.Thumbnail +import app.vimusic.android.ui.screens.playlistRoute +import app.vimusic.android.ui.screens.searchResultRoute +import app.vimusic.android.ui.screens.settingsRoute +import app.vimusic.android.utils.DisposableListener +import app.vimusic.android.utils.KeyedCrossfade +import app.vimusic.android.utils.LocalMonetCompat +import app.vimusic.android.utils.asMediaItem +import app.vimusic.android.utils.collectProvidedBitmapAsState +import app.vimusic.android.utils.forcePlay +import app.vimusic.android.utils.intent +import app.vimusic.android.utils.invokeOnReady +import app.vimusic.android.utils.isInPip +import app.vimusic.android.utils.maybeEnterPip +import app.vimusic.android.utils.maybeExitPip +import app.vimusic.android.utils.setDefaultPalette +import app.vimusic.android.utils.shouldBePlaying +import app.vimusic.android.utils.toast +import app.vimusic.compose.persist.LocalPersistMap +import app.vimusic.compose.persist.PersistMap +import app.vimusic.compose.preferences.PreferencesHolder +import app.vimusic.core.ui.Darkness +import app.vimusic.core.ui.Dimensions +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.SystemBarAppearance +import app.vimusic.core.ui.amoled +import app.vimusic.core.ui.appearance +import app.vimusic.core.ui.rippleConfiguration +import app.vimusic.core.ui.shimmerTheme +import app.vimusic.core.ui.utils.activityIntentBundle +import app.vimusic.core.ui.utils.isAtLeastAndroid12 +import app.vimusic.core.ui.utils.songBundle +import app.vimusic.providers.innertube.Innertube +import app.vimusic.providers.innertube.models.bodies.BrowseBody +import app.vimusic.providers.innertube.requests.playlistPage +import app.vimusic.providers.innertube.requests.song +import coil3.ImageLoader +import coil3.PlatformContext +import coil3.SingletonImageLoader +import coil3.bitmapFactoryExifOrientationStrategy +import coil3.decode.ExifOrientationStrategy +import coil3.disk.DiskCache +import coil3.disk.directory +import coil3.memory.MemoryCache +import coil3.request.crossfade +import coil3.util.DebugLogger +import com.kieronquinn.monetcompat.core.MonetActivityAccessException +import com.kieronquinn.monetcompat.core.MonetCompat +import com.kieronquinn.monetcompat.interfaces.MonetColorsChangedListener +import com.valentinilk.shimmer.LocalShimmerTheme +import dev.kdrag0n.monet.theme.ColorScheme +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private const val TAG = "MainActivity" +private val coroutineScope = CoroutineScope(Dispatchers.IO) + +// Viewmodel in order to avoid recreating the entire Player state (WORKAROUND) +class MainViewModel : ViewModel() { + var binder: PlayerService.Binder? by mutableStateOf(null) + + suspend fun awaitBinder(): PlayerService.Binder = + binder ?: snapshotFlow { binder }.filterNotNull().first() +} + +class MainActivity : ComponentActivity(), MonetColorsChangedListener { + private val vm: MainViewModel by viewModels() + + private val serviceConnection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + if (service is PlayerService.Binder) vm.binder = service + } + + override fun onServiceDisconnected(name: ComponentName?) { + vm.binder = null + // Try to rebind, otherwise fail + unbindService(this) + bindService(intent(), this, Context.BIND_AUTO_CREATE) + } + } + + private var _monet: MonetCompat? by mutableStateOf(null) + private val monet get() = _monet ?: throw MonetActivityAccessException() + + override fun onStart() { + super.onStart() + bindService(intent(), serviceConnection, Context.BIND_AUTO_CREATE) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + WindowCompat.setDecorFitsSystemWindows(window, false) + + MonetCompat.setup(this) + _monet = MonetCompat.getInstance() + monet.setDefaultPalette() + monet.addMonetColorsChangedListener( + listener = this, + notifySelf = false + ) + monet.updateMonetColors() + monet.invokeOnReady { + setContent() + } + + intent?.let { handleIntent(it) } + addOnNewIntentListener(::handleIntent) + } + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun AppWrapper( + modifier: Modifier = Modifier, + content: @Composable BoxWithConstraintsScope.() -> Unit + ) = with(AppearancePreferences) { + val sampleBitmap = vm.binder.collectProvidedBitmapAsState() + val appearance = appearance( + source = colorSource, + mode = colorMode, + darkness = darkness, + fontFamily = fontFamily, + materialAccentColor = Color(monet.getAccentColor(this@MainActivity)), + sampleBitmap = sampleBitmap, + applyFontPadding = applyFontPadding, + thumbnailRoundness = thumbnailRoundness.dp + ) + + SystemBarAppearance(palette = appearance.colorPalette) + + BoxWithConstraints( + modifier = modifier + .fillMaxSize() + .background(appearance.colorPalette.background0) + ) { + CompositionLocalProvider( + LocalAppearance provides appearance, + LocalPlayerServiceBinder provides vm.binder, + LocalCredentialManager provides Dependencies.credentialManager, + LocalIndication provides ripple(), + LocalRippleConfiguration provides rippleConfiguration(appearance = appearance), + LocalShimmerTheme provides shimmerTheme(), + LocalLayoutDirection provides LayoutDirection.Ltr, + LocalPersistMap provides Dependencies.application.persistMap, + LocalMonetCompat provides monet + ) { + content() + } + } + } + + @Suppress("CyclomaticComplexMethod") + @OptIn(ExperimentalLayoutApi::class) + fun setContent() = setContent { + AppWrapper { + val density = LocalDensity.current + val windowsInsets = WindowInsets.systemBars + val bottomDp = with(density) { windowsInsets.getBottom(density).toDp() } + + val imeVisible = WindowInsets.isImeVisible + val imeBottomDp = with(density) { WindowInsets.ime.getBottom(density).toDp() } + val animatedBottomDp by animateDpAsState( + targetValue = if (imeVisible) 0.dp else bottomDp, + label = "" + ) + + val playerBottomSheetState = rememberBottomSheetState( + key = vm.binder, + dismissedBound = 0.dp, + collapsedBound = Dimensions.items.collapsedPlayerHeight + bottomDp, + expandedBound = maxHeight + ) + + val playerAwareWindowInsets = remember( + bottomDp, + animatedBottomDp, + playerBottomSheetState.value, + imeVisible, + imeBottomDp + ) { + val bottom = + if (imeVisible) imeBottomDp.coerceAtLeast(playerBottomSheetState.value) + else playerBottomSheetState.value.coerceIn( + animatedBottomDp..playerBottomSheetState.collapsedBound + ) + + windowsInsets + .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top) + .add(WindowInsets(bottom = bottom)) + } + + val pip = isInPip( + onChange = { + if (!it || vm.binder?.player?.shouldBePlaying != true) return@isInPip + playerBottomSheetState.expandSoft() + } + ) + + KeyedCrossfade(state = pip) { currentPip -> + if (currentPip) Thumbnail( + isShowingLyrics = true, + onShowLyrics = { }, + isShowingStatsForNerds = false, + onShowStatsForNerds = { }, + onOpenDialog = { }, + likedAt = null, + setLikedAt = { }, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.FillBounds, + shouldShowSynchronizedLyrics = true, + setShouldShowSynchronizedLyrics = { }, + showLyricsControls = false + ) else CompositionLocalProvider( + LocalPlayerAwareWindowInsets provides playerAwareWindowInsets + ) { + val isDownloading by downloadState.collectAsState() + + Box { + HomeScreen() + } + + AnimatedVisibility( + visible = isDownloading, + modifier = Modifier.padding(playerAwareWindowInsets.asPaddingValues()) + ) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.TopCenter) + ) + } + + CompositionLocalProvider( + LocalAppearance provides LocalAppearance.current.let { + if (it.colorPalette.isDark && AppearancePreferences.darkness == Darkness.AMOLED) { + it.copy(colorPalette = it.colorPalette.amoled()) + } else it + } + ) { + Player( + layoutState = playerBottomSheetState, + modifier = Modifier.align(Alignment.BottomCenter) + ) + } + + BottomSheetMenu( + modifier = Modifier.align(Alignment.BottomCenter) + ) + } + } + + vm.binder?.player.DisposableListener { + object : Player.Listener { + override fun onMediaItemTransition( + mediaItem: MediaItem?, + reason: Int + ) = when { + mediaItem == null -> { + maybeExitPip() + playerBottomSheetState.dismissSoft() + } + + reason == Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED && + mediaItem.mediaMetadata.extras?.songBundle?.isFromPersistentQueue != true + -> playerBottomSheetState.expandSoft() + + playerBottomSheetState.dismissed -> playerBottomSheetState.collapseSoft() + + else -> Unit + } + } + } + } + } + + @Suppress("CyclomaticComplexMethod") + private fun handleIntent(intent: Intent) = lifecycleScope.launch(Dispatchers.IO) { + val extras = intent.extras?.activityIntentBundle + + when (intent.action) { + Intent.ACTION_SEARCH -> { + val query = extras?.query ?: return@launch + extras.query = null + + searchResultRoute.ensureGlobal(query) + } + + Intent.ACTION_APPLICATION_PREFERENCES -> settingsRoute.ensureGlobal() + + Intent.ACTION_VIEW, Intent.ACTION_SEND -> { + val uri = intent.data + ?: runCatching { extras?.text?.toUri() }.getOrNull() + ?: return@launch + + intent.data = null + extras?.text = null + + handleUrl(uri, vm.awaitBinder()) + } + + MediaStore.INTENT_ACTION_MEDIA_PLAY_FROM_SEARCH -> { + val query = when (extras?.mediaFocus) { + null, "vnd.android.cursor.item/*" -> extras?.query ?: extras?.text + MediaStore.Audio.Genres.ENTRY_CONTENT_TYPE -> extras.genre + MediaStore.Audio.Artists.ENTRY_CONTENT_TYPE -> extras.artist + MediaStore.Audio.Albums.ENTRY_CONTENT_TYPE -> extras.album + "vnd.android.cursor.item/audio" -> listOfNotNull( + extras.album, + extras.artist, + extras.genre, + extras.title + ).joinToString(separator = " ") + + @Suppress("deprecation") + MediaStore.Audio.Playlists.ENTRY_CONTENT_TYPE + -> extras.playlist + + else -> null + } + + if (!query.isNullOrBlank()) vm.awaitBinder().playFromSearch(query) + } + } + } + + override fun onDestroy() { + super.onDestroy() + monet.removeMonetColorsChangedListener(this) + _monet = null + + removeOnNewIntentListener(::handleIntent) + } + + override fun onStop() { + unbindService(serviceConnection) + super.onStop() + } + + override fun onMonetColorsChanged( + monet: MonetCompat, + monetColors: ColorScheme, + isInitialChange: Boolean + ) { + if (!isInitialChange) recreate() + } + + override fun onUserLeaveHint() { + super.onUserLeaveHint() + + if (AppearancePreferences.autoPip && vm.binder?.player?.shouldBePlaying == true) maybeEnterPip() + } +} + +context(Context) +@Suppress("CyclomaticComplexMethod") +fun handleUrl( + uri: Uri, + binder: PlayerService.Binder? +) { + val path = uri.pathSegments.firstOrNull() + Log.d(TAG, "Opening url: $uri ($path)") + + coroutineScope.launch { + when (path) { + "search" -> uri.getQueryParameter("q")?.let { query -> + searchResultRoute.ensureGlobal(query) + } + + "playlist" -> uri.getQueryParameter("list")?.let { playlistId -> + val browseId = "VL$playlistId" + + if (playlistId.startsWith("OLAK5uy_")) Innertube.playlistPage( + body = BrowseBody(browseId = browseId) + ) + ?.getOrNull() + ?.let { page -> + page.songsPage?.items?.firstOrNull()?.album?.endpoint?.browseId + ?.let { albumRoute.ensureGlobal(it) } + } ?: withContext(Dispatchers.Main) { + toast(getString(R.string.error_url, uri)) + } + else playlistRoute.ensureGlobal( + p0 = browseId, + p1 = uri.getQueryParameter("params"), + p2 = null, + p3 = playlistId.startsWith("RDCLAK5uy_") + ) + } + + "channel", "c" -> uri.lastPathSegment?.let { channelId -> + artistRoute.ensureGlobal(channelId) + } + + else -> when { + path == "watch" -> uri.getQueryParameter("v") + uri.host == "youtu.be" -> path + else -> { + withContext(Dispatchers.Main) { + toast(getString(R.string.error_url, uri)) + } + null + } + }?.let { videoId -> + Innertube.song(videoId)?.getOrNull()?.let { song -> + withContext(Dispatchers.Main) { + binder?.player?.forcePlay(song.asMediaItem) + } + } + } + } + } +} + +val LocalPlayerServiceBinder = staticCompositionLocalOf { null } +val LocalPlayerAwareWindowInsets = + compositionLocalOf { error("No player insets provided") } +val LocalCredentialManager = staticCompositionLocalOf { Dependencies.credentialManager } + +class MainApplication : Application(), SingletonImageLoader.Factory, Configuration.Provider { + override fun onCreate() { + StrictMode.setVmPolicy( + VmPolicy.Builder() + .let { + if (isAtLeastAndroid12) it.detectUnsafeIntentLaunch() + else it + } + .penaltyLog() + .penaltyDeath() + .build() + ) + + MonetCompat.debugLog = BuildConfig.DEBUG + super.onCreate() + + Dependencies.init(this) + MonetCompat.enablePaletteCompat() + ServiceNotifications.createAll() + } + + override fun newImageLoader(context: PlatformContext) = ImageLoader.Builder(this) + .crossfade(true) + .memoryCache { + MemoryCache.Builder() + .maxSizePercent(context, 0.1) + .build() + } + .diskCache { + DiskCache.Builder() + .directory(context.cacheDir.resolve("coil")) + .maxSizeBytes(DataPreferences.coilDiskCacheMaxSize.bytes) + .build() + } + .bitmapFactoryExifOrientationStrategy(ExifOrientationStrategy.IGNORE) + .let { if (BuildConfig.DEBUG) it.logger(DebugLogger()) else it } + .build() + + val persistMap = PersistMap() + + override val workManagerConfiguration = Configuration.Builder() + .setMinimumLoggingLevel(if (BuildConfig.DEBUG) Log.DEBUG else Log.INFO) + .build() +} + +object Dependencies { + lateinit var application: MainApplication + private set + + val credentialManager by lazy { CredentialManager.create(application) } + + internal fun init(application: MainApplication) { + this.application = application + DatabaseInitializer() + } +} + +open class GlobalPreferencesHolder : PreferencesHolder(Dependencies.application, "preferences") diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Album.kt b/app/src/main/kotlin/app/vimusic/android/models/Album.kt similarity index 72% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Album.kt rename to app/src/main/kotlin/app/vimusic/android/models/Album.kt index 57f6827..36afc80 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Album.kt +++ b/app/src/main/kotlin/app/vimusic/android/models/Album.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.vimusic.models +package app.vimusic.android.models import androidx.compose.runtime.Immutable import androidx.room.Entity @@ -9,10 +9,12 @@ import androidx.room.PrimaryKey data class Album( @PrimaryKey val id: String, val title: String? = null, + val description: String? = null, val thumbnailUrl: String? = null, val year: String? = null, val authorsText: String? = null, val shareUrl: String? = null, val timestamp: Long? = null, - val bookmarkedAt: Long? = null + val bookmarkedAt: Long? = null, + val otherInfo: String? = null ) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Artist.kt b/app/src/main/kotlin/app/vimusic/android/models/Artist.kt similarity index 79% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Artist.kt rename to app/src/main/kotlin/app/vimusic/android/models/Artist.kt index 92fb7d1..89ee3f5 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Artist.kt +++ b/app/src/main/kotlin/app/vimusic/android/models/Artist.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.vimusic.models +package app.vimusic.android.models import androidx.compose.runtime.Immutable import androidx.room.Entity @@ -11,5 +11,5 @@ data class Artist( val name: String? = null, val thumbnailUrl: String? = null, val timestamp: Long? = null, - val bookmarkedAt: Long? = null, + val bookmarkedAt: Long? = null ) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Event.kt b/app/src/main/kotlin/app/vimusic/android/models/Event.kt similarity index 94% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Event.kt rename to app/src/main/kotlin/app/vimusic/android/models/Event.kt index 912b88a..8716839 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Event.kt +++ b/app/src/main/kotlin/app/vimusic/android/models/Event.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.vimusic.models +package app.vimusic.android.models import androidx.compose.runtime.Immutable import androidx.room.ColumnInfo diff --git a/app/src/main/kotlin/app/vimusic/android/models/EventWithSong.kt b/app/src/main/kotlin/app/vimusic/android/models/EventWithSong.kt new file mode 100644 index 0000000..a2e96ef --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/models/EventWithSong.kt @@ -0,0 +1,16 @@ +package app.vimusic.android.models + +import androidx.compose.runtime.Immutable +import androidx.room.Embedded +import androidx.room.Relation + +@Immutable +data class EventWithSong( + @Embedded val event: Event, + @Relation( + entity = Song::class, + parentColumn = "songId", + entityColumn = "id" + ) + val song: Song +) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Format.kt b/app/src/main/kotlin/app/vimusic/android/models/Format.kt similarity index 94% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Format.kt rename to app/src/main/kotlin/app/vimusic/android/models/Format.kt index 88fef51..7bb2ab2 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Format.kt +++ b/app/src/main/kotlin/app/vimusic/android/models/Format.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.vimusic.models +package app.vimusic.android.models import androidx.compose.runtime.Immutable import androidx.room.Entity diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Info.kt b/app/src/main/kotlin/app/vimusic/android/models/Info.kt similarity index 63% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Info.kt rename to app/src/main/kotlin/app/vimusic/android/models/Info.kt index 161a3ca..033f403 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Info.kt +++ b/app/src/main/kotlin/app/vimusic/android/models/Info.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.vimusic.models +package app.vimusic.android.models data class Info( val id: String, diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Lyrics.kt b/app/src/main/kotlin/app/vimusic/android/models/Lyrics.kt similarity index 76% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Lyrics.kt rename to app/src/main/kotlin/app/vimusic/android/models/Lyrics.kt index da6c770..006a2a4 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Lyrics.kt +++ b/app/src/main/kotlin/app/vimusic/android/models/Lyrics.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.vimusic.models +package app.vimusic.android.models import androidx.compose.runtime.Immutable import androidx.room.Entity @@ -12,12 +12,13 @@ import androidx.room.PrimaryKey entity = Song::class, parentColumns = ["id"], childColumns = ["songId"], - onDelete = ForeignKey.CASCADE, + onDelete = ForeignKey.CASCADE ) ] ) -class Lyrics( +data class Lyrics( @PrimaryKey val songId: String, val fixed: String?, val synced: String?, + val startTime: Long? = null ) diff --git a/app/src/main/kotlin/app/vimusic/android/models/Mood.kt b/app/src/main/kotlin/app/vimusic/android/models/Mood.kt new file mode 100644 index 0000000..6af4bd3 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/models/Mood.kt @@ -0,0 +1,23 @@ +package app.vimusic.android.models + +import android.os.Parcelable +import androidx.compose.ui.graphics.Color +import app.vimusic.core.ui.ColorParceler +import app.vimusic.providers.innertube.Innertube +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.WriteWith + +@Parcelize +data class Mood( + val name: String, + val color: @WriteWith Color, + val browseId: String?, + val params: String? +) : Parcelable + +fun Innertube.Mood.Item.toUiMood() = Mood( + name = title, + color = Color(stripeColor), + browseId = endpoint.browseId, + params = endpoint.params +) diff --git a/app/src/main/kotlin/app/vimusic/android/models/PipedSession.kt b/app/src/main/kotlin/app/vimusic/android/models/PipedSession.kt new file mode 100644 index 0000000..8d80336 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/models/PipedSession.kt @@ -0,0 +1,26 @@ +package app.vimusic.android.models + +import androidx.compose.runtime.Immutable +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import app.vimusic.providers.piped.models.authenticatedWith +import io.ktor.http.Url + +@Immutable +@Entity( + indices = [ + Index( + value = ["apiBaseUrl", "username"], + unique = true + ) + ] +) +data class PipedSession( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val apiBaseUrl: Url, + val token: String, + val username: String // the username should never change on piped +) { + fun toApiSession() = apiBaseUrl authenticatedWith token +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Playlist.kt b/app/src/main/kotlin/app/vimusic/android/models/Playlist.kt similarity index 68% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Playlist.kt rename to app/src/main/kotlin/app/vimusic/android/models/Playlist.kt index f76a8a0..4d70d46 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Playlist.kt +++ b/app/src/main/kotlin/app/vimusic/android/models/Playlist.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.vimusic.models +package app.vimusic.android.models import androidx.compose.runtime.Immutable import androidx.room.Entity @@ -9,5 +9,6 @@ import androidx.room.PrimaryKey data class Playlist( @PrimaryKey(autoGenerate = true) val id: Long = 0, val name: String, - val browseId: String? = null + val browseId: String? = null, + val thumbnail: String? = null ) diff --git a/app/src/main/kotlin/app/vimusic/android/models/PlaylistPreview.kt b/app/src/main/kotlin/app/vimusic/android/models/PlaylistPreview.kt new file mode 100644 index 0000000..61152f3 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/models/PlaylistPreview.kt @@ -0,0 +1,19 @@ +package app.vimusic.android.models + +import androidx.compose.runtime.Immutable + +@Immutable +data class PlaylistPreview( + val id: Long, + val name: String, + val songCount: Int, + val thumbnail: String? +) { + val playlist by lazy { + Playlist( + id = id, + name = name, + browseId = null + ) + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/PlaylistWithSongs.kt b/app/src/main/kotlin/app/vimusic/android/models/PlaylistWithSongs.kt similarity index 93% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/models/PlaylistWithSongs.kt rename to app/src/main/kotlin/app/vimusic/android/models/PlaylistWithSongs.kt index 27d8f9a..4edcd66 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/PlaylistWithSongs.kt +++ b/app/src/main/kotlin/app/vimusic/android/models/PlaylistWithSongs.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.vimusic.models +package app.vimusic.android.models import androidx.compose.runtime.Immutable import androidx.room.Embedded diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/QueuedMediaItem.kt b/app/src/main/kotlin/app/vimusic/android/models/QueuedMediaItem.kt similarity index 91% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/models/QueuedMediaItem.kt rename to app/src/main/kotlin/app/vimusic/android/models/QueuedMediaItem.kt index 27cde9a..95b6261 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/QueuedMediaItem.kt +++ b/app/src/main/kotlin/app/vimusic/android/models/QueuedMediaItem.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.vimusic.models +package app.vimusic.android.models import androidx.compose.runtime.Immutable import androidx.media3.common.MediaItem diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SearchQuery.kt b/app/src/main/kotlin/app/vimusic/android/models/SearchQuery.kt similarity index 90% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SearchQuery.kt rename to app/src/main/kotlin/app/vimusic/android/models/SearchQuery.kt index a3479f1..7008150 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SearchQuery.kt +++ b/app/src/main/kotlin/app/vimusic/android/models/SearchQuery.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.vimusic.models +package app.vimusic.android.models import androidx.compose.runtime.Immutable import androidx.room.Entity diff --git a/app/src/main/kotlin/app/vimusic/android/models/Song.kt b/app/src/main/kotlin/app/vimusic/android/models/Song.kt new file mode 100644 index 0000000..968fede --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/models/Song.kt @@ -0,0 +1,25 @@ +package app.vimusic.android.models + +import androidx.compose.runtime.Immutable +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Immutable +@Entity +data class Song( + @PrimaryKey val id: String, + val title: String, + val artistsText: String? = null, + val durationText: String?, + val thumbnailUrl: String?, + val likedAt: Long? = null, + val totalPlayTimeMs: Long = 0, + val loudnessBoost: Float? = null, + @ColumnInfo(defaultValue = "false") + val blacklisted: Boolean = false, + @ColumnInfo(defaultValue = "false") + val explicit: Boolean = false +) { + fun toggleLike() = copy(likedAt = if (likedAt == null) System.currentTimeMillis() else null) +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SongAlbumMap.kt b/app/src/main/kotlin/app/vimusic/android/models/SongAlbumMap.kt similarity index 95% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SongAlbumMap.kt rename to app/src/main/kotlin/app/vimusic/android/models/SongAlbumMap.kt index 4fc61a3..9ca2388 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SongAlbumMap.kt +++ b/app/src/main/kotlin/app/vimusic/android/models/SongAlbumMap.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.vimusic.models +package app.vimusic.android.models import androidx.compose.runtime.Immutable import androidx.room.ColumnInfo diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SongArtistMap.kt b/app/src/main/kotlin/app/vimusic/android/models/SongArtistMap.kt similarity index 95% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SongArtistMap.kt rename to app/src/main/kotlin/app/vimusic/android/models/SongArtistMap.kt index d250954..90134f2 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SongArtistMap.kt +++ b/app/src/main/kotlin/app/vimusic/android/models/SongArtistMap.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.vimusic.models +package app.vimusic.android.models import androidx.compose.runtime.Immutable import androidx.room.ColumnInfo diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SongPlaylistMap.kt b/app/src/main/kotlin/app/vimusic/android/models/SongPlaylistMap.kt similarity index 95% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SongPlaylistMap.kt rename to app/src/main/kotlin/app/vimusic/android/models/SongPlaylistMap.kt index 21507b9..5cb6674 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SongPlaylistMap.kt +++ b/app/src/main/kotlin/app/vimusic/android/models/SongPlaylistMap.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.vimusic.models +package app.vimusic.android.models import androidx.compose.runtime.Immutable import androidx.room.ColumnInfo diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SongWithContentLength.kt b/app/src/main/kotlin/app/vimusic/android/models/SongWithContentLength.kt similarity index 83% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SongWithContentLength.kt rename to app/src/main/kotlin/app/vimusic/android/models/SongWithContentLength.kt index e43d56e..57b3b54 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SongWithContentLength.kt +++ b/app/src/main/kotlin/app/vimusic/android/models/SongWithContentLength.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.vimusic.models +package app.vimusic.android.models import androidx.compose.runtime.Immutable import androidx.room.Embedded diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SortedSongPlaylistMap.kt b/app/src/main/kotlin/app/vimusic/android/models/SortedSongPlaylistMap.kt similarity index 90% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SortedSongPlaylistMap.kt rename to app/src/main/kotlin/app/vimusic/android/models/SortedSongPlaylistMap.kt index 0e34710..d19dc46 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SortedSongPlaylistMap.kt +++ b/app/src/main/kotlin/app/vimusic/android/models/SortedSongPlaylistMap.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.vimusic.models +package app.vimusic.android.models import androidx.compose.runtime.Immutable import androidx.room.ColumnInfo diff --git a/app/src/main/kotlin/app/vimusic/android/models/ui/UiMedia.kt b/app/src/main/kotlin/app/vimusic/android/models/ui/UiMedia.kt new file mode 100644 index 0000000..2e68706 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/models/ui/UiMedia.kt @@ -0,0 +1,20 @@ +package app.vimusic.android.models.ui + +import androidx.media3.common.MediaItem +import app.vimusic.core.ui.utils.songBundle + +data class UiMedia( + val id: String, + val title: String, + val artist: String, + val duration: Long, + val explicit: Boolean +) + +fun MediaItem.toUiMedia(duration: Long) = UiMedia( + id = mediaId, + title = mediaMetadata.title?.toString().orEmpty(), + artist = mediaMetadata.artist?.toString().orEmpty(), + duration = duration, + explicit = mediaMetadata.extras?.songBundle?.explicit == true +) diff --git a/app/src/main/kotlin/app/vimusic/android/preferences/AppearancePreferences.kt b/app/src/main/kotlin/app/vimusic/android/preferences/AppearancePreferences.kt new file mode 100644 index 0000000..809ff1e --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/preferences/AppearancePreferences.kt @@ -0,0 +1,44 @@ +package app.vimusic.android.preferences + +import app.vimusic.android.GlobalPreferencesHolder +import app.vimusic.android.preferences.OldPreferences.ColorPaletteMode +import app.vimusic.android.preferences.OldPreferences.ColorPaletteName +import app.vimusic.core.ui.BuiltInFontFamily +import app.vimusic.core.ui.ColorMode +import app.vimusic.core.ui.ColorSource +import app.vimusic.core.ui.Darkness +import app.vimusic.core.ui.ThumbnailRoundness + +object AppearancePreferences : GlobalPreferencesHolder() { + var colorSource by enum( + when (OldPreferences.oldColorPaletteName) { + ColorPaletteName.Default, ColorPaletteName.PureBlack -> ColorSource.Default + ColorPaletteName.Dynamic, ColorPaletteName.AMOLED -> ColorSource.Dynamic + ColorPaletteName.MaterialYou -> ColorSource.MaterialYou + } + ) + var colorMode by enum( + when (OldPreferences.oldColorPaletteMode) { + ColorPaletteMode.Light -> ColorMode.Light + ColorPaletteMode.Dark -> ColorMode.Dark + ColorPaletteMode.System -> ColorMode.System + } + ) + var darkness by enum( + when (OldPreferences.oldColorPaletteName) { + ColorPaletteName.Default, ColorPaletteName.Dynamic, ColorPaletteName.MaterialYou -> Darkness.Normal + ColorPaletteName.PureBlack -> Darkness.PureBlack + ColorPaletteName.AMOLED -> Darkness.AMOLED + } + ) + var thumbnailRoundness by enum(ThumbnailRoundness.Medium) + var fontFamily by enum(BuiltInFontFamily.Poppins) + var applyFontPadding by boolean(false) + val isShowingThumbnailInLockscreenProperty = boolean(true) + var isShowingThumbnailInLockscreen by isShowingThumbnailInLockscreenProperty + var swipeToHideSong by boolean(false) + var swipeToHideSongConfirm by boolean(true) + var maxThumbnailSize by int(1920) + var hideExplicit by boolean(false) + var autoPip by boolean(false) +} diff --git a/app/src/main/kotlin/app/vimusic/android/preferences/DataPreferences.kt b/app/src/main/kotlin/app/vimusic/android/preferences/DataPreferences.kt new file mode 100644 index 0000000..799ae14 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/preferences/DataPreferences.kt @@ -0,0 +1,55 @@ +package app.vimusic.android.preferences + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import app.vimusic.android.GlobalPreferencesHolder +import app.vimusic.android.R +import app.vimusic.core.data.enums.CoilDiskCacheSize +import app.vimusic.core.data.enums.ExoPlayerDiskCacheSize +import app.vimusic.providers.innertube.Innertube +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours + +object DataPreferences : GlobalPreferencesHolder() { + var coilDiskCacheMaxSize by enum(CoilDiskCacheSize.`128MB`) + var exoPlayerDiskCacheMaxSize by enum(ExoPlayerDiskCacheSize.`2GB`) + var pauseHistory by boolean(false) + var pausePlaytime by boolean(false) + var pauseSearchHistory by boolean(false) + val topListLengthProperty = int(50) + var topListLength by topListLengthProperty + val topListPeriodProperty = enum(TopListPeriod.AllTime) + var topListPeriod by topListPeriodProperty + var quickPicksSource by enum(QuickPicksSource.Trending) + var versionCheckPeriod by enum(VersionCheckPeriod.Off) + var shouldCacheQuickPicks by boolean(true) + var cachedQuickPicks by json(Innertube.RelatedPage()) + var autoSyncPlaylists by boolean(true) + + enum class TopListPeriod( + val displayName: @Composable () -> String, + val duration: Duration? = null + ) { + PastDay(displayName = { stringResource(R.string.past_24_hours) }, duration = 1.days), + PastWeek(displayName = { stringResource(R.string.past_week) }, duration = 7.days), + PastMonth(displayName = { stringResource(R.string.past_month) }, duration = 30.days), + PastYear(displayName = { stringResource(R.string.past_year) }, 365.days), + AllTime(displayName = { stringResource(R.string.all_time) }) + } + + enum class QuickPicksSource(val displayName: @Composable () -> String) { + Trending(displayName = { stringResource(R.string.trending) }), + LastInteraction(displayName = { stringResource(R.string.last_interaction) }) + } + + enum class VersionCheckPeriod( + val displayName: @Composable () -> String, + val period: Duration? + ) { + Off(displayName = { stringResource(R.string.off_text) }, period = null), + Hourly(displayName = { stringResource(R.string.hourly) }, period = 1.hours), + Daily(displayName = { stringResource(R.string.daily) }, period = 1.days), + Weekly(displayName = { stringResource(R.string.weekly) }, period = 7.days) + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/preferences/OldPreferences.kt b/app/src/main/kotlin/app/vimusic/android/preferences/OldPreferences.kt new file mode 100644 index 0000000..d01cb5e --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/preferences/OldPreferences.kt @@ -0,0 +1,22 @@ +package app.vimusic.android.preferences + +import app.vimusic.android.GlobalPreferencesHolder + +internal object OldPreferences : GlobalPreferencesHolder() { + val oldColorPaletteName by enum(ColorPaletteName.Dynamic, "colorPaletteName") + val oldColorPaletteMode by enum(ColorPaletteMode.System, "colorPaletteMode") + + enum class ColorPaletteName { + Default, + Dynamic, + MaterialYou, + PureBlack, + AMOLED + } + + enum class ColorPaletteMode { + Light, + Dark, + System + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/preferences/OrderPreferences.kt b/app/src/main/kotlin/app/vimusic/android/preferences/OrderPreferences.kt new file mode 100644 index 0000000..c36170a --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/preferences/OrderPreferences.kt @@ -0,0 +1,22 @@ +package app.vimusic.android.preferences + +import app.vimusic.android.GlobalPreferencesHolder +import app.vimusic.core.data.enums.AlbumSortBy +import app.vimusic.core.data.enums.ArtistSortBy +import app.vimusic.core.data.enums.PlaylistSortBy +import app.vimusic.core.data.enums.SongSortBy +import app.vimusic.core.data.enums.SortOrder + +object OrderPreferences : GlobalPreferencesHolder() { + var songSortOrder by enum(SortOrder.Descending) + var localSongSortOrder by enum(SortOrder.Descending) + var playlistSortOrder by enum(SortOrder.Descending) + var albumSortOrder by enum(SortOrder.Descending) + var artistSortOrder by enum(SortOrder.Descending) + + var songSortBy by enum(SongSortBy.DateAdded) + var localSongSortBy by enum(SongSortBy.DateAdded) + var playlistSortBy by enum(PlaylistSortBy.DateAdded) + var albumSortBy by enum(AlbumSortBy.DateAdded) + var artistSortBy by enum(ArtistSortBy.DateAdded) +} diff --git a/app/src/main/kotlin/app/vimusic/android/preferences/PlayerPreferences.kt b/app/src/main/kotlin/app/vimusic/android/preferences/PlayerPreferences.kt new file mode 100644 index 0000000..3c3347c --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/preferences/PlayerPreferences.kt @@ -0,0 +1,119 @@ +package app.vimusic.android.preferences + +import android.media.audiofx.PresetReverb +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import app.vimusic.android.GlobalPreferencesHolder +import app.vimusic.android.R + +object PlayerPreferences : GlobalPreferencesHolder() { + val isInvincibilityEnabledProperty = boolean(false) + var isInvincibilityEnabled by isInvincibilityEnabledProperty + val trackLoopEnabledProperty = boolean(false) + var trackLoopEnabled by trackLoopEnabledProperty + val queueLoopEnabledProperty = boolean(true) + var queueLoopEnabled by queueLoopEnabledProperty + val skipSilenceProperty = boolean(false) + var skipSilence by skipSilenceProperty + val volumeNormalizationProperty = boolean(false) + var volumeNormalization by volumeNormalizationProperty + val volumeNormalizationBaseGainProperty = float(5.00f) + var volumeNormalizationBaseGain by volumeNormalizationBaseGainProperty + val bassBoostProperty = boolean(false) + var bassBoost by bassBoostProperty + val bassBoostLevelProperty = int(5) + var bassBoostLevel by bassBoostLevelProperty + val reverbProperty = enum(Reverb.None) + var reverb by reverbProperty + val resumePlaybackWhenDeviceConnectedProperty = boolean(false) + var resumePlaybackWhenDeviceConnected by resumePlaybackWhenDeviceConnectedProperty + val speedProperty = float(1f) + var speed by speedProperty + val pitchProperty = float(1f) + var pitch by pitchProperty + var minimumSilence by long(2_000_000L) + var persistentQueue by boolean(true) + var stopWhenClosed by boolean(false) + + var isShowingLyrics by boolean(false) + var isShowingSynchronizedLyrics by boolean(false) + + var isShowingPrevButtonCollapsed by boolean(false) + var horizontalSwipeToClose by boolean(false) + var horizontalSwipeToRemoveItem by boolean(false) + + var playerLayout by enum(PlayerLayout.New) + var seekBarStyle by enum(SeekBarStyle.Wavy) + var wavySeekBarQuality by enum(WavySeekBarQuality.Great) + var showLike by boolean(false) + var showRemaining by boolean(false) + var lyricsKeepScreenAwake by boolean(false) + var lyricsShowSystemBars by boolean(true) + + var skipOnError by boolean(false) + var handleAudioFocus by boolean(true) + + var pauseCache by boolean(false) + + val sponsorBlockEnabledProperty = boolean(false) + var sponsorBlockEnabled by sponsorBlockEnabledProperty + + enum class PlayerLayout(val displayName: @Composable () -> String) { + Classic(displayName = { stringResource(R.string.classic_player_layout_name) }), + New(displayName = { stringResource(R.string.new_player_layout_name) }) + } + + enum class SeekBarStyle(val displayName: @Composable () -> String) { + Static(displayName = { stringResource(R.string.static_seek_bar_name) }), + Wavy(displayName = { stringResource(R.string.wavy_seek_bar_name) }) + } + + enum class WavySeekBarQuality( + val quality: Float, + val displayName: @Composable () -> String + ) { + Poor(quality = 50f, displayName = { stringResource(R.string.seek_bar_quality_poor) }), + Low(quality = 25f, displayName = { stringResource(R.string.seek_bar_quality_low) }), + Medium(quality = 15f, displayName = { stringResource(R.string.seek_bar_quality_medium) }), + High(quality = 5f, displayName = { stringResource(R.string.seek_bar_quality_high) }), + Great(quality = 1f, displayName = { stringResource(R.string.seek_bar_quality_great) }), + Subpixel( + quality = 0.5f, + displayName = { stringResource(R.string.seek_bar_quality_subpixel) } + ) + } + + enum class Reverb( + val preset: Short, + val displayName: @Composable () -> String + ) { + None( + preset = PresetReverb.PRESET_NONE, + displayName = { stringResource(R.string.none) } + ), + SmallRoom( + preset = PresetReverb.PRESET_SMALLROOM, + displayName = { stringResource(R.string.reverb_small_room) } + ), + MediumRoom( + preset = PresetReverb.PRESET_MEDIUMROOM, + displayName = { stringResource(R.string.reverb_medium_room) } + ), + LargeRoom( + preset = PresetReverb.PRESET_LARGEROOM, + displayName = { stringResource(R.string.reverb_large_room) } + ), + MediumHall( + preset = PresetReverb.PRESET_MEDIUMHALL, + displayName = { stringResource(R.string.reverb_medium_hall) } + ), + LargeHall( + preset = PresetReverb.PRESET_LARGEHALL, + displayName = { stringResource(R.string.reverb_large_hall) } + ), + Plate( + preset = PresetReverb.PRESET_PLATE, + displayName = { stringResource(R.string.reverb_plate) } + ) + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/preferences/UIStatePreferences.kt b/app/src/main/kotlin/app/vimusic/android/preferences/UIStatePreferences.kt new file mode 100644 index 0000000..abcc435 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/preferences/UIStatePreferences.kt @@ -0,0 +1,41 @@ +package app.vimusic.android.preferences + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.SnapshotMutationPolicy +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import app.vimusic.android.GlobalPreferencesHolder +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +object UIStatePreferences : GlobalPreferencesHolder() { + var homeScreenTabIndex by int(0) + var searchResultScreenTabIndex by int(0) + + var artistScreenTabIndexProperty = int(0) + var artistScreenTabIndex by artistScreenTabIndexProperty + + private var visibleTabs by json(mapOf>()) + + @Composable + fun mutableTabStateOf( + key: String, + default: ImmutableList = persistentListOf() + ): MutableState> = remember(key, default, visibleTabs) { + mutableStateOf( + value = visibleTabs.getOrDefault(key, default).toImmutableList(), + policy = object : SnapshotMutationPolicy> { + override fun equivalent( + a: ImmutableList, + b: ImmutableList + ): Boolean { + val eq = a == b + if (!eq) visibleTabs += key to b + return eq + } + } + ) + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/service/BitmapProvider.kt b/app/src/main/kotlin/app/vimusic/android/service/BitmapProvider.kt new file mode 100644 index 0000000..3782dcb --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/service/BitmapProvider.kt @@ -0,0 +1,100 @@ +package app.vimusic.android.service + +import android.content.Context +import android.content.res.Configuration +import android.graphics.Bitmap +import android.net.Uri +import androidx.core.graphics.applyCanvas +import app.vimusic.android.utils.thumbnail +import coil3.imageLoader +import coil3.request.Disposable +import coil3.request.ImageRequest +import coil3.request.allowHardware +import coil3.toBitmap + +context(Context) +class BitmapProvider( + private val getBitmapSize: () -> Int, + private val getColor: (isDark: Boolean) -> Int +) { + var lastUri: Uri? = null + private set + + private var lastBitmap: Bitmap? = null + set(value) { + field = value + listener?.invoke(value) + } + private var lastIsSystemInDarkMode = false + private var currentTask: Disposable? = null + + private lateinit var defaultBitmap: Bitmap + val bitmap get() = lastBitmap ?: defaultBitmap + + private var listener: ((Bitmap?) -> Unit)? = null + + init { + setDefaultBitmap() + } + + fun setDefaultBitmap(): Boolean { + val isSystemInDarkMode = resources.configuration.uiMode and + Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES + + if (::defaultBitmap.isInitialized && isSystemInDarkMode == lastIsSystemInDarkMode) return false + + lastIsSystemInDarkMode = isSystemInDarkMode + + val size = getBitmapSize() + defaultBitmap = Bitmap.createBitmap( + /* width = */ size, + /* height = */ size, + /* config = */ Bitmap.Config.ARGB_8888 + ).applyCanvas { + drawColor(getColor(isSystemInDarkMode)) + } + + return lastBitmap == null + } + + fun load( + uri: Uri?, + onDone: (Bitmap) -> Unit = { } + ) { + if (lastUri == uri) { + listener?.invoke(lastBitmap) + return + } + + currentTask?.dispose() + lastUri = uri + + if (uri == null) { + lastBitmap = null + onDone(bitmap) + return + } + + currentTask = applicationContext.imageLoader.enqueue( + ImageRequest.Builder(applicationContext) + .data(uri.thumbnail(getBitmapSize())) + .allowHardware(false) + .listener( + onError = { _, _ -> + lastBitmap = null + onDone(bitmap) + }, + onSuccess = { _, result -> + lastBitmap = result.image.run { toBitmap(width, height) } + onDone(bitmap) + } + ) + .build() + ) + } + + fun setListener(callback: ((Bitmap?) -> Unit)?) { + listener = callback + listener?.invoke(lastBitmap) + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/service/PlaybackExceptions.kt b/app/src/main/kotlin/app/vimusic/android/service/PlaybackExceptions.kt new file mode 100644 index 0000000..adc7b55 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/service/PlaybackExceptions.kt @@ -0,0 +1,37 @@ +@file:OptIn(UnstableApi::class) + +package app.vimusic.android.service + +import androidx.annotation.OptIn +import androidx.media3.common.PlaybackException +import androidx.media3.common.util.UnstableApi + +class PlayableFormatNotFoundException(cause: Throwable? = null) : PlaybackException( + /* message = */ "Playable format not found", + /* cause = */ cause, + /* errorCode = */ ERROR_CODE_IO_FILE_NOT_FOUND +) + +class UnplayableException(cause: Throwable? = null) : PlaybackException( + /* message = */ "Unplayable", + /* cause = */ cause, + /* errorCode = */ ERROR_CODE_IO_UNSPECIFIED +) + +class LoginRequiredException(cause: Throwable? = null) : PlaybackException( + /* message = */ "Login required", + /* cause = */ cause, + /* errorCode = */ ERROR_CODE_AUTHENTICATION_EXPIRED +) + +class VideoIdMismatchException(cause: Throwable? = null) : PlaybackException( + /* message = */ "Requested video ID doesn't match returned video ID", + /* cause = */ cause, + /* errorCode = */ ERROR_CODE_IO_UNSPECIFIED +) + +class RestrictedVideoException(cause: Throwable? = null) : PlaybackException( + /* message = */ "This video is restricted", + /* cause = */ cause, + /* errorCode = */ ERROR_CODE_PARENTAL_CONTROL_RESTRICTED +) diff --git a/app/src/main/kotlin/app/vimusic/android/service/PlayerMediaBrowserService.kt b/app/src/main/kotlin/app/vimusic/android/service/PlayerMediaBrowserService.kt new file mode 100644 index 0000000..56d6273 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/service/PlayerMediaBrowserService.kt @@ -0,0 +1,371 @@ +package app.vimusic.android.service + +import android.content.ComponentName +import android.content.ContentResolver +import android.content.Context +import android.content.ServiceConnection +import android.media.session.MediaSession +import android.net.Uri +import android.os.Bundle +import android.os.IBinder +import android.service.media.MediaBrowserService +import androidx.annotation.DrawableRes +import androidx.annotation.OptIn +import androidx.core.net.toUri +import androidx.core.os.bundleOf +import androidx.media3.common.util.UnstableApi +import app.vimusic.android.Database +import app.vimusic.android.R +import app.vimusic.android.models.Album +import app.vimusic.android.models.PlaylistPreview +import app.vimusic.android.models.Song +import app.vimusic.android.models.SongWithContentLength +import app.vimusic.android.preferences.DataPreferences +import app.vimusic.android.preferences.OrderPreferences +import app.vimusic.android.utils.asMediaItem +import app.vimusic.android.utils.forcePlayAtIndex +import app.vimusic.android.utils.forceSeekToNext +import app.vimusic.android.utils.forceSeekToPrevious +import app.vimusic.android.utils.intent +import app.vimusic.core.data.utils.CallValidator +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.cancellable +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import android.media.MediaDescription as BrowserMediaDescription +import android.media.browse.MediaBrowser.MediaItem as BrowserMediaItem + +class PlayerMediaBrowserService : MediaBrowserService(), ServiceConnection { + private val coroutineScope = CoroutineScope(Dispatchers.IO) + private var lastSongs = emptyList() + + private var bound = false + + private val callValidator by lazy { + CallValidator(applicationContext, R.xml.allowed_media_browser_callers) + } + + override fun onDestroy() { + if (bound) unbindService(this) + super.onDestroy() + } + + override fun onServiceConnected(className: ComponentName, service: IBinder) { + if (service !is PlayerService.Binder) return + bound = true + sessionToken = service.mediaSession.sessionToken + service.mediaSession.setCallback(SessionCallback(service)) + } + + override fun onServiceDisconnected(name: ComponentName) = Unit + + override fun onGetRoot( + clientPackageName: String, + clientUid: Int, + rootHints: Bundle? + ) = if (callValidator.canCall(clientPackageName, clientUid)) { + bindService(intent(), this, Context.BIND_AUTO_CREATE) + BrowserRoot( + MediaId.ROOT.id, + bundleOf("android.media.browse.CONTENT_STYLE_BROWSABLE_HINT" to 1) + ) + } else null + + override fun onLoadChildren( + parentId: String, + result: Result> + ) = runBlocking(Dispatchers.IO) { + result.sendResult( + when (MediaId(parentId)) { + MediaId.ROOT -> mutableListOf( + songsBrowserMediaItem, + playlistsBrowserMediaItem, + albumsBrowserMediaItem + ) + + MediaId.SONGS -> + Database + .songsByPlayTimeDesc(limit = 30) + .first() + .also { lastSongs = it } + .map { it.asBrowserMediaItem } + .toMutableList() + .apply { + if (isNotEmpty()) add(0, shuffleBrowserMediaItem) + } + + MediaId.PLAYLISTS -> + Database + .playlistPreviewsByDateAddedDesc() + .first() + .map { it.asBrowserMediaItem } + .toMutableList() + .apply { + add(0, favoritesBrowserMediaItem) + add(1, offlineBrowserMediaItem) + add(2, topBrowserMediaItem) + add(3, localBrowserMediaItem) + } + + MediaId.ALBUMS -> + Database + .albumsByRowIdDesc() + .first() + .map { it.asBrowserMediaItem } + .toMutableList() + + else -> mutableListOf() + } + ) + } + + private fun uriFor(@DrawableRes id: Int) = Uri.Builder() + .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) + .authority(resources.getResourcePackageName(id)) + .appendPath(resources.getResourceTypeName(id)) + .appendPath(resources.getResourceEntryName(id)) + .build() + + private val shuffleBrowserMediaItem + inline get() = BrowserMediaItem( + BrowserMediaDescription.Builder() + .setMediaId(MediaId.SHUFFLE.id) + .setTitle(getString(R.string.shuffle)) + .setIconUri(uriFor(R.drawable.shuffle)) + .build(), + BrowserMediaItem.FLAG_PLAYABLE + ) + + private val songsBrowserMediaItem + inline get() = BrowserMediaItem( + BrowserMediaDescription.Builder() + .setMediaId(MediaId.SONGS.id) + .setTitle(getString(R.string.songs)) + .setIconUri(uriFor(R.drawable.musical_notes)) + .build(), + BrowserMediaItem.FLAG_BROWSABLE + ) + + private val playlistsBrowserMediaItem + inline get() = BrowserMediaItem( + BrowserMediaDescription.Builder() + .setMediaId(MediaId.PLAYLISTS.id) + .setTitle(getString(R.string.playlists)) + .setIconUri(uriFor(R.drawable.playlist)) + .build(), + BrowserMediaItem.FLAG_BROWSABLE + ) + + private val albumsBrowserMediaItem + inline get() = BrowserMediaItem( + BrowserMediaDescription.Builder() + .setMediaId(MediaId.ALBUMS.id) + .setTitle(getString(R.string.albums)) + .setIconUri(uriFor(R.drawable.disc)) + .build(), + BrowserMediaItem.FLAG_BROWSABLE + ) + + private val favoritesBrowserMediaItem + inline get() = BrowserMediaItem( + BrowserMediaDescription.Builder() + .setMediaId(MediaId.FAVORITES.id) + .setTitle(getString(R.string.favorites)) + .setIconUri(uriFor(R.drawable.heart)) + .build(), + BrowserMediaItem.FLAG_PLAYABLE + ) + + private val offlineBrowserMediaItem + inline get() = BrowserMediaItem( + BrowserMediaDescription.Builder() + .setMediaId(MediaId.OFFLINE.id) + .setTitle(getString(R.string.offline)) + .setIconUri(uriFor(R.drawable.airplane)) + .build(), + BrowserMediaItem.FLAG_PLAYABLE + ) + + private val topBrowserMediaItem + inline get() = BrowserMediaItem( + BrowserMediaDescription.Builder() + .setMediaId(MediaId.TOP.id) + .setTitle( + getString( + R.string.format_my_top_playlist, + DataPreferences.topListLength.toString() + ) + ) + .setIconUri(uriFor(R.drawable.trending)) + .build(), + BrowserMediaItem.FLAG_PLAYABLE + ) + + private val localBrowserMediaItem + inline get() = BrowserMediaItem( + BrowserMediaDescription.Builder() + .setMediaId(MediaId.LOCAL.id) + .setTitle(getString(R.string.local)) + .setIconUri(uriFor(R.drawable.download)) + .build(), + BrowserMediaItem.FLAG_PLAYABLE + ) + + private val Song.asBrowserMediaItem + inline get() = BrowserMediaItem( + BrowserMediaDescription.Builder() + .setMediaId((MediaId.SONGS / id).id) + .setTitle(title) + .setSubtitle(artistsText) + .setIconUri(thumbnailUrl?.toUri()) + .build(), + BrowserMediaItem.FLAG_PLAYABLE + ) + + private val PlaylistPreview.asBrowserMediaItem + inline get() = BrowserMediaItem( + BrowserMediaDescription.Builder() + .setMediaId((MediaId.PLAYLISTS / playlist.id.toString()).id) + .setTitle(playlist.name) + .setSubtitle( + resources.getQuantityString( + R.plurals.song_count_plural, + songCount, + songCount + ) + ) + .setIconUri(uriFor(R.drawable.playlist)) + .build(), + BrowserMediaItem.FLAG_PLAYABLE + ) + + private val Album.asBrowserMediaItem + inline get() = BrowserMediaItem( + BrowserMediaDescription.Builder() + .setMediaId((MediaId.ALBUMS / id).id) + .setTitle(title) + .setSubtitle(authorsText) + .setIconUri(thumbnailUrl?.toUri()) + .build(), + BrowserMediaItem.FLAG_PLAYABLE + ) + + private inner class SessionCallback( + private val binder: PlayerService.Binder + ) : MediaSession.Callback() { + override fun onPlay() = binder.player.play() + override fun onPause() = binder.player.pause() + override fun onSkipToPrevious() = binder.player.forceSeekToPrevious() + override fun onSkipToNext() = binder.player.forceSeekToNext() + override fun onSeekTo(pos: Long) = binder.player.seekTo(pos) + override fun onSkipToQueueItem(id: Long) = binder.player.seekToDefaultPosition(id.toInt()) + override fun onPlayFromSearch(query: String?, extras: Bundle?) { + if (query.isNullOrBlank()) return + binder.playFromSearch(query) + } + + @Suppress("CyclomaticComplexMethod") + @OptIn(UnstableApi::class) + override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { + val data = mediaId?.split('/') ?: return + var index = 0 + + coroutineScope.launch { + val mediaItems = when (data.getOrNull(0)?.let { MediaId(it) }) { + MediaId.SHUFFLE -> lastSongs + + MediaId.SONGS -> data.getOrNull(1)?.let { songId -> + index = lastSongs.indexOfFirst { it.id == songId } + lastSongs + } + + MediaId.FAVORITES -> + Database + .favorites() + .first() + .shuffled() + + MediaId.OFFLINE -> + Database + .songsWithContentLength() + .first() + .filter { binder.isCached(it) } + .map(SongWithContentLength::song) + .shuffled() + + MediaId.TOP -> { + val duration = DataPreferences.topListPeriod.duration + val length = DataPreferences.topListLength + + val flow = if (duration != null) Database.trending( + limit = length, + period = duration.inWholeMilliseconds + ) else Database + .songsByPlayTimeDesc(limit = length) + .distinctUntilChanged() + .cancellable() + + flow.first() + } + + MediaId.LOCAL -> + Database + .songs( + sortBy = OrderPreferences.localSongSortBy, + sortOrder = OrderPreferences.localSongSortOrder, + isLocal = true + ) + .map { songs -> songs.filter { it.durationText != "0:00" } } + .first() + + MediaId.PLAYLISTS -> + data + .getOrNull(1) + ?.toLongOrNull() + ?.let(Database::playlistWithSongs) + ?.first() + ?.songs + ?.shuffled() + + MediaId.ALBUMS -> + data + .getOrNull(1) + ?.let(Database::albumSongs) + ?.first() + + else -> emptyList() + }?.map(Song::asMediaItem) ?: return@launch + + withContext(Dispatchers.Main) { + binder.player.forcePlayAtIndex( + items = mediaItems, + index = index.coerceIn(0, mediaItems.size) + ) + } + } + } + } + + @JvmInline + private value class MediaId(val id: String) : CharSequence by id { + companion object { + val ROOT = MediaId("root") + val SONGS = MediaId("songs") + val PLAYLISTS = MediaId("playlists") + val ALBUMS = MediaId("albums") + + val FAVORITES = MediaId("favorites") + val OFFLINE = MediaId("offline") + val TOP = MediaId("top") + val LOCAL = MediaId("local") + val SHUFFLE = MediaId("shuffle") + } + + operator fun div(other: String) = MediaId("$id/$other") + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/service/PlayerService.kt b/app/src/main/kotlin/app/vimusic/android/service/PlayerService.kt new file mode 100644 index 0000000..7487820 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/service/PlayerService.kt @@ -0,0 +1,1435 @@ +package app.vimusic.android.service + +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.graphics.Bitmap +import android.graphics.Color +import android.media.AudioDeviceCallback +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.media.MediaDescription +import android.media.MediaMetadata +import android.media.audiofx.AudioEffect +import android.media.audiofx.BassBoost +import android.media.audiofx.LoudnessEnhancer +import android.media.audiofx.PresetReverb +import android.media.session.MediaSession +import android.media.session.PlaybackState +import android.os.Bundle +import android.support.v4.media.session.MediaSessionCompat +import android.text.format.DateUtils +import androidx.annotation.OptIn +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat.startForegroundService +import androidx.core.content.getSystemService +import androidx.core.net.toUri +import androidx.media3.common.AudioAttributes +import androidx.media3.common.AuxEffectInfo +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.Timeline +import androidx.media3.common.audio.SonicAudioProcessor +import androidx.media3.common.util.UnstableApi +import androidx.media3.database.StandaloneDatabaseProvider +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.HttpDataSource.InvalidResponseCodeException +import androidx.media3.datasource.ResolvingDataSource +import androidx.media3.datasource.cache.Cache +import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor +import androidx.media3.datasource.cache.NoOpCacheEvictor +import androidx.media3.datasource.cache.SimpleCache +import androidx.media3.exoplayer.DefaultRenderersFactory +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.analytics.AnalyticsListener +import androidx.media3.exoplayer.analytics.PlaybackStats +import androidx.media3.exoplayer.analytics.PlaybackStatsListener +import androidx.media3.exoplayer.audio.AudioSink +import androidx.media3.exoplayer.audio.DefaultAudioOffloadSupportProvider +import androidx.media3.exoplayer.audio.DefaultAudioSink +import androidx.media3.exoplayer.audio.DefaultAudioSink.DefaultAudioProcessorChain +import androidx.media3.exoplayer.audio.SilenceSkippingAudioProcessor +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy +import androidx.media3.extractor.DefaultExtractorsFactory +import app.vimusic.android.Database +import app.vimusic.android.MainActivity +import app.vimusic.android.R +import app.vimusic.android.models.Event +import app.vimusic.android.models.Format +import app.vimusic.android.models.QueuedMediaItem +import app.vimusic.android.models.Song +import app.vimusic.android.models.SongWithContentLength +import app.vimusic.android.preferences.AppearancePreferences +import app.vimusic.android.preferences.DataPreferences +import app.vimusic.android.preferences.PlayerPreferences +import app.vimusic.android.query +import app.vimusic.android.transaction +import app.vimusic.android.utils.ActionReceiver +import app.vimusic.android.utils.ConditionalCacheDataSourceFactory +import app.vimusic.android.utils.GlyphInterface +import app.vimusic.android.utils.InvincibleService +import app.vimusic.android.utils.TimerJob +import app.vimusic.android.utils.YouTubeRadio +import app.vimusic.android.utils.activityPendingIntent +import app.vimusic.android.utils.asDataSource +import app.vimusic.android.utils.broadcastPendingIntent +import app.vimusic.android.utils.defaultDataSource +import app.vimusic.android.utils.findCause +import app.vimusic.android.utils.findNextMediaItemById +import app.vimusic.android.utils.forcePlayFromBeginning +import app.vimusic.android.utils.forceSeekToNext +import app.vimusic.android.utils.forceSeekToPrevious +import app.vimusic.android.utils.get +import app.vimusic.android.utils.handleRangeErrors +import app.vimusic.android.utils.handleUnknownErrors +import app.vimusic.android.utils.intent +import app.vimusic.android.utils.mediaItems +import app.vimusic.android.utils.progress +import app.vimusic.android.utils.readOnlyWhen +import app.vimusic.android.utils.setPlaybackPitch +import app.vimusic.android.utils.shouldBePlaying +import app.vimusic.android.utils.thumbnail +import app.vimusic.android.utils.timer +import app.vimusic.android.utils.toast +import app.vimusic.compose.preferences.SharedPreferencesProperty +import app.vimusic.core.data.enums.ExoPlayerDiskCacheSize +import app.vimusic.core.data.utils.UriCache +import app.vimusic.core.ui.utils.EqualizerIntentBundleAccessor +import app.vimusic.core.ui.utils.isAtLeastAndroid10 +import app.vimusic.core.ui.utils.isAtLeastAndroid12 +import app.vimusic.core.ui.utils.isAtLeastAndroid13 +import app.vimusic.core.ui.utils.isAtLeastAndroid6 +import app.vimusic.core.ui.utils.isAtLeastAndroid8 +import app.vimusic.core.ui.utils.isAtLeastAndroid9 +import app.vimusic.core.ui.utils.songBundle +import app.vimusic.core.ui.utils.streamVolumeFlow +import app.vimusic.providers.innertube.Innertube +import app.vimusic.providers.innertube.models.NavigationEndpoint +import app.vimusic.providers.innertube.models.bodies.PlayerBody +import app.vimusic.providers.innertube.models.bodies.SearchBody +import app.vimusic.providers.innertube.requests.player +import app.vimusic.providers.innertube.requests.searchPage +import app.vimusic.providers.innertube.utils.from +import app.vimusic.providers.sponsorblock.SponsorBlock +import app.vimusic.providers.sponsorblock.models.Action +import app.vimusic.providers.sponsorblock.models.Category +import app.vimusic.providers.sponsorblock.requests.segments +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.cancellable +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapMerge +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.takeWhile +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield +import kotlinx.datetime.Clock +import java.io.IOException +import kotlin.math.roundToInt +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +import android.os.Binder as AndroidBinder + +const val LOCAL_KEY_PREFIX = "local:" +private const val TAG = "PlayerService" + +@get:OptIn(UnstableApi::class) +val DataSpec.isLocal get() = key?.startsWith(LOCAL_KEY_PREFIX) == true + +val MediaItem.isLocal get() = mediaId.startsWith(LOCAL_KEY_PREFIX) +val Song.isLocal get() = id.startsWith(LOCAL_KEY_PREFIX) + +private const val LIKE_ACTION = "LIKE" + +@kotlin.OptIn(ExperimentalCoroutinesApi::class) +@Suppress("LargeClass", "TooManyFunctions") // intended in this class: it is a service +@OptIn(UnstableApi::class) +class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListener.Callback { + private lateinit var mediaSession: MediaSession + private lateinit var cache: SimpleCache + private lateinit var player: ExoPlayer + + private val defaultActions = + PlaybackState.ACTION_PLAY or + PlaybackState.ACTION_PAUSE or + PlaybackState.ACTION_PLAY_PAUSE or + PlaybackState.ACTION_STOP or + PlaybackState.ACTION_SKIP_TO_PREVIOUS or + PlaybackState.ACTION_SKIP_TO_NEXT or + PlaybackState.ACTION_SKIP_TO_QUEUE_ITEM or + PlaybackState.ACTION_SEEK_TO or + PlaybackState.ACTION_REWIND or + PlaybackState.ACTION_PLAY_FROM_SEARCH + + private val stateBuilder + get() = PlaybackState.Builder().setActions( + defaultActions.let { + if (isAtLeastAndroid12) it or PlaybackState.ACTION_SET_PLAYBACK_SPEED else it + } + ).addCustomAction( + /* action = */ LIKE_ACTION, + /* name = */ getString(R.string.like), + /* icon = */ if (isLikedState.value) R.drawable.heart else R.drawable.heart_outline + ) + + private val playbackStateMutex = Mutex() + private val metadataBuilder = MediaMetadata.Builder() + + private var timerJob: TimerJob? by mutableStateOf(null) + private var radio: YouTubeRadio? = null + + private lateinit var bitmapProvider: BitmapProvider + + private val coroutineScope = CoroutineScope(Dispatchers.IO + Job()) + private var preferenceUpdaterJob: Job? = null + private var volumeNormalizationJob: Job? = null + private var sponsorBlockJob: Job? = null + + override var isInvincibilityEnabled by mutableStateOf(false) + + private var audioManager: AudioManager? = null + private var audioDeviceCallback: AudioDeviceCallback? = null + + private var loudnessEnhancer: LoudnessEnhancer? = null + private var bassBoost: BassBoost? = null + private var reverb: PresetReverb? = null + + private val binder = Binder() + + private var isNotificationStarted = false + override val notificationId get() = ServiceNotifications.default.notificationId!! + private val notificationActionReceiver = NotificationActionReceiver() + + private val mediaItemState = MutableStateFlow(null) + private val isLikedState = mediaItemState + .flatMapMerge { item -> + item?.mediaId?.let { + Database + .likedAt(it) + .distinctUntilChanged() + .cancellable() + } ?: flowOf(null) + } + .map { it != null } + .onEach { + updateNotification() + } + .stateIn( + scope = coroutineScope, + started = SharingStarted.Eagerly, + initialValue = false + ) + + private val glyphInterface by lazy { GlyphInterface(applicationContext) } + + private var poiTimestamp: Long? by mutableStateOf(null) + + override fun onBind(intent: Intent?): AndroidBinder { + super.onBind(intent) + return binder + } + + @Suppress("CyclomaticComplexMethod") + override fun onCreate() { + super.onCreate() + + glyphInterface.tryInit() + + bitmapProvider = BitmapProvider( + getBitmapSize = { + (512 * resources.displayMetrics.density) + .roundToInt() + .coerceAtMost(AppearancePreferences.maxThumbnailSize) + }, + getColor = { isSystemInDarkMode -> + if (isSystemInDarkMode) Color.BLACK else Color.WHITE + } + ) + + cache = createCache(this) + player = ExoPlayer.Builder(this, createRendersFactory(), createMediaSourceFactory()) + .setHandleAudioBecomingNoisy(true) + .setWakeMode(C.WAKE_MODE_LOCAL) + .setAudioAttributes( + /* audioAttributes = */ AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) + .build(), + /* handleAudioFocus = */ PlayerPreferences.handleAudioFocus + ) + .setUsePlatformDiagnostics(false) + .build() + .apply { + skipSilenceEnabled = PlayerPreferences.skipSilence + addListener(this@PlayerService) + addAnalyticsListener( + PlaybackStatsListener( + /* keepHistory = */ false, + /* callback = */ this@PlayerService + ) + ) + } + + updateRepeatMode() + maybeRestorePlayerQueue() + + mediaSession = MediaSession(baseContext, TAG).apply { + setCallback(SessionCallback()) + setPlaybackState(stateBuilder.build()) + setSessionActivity(activityPendingIntent()) + isActive = true + } + + coroutineScope.launch { + var first = true + combine(mediaItemState, isLikedState) { mediaItem, _ -> + // work around NPE in other processes + if (first) { + first = false + return@combine + } + + if (mediaItem == null) return@combine + withContext(Dispatchers.Main) { + updatePlaybackState() + updateNotification() + } + }.collect() + } + + notificationActionReceiver.register() + maybeResumePlaybackWhenDeviceConnected() + + preferenceUpdaterJob = coroutineScope.launch { + fun subscribe( + prop: SharedPreferencesProperty, + callback: (T) -> Unit + ) = launch { prop.stateFlow.collectLatest { handler.post { callback(it) } } } + + subscribe(AppearancePreferences.isShowingThumbnailInLockscreenProperty) { + maybeShowSongCoverInLockScreen() + } + + subscribe(PlayerPreferences.bassBoostLevelProperty) { maybeBassBoost() } + subscribe(PlayerPreferences.bassBoostProperty) { maybeBassBoost() } + subscribe(PlayerPreferences.reverbProperty) { maybeReverb() } + subscribe(PlayerPreferences.isInvincibilityEnabledProperty) { + this@PlayerService.isInvincibilityEnabled = it + } + subscribe(PlayerPreferences.pitchProperty) { + player.setPlaybackPitch(it.coerceAtLeast(0.01f)) + } + subscribe(PlayerPreferences.queueLoopEnabledProperty) { updateRepeatMode() } + subscribe(PlayerPreferences.resumePlaybackWhenDeviceConnectedProperty) { + maybeResumePlaybackWhenDeviceConnected() + } + subscribe(PlayerPreferences.skipSilenceProperty) { player.skipSilenceEnabled = it } + subscribe(PlayerPreferences.speedProperty) { + player.setPlaybackSpeed(it.coerceAtLeast(0.01f)) + } + subscribe(PlayerPreferences.trackLoopEnabledProperty) { updateRepeatMode() } + subscribe(PlayerPreferences.volumeNormalizationBaseGainProperty) { maybeNormalizeVolume() } + subscribe(PlayerPreferences.volumeNormalizationProperty) { maybeNormalizeVolume() } + subscribe(PlayerPreferences.sponsorBlockEnabledProperty) { maybeSponsorBlock() } + + launch { + val audioManager = getSystemService() + val stream = AudioManager.STREAM_MUSIC + + val min = when { + audioManager == null -> 0 + isAtLeastAndroid9 -> audioManager.getStreamMinVolume(stream) + + else -> 0 + } + + streamVolumeFlow(stream).collectLatest { + handler.post { if (it == min) player.pause() } + } + } + } + } + + private fun updateRepeatMode() { + player.repeatMode = when { + PlayerPreferences.trackLoopEnabled -> Player.REPEAT_MODE_ONE + PlayerPreferences.queueLoopEnabled -> Player.REPEAT_MODE_ALL + else -> Player.REPEAT_MODE_OFF + } + } + + override fun onTaskRemoved(rootIntent: Intent?) { + if (!player.shouldBePlaying || PlayerPreferences.stopWhenClosed) + broadcastPendingIntent().send() + super.onTaskRemoved(rootIntent) + } + + override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) = + maybeSavePlayerQueue() + + override fun onDestroy() { + runCatching { + maybeSavePlayerQueue() + + player.removeListener(this) + player.stop() + player.release() + + unregisterReceiver(notificationActionReceiver) + + mediaSession.isActive = false + mediaSession.release() + cache.release() + + loudnessEnhancer?.release() + preferenceUpdaterJob?.cancel() + + coroutineScope.cancel() + glyphInterface.close() + } + + super.onDestroy() + } + + override fun shouldBeInvincible() = !player.shouldBePlaying + + override fun onConfigurationChanged(newConfig: Configuration) { + handler.post { + if (!bitmapProvider.setDefaultBitmap() || player.currentMediaItem == null) return@post + updateNotification() + } + + super.onConfigurationChanged(newConfig) + } + + override fun onPlaybackStatsReady( + eventTime: AnalyticsListener.EventTime, + playbackStats: PlaybackStats + ) { + val totalPlayTimeMs = playbackStats.totalPlayTimeMs + if (totalPlayTimeMs < 5000) return + + val mediaItem = eventTime.timeline[eventTime.windowIndex].mediaItem + + if (!DataPreferences.pausePlaytime) query { + runCatching { + Database.incrementTotalPlayTimeMs(mediaItem.mediaId, totalPlayTimeMs) + } + } + + if (!DataPreferences.pauseHistory) query { + runCatching { + Database.insert( + Event( + songId = mediaItem.mediaId, + timestamp = System.currentTimeMillis(), + playTime = totalPlayTimeMs + ) + ) + } + } + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + if ( + AppearancePreferences.hideExplicit && + mediaItem?.mediaMetadata?.extras?.songBundle?.explicit == true + ) { + player.forceSeekToNext() + return + } + + mediaItemState.update { mediaItem } + + maybeRecoverPlaybackError() + maybeNormalizeVolume() + maybeProcessRadio() + + with(bitmapProvider) { + when { + mediaItem == null -> load(null) + mediaItem.mediaMetadata.artworkUri == lastUri -> bitmapProvider.load(lastUri) + } + } + + if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO || reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK) + updateMediaSessionQueue(player.currentTimeline) + } + + override fun onTimelineChanged(timeline: Timeline, reason: Int) { + if (reason != Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) return + updateMediaSessionQueue(timeline) + maybeSavePlayerQueue() + } + + override fun onPlayerError(error: PlaybackException) { + super.onPlayerError(error) + + if ( + error.findCause()?.responseCode == 416 + ) { + player.pause() + player.prepare() + player.play() + return + } + + if (!PlayerPreferences.skipOnError || !player.hasNextMediaItem()) return + + val prev = player.currentMediaItem ?: return + player.seekToNextMediaItem() + + ServiceNotifications.autoSkip.sendNotification(this) { + this + .setSmallIcon(R.drawable.app_icon) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .setOnlyAlertOnce(false) + .setContentIntent(activityPendingIntent()) + .setContentText( + prev.mediaMetadata.title?.let { + getString(R.string.skip_on_error_notification, it) + } ?: getString(R.string.skip_on_error_notification_unknown_song) + ) + .setContentTitle(getString(R.string.skip_on_error)) + } + } + + private fun updateMediaSessionQueue(timeline: Timeline) { + val builder = MediaDescription.Builder() + + val currentMediaItemIndex = player.currentMediaItemIndex + val lastIndex = timeline.windowCount - 1 + var startIndex = currentMediaItemIndex - 7 + var endIndex = currentMediaItemIndex + 7 + + if (startIndex < 0) endIndex -= startIndex + + if (endIndex > lastIndex) { + startIndex -= (endIndex - lastIndex) + endIndex = lastIndex + } + + startIndex = startIndex.coerceAtLeast(0) + + mediaSession.setQueue( + List(endIndex - startIndex + 1) { index -> + val mediaItem = timeline.getWindow(index + startIndex, Timeline.Window()).mediaItem + MediaSession.QueueItem( + builder + .setMediaId(mediaItem.mediaId) + .setTitle(mediaItem.mediaMetadata.title) + .setSubtitle(mediaItem.mediaMetadata.artist) + .setIconUri(mediaItem.mediaMetadata.artworkUri) + .build(), + (index + startIndex).toLong() + ) + } + ) + } + + private fun maybeRecoverPlaybackError() { + if (player.playerError != null) player.prepare() + } + + private fun maybeProcessRadio() { + if (player.mediaItemCount - player.currentMediaItemIndex > 3) return + + radio?.let { radio -> + coroutineScope.launch(Dispatchers.Main) { + player.addMediaItems(radio.process()) + } + } + } + + private fun maybeSavePlayerQueue() { + if (!PlayerPreferences.persistentQueue) return + + val mediaItems = player.currentTimeline.mediaItems + val mediaItemIndex = player.currentMediaItemIndex + val mediaItemPosition = player.currentPosition + + transaction { + runCatching { + Database.clearQueue() + Database.insert( + mediaItems.mapIndexed { index, mediaItem -> + QueuedMediaItem( + mediaItem = mediaItem, + position = if (index == mediaItemIndex) mediaItemPosition else null + ) + } + ) + } + } + } + + private fun maybeRestorePlayerQueue() { + if (!PlayerPreferences.persistentQueue) return + + transaction { + val queue = Database.queue() + if (queue.isEmpty()) return@transaction + Database.clearQueue() + + val index = queue + .indexOfFirst { it.position != null } + .coerceAtLeast(0) + + handler.post { + runCatching { + player.setMediaItems( + /* mediaItems = */ queue.map { item -> + item.mediaItem.buildUpon() + .setUri(item.mediaItem.mediaId) + .setCustomCacheKey(item.mediaItem.mediaId) + .build() + .apply { + mediaMetadata.extras?.songBundle?.apply { + isFromPersistentQueue = true + } + } + }, + /* startIndex = */ index, + /* startPositionMs = */ queue[index].position ?: C.TIME_UNSET + ) + player.prepare() + + isNotificationStarted = true + startForegroundService(this@PlayerService, intent()) + startForeground() + } + } + } + } + + private fun maybeNormalizeVolume() { + if (!PlayerPreferences.volumeNormalization) { + loudnessEnhancer?.enabled = false + loudnessEnhancer?.release() + loudnessEnhancer = null + volumeNormalizationJob?.cancel() + volumeNormalizationJob?.invokeOnCompletion { volumeNormalizationJob = null } + player.volume = 1f + return + } + + runCatching { + if (loudnessEnhancer == null) loudnessEnhancer = LoudnessEnhancer(player.audioSessionId) + }.onFailure { return } + + val songId = player.currentMediaItem?.mediaId ?: return + volumeNormalizationJob?.cancel() + volumeNormalizationJob = coroutineScope.launch { + runCatching { + fun Float?.toMb() = ((this ?: 0f) * 100).toInt() + + Database.loudnessDb(songId).cancellable().collectLatest { loudness -> + val loudnessMb = loudness.toMb().let { + if (it !in -2000..2000) { + withContext(Dispatchers.Main) { + toast( + getString( + R.string.loudness_normalization_extreme, + getString(R.string.format_db, (it / 100f).toString()) + ) + ) + } + + 0 + } else it + } + + Database.loudnessBoost(songId).cancellable().collectLatest { boost -> + withContext(Dispatchers.Main) { + loudnessEnhancer?.setTargetGain( + PlayerPreferences.volumeNormalizationBaseGain.toMb() + boost.toMb() - loudnessMb + ) + loudnessEnhancer?.enabled = true + } + } + } + } + } + } + + @Suppress("CyclomaticComplexMethod") // TODO: evaluate CyclomaticComplexMethod threshold + private fun maybeSponsorBlock() { + poiTimestamp = null + + if (!PlayerPreferences.sponsorBlockEnabled) { + sponsorBlockJob?.cancel() + sponsorBlockJob?.invokeOnCompletion { sponsorBlockJob = null } + return + } + + sponsorBlockJob?.cancel() + sponsorBlockJob = coroutineScope.launch { + mediaItemState.onStart { emit(mediaItemState.value) }.collectLatest { mediaItem -> + poiTimestamp = null + val videoId = mediaItem?.mediaId + ?.removePrefix("https://youtube.com/watch?v=") + ?.takeIf { it.isNotBlank() } ?: return@collectLatest + + SponsorBlock + .segments(videoId) + ?.onSuccess { segments -> + poiTimestamp = + segments.find { it.category == Category.PoiHighlight }?.start?.inWholeMilliseconds + } + ?.map { segments -> + segments + .sortedBy { it.start.inWholeMilliseconds } + .filter { it.action == Action.Skip } + } + ?.mapCatching { segments -> + suspend fun posMillis() = + withContext(Dispatchers.Main) { player.currentPosition } + + suspend fun speed() = + withContext(Dispatchers.Main) { player.playbackParameters.speed } + + suspend fun seek(millis: Long) = + withContext(Dispatchers.Main) { player.seekTo(millis) } + + val ctx = currentCoroutineContext() + val lastSegmentEnd = + segments.lastOrNull()?.end?.inWholeMilliseconds ?: return@mapCatching + + @Suppress("LoopWithTooManyJumpStatements") + do { + if (lastSegmentEnd < posMillis()) { + yield() + continue + } + + val nextSegment = + segments.firstOrNull { posMillis() < it.end.inWholeMilliseconds } + ?: continue + + // Wait for next segment + if (nextSegment.start.inWholeMilliseconds > posMillis()) delay( + ((nextSegment.start.inWholeMilliseconds - posMillis()) / speed().toDouble()).milliseconds + ) + + if (posMillis().milliseconds !in nextSegment.start..nextSegment.end) { + // Player is not in the segment for some reason, maybe the user seeked in the meantime + yield() + continue + } + + seek(nextSegment.end.inWholeMilliseconds) + } while (ctx.isActive) + }?.onFailure { + it.printStackTrace() + } + } + } + } + + private fun maybeBassBoost() { + if (!PlayerPreferences.bassBoost) { + runCatching { + bassBoost?.enabled = false + bassBoost?.release() + } + bassBoost = null + maybeNormalizeVolume() + return + } + + runCatching { + if (bassBoost == null) bassBoost = BassBoost(0, player.audioSessionId) + bassBoost?.setStrength(PlayerPreferences.bassBoostLevel.toShort()) + bassBoost?.enabled = true + }.onFailure { + toast(getString(R.string.error_bassboost_init)) + } + } + + private fun maybeReverb() { + if (PlayerPreferences.reverb == PlayerPreferences.Reverb.None) { + runCatching { + reverb?.enabled = false + player.clearAuxEffectInfo() + reverb?.release() + } + reverb = null + return + } + + runCatching { + if (reverb == null) reverb = PresetReverb(1, player.audioSessionId) + reverb?.preset = PlayerPreferences.reverb.preset + reverb?.enabled = true + reverb?.id?.let { player.setAuxEffectInfo(AuxEffectInfo(it, 1f)) } + } + } + + private fun maybeShowSongCoverInLockScreen() = handler.post { + val bitmap = if (isAtLeastAndroid13 || AppearancePreferences.isShowingThumbnailInLockscreen) + bitmapProvider.bitmap else null + val uri = player.mediaMetadata.artworkUri?.toString()?.thumbnail(512) + + metadataBuilder.putBitmap(MediaMetadata.METADATA_KEY_ART, bitmap) + metadataBuilder.putString(MediaMetadata.METADATA_KEY_ART_URI, uri) + + metadataBuilder.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, bitmap) + metadataBuilder.putString(MediaMetadata.METADATA_KEY_ALBUM_ART_URI, uri) + + if (isAtLeastAndroid13 && player.currentMediaItemIndex == 0) + metadataBuilder.putText( + MediaMetadata.METADATA_KEY_TITLE, + "${player.mediaMetadata.title} " + ) + + mediaSession.setMetadata(metadataBuilder.build()) + } + + private fun maybeResumePlaybackWhenDeviceConnected() { + if (!isAtLeastAndroid6) return + + if (!PlayerPreferences.resumePlaybackWhenDeviceConnected) { + audioManager?.unregisterAudioDeviceCallback(audioDeviceCallback) + audioDeviceCallback = null + return + } + if (audioManager == null) audioManager = getSystemService() + + audioDeviceCallback = + @SuppressLint("NewApi") + object : AudioDeviceCallback() { + private fun canPlayMusic(audioDeviceInfo: AudioDeviceInfo) = + audioDeviceInfo.isSink && ( + audioDeviceInfo.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP || + audioDeviceInfo.type == AudioDeviceInfo.TYPE_WIRED_HEADSET || + audioDeviceInfo.type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES + ) + .let { + if (!isAtLeastAndroid8) it else + it || audioDeviceInfo.type == AudioDeviceInfo.TYPE_USB_HEADSET + } + + override fun onAudioDevicesAdded(addedDevices: Array) { + if (!player.isPlaying && addedDevices.any(::canPlayMusic)) player.play() + } + + override fun onAudioDevicesRemoved(removedDevices: Array) = Unit + } + + audioManager?.registerAudioDeviceCallback(audioDeviceCallback, handler) + } + + private fun sendOpenEqualizerIntent() = sendBroadcast( + Intent(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION).apply { + replaceExtras( + EqualizerIntentBundleAccessor.bundle { + audioSession = player.audioSessionId + packageName = packageName + contentType = AudioEffect.CONTENT_TYPE_MUSIC + } + ) + } + ) + + private fun sendCloseEqualizerIntent() = sendBroadcast( + Intent(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION).apply { + replaceExtras( + EqualizerIntentBundleAccessor.bundle { + audioSession = player.audioSessionId + } + ) + } + ) + + private fun updatePlaybackState() = coroutineScope.launch { + playbackStateMutex.withLock { + withContext(Dispatchers.Main) { + mediaSession.setPlaybackState( + stateBuilder + .setState(player.androidPlaybackState, player.currentPosition, 1f) + .setBufferedPosition(player.bufferedPosition) + .build() + ) + } + } + } + + private val Player.androidPlaybackState + get() = when (playbackState) { + Player.STATE_BUFFERING -> if (playWhenReady) PlaybackState.STATE_BUFFERING else PlaybackState.STATE_PAUSED + Player.STATE_READY -> if (playWhenReady) PlaybackState.STATE_PLAYING else PlaybackState.STATE_PAUSED + Player.STATE_ENDED -> PlaybackState.STATE_STOPPED + Player.STATE_IDLE -> PlaybackState.STATE_NONE + else -> PlaybackState.STATE_NONE + } + + // legacy behavior may cause inconsistencies, but not available on sdk 24 or lower + @Suppress("DEPRECATION") + override fun onEvents(player: Player, events: Player.Events) { + if (player.duration != C.TIME_UNSET) mediaSession.setMetadata( + metadataBuilder + .putText( + MediaMetadata.METADATA_KEY_TITLE, + player.mediaMetadata.title?.toString().orEmpty() + ) + .putText( + MediaMetadata.METADATA_KEY_ARTIST, + player.mediaMetadata.artist?.toString().orEmpty() + ) + .putText( + MediaMetadata.METADATA_KEY_ALBUM, + player.mediaMetadata.albumTitle?.toString().orEmpty() + ) + .putLong(MediaMetadata.METADATA_KEY_DURATION, player.duration) + .build() + ) + + updatePlaybackState() + + if ( + !events.containsAny( + Player.EVENT_PLAYBACK_STATE_CHANGED, + Player.EVENT_PLAY_WHEN_READY_CHANGED, + Player.EVENT_IS_PLAYING_CHANGED, + Player.EVENT_POSITION_DISCONTINUITY, + Player.EVENT_IS_LOADING_CHANGED, + Player.EVENT_MEDIA_METADATA_CHANGED + ) + ) return + + val notification = notification() + + if (notification == null) { + isNotificationStarted = false + makeInvincible(false) + stopForeground(false) + sendCloseEqualizerIntent() + ServiceNotifications.default.cancel(this) + return + } + + if (player.shouldBePlaying && !isNotificationStarted) { + isNotificationStarted = true + startForegroundService(this@PlayerService, intent()) + startForeground() + makeInvincible(false) + sendOpenEqualizerIntent() + } else { + if (!player.shouldBePlaying) { + isNotificationStarted = false + stopForeground(false) + makeInvincible(true) + sendCloseEqualizerIntent() + } + updateNotification() + } + } + + private fun notification(): (NotificationCompat.Builder.() -> NotificationCompat.Builder)? { + if (player.currentMediaItem == null) return null + + val mediaMetadata = player.mediaMetadata + + bitmapProvider.load(mediaMetadata.artworkUri) { + maybeShowSongCoverInLockScreen() + updateNotification() + } + + return { + this + .setContentTitle(mediaMetadata.title?.toString().orEmpty()) + .setContentText(mediaMetadata.artist?.toString().orEmpty()) + .setSubText(player.playerError?.message) + .setLargeIcon(bitmapProvider.bitmap) + .setAutoCancel(false) + .setOnlyAlertOnce(true) + .setShowWhen(false) + .setSmallIcon( + player.playerError?.let { R.drawable.alert_circle } ?: R.drawable.app_icon + ) + .setOngoing(false) + .setContentIntent( + activityPendingIntent(flags = PendingIntent.FLAG_UPDATE_CURRENT) + ) + .setDeleteIntent(broadcastPendingIntent()) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setCategory(NotificationCompat.CATEGORY_TRANSPORT) + .setStyle( + androidx.media.app.NotificationCompat.MediaStyle() + .setShowActionsInCompactView(0, 1, 2) + .setMediaSession(MediaSessionCompat.Token.fromToken(mediaSession.sessionToken)) + ) + .addAction( + R.drawable.play_skip_back, + getString(R.string.skip_back), + notificationActionReceiver.previous.pendingIntent + ) + .let { + if (player.shouldBePlaying) it.addAction( + R.drawable.pause, + getString(R.string.pause), + notificationActionReceiver.pause.pendingIntent + ) + else it.addAction( + R.drawable.play, + getString(R.string.play), + notificationActionReceiver.play.pendingIntent + ) + } + .addAction( + R.drawable.play_skip_forward, + getString(R.string.skip_forward), + notificationActionReceiver.next.pendingIntent + ) + .addAction( + if (isLikedState.value) R.drawable.heart else R.drawable.heart_outline, + getString(R.string.like), + notificationActionReceiver.like.pendingIntent + ) + } + } + + private fun updateNotification() = runCatching { + handler.post { + notification()?.let { ServiceNotifications.default.sendNotification(this, it) } + } + } + + override fun startForeground() { + notification() + ?.let { ServiceNotifications.default.startForeground(this, it) } + } + + private fun createMediaSourceFactory() = DefaultMediaSourceFactory( + /* dataSourceFactory = */ createYouTubeDataSourceResolverFactory( + findMediaItem = { videoId -> + withContext(Dispatchers.Main) { + player.findNextMediaItemById(videoId) + } + }, + context = applicationContext, + cache = cache + ), + /* extractorsFactory = */ DefaultExtractorsFactory() + ).setLoadErrorHandlingPolicy( + object : DefaultLoadErrorHandlingPolicy() { + override fun isEligibleForFallback(exception: IOException) = true + } + ) + + private fun createRendersFactory() = object : DefaultRenderersFactory(this) { + override fun buildAudioSink( + context: Context, + enableFloatOutput: Boolean, + enableAudioTrackPlaybackParams: Boolean + ): AudioSink { + val minimumSilenceDuration = + PlayerPreferences.minimumSilence.coerceIn(1000L..2_000_000L) + + return DefaultAudioSink.Builder(applicationContext) + .setEnableFloatOutput(enableFloatOutput) + .setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams) + .setAudioOffloadSupportProvider( + DefaultAudioOffloadSupportProvider(applicationContext) + ) + .setAudioProcessorChain( + DefaultAudioProcessorChain( + arrayOf(), + SilenceSkippingAudioProcessor( + /* minimumSilenceDurationUs = */ minimumSilenceDuration, + /* silenceRetentionRatio = */ 0.01f, + /* maxSilenceToKeepDurationUs = */ minimumSilenceDuration, + /* minVolumeToKeepPercentageWhenMuting = */ 0, + /* silenceThresholdLevel = */ 256 + ), + SonicAudioProcessor() + ) + ) + .build() + .apply { + if (isAtLeastAndroid10) setOffloadMode(AudioSink.OFFLOAD_MODE_DISABLED) + } + } + } + + @Stable + inner class Binder : AndroidBinder() { + val player: ExoPlayer + get() = this@PlayerService.player + + val cache: Cache + get() = this@PlayerService.cache + + val mediaSession + get() = this@PlayerService.mediaSession + + val sleepTimerMillisLeft: StateFlow? + get() = timerJob?.millisLeft + + private var radioJob: Job? = null + + var isLoadingRadio by mutableStateOf(false) + private set + + var invincible + get() = isInvincibilityEnabled + set(value) { + isInvincibilityEnabled = value + } + + val poiTimestamp get() = this@PlayerService.poiTimestamp + + fun setBitmapListener(listener: ((Bitmap?) -> Unit)?) = bitmapProvider.setListener(listener) + + @kotlin.OptIn(FlowPreview::class) + fun startSleepTimer(delayMillis: Long) { + timerJob?.cancel() + + timerJob = coroutineScope.timer(delayMillis) { + ServiceNotifications.sleepTimer.sendNotification(this@PlayerService) { + this + .setContentTitle(getString(R.string.sleep_timer_ended)) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .setShowWhen(true) + .setSmallIcon(R.drawable.app_icon) + } + + handler.post { + player.pause() + player.stop() + + glyphInterface.glyph { + turnOff() + } + } + }.also { job -> + glyphInterface.progress( + job + .millisLeft + .takeWhile { it != null } + .debounce(500.milliseconds) + .map { ((it ?: 0L) / delayMillis.toFloat() * 100).toInt() } + ) + } + } + + fun cancelSleepTimer() { + timerJob?.cancel() + timerJob = null + } + + fun setupRadio(endpoint: NavigationEndpoint.Endpoint.Watch?) = + startRadio(endpoint = endpoint, justAdd = true) + + fun playRadio(endpoint: NavigationEndpoint.Endpoint.Watch?) = + startRadio(endpoint = endpoint, justAdd = false) + + private fun startRadio(endpoint: NavigationEndpoint.Endpoint.Watch?, justAdd: Boolean) { + radioJob?.cancel() + radio = null + + YouTubeRadio( + endpoint?.videoId, + endpoint?.playlistId, + endpoint?.playlistSetVideoId, + endpoint?.params + ).let { radioData -> + isLoadingRadio = true + radioJob = coroutineScope.launch { + val items = radioData.process().let { Database.filterBlacklistedSongs(it) } + + withContext(Dispatchers.Main) { + if (justAdd) player.addMediaItems(items.drop(1)) + else player.forcePlayFromBeginning(items) + } + + radio = radioData + isLoadingRadio = false + } + } + } + + fun stopRadio() { + isLoadingRadio = false + radioJob?.cancel() + radio = null + } + + /** + * This method should ONLY be called when the application (sc. activity) is in the foreground! + */ + fun restartForegroundOrStop() { + player.pause() + isInvincibilityEnabled = false + stopSelf() + } + + fun isCached(song: SongWithContentLength) = + song.contentLength?.let { cache.isCached(song.song.id, 0L, it) } ?: false + + fun playFromSearch(query: String) { + coroutineScope.launch { + Innertube.searchPage( + body = SearchBody( + query = query, + params = Innertube.SearchFilter.Song.value + ), + fromMusicShelfRendererContent = Innertube.SongItem.Companion::from + ) + ?.getOrNull() + ?.items + ?.firstOrNull() + ?.info + ?.endpoint + ?.let { playRadio(it) } + } + } + } + + private fun likeAction() = mediaItemState.value?.let { mediaItem -> + query { + runCatching { + Database.like( + songId = mediaItem.mediaId, + likedAt = if (isLikedState.value) null else System.currentTimeMillis() + ) + } + } + }.let { } + + private inner class SessionCallback : MediaSession.Callback() { + override fun onPlay() = player.play() + override fun onPause() = player.pause() + override fun onSkipToPrevious() = runCatching(player::forceSeekToPrevious).let { } + override fun onSkipToNext() = runCatching(player::forceSeekToNext).let { } + override fun onSeekTo(pos: Long) = player.seekTo(pos) + override fun onStop() = player.pause() + override fun onRewind() = player.seekToDefaultPosition() + override fun onSkipToQueueItem(id: Long) = + runCatching { player.seekToDefaultPosition(id.toInt()) }.let { } + + override fun onSetPlaybackSpeed(speed: Float) { + PlayerPreferences.speed = speed.coerceIn(0.01f..2f) + } + + override fun onPlayFromSearch(query: String?, extras: Bundle?) { + if (query.isNullOrBlank()) return + binder.playFromSearch(query) + } + + override fun onCustomAction(action: String, extras: Bundle?) { + super.onCustomAction(action, extras) + if (action == LIKE_ACTION) likeAction() + } + } + + inner class NotificationActionReceiver internal constructor() : + ActionReceiver("app.vimusic.android") { + val pause by action { _, _ -> + player.pause() + } + val play by action { _, _ -> + player.play() + } + val next by action { _, _ -> + player.forceSeekToNext() + } + val previous by action { _, _ -> + player.forceSeekToPrevious() + } + val like by action { _, _ -> + likeAction() + } + } + + class NotificationDismissReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + context.stopService(context.intent()) + } + } + + companion object { + private const val DEFAULT_CACHE_DIRECTORY = "exoplayer" + private const val DEFAULT_CHUNK_LENGTH = 512 * 1024L + + fun createDatabaseProvider(context: Context) = StandaloneDatabaseProvider(context) + fun createCache( + context: Context, + directoryName: String = DEFAULT_CACHE_DIRECTORY, + size: ExoPlayerDiskCacheSize = DataPreferences.exoPlayerDiskCacheMaxSize + ) = with(context) { + val cacheEvictor = when (size) { + ExoPlayerDiskCacheSize.Unlimited -> NoOpCacheEvictor() + else -> LeastRecentlyUsedCacheEvictor(size.bytes) + } + + val directory = cacheDir.resolve(directoryName).apply { + if (!exists()) mkdir() + } + + SimpleCache(directory, cacheEvictor, createDatabaseProvider(context)) + } + + @Suppress("CyclomaticComplexMethod") + fun createYouTubeDataSourceResolverFactory( + context: Context, + cache: Cache, + chunkLength: Long? = DEFAULT_CHUNK_LENGTH, + findMediaItem: suspend (videoId: String) -> MediaItem? = { null }, + uriCache: UriCache = UriCache() + ): DataSource.Factory = ResolvingDataSource.Factory( + ConditionalCacheDataSourceFactory( + cacheDataSourceFactory = cache.readOnlyWhen { PlayerPreferences.pauseCache }.asDataSource, + upstreamDataSourceFactory = context.defaultDataSource, + shouldCache = { !it.isLocal } + ) + ) { dataSpec -> + val mediaId = dataSpec.key?.removePrefix("https://youtube.com/watch?v=") + ?: error("A key must be set") + + fun DataSpec.ranged(contentLength: Long?) = contentLength?.let { + if (chunkLength == null) return@let null + + val start = dataSpec.uriPositionOffset + val length = (contentLength - start).coerceAtMost(chunkLength) + val rangeText = "$start-${start + length}" + + this.subrange(start, length) + .withAdditionalHeaders(mapOf("Range" to "bytes=$rangeText")) + } ?: this + + if ( + dataSpec.isLocal || (chunkLength != null && cache.isCached( + /* key = */ mediaId, + /* position = */ dataSpec.position, + /* length = */ chunkLength + )) + ) dataSpec + else uriCache[mediaId]?.let { cachedUri -> + dataSpec + .withUri(cachedUri.uri) + .ranged(cachedUri.meta) + } ?: run { + val body = runBlocking(Dispatchers.IO) { + Innertube.player(PlayerBody(videoId = mediaId)) + }?.getOrThrow() + + if (body?.videoDetails?.videoId != mediaId) throw VideoIdMismatchException() + + val format = body.streamingData?.highestQualityFormat + ?: throw PlayableFormatNotFoundException() + val url = when (val status = body.playabilityStatus?.status) { + "OK" -> { + val mediaItem = runCatching { + runBlocking(Dispatchers.IO) { findMediaItem(mediaId) } + }.getOrNull() + val extras = mediaItem?.mediaMetadata?.extras?.songBundle + + if (extras?.durationText == null) format.approxDurationMs + ?.div(1000) + ?.let(DateUtils::formatElapsedTime) + ?.removePrefix("0") + ?.let { durationText -> + extras?.durationText = durationText + Database.updateDurationText(mediaId, durationText) + } + + transaction { + runCatching { + mediaItem?.let(Database::insert) + + Database.insert( + Format( + songId = mediaId, + itag = format.itag, + mimeType = format.mimeType, + bitrate = format.bitrate, + loudnessDb = body.playerConfig?.audioConfig?.normalizedLoudnessDb, + contentLength = format.contentLength, + lastModified = format.lastModified + ) + ) + } + } + + runCatching { + runBlocking(Dispatchers.IO) { + format.findUrl() + } + }.getOrElse { + throw RestrictedVideoException(it) + } + } + + "UNPLAYABLE" -> throw UnplayableException() + "LOGIN_REQUIRED" -> throw LoginRequiredException() + + else -> throw PlaybackException( + /* message = */ status, + /* cause = */ null, + /* errorCode = */ PlaybackException.ERROR_CODE_REMOTE_ERROR + ) + } ?: throw UnplayableException() + + val uri = url.toUri().let { + if (body.cpn == null) it + else it + .buildUpon() + .appendQueryParameter("cpn", body.cpn) + .build() + } + + uriCache.push( + key = mediaId, + meta = format.contentLength, + uri = uri, + validUntil = body.streamingData?.expiresInSeconds?.seconds?.let { Clock.System.now() + it } + ) + + dataSpec + .withUri(uri) + .ranged(format.contentLength) + } + } + .handleUnknownErrors { + uriCache.clear() + } + .handleRangeErrors() + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/service/PrecacheService.kt b/app/src/main/kotlin/app/vimusic/android/service/PrecacheService.kt new file mode 100644 index 0000000..e5fffda --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/service/PrecacheService.kt @@ -0,0 +1,318 @@ +package app.vimusic.android.service + +import android.content.ComponentName +import android.content.Context +import android.content.ServiceConnection +import android.net.Uri +import android.os.IBinder +import androidx.annotation.OptIn +import androidx.core.app.NotificationCompat +import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.cache.Cache +import androidx.media3.datasource.cache.CacheSpan +import androidx.media3.datasource.cache.ContentMetadataMutations +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.DownloadManager +import androidx.media3.exoplayer.offline.DownloadNotificationHelper +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import androidx.media3.exoplayer.scheduler.Requirements +import androidx.media3.exoplayer.workmanager.WorkManagerScheduler +import app.vimusic.android.Database +import app.vimusic.android.R +import app.vimusic.android.transaction +import app.vimusic.android.utils.ActionReceiver +import app.vimusic.android.utils.download +import app.vimusic.android.utils.intent +import app.vimusic.android.utils.toast +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import java.io.File +import java.util.concurrent.Executors +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine +import kotlin.time.Duration.Companion.milliseconds + +private val executor = Executors.newCachedThreadPool() +private val coroutineScope = CoroutineScope( + executor.asCoroutineDispatcher() + + SupervisorJob() + + CoroutineName("PrecacheService-Worker-Scope") +) + +// While the class is not a singleton (lifecycle), there should only be one download state at a time +private val mutableDownloadState = MutableStateFlow(false) +val downloadState = mutableDownloadState.asStateFlow() + +private const val DOWNLOAD_NOTIFICATION_UPDATE_INTERVAL = 1000L // default +private const val DOWNLOAD_WORK_NAME = "precacher-work" + +@OptIn(UnstableApi::class) +class PrecacheService : DownloadService( + /* foregroundNotificationId = */ ServiceNotifications.download.notificationId!!, + /* foregroundNotificationUpdateInterval = */ DOWNLOAD_NOTIFICATION_UPDATE_INTERVAL, + /* channelId = */ ServiceNotifications.download.id, + /* channelNameResourceId = */ R.string.pre_cache, + /* channelDescriptionResourceId = */ 0 +) { + private val downloadQueue = + Channel(onBufferOverflow = BufferOverflow.DROP_OLDEST) + + private val downloadNotificationHelper by lazy { + DownloadNotificationHelper( + /* context = */ this, + /* channelId = */ ServiceNotifications.download.id + ) + } + + private val notificationActionReceiver = NotificationActionReceiver() + + private val waiters = mutableListOf<() -> Unit>() + + private val serviceConnection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + if (service !is PlayerService.Binder) return + bound = true + binder = service + waiters.forEach { it() } + waiters.clear() + } + + override fun onServiceDisconnected(name: ComponentName?) { + bound = false + binder = null + waiters.forEach { it() } + waiters.clear() + } + } + + inner class NotificationActionReceiver : ActionReceiver("app.vimusic.android.precache") { + val cancel by action { context, _ -> + runCatching { + sendPauseDownloads( + /* context = */ context, + /* clazz = */ PrecacheService::class.java, + /* foreground = */ true + ) + }.recoverCatching { + sendPauseDownloads( + /* context = */ context, + /* clazz = */ PrecacheService::class.java, + /* foreground = */ false + ) + } + } + } + + @get:Synchronized + @set:Synchronized + private var bound = false + private var binder: PlayerService.Binder? = null + + private var progressUpdaterJob: Job? = null + + override fun onCreate() { + super.onCreate() + + notificationActionReceiver.register() + mutableDownloadState.update { false } + } + + @kotlin.OptIn(FlowPreview::class) + override fun getDownloadManager(): DownloadManager { + runCatching { + if (bound) unbindService(serviceConnection) + bindService(intent(), serviceConnection, Context.BIND_AUTO_CREATE) + }.exceptionOrNull()?.let { + it.printStackTrace() + toast(getString(R.string.error_pre_cache)) + } + + val cache = BlockingDeferredCache { + suspendCoroutine { + waiters += { it.resume(Unit) } + } + binder?.cache ?: run { + toast(getString(R.string.error_pre_cache)) + error("PlayerService failed to start, crashing...") + } + } + + progressUpdaterJob?.cancel() + progressUpdaterJob = coroutineScope.launch { + downloadQueue + .receiveAsFlow() + .debounce(100.milliseconds) + .collect { downloadManager -> + mutableDownloadState.update { !downloadManager.isIdle } + } + } + + return DownloadManager( + /* context = */ this, + /* databaseProvider = */ PlayerService.createDatabaseProvider(this), + /* cache = */ cache, + /* upstreamFactory = */ PlayerService.createYouTubeDataSourceResolverFactory( + findMediaItem = { null }, + context = this, + cache = cache, + chunkLength = null + ), + /* executor = */ executor + ).apply { + maxParallelDownloads = 3 + minRetryCount = 1 + requirements = Requirements(Requirements.NETWORK) + + addListener( + object : DownloadManager.Listener { + override fun onIdle(downloadManager: DownloadManager) = + mutableDownloadState.update { false } + + override fun onDownloadChanged( + downloadManager: DownloadManager, + download: Download, + finalException: Exception? + ) = downloadQueue.trySend(downloadManager).let { } + + override fun onDownloadRemoved( + downloadManager: DownloadManager, + download: Download + ) = downloadQueue.trySend(downloadManager).let { } + } + ) + } + } + + override fun getScheduler() = WorkManagerScheduler(this, DOWNLOAD_WORK_NAME) + + override fun getForegroundNotification( + downloads: MutableList, + notMetRequirements: Int + ) = NotificationCompat + .Builder( + /* context = */ this, + /* notification = */ downloadNotificationHelper.buildProgressNotification( + /* context = */ this, + /* smallIcon = */ R.drawable.download, + /* contentIntent = */ null, + /* message = */ null, + /* downloads = */ downloads, + /* notMetRequirements = */ notMetRequirements + ) + ) + .setChannelId(ServiceNotifications.download.id) + .addAction( + NotificationCompat.Action.Builder( + /* icon = */ R.drawable.close, + /* title = */ getString(R.string.cancel), + /* intent = */ notificationActionReceiver.cancel.pendingIntent + ).build() + ) + .build() + + override fun onDestroy() { + super.onDestroy() + + runCatching { + if (bound) unbindService(serviceConnection) + } + + unregisterReceiver(notificationActionReceiver) + mutableDownloadState.update { false } + } + + companion object { + fun scheduleCache(context: Context, mediaItem: MediaItem) { + if (mediaItem.isLocal) return + + val downloadRequest = DownloadRequest + .Builder( + /* id = */ mediaItem.mediaId, + /* uri = */ mediaItem.requestMetadata.mediaUri + ?: Uri.parse("https://youtube.com/watch?v=${mediaItem.mediaId}") + ) + .setCustomCacheKey(mediaItem.mediaId) + .setData(mediaItem.mediaId.encodeToByteArray()) + .build() + + transaction { + runCatching { + Database.insert(mediaItem) + }.also { if (it.isFailure) return@transaction } + + coroutineScope.launch { + context.download(downloadRequest).exceptionOrNull()?.let { + if (it is CancellationException) throw it + + it.printStackTrace() + context.toast(context.getString(R.string.error_pre_cache)) + } + } + } + } + } +} + +@Suppress("TooManyFunctions") +@OptIn(UnstableApi::class) +class BlockingDeferredCache(private val cache: Deferred) : Cache { + constructor(init: suspend () -> Cache) : this(coroutineScope.async { init() }) + + private val resolvedCache by lazy { runBlocking { cache.await() } } + + override fun getUid() = resolvedCache.uid + override fun release() = resolvedCache.release() + override fun addListener(key: String, listener: Cache.Listener) = + resolvedCache.addListener(key, listener) + + override fun removeListener(key: String, listener: Cache.Listener) = + resolvedCache.removeListener(key, listener) + + override fun getCachedSpans(key: String) = resolvedCache.getCachedSpans(key) + override fun getKeys(): MutableSet = resolvedCache.keys + override fun getCacheSpace() = resolvedCache.cacheSpace + override fun startReadWrite(key: String, position: Long, length: Long) = + resolvedCache.startReadWrite(key, position, length) + + override fun startReadWriteNonBlocking(key: String, position: Long, length: Long) = + resolvedCache.startReadWriteNonBlocking(key, position, length) + + override fun startFile(key: String, position: Long, length: Long) = + resolvedCache.startFile(key, position, length) + + override fun commitFile(file: File, length: Long) = resolvedCache.commitFile(file, length) + override fun releaseHoleSpan(holeSpan: CacheSpan) = resolvedCache.releaseHoleSpan(holeSpan) + override fun removeResource(key: String) = resolvedCache.removeResource(key) + override fun removeSpan(span: CacheSpan) = resolvedCache.removeSpan(span) + override fun isCached(key: String, position: Long, length: Long) = + resolvedCache.isCached(key, position, length) + + override fun getCachedLength(key: String, position: Long, length: Long) = + resolvedCache.getCachedLength(key, position, length) + + override fun getCachedBytes(key: String, position: Long, length: Long) = + resolvedCache.getCachedBytes(key, position, length) + + override fun applyContentMetadataMutations(key: String, mutations: ContentMetadataMutations) = + resolvedCache.applyContentMetadataMutations(key, mutations) + + override fun getContentMetadata(key: String) = resolvedCache.getContentMetadata(key) +} diff --git a/app/src/main/kotlin/app/vimusic/android/service/ServiceNotifications.kt b/app/src/main/kotlin/app/vimusic/android/service/ServiceNotifications.kt new file mode 100644 index 0000000..c167cc0 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/service/ServiceNotifications.kt @@ -0,0 +1,181 @@ +package app.vimusic.android.service + +import android.app.Application +import android.app.Notification +import android.app.NotificationManager +import android.app.Service +import android.content.Context +import android.os.Handler +import android.os.Looper +import androidx.annotation.OptIn +import androidx.annotation.StringRes +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.getSystemService +import androidx.media3.common.util.NotificationUtil.Importance +import androidx.media3.common.util.UnstableApi +import app.vimusic.android.R +import java.util.concurrent.atomic.AtomicInteger +import kotlin.math.absoluteValue +import kotlin.properties.PropertyDelegateProvider +import kotlin.properties.ReadOnlyProperty +import kotlin.random.Random +import kotlin.reflect.KProperty + +abstract class NotificationChannels { + private val handler = Handler(Looper.getMainLooper()) + + @OptIn(UnstableApi::class) + inner class Channel internal constructor( + val id: String, + @StringRes + val description: Int, + val notificationId: Int? = null, + val importance: @Importance Int, + val options: NotificationChannelCompat.Builder.() -> NotificationChannelCompat.Builder + ) { + private val Context.notificationManager + get() = getSystemService() + ?: error("No NotificationManager available") + + private fun createNotification( + context: Context, + notification: NotificationCompat.Builder.() -> NotificationCompat.Builder + ): Pair = + (notificationId ?: randomNotificationId()) to NotificationCompat.Builder(context, id) + .let { + if (notificationId == null) it else it.setOnlyAlertOnce(false) + } + .run(notification) + .build() + + fun upsertChannel(context: Context) = NotificationManagerCompat + .from(context) + .createNotificationChannel( + NotificationChannelCompat.Builder(id, importance) + .setName(context.getString(description)) + .run(options) + .build() + ) + + fun sendNotification( + context: Context, + notification: NotificationCompat.Builder.() -> NotificationCompat.Builder + ) = runCatching { + handler.post { + val manager = context.notificationManager + upsertChannel(context) + val (id, notif) = createNotification(context, notification) + manager.notify(id, notif) + } + } + + context(Service) + fun startForeground( + context: Context, + notification: NotificationCompat.Builder.() -> NotificationCompat.Builder + ) = runCatching { + handler.post { + upsertChannel(context) + val (id, notif) = createNotification(context, notification) + startForeground(id, notif) + } + } + + fun cancel( + context: Context, + notificationId: Int? = null + ) = runCatching { + handler.post { + context.notificationManager.cancel((this.notificationId ?: notificationId)!!) + } + } + } + + private val mutableChannels = mutableListOf() + private val index = AtomicInteger(1001) + + private fun randomNotificationId(): Int { + var random = Random.nextInt().absoluteValue + while (random in 1001..2001) { + random = Random.nextInt().absoluteValue + } + return random + } + + context(Application) + fun createAll() = handler.post { + mutableChannels.forEach { it.upsertChannel(this@Application) } + } + + @OptIn(UnstableApi::class) + fun channel( + name: String? = null, + @StringRes + description: Int, + importance: @Importance Int, + singleNotification: Boolean, + options: NotificationChannelCompat.Builder.() -> NotificationChannelCompat.Builder = { this } + ) = readOnlyProvider { _, property -> + val channel = Channel( + id = "${name?.lowercase() ?: property.name.lowercase()}_channel_id", + description = description, + notificationId = if (singleNotification) index.getAndIncrement().also { + if (it > 2001) error("More than 1000 unique notifications created!") + } else null, + importance = importance, + options = options + ) + mutableChannels += channel + { _, _ -> channel } + } +} + +inline fun readOnlyProvider( + crossinline provide: ( + thisRef: ThisRef, + property: KProperty<*> + ) -> (thisRef: ThisRef, property: KProperty<*>) -> Return +) = PropertyDelegateProvider> { thisRef, property -> + val provider = provide(thisRef, property) + ReadOnlyProperty { innerThisRef, innerProperty -> provider(innerThisRef, innerProperty) } +} + +object ServiceNotifications : NotificationChannels() { + val default by channel( + description = R.string.now_playing, + importance = NotificationManagerCompat.IMPORTANCE_LOW, + singleNotification = true + ) + + val sleepTimer by channel( + name = "sleep_timer", + description = R.string.sleep_timer, + importance = NotificationManagerCompat.IMPORTANCE_LOW, + singleNotification = true + ) + + val download by channel( + description = R.string.pre_cache, + importance = NotificationManagerCompat.IMPORTANCE_LOW, + singleNotification = true + ) + + val version by channel( + description = R.string.version_check, + importance = NotificationManagerCompat.IMPORTANCE_HIGH, + singleNotification = true + ) { + setLightsEnabled(true).setVibrationEnabled(true) + } + + val autoSkip by channel( + name = "autoskip", + description = R.string.skip_on_error, + importance = NotificationManagerCompat.IMPORTANCE_HIGH, + singleNotification = true + ) { + setLightsEnabled(true).setVibrationEnabled(true) + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/components/BottomSheet.kt b/app/src/main/kotlin/app/vimusic/android/ui/components/BottomSheet.kt new file mode 100644 index 0000000..6292b6d --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/components/BottomSheet.kt @@ -0,0 +1,302 @@ +package app.vimusic.android.ui.components + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Indication +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.MutatePriority +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.DragScope +import androidx.compose.foundation.gestures.DraggableState +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.SaverScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.util.VelocityTracker +import androidx.compose.ui.input.pointer.util.addPointerInputChange +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.coerceAtLeast +import androidx.compose.ui.unit.coerceAtMost +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.times +import app.vimusic.compose.routing.CallbackPredictiveBackHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Composable +fun BottomSheet( + state: BottomSheetState, + collapsedContent: @Composable BoxScope.(Modifier) -> Unit, + modifier: Modifier = Modifier, + onDismiss: (() -> Unit)? = null, + indication: Indication? = LocalIndication.current, + backHandlerEnabled: Boolean = true, + content: @Composable BoxScope.() -> Unit +) = Box( + modifier = modifier + .offset(y = (state.expandedBound - state.value).coerceAtLeast(0.dp)) + .pointerInput(state) { + val velocityTracker = VelocityTracker() + + detectVerticalDragGestures( + onVerticalDrag = { change, dragAmount -> + velocityTracker.addPointerInputChange(change) + state.dispatchRawDelta(dragAmount) + }, + onDragCancel = { + velocityTracker.resetTracking() + state.snapTo(state.collapsedBound) + }, + onDragEnd = { + val velocity = -velocityTracker.calculateVelocity().y + velocityTracker.resetTracking() + state.fling(velocity, onDismiss) + } + ) + } +) { + if (state.value > state.collapsedBound) CallbackPredictiveBackHandler( + enabled = !state.collapsing && backHandlerEnabled, + onStart = { }, + onProgress = { state.collapse(progress = it) }, + onFinish = { state.collapseSoft() }, + onCancel = { state.expandSoft() } + ) + if (!state.dismissed && !state.collapsed) content() + + if (!state.expanded && (onDismiss == null || !state.dismissed)) Box( + modifier = Modifier + .graphicsLayer { + alpha = 1f - (state.progress * 16).coerceAtMost(1f) + } + .fillMaxWidth() + .height(state.collapsedBound) + ) { + collapsedContent( + Modifier.clickable( + onClick = state::expandSoft, + indication = indication, + interactionSource = remember { MutableInteractionSource() } + ) + ) + } +} + +@Suppress("TooManyFunctions") +@Stable +class BottomSheetState +@Suppress("LongParameterList") +internal constructor( + density: Density, + initialValue: Dp, + private val coroutineScope: CoroutineScope, + private val onAnchorChanged: (Anchor) -> Unit, + val dismissedBound: Dp, + val collapsedBound: Dp, + val expandedBound: Dp +) : DraggableState { + private val animatable = Animatable( + initialValue = initialValue, + typeConverter = Dp.VectorConverter + ) + val value by animatable.asState() + + private val draggableState = DraggableState { delta -> + coroutineScope.launch { + animatable.snapTo(animatable.value - with(density) { delta.toDp() }) + } + } + + override suspend fun drag(dragPriority: MutatePriority, block: suspend DragScope.() -> Unit) = + draggableState.drag(dragPriority, block) + + override fun dispatchRawDelta(delta: Float) = draggableState.dispatchRawDelta(delta) + + val dismissed by derivedStateOf { value == dismissedBound } + val collapsed by derivedStateOf { value == collapsedBound } + val expanded by derivedStateOf { value == expandedBound } + val collapsing by derivedStateOf { + animatable.targetValue == collapsedBound || animatable.targetValue == dismissedBound + } + val progress by derivedStateOf { 1f - (expandedBound - value) / (expandedBound - collapsedBound) } + + private fun deferAnimateTo( + newValue: Dp, + spec: AnimationSpec = spring() + ) = coroutineScope.launch { + animatable.animateTo(newValue, spec) + } + + private fun collapse(spec: AnimationSpec = spring()) { + onAnchorChanged(Anchor.Collapsed) + deferAnimateTo(collapsedBound, spec) + } + + private fun expand(spec: AnimationSpec = spring()) { + onAnchorChanged(Anchor.Expanded) + deferAnimateTo(expandedBound, spec) + } + + private fun dismiss(spec: AnimationSpec = spring()) { + onAnchorChanged(Anchor.Dismissed) + deferAnimateTo(dismissedBound, spec) + } + + fun collapse(progress: Float) { + snapTo(expandedBound - progress * (expandedBound - collapsedBound)) + } + + fun collapseSoft() = collapse(tween(300)) + fun expandSoft() = expand(tween(300)) + fun dismissSoft() = dismiss(tween(300)) + + fun snapTo(value: Dp) = coroutineScope.launch { + animatable.snapTo(value) + } + + fun fling(velocity: Float, onDismiss: (() -> Unit)?) = when { + velocity > 250 -> expand() + velocity < -250 -> { + if (value < collapsedBound && onDismiss != null) { + dismiss() + onDismiss() + } else collapse() + } + + else -> { + val l1 = (collapsedBound - dismissedBound) / 2 + val l2 = (expandedBound - collapsedBound) / 2 + + when (value) { + in dismissedBound..l1 -> { + if (onDismiss != null) { + dismiss() + onDismiss() + } else collapse() + } + + in l1..l2 -> collapse() + in l2..expandedBound -> expand() + + else -> Unit + } + } + } + + val preUpPostDownNestedScrollConnection + get() = object : NestedScrollConnection { + var isTopReached = false + + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + if (expanded && available.y < 0) isTopReached = false + + return if (isTopReached && available.y < 0 && source == NestedScrollSource.UserInput) { + dispatchRawDelta(available.y) + available + } else Offset.Zero + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + if (!isTopReached) isTopReached = consumed.y == 0f && available.y > 0 + + return if (isTopReached && source == NestedScrollSource.UserInput) { + dispatchRawDelta(available.y) + available + } else Offset.Zero + } + + override suspend fun onPreFling(available: Velocity) = if (isTopReached) { + val velocity = -available.y + fling(velocity, null) + + available + } else Velocity.Zero + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + isTopReached = false + return Velocity.Zero + } + } + + @JvmInline + value class Anchor private constructor(internal val value: Int) { + companion object { + val Dismissed = Anchor(value = 0) + val Collapsed = Anchor(value = 1) + val Expanded = Anchor(value = 2) + } + + object Saver : androidx.compose.runtime.saveable.Saver { + override fun restore(value: Int) = when (value) { + 0 -> Dismissed + 1 -> Collapsed + 2 -> Expanded + else -> error("Anchor $value does not exist!") + } + + override fun SaverScope.save(value: Anchor) = value.value + } + } +} + +@Composable +fun rememberBottomSheetState( + dismissedBound: Dp, + expandedBound: Dp, + key: Any? = Unit, + collapsedBound: Dp = dismissedBound, + initialAnchor: BottomSheetState.Anchor = BottomSheetState.Anchor.Dismissed +): BottomSheetState { + val density = LocalDensity.current + val coroutineScope = rememberCoroutineScope() + + var previousAnchor by rememberSaveable(stateSaver = BottomSheetState.Anchor.Saver) { + mutableStateOf(initialAnchor) + } + + return remember(key, dismissedBound, expandedBound, collapsedBound, coroutineScope) { + BottomSheetState( + density = density, + onAnchorChanged = { previousAnchor = it }, + coroutineScope = coroutineScope, + dismissedBound = dismissedBound.coerceAtMost(expandedBound), + collapsedBound = collapsedBound, + expandedBound = expandedBound, + initialValue = when (previousAnchor) { + BottomSheetState.Anchor.Dismissed -> dismissedBound + BottomSheetState.Anchor.Collapsed -> collapsedBound + BottomSheetState.Anchor.Expanded -> expandedBound + else -> error("Unknown BottomSheet anchor") + } + ) + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/components/FadingRow.kt b/app/src/main/kotlin/app/vimusic/android/ui/components/FadingRow.kt new file mode 100644 index 0000000..0c2f090 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/components/FadingRow.kt @@ -0,0 +1,51 @@ +package app.vimusic.android.ui.components + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.rememberScrollState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import app.vimusic.android.ui.modifiers.horizontalFadingEdge + +@Composable +inline fun FadingRow( + modifier: Modifier = Modifier, + segments: Int = 12, + verticalAlignment: Alignment.Vertical = Alignment.Top, + content: @Composable RowScope.() -> Unit +) { + val scrollState = rememberScrollState() + val alphaLeft by animateFloatAsState( + targetValue = if (scrollState.canScrollBackward) 1f else 0f, + label = "" + ) + val alphaRight by animateFloatAsState( + targetValue = if (scrollState.canScrollForward) 1f else 0f, + label = "" + ) + + Row( + modifier = modifier + .horizontalFadingEdge( + left = true, + middle = segments - 2, + right = false, + alpha = alphaLeft + ) + .horizontalFadingEdge( + left = false, + middle = segments - 2, + right = true, + alpha = alphaRight + ) + .horizontalScroll(scrollState), + horizontalArrangement = Arrangement.Center, + verticalAlignment = verticalAlignment, + content = content + ) +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/components/Menu.kt b/app/src/main/kotlin/app/vimusic/android/ui/components/Menu.kt new file mode 100644 index 0000000..2663845 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/components/Menu.kt @@ -0,0 +1,115 @@ +package app.vimusic.android.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.LocalOverscrollConfiguration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.unit.times +import app.vimusic.android.LocalPlayerAwareWindowInsets +import app.vimusic.android.ui.modifiers.pressable + +val LocalMenuState = staticCompositionLocalOf { MenuState() } + +@Stable +class MenuState { + var isDisplayed by mutableStateOf(false) + private set + + var content by mutableStateOf<@Composable () -> Unit>({}) + private set + + fun display(content: @Composable () -> Unit) { + this.content = content + isDisplayed = true + } + + fun hide() { + isDisplayed = false + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun BottomSheetMenu( + modifier: Modifier = Modifier, + state: MenuState = LocalMenuState.current +) = BoxWithConstraints(modifier = modifier) { + val windowInsets = LocalPlayerAwareWindowInsets.current + + val height = 0.8f * maxHeight + + val bottomSheetState = rememberBottomSheetState( + dismissedBound = -windowInsets + .only(WindowInsetsSides.Bottom) + .asPaddingValues() + .calculateBottomPadding(), + expandedBound = height + ) + + LaunchedEffect(state.isDisplayed) { + if (state.isDisplayed) bottomSheetState.expandSoft() + else bottomSheetState.dismissSoft() + } + + LaunchedEffect(bottomSheetState.collapsed) { + if (bottomSheetState.collapsed) state.hide() + } + + AnimatedVisibility( + visible = state.isDisplayed, + enter = fadeIn(), + exit = fadeOut() + ) { + Spacer( + modifier = Modifier + .pressable(onRelease = state::hide) + .alpha(bottomSheetState.progress * 0.5f) + .background(Color.Black) + .fillMaxSize() + ) + } + + CompositionLocalProvider(LocalOverscrollConfiguration provides null) { + if (!bottomSheetState.dismissed) BottomSheet( // This way the back gesture gets handled correctly + state = bottomSheetState, + collapsedContent = { }, + onDismiss = { state.hide() }, + indication = null, + modifier = Modifier.align(Alignment.BottomCenter) + ) { + Column( + verticalArrangement = Arrangement.Bottom, + modifier = Modifier + .align(Alignment.BottomCenter) + .sizeIn(maxHeight = height) + .nestedScroll(bottomSheetState.preUpPostDownNestedScrollConnection) + ) { + state.content() + } + } + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/components/MusicBars.kt b/app/src/main/kotlin/app/vimusic/android/ui/components/MusicBars.kt new file mode 100644 index 0000000..2f2e707 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/components/MusicBars.kt @@ -0,0 +1,81 @@ +package app.vimusic.android.ui.components + +import androidx.compose.animation.core.Animatable +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEachIndexed +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +// @formatter:off +@Suppress("MaximumLineLength") +private val steps = persistentListOf( + arrayOf(0.8f, 0.1f, 0.9f, 0.9f, 0.7f, 0.9f, 0.8f, 0.1f, 0.3f, 0.8f, 0.6f, 0.0f, 0.3f, 0.4f, 0.9f, 0.7f, 0.9f, 0.6f, 0.9f, 0.1f, 0.3f, 0.0f, 0.5f, 0.4f, 0.7f, 0.9f), + arrayOf(0.8f, 0.5f, 0.0f, 0.5f, 0.7f, 0.9f, 0.8f, 0.7f, 0.5f, 0.9f, 0.4f, 0.5f, 0.7f, 0.3f, 0.1f, 0.0f, 0.7f, 0.9f, 0.5f, 0.7f, 0.4f, 0.0f, 0.4f, 0.3f, 0.6f, 0.9f), + arrayOf(0.4f, 0.5f, 0.0f, 0.4f, 0.5f, 0.0f, 0.4f, 0.5f, 0.0f, 0.5f, 0.4f, 0.3f, 0.8f, 0.7f, 0.9f, 0.5f, 0.6f, 0.4f, 0.3f, 0.9f, 0.6f, 0.7f, 0.9f, 0.6f, 0.7f, 0.3f) +) +// @formatter:on + +@Composable +fun MusicBars( + color: Color, + modifier: Modifier = Modifier, + barWidth: Dp = 4.dp, + cornerRadius: Dp = 16.dp, + space: Dp = 4.dp +) { + val animatables = remember { List(steps.size) { Animatable(0f) } } + + LaunchedEffect(Unit) { + animatables.fastForEachIndexed { i, animatable -> + launch { + var step = 0 + val steps = steps[i] + while (isActive) { + animatable.animateTo(steps[step]) + step = (step + 1) % steps.size + } + } + } + } + + Canvas( + modifier = modifier + .fillMaxHeight() + .width(barWidth * animatables.size + space * animatables.lastIndex) + ) { + val radius = CornerRadius(cornerRadius.toPx()) + val barWidthPx = barWidth.toPx() + val barHeightPx = size.height + val stride = barWidthPx + space.toPx() + + animatables.fastForEachIndexed { i, animatable -> + val value = animatable.value + + drawRoundRect( + color = color, + topLeft = Offset( + x = i * stride, + y = barHeightPx * value + ), + size = Size( + width = barWidthPx, + height = barHeightPx * (1 - value) + ), + cornerRadius = radius + ) + } + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/components/SeekBar.kt b/app/src/main/kotlin/app/vimusic/android/ui/components/SeekBar.kt new file mode 100644 index 0000000..c857c84 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/components/SeekBar.kt @@ -0,0 +1,488 @@ +package app.vimusic.android.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateDp +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.media3.common.C +import app.vimusic.android.models.ui.UiMedia +import app.vimusic.android.preferences.PlayerPreferences +import app.vimusic.android.service.PlayerService +import app.vimusic.android.utils.formatAsDuration +import app.vimusic.android.utils.semiBold +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.utils.roundedShape +import kotlin.math.PI +import kotlin.math.sin + +// TODO: de-couple from binder + +@Composable +fun SeekBar( + binder: PlayerService.Binder, + position: Long, + media: UiMedia, + modifier: Modifier = Modifier, + color: Color = LocalAppearance.current.colorPalette.text, + backgroundColor: Color = LocalAppearance.current.colorPalette.background2, + shape: Shape = 8.dp.roundedShape, + isActive: Boolean = binder.player.isPlaying, + alwaysShowDuration: Boolean = false, + scrubberRadius: Dp = 6.dp, + style: PlayerPreferences.SeekBarStyle = PlayerPreferences.seekBarStyle, + range: ClosedRange = 0L..media.duration +) { + var scrubbingPosition by remember(media) { mutableStateOf(null) } + val animatedPosition by animateFloatAsState( + targetValue = scrubbingPosition?.toFloat() ?: position.toFloat(), + label = "" + ) + + var isDragging by remember { mutableStateOf(false) } + + val onSeekStart: (Long) -> Unit = { scrubbingPosition = it } + val onSeek: (Long) -> Unit = { delta -> + scrubbingPosition = if (media.duration == C.TIME_UNSET) null + else scrubbingPosition?.let { (it + delta).coerceIn(range) } + } + val onSeekEnd = { + scrubbingPosition?.let(binder.player::seekTo) + scrubbingPosition = null + } + + val innerModifier = modifier + .pointerInput(range) { + if (range.endInclusive < range.start) return@pointerInput + + detectDrags( + setIsDragging = { isDragging = it }, + range = range, + onSeekStart = onSeekStart, + onSeek = onSeek, + onSeekEnd = onSeekEnd + ) + } + .pointerInput(range) { + detectTaps( + range = range, + onSeekStart = onSeekStart, + onSeekEnd = onSeekEnd + ) + } + + when (style) { + PlayerPreferences.SeekBarStyle.Static -> { + ClassicSeekBarBody( + position = scrubbingPosition ?: animatedPosition.toLong(), + duration = media.duration, + poiTimestamp = binder.poiTimestamp, + isDragging = isDragging, + color = color, + backgroundColor = backgroundColor, + showDuration = alwaysShowDuration || scrubbingPosition != null, + modifier = innerModifier, + scrubberRadius = scrubberRadius, + shape = shape + ) + } + + PlayerPreferences.SeekBarStyle.Wavy -> { + WavySeekBarBody( + position = scrubbingPosition ?: animatedPosition.toLong(), + duration = media.duration, + poiTimestamp = binder.poiTimestamp, + isDragging = isDragging, + color = color, + backgroundColor = backgroundColor, + modifier = innerModifier, + scrubberRadius = scrubberRadius, + shape = shape, + showDuration = alwaysShowDuration || scrubbingPosition != null, + isActive = isActive + ) + } + } +} + +@Composable +private fun ClassicSeekBarBody( + position: Long, + duration: Long, + poiTimestamp: Long?, + isDragging: Boolean, + color: Color, + backgroundColor: Color, + scrubberRadius: Dp, + shape: Shape, + showDuration: Boolean, + modifier: Modifier = Modifier, + range: ClosedRange = 0L..duration, + barHeight: Dp = 3.dp, + scrubberColor: Color = color, + drawSteps: Boolean = false +) = Column { + val transition = updateTransition( + targetState = isDragging, + label = null + ) + + val currentBarHeight by transition.animateDp(label = "") { if (it) scrubberRadius else barHeight } + val currentScrubberRadius by transition.animateDp(label = "") { if (it) 0.dp else scrubberRadius } + + Box( + modifier = modifier + .padding(horizontal = scrubberRadius) + .drawWithContent { + drawContent() + + val scrubberPosition = + if (range.endInclusive < range.start) 0f + else (position.toFloat() - range.start) / (range.endInclusive - range.start) * size.width + + drawCircle( + color = scrubberColor, + radius = currentScrubberRadius.toPx(), + center = center.copy(x = scrubberPosition) + ) + + if (poiTimestamp != null && position < poiTimestamp) drawPoi( + range = range, + position = poiTimestamp, + color = color + ) + + if (drawSteps) for (i in position + 1..range.endInclusive) { + val stepPosition = + (i.toFloat() - range.start) / (range.endInclusive - range.start) * size.width + + drawCircle( + color = scrubberColor, + radius = scrubberRadius.toPx() / 2, + center = center.copy(x = stepPosition) + ) + } + } + .height(scrubberRadius) + ) { + Spacer( + modifier = Modifier + .height(currentBarHeight) + .fillMaxWidth() + .background(color = backgroundColor, shape = shape) + .align(Alignment.Center) + ) + + Spacer( + modifier = Modifier + .height(currentBarHeight) + .fillMaxWidth((position.toFloat() - range.start) / (range.endInclusive - range.start).toFloat()) + .background(color = color, shape = shape) + .align(Alignment.CenterStart) + ) + } + + Duration( + position = position, + duration = duration, + show = showDuration + ) +} + +@Composable +private fun WavySeekBarBody( + position: Long, + duration: Long, + poiTimestamp: Long?, + isDragging: Boolean, + color: Color, + backgroundColor: Color, + shape: Shape, + showDuration: Boolean, + modifier: Modifier = Modifier, + range: ClosedRange = 0L..duration, + isActive: Boolean = true, + scrubberRadius: Dp = 6.dp +) = Column { + val transition = updateTransition( + targetState = isDragging, + label = null + ) + + val currentAmplitude by transition.animateDp(label = "") { if (it || !isActive) 0.dp else 2.dp } + val currentScrubberHeight by transition.animateDp(label = "") { if (it) 20.dp else 15.dp } + + val fraction = (position - range.start) / (range.endInclusive - range.start).toFloat() + val progress by rememberInfiniteTransition(label = "").animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(2000, easing = LinearEasing)), + label = "" + ) + + Box( + modifier = modifier + .padding(horizontal = scrubberRadius) + .drawWithContent { + drawContent() + + if (poiTimestamp != null && position < poiTimestamp) drawPoi( + range = range, + position = poiTimestamp, + color = color + ) + + drawScrubber( + range = range, + position = position, + color = color, + height = currentScrubberHeight + ) + } + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(6.dp) + ) { + Spacer( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(1f - fraction) + .background( + color = backgroundColor, + shape = shape + ) + .align(Alignment.CenterEnd) + ) + + Canvas( + modifier = Modifier + .fillMaxWidth(fraction) + .height(currentAmplitude) + .align(Alignment.CenterStart) + ) { + drawPath( + path = wavePath( + size = size, + progress = progress + ), + color = color, + style = Stroke( + width = 3.dp.toPx(), + cap = StrokeCap.Round + ) + ) + } + } + } + + Duration( + position = position, + duration = duration, + show = showDuration + ) +} + +private suspend fun PointerInputScope.detectDrags( + setIsDragging: (Boolean) -> Unit, + range: ClosedRange, + onSeekStart: (updated: Long) -> Unit, + onSeek: (delta: Long) -> Unit, + onSeekEnd: () -> Unit +) { + var acc = 0f + + detectHorizontalDragGestures( + onDragStart = { offset -> + setIsDragging(true) + onSeekStart((offset.x / size.width * (range.endInclusive - range.start).toFloat() + range.start).toLong()) + }, + onHorizontalDrag = { _, delta -> + acc += delta / size.width * (range.endInclusive - range.start).toFloat() + + if (acc !in -1f..1f) { + onSeek(acc.toLong()) + acc -= acc.toLong() + } + }, + onDragEnd = { + setIsDragging(false) + acc = 0f + onSeekEnd() + }, + onDragCancel = { + setIsDragging(false) + acc = 0f + + onSeekEnd() + } + ) +} + +private suspend fun PointerInputScope.detectTaps( + range: ClosedRange, + onSeekStart: (updated: Long) -> Unit, + onSeekEnd: () -> Unit +) { + if (range.endInclusive < range.start) return + + detectTapGestures( + onTap = { offset -> + onSeekStart( + (offset.x / size.width * (range.endInclusive - range.start).toFloat() + range.start).toLong() + ) + onSeekEnd() + } + ) +} + +private fun ContentDrawScope.drawScrubber( + range: ClosedRange, + position: Long, + color: Color, + height: Dp +) { + val scrubberPosition = if (range.endInclusive < range.start) 0f + else (position - range.start) / (range.endInclusive - range.start).toFloat() * size.width + + val widthPx = 5.dp.toPx() + val heightPx = height.toPx() + + drawRoundRect( + color = color, + topLeft = Offset( + x = scrubberPosition - widthPx / 2, + y = (size.height - heightPx) / 2f + ), + size = Size( + width = widthPx, + height = heightPx + ), + cornerRadius = CornerRadius(widthPx / 2) + ) +} + +private fun ContentDrawScope.drawPoi( + range: ClosedRange, + position: Long, + color: Color, + width: Dp = 4.dp +) { + val poiPosition = if (range.endInclusive < range.start) 0f + else (position - range.start) / (range.endInclusive - range.start).toFloat() * size.width + + drawRoundRect( + color = color, + topLeft = Offset(x = poiPosition, y = 0f), + size = Size(width = width.toPx(), height = size.height), + cornerRadius = CornerRadius((width / 2).toPx()) + ) +} + +@Composable +private fun Duration( + position: Long, + duration: Long, + show: Boolean +) = AnimatedVisibility( + visible = show, + enter = fadeIn() + expandVertically { -it }, + exit = fadeOut() + shrinkVertically { -it } +) { + val typography = LocalAppearance.current.typography + + Column { + Spacer(Modifier.height(8.dp)) + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + BasicText( + text = if (PlayerPreferences.showRemaining) "-${formatAsDuration(duration - position)}" + else formatAsDuration(position), + style = typography.xxs.semiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.clickable { + PlayerPreferences.showRemaining = !PlayerPreferences.showRemaining + } + ) + + if (duration != C.TIME_UNSET) BasicText( + text = formatAsDuration(duration), + style = typography.xxs.semiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +private fun Density.wavePath( + size: Size, + progress: Float, + quality: Float = PlayerPreferences.wavySeekBarQuality.quality +) = Path().apply { + val (width, height) = size + val progressTau = progress * 2 * PI.toFloat() + val scale = 7.dp.toPx() + + fun f(x: Float) = (sin(x / scale + progressTau) + 0.5f) * height + + moveTo(0f, f(0f)) + + var x = 0f + while (x < width) { + lineTo(x, f(x)) + x += quality + } + lineTo(width, f(width)) +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/ShimmerHost.kt b/app/src/main/kotlin/app/vimusic/android/ui/components/ShimmerHost.kt similarity index 58% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/ShimmerHost.kt rename to app/src/main/kotlin/app/vimusic/android/ui/components/ShimmerHost.kt index 8f0f09b..5f745f0 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/ShimmerHost.kt +++ b/app/src/main/kotlin/app/vimusic/android/ui/components/ShimmerHost.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.vimusic.ui.components +package app.vimusic.android.ui.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -19,20 +19,18 @@ fun ShimmerHost( horizontalAlignment: Alignment.Horizontal = Alignment.Start, verticalArrangement: Arrangement.Vertical = Arrangement.Top, content: @Composable ColumnScope.() -> Unit -) { - Column( - horizontalAlignment = horizontalAlignment, - verticalArrangement = verticalArrangement, - modifier = modifier - .shimmer() - .graphicsLayer(alpha = 0.99f) - .drawWithContent { - drawContent() - drawRect( - brush = Brush.verticalGradient(listOf(Color.Black, Color.Transparent)), - blendMode = BlendMode.DstIn - ) - }, - content = content - ) -} +) = Column( + horizontalAlignment = horizontalAlignment, + verticalArrangement = verticalArrangement, + modifier = modifier + .shimmer() + .graphicsLayer(alpha = 0.99f) + .drawWithContent { + drawContent() + drawRect( + brush = Brush.verticalGradient(listOf(Color.Black, Color.Transparent)), + blendMode = BlendMode.DstIn + ) + }, + content = content +) diff --git a/app/src/main/kotlin/app/vimusic/android/ui/components/themed/Attribution.kt b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/Attribution.kt new file mode 100644 index 0000000..db3ca4b --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/Attribution.kt @@ -0,0 +1,100 @@ +package app.vimusic.android.ui.components.themed + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import app.vimusic.android.LocalPlayerAwareWindowInsets +import app.vimusic.android.R +import app.vimusic.android.utils.align +import app.vimusic.android.utils.disabled +import app.vimusic.android.utils.secondary +import app.vimusic.android.utils.semiBold +import app.vimusic.core.ui.LocalAppearance + +@Composable +fun Attribution( + text: String, + modifier: Modifier = Modifier +) = Column { + val (_, typography) = LocalAppearance.current + val windowInsets = LocalPlayerAwareWindowInsets.current + + val endPaddingValues = windowInsets + .only(WindowInsetsSides.End) + .asPaddingValues() + + val attributionsIndex = text.lastIndexOf("\n\n${stringResource(R.string.from_wikipedia)}") + + var expanded by rememberSaveable { mutableStateOf(false) } + var overflow by rememberSaveable { mutableStateOf(false) } + + AnimatedContent( + targetState = expanded, + label = "" + ) { isExpanded -> + Row( + modifier = modifier + .padding(endPaddingValues) + .let { + if (overflow) it.clickable { + expanded = !expanded + } else it + } + ) { + BasicText( + text = stringResource(R.string.quote_open), + style = typography.xxl.semiBold, + modifier = Modifier + .offset(y = (-8).dp) + .align(Alignment.Top) + ) + BasicText( + text = if (attributionsIndex == -1) text else text.substring(0, attributionsIndex), + style = typography.xxs.secondary, + modifier = Modifier + .padding(horizontal = 8.dp) + .weight(1f), + maxLines = if (isExpanded) Int.MAX_VALUE else 1, + overflow = TextOverflow.Ellipsis, + onTextLayout = { + if (!expanded) overflow = it.hasVisualOverflow + } + ) + + BasicText( + text = stringResource(R.string.quote_close), + style = typography.xxl.semiBold, + modifier = Modifier + .offset(y = 4.dp) + .align(Alignment.Bottom) + ) + } + } + + if (attributionsIndex != -1) BasicText( + text = stringResource(R.string.wikipedia_cc_by_sa), + style = typography.xxs.disabled.align(TextAlign.End), + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp) + .padding(endPaddingValues) + ) +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/components/themed/BigIconButton.kt b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/BigIconButton.kt new file mode 100644 index 0000000..039fd4a --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/BigIconButton.kt @@ -0,0 +1,46 @@ +package app.vimusic.android.ui.components.themed + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.utils.roundedShape + +@Composable +fun BigIconButton( + @DrawableRes iconId: Int, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + backgroundColor: Color = LocalAppearance.current.colorPalette.background2, + contentColor: Color = LocalAppearance.current.colorPalette.text, + shape: Shape = 32.dp.roundedShape +) = Box( + modifier + .clip(shape) + .let { + if (onClick == null) it else it.clickable(onClick = onClick) + } + .background(backgroundColor) + .height(64.dp), + contentAlignment = Alignment.Center +) { + Image( + painter = painterResource(iconId), + contentDescription = null, + modifier = Modifier.size(24.dp), + colorFilter = ColorFilter.tint(contentColor) + ) +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/components/themed/Dialog.kt b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/Dialog.kt new file mode 100644 index 0000000..67e65c2 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/Dialog.kt @@ -0,0 +1,424 @@ +package app.vimusic.android.ui.components.themed + +import androidx.annotation.IntRange +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.center +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import app.vimusic.android.R +import app.vimusic.android.utils.center +import app.vimusic.android.utils.drawCircle +import app.vimusic.android.utils.medium +import app.vimusic.android.utils.semiBold +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.utils.roundedShape +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.delay + +@Composable +fun TextFieldDialog( + hintText: String, + onDismiss: () -> Unit, + onAccept: (String) -> Unit, + modifier: Modifier = Modifier, + cancelText: String = stringResource(R.string.cancel), + doneText: String = stringResource(R.string.done), + initialTextInput: String = "", + singleLine: Boolean = true, + maxLines: Int = 1, + onCancel: () -> Unit = onDismiss, + isTextInputValid: (String) -> Boolean = { it.isNotEmpty() }, + keyboardOptions: KeyboardOptions = KeyboardOptions() +) = DefaultDialog( + onDismiss = onDismiss, + modifier = modifier +) { + val focusRequester = remember { FocusRequester() } + val (_, typography) = LocalAppearance.current + + var value by rememberSaveable(initialTextInput) { mutableStateOf(initialTextInput) } + + LaunchedEffect(Unit) { + delay(300) + focusRequester.requestFocus() + } + + TextField( + value = value, + onValueChange = { value = it }, + textStyle = typography.xs.semiBold.center, + singleLine = singleLine, + maxLines = maxLines, + hintText = hintText, + keyboardActions = KeyboardActions( + onDone = { + if (isTextInputValid(value)) { + onDismiss() + onAccept(value) + } + } + ), + keyboardOptions = keyboardOptions, + modifier = Modifier + .padding(all = 16.dp) + .weight(weight = 1f, fill = false) + .focusRequester(focusRequester) + ) + + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier.fillMaxWidth() + ) { + DialogTextButton( + text = cancelText, + onClick = onCancel + ) + + DialogTextButton( + primary = true, + text = doneText, + onClick = { + if (isTextInputValid(value)) { + onDismiss() + onAccept(value) + } + } + ) + } +} + +@Composable +fun NumberFieldDialog( + onDismiss: () -> Unit, + onAccept: (T) -> Unit, + initialValue: T, + defaultValue: T, + convert: (String) -> T?, + range: ClosedRange, + modifier: Modifier = Modifier, + cancelText: String = stringResource(R.string.cancel), + doneText: String = stringResource(R.string.done), + onCancel: () -> Unit = onDismiss +) where T : Number, T : Comparable = TextFieldDialog( + hintText = "", + onDismiss = onDismiss, + onAccept = { onAccept((convert(it) ?: defaultValue).coerceIn(range)) }, + modifier = modifier, + cancelText = cancelText, + doneText = doneText, + initialTextInput = initialValue.toString(), + onCancel = onCancel, + isTextInputValid = { true }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) +) + +@Composable +fun ConfirmationDialog( + text: String, + onDismiss: () -> Unit, + onConfirm: () -> Unit, + modifier: Modifier = Modifier, + cancelText: String = stringResource(R.string.cancel), + confirmText: String = stringResource(R.string.confirm), + onCancel: () -> Unit = onDismiss +) = DefaultDialog( + onDismiss = onDismiss, + modifier = modifier +) { + ConfirmationDialogBody( + text = text, + onDismiss = onDismiss, + onConfirm = onConfirm, + cancelText = cancelText, + confirmText = confirmText, + onCancel = onCancel + ) +} + +@Suppress("ModifierMissing", "UnusedReceiverParameter") +@Composable +fun ColumnScope.ConfirmationDialogBody( + text: String, + onDismiss: () -> Unit, + onConfirm: () -> Unit, + cancelText: String = stringResource(R.string.cancel), + confirmText: String = stringResource(R.string.confirm), + onCancel: () -> Unit = onDismiss +) { + val (_, typography) = LocalAppearance.current + + BasicText( + text = text, + style = typography.xs.medium.center, + modifier = Modifier.padding(all = 16.dp) + ) + + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier.fillMaxWidth() + ) { + DialogTextButton( + text = cancelText, + onClick = { + onCancel() + onDismiss() + } + ) + + DialogTextButton( + text = confirmText, + primary = true, + onClick = { + onConfirm() + onDismiss() + } + ) + } +} + +@Composable +fun DefaultDialog( + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, + horizontalPadding: Dp = 24.dp, + content: @Composable ColumnScope.() -> Unit +) = Dialog(onDismissRequest = onDismiss) { + Column( + horizontalAlignment = horizontalAlignment, + modifier = modifier + .padding(all = 48.dp) + .background( + color = LocalAppearance.current.colorPalette.background1, + shape = 8.dp.roundedShape + ) + .padding( + horizontal = horizontalPadding, + vertical = 16.dp + ), + content = content + ) +} + +@Composable +fun ValueSelectorDialog( + onDismiss: () -> Unit, + title: String, + selectedValue: T, + values: ImmutableList, + onValueSelect: (T) -> Unit, + modifier: Modifier = Modifier, + valueText: @Composable (T) -> String = { it.toString() } +) = Dialog(onDismissRequest = onDismiss) { + ValueSelectorDialogBody( + onDismiss = onDismiss, + title = title, + selectedValue = selectedValue, + values = values, + onValueSelect = onValueSelect, + modifier = modifier + .padding(all = 48.dp) + .background( + color = LocalAppearance.current.colorPalette.background1, + shape = 8.dp.roundedShape + ) + .padding(vertical = 16.dp), + valueText = valueText + ) +} + +@Composable +fun ValueSelectorDialogBody( + onDismiss: () -> Unit, + title: String, + selectedValue: T?, + values: ImmutableList, + onValueSelect: (T) -> Unit, + modifier: Modifier = Modifier, + valueText: @Composable (T) -> String = { it.toString() } +) = Column(modifier = modifier) { + val (colorPalette, typography) = LocalAppearance.current + + BasicText( + text = title, + style = typography.s.semiBold, + modifier = Modifier.padding(vertical = 8.dp, horizontal = 24.dp) + ) + + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + values.forEach { value -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .clickable( + onClick = { + onDismiss() + onValueSelect(value) + } + ) + .padding(vertical = 12.dp, horizontal = 24.dp) + .fillMaxWidth() + ) { + if (selectedValue == value) Canvas( + modifier = Modifier + .size(18.dp) + .background( + color = colorPalette.accent, + shape = CircleShape + ) + ) { + drawCircle( + color = colorPalette.onAccent, + radius = 4.dp.toPx(), + center = size.center, + shadow = Shadow( + color = Color.Black.copy(alpha = 0.4f), + blurRadius = 4.dp.toPx(), + offset = Offset(x = 0f, y = 1.dp.toPx()) + ) + ) + } else Spacer( + modifier = Modifier + .size(18.dp) + .border( + width = 1.dp, + color = colorPalette.textDisabled, + shape = CircleShape + ) + ) + + BasicText( + text = valueText(value), + style = typography.xs.medium + ) + } + } + } + + Box( + modifier = Modifier + .align(Alignment.End) + .padding(end = 24.dp) + ) { + DialogTextButton( + text = stringResource(R.string.cancel), + onClick = onDismiss + ) + } +} + +@Suppress("ModifierMissing") // intentional, I guess +@Composable +fun ColumnScope.SliderDialogBody( + provideState: @Composable () -> MutableState, + onSlideComplete: (newState: Float) -> Unit, + min: Float, + max: Float, + toDisplay: @Composable (Float) -> String = { it.toString() }, + @IntRange(from = 0) steps: Int = 0, + label: String? = null +) { + val (_, typography) = LocalAppearance.current + var state by provideState() + + if (label != null) BasicText( + text = label, + style = typography.xs.semiBold, + modifier = Modifier.padding(vertical = 8.dp, horizontal = 24.dp) + ) + + Slider( + state = state, + setState = { state = it }, + onSlideComplete = { onSlideComplete(state) }, + range = min..max, + steps = steps, + modifier = Modifier + .height(36.dp) + .fillMaxWidth() + .padding(horizontal = 24.dp) + ) + + BasicText( + text = toDisplay(state), + style = typography.s.semiBold, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(vertical = 8.dp) + ) +} + +@Composable +fun SliderDialog( + onDismiss: () -> Unit, + title: String, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit = { } +) = Dialog(onDismissRequest = onDismiss) { + val (colorPalette, typography) = LocalAppearance.current + + Column( + modifier = modifier + .padding(all = 48.dp) + .background(color = colorPalette.background1, shape = 8.dp.roundedShape) + .padding(vertical = 16.dp) + ) { + BasicText( + text = title, + style = typography.s.semiBold, + modifier = Modifier.padding(vertical = 8.dp, horizontal = 24.dp) + ) + + content() + + Box( + modifier = Modifier + .align(Alignment.End) + .padding(end = 24.dp) + ) { + DialogTextButton( + text = stringResource(R.string.confirm), + onClick = onDismiss, + modifier = Modifier + ) + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/DialogTextButton.kt b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/DialogTextButton.kt similarity index 61% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/DialogTextButton.kt rename to app/src/main/kotlin/app/vimusic/android/ui/components/themed/DialogTextButton.kt index c3bedcf..86cb98f 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/DialogTextButton.kt +++ b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/DialogTextButton.kt @@ -1,18 +1,19 @@ -package it.vfsfitvnm.vimusic.ui.components.themed +package app.vimusic.android.ui.components.themed import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.utils.color -import it.vfsfitvnm.vimusic.utils.medium +import app.vimusic.android.utils.disabled +import app.vimusic.android.utils.medium +import app.vimusic.android.utils.primary +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.utils.roundedShape @Composable fun DialogTextButton( @@ -20,21 +21,21 @@ fun DialogTextButton( onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, - primary: Boolean = false, + primary: Boolean = false ) { val (colorPalette, typography) = LocalAppearance.current - val textColor = when { - !enabled -> colorPalette.textDisabled - primary -> colorPalette.onAccent - else -> colorPalette.text - } - BasicText( text = text, - style = typography.xs.medium.color(textColor), + style = typography.xs.medium.let { + when { + !enabled -> it.disabled + primary -> it.primary + else -> it + } + }, modifier = modifier - .clip(RoundedCornerShape(36.dp)) + .clip(36.dp.roundedShape) .background(if (primary) colorPalette.accent else Color.Transparent) .clickable(enabled = enabled, onClick = onClick) .padding(horizontal = 20.dp, vertical = 16.dp) diff --git a/app/src/main/kotlin/app/vimusic/android/ui/components/themed/Divider.kt b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/Divider.kt new file mode 100644 index 0000000..8ae8dba --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/Divider.kt @@ -0,0 +1,72 @@ +package app.vimusic.android.ui.components.themed + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import app.vimusic.core.ui.LocalAppearance + +/** + * A simple horizontal divider, derived from Material Design + */ +@Composable +fun HorizontalDivider( + modifier: Modifier = Modifier, + thickness: Dp = 1.dp, + color: Color = LocalAppearance.current.colorPalette.textDisabled +) = Canvas( + modifier = modifier + .fillMaxWidth() + .height(thickness) +) { + val stroke = thickness.toPx() + + drawLine( + color = color, + strokeWidth = stroke, + start = Offset( + x = 0f, + y = stroke / 2 + ), + end = Offset( + x = size.width, + y = stroke / 2 + ) + ) +} + +/** + * A simple vertical divider, derived from Material Design + */ +@Composable +fun VerticalDivider( + modifier: Modifier = Modifier, + thickness: Dp = 1.dp, + color: Color = LocalAppearance.current.colorPalette.textDisabled +) = Canvas( + modifier = modifier + .fillMaxHeight() + .width(thickness) +) { + val stroke = thickness.toPx() + + drawLine( + color = color, + strokeWidth = stroke, + start = Offset( + x = stroke / 2, + y = 0f + ), + end = Offset( + x = stroke / 2, + y = size.height + ) + ) +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/components/themed/FloatingActionsContainer.kt b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/FloatingActionsContainer.kt new file mode 100644 index 0000000..aca2107 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/FloatingActionsContainer.kt @@ -0,0 +1,166 @@ +package app.vimusic.android.ui.components.themed + +import androidx.annotation.DrawableRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import app.vimusic.android.LocalPlayerAwareWindowInsets +import app.vimusic.android.R +import app.vimusic.android.utils.ScrollingInfo +import app.vimusic.android.utils.scrollingInfo +import app.vimusic.android.utils.smoothScrollToTop +import kotlinx.coroutines.launch + +@Composable +fun BoxScope.FloatingActionsContainerWithScrollToTop( + lazyGridState: LazyGridState, + modifier: Modifier = Modifier, + visible: Boolean = true, + @DrawableRes icon: Int? = null, + @DrawableRes scrollIcon: Int? = R.drawable.chevron_up, + onClick: (() -> Unit)? = null, + onScrollToTop: (suspend () -> Unit)? = lazyGridState::smoothScrollToTop, + reverse: Boolean = false, + insets: WindowInsets = LocalPlayerAwareWindowInsets.current +) = FloatingActions( + state = if (visible) lazyGridState.scrollingInfo() else null, + onScrollToTop = onScrollToTop, + reverse = reverse, + icon = icon, + scrollIcon = scrollIcon, + onClick = onClick, + insets = insets, + modifier = modifier +) + +@Composable +fun BoxScope.FloatingActionsContainerWithScrollToTop( + lazyListState: LazyListState, + modifier: Modifier = Modifier, + visible: Boolean = true, + @DrawableRes icon: Int? = null, + @DrawableRes scrollIcon: Int? = R.drawable.chevron_up, + onClick: (() -> Unit)? = null, + onScrollToTop: (suspend () -> Unit)? = lazyListState::smoothScrollToTop, + reverse: Boolean = false, + insets: WindowInsets = LocalPlayerAwareWindowInsets.current +) = FloatingActions( + state = if (visible) lazyListState.scrollingInfo() else null, + onScrollToTop = onScrollToTop, + reverse = reverse, + icon = icon, + scrollIcon = scrollIcon, + onClick = onClick, + insets = insets, + modifier = modifier +) + +@Composable +fun BoxScope.FloatingActionsContainerWithScrollToTop( + scrollState: ScrollState, + modifier: Modifier = Modifier, + visible: Boolean = true, + @DrawableRes icon: Int? = null, + @DrawableRes scrollIcon: Int? = R.drawable.chevron_up, + onClick: (() -> Unit)? = null, + onScrollToTop: (suspend () -> Unit)? = scrollState::smoothScrollToTop, + reverse: Boolean = false, + insets: WindowInsets = LocalPlayerAwareWindowInsets.current +) = FloatingActions( + state = if (visible) scrollState.scrollingInfo() else null, + onScrollToTop = onScrollToTop, + reverse = reverse, + icon = icon, + scrollIcon = scrollIcon, + onClick = onClick, + insets = insets, + modifier = modifier +) + +@Composable +private fun BoxScope.FloatingActions( + state: ScrollingInfo?, + insets: WindowInsets, + modifier: Modifier = Modifier, + onScrollToTop: (suspend () -> Unit)? = null, + reverse: Boolean = false, + @DrawableRes icon: Int? = null, + @DrawableRes scrollIcon: Int? = R.drawable.chevron_up, + onClick: (() -> Unit)? = null +) = Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.Bottom, + modifier = modifier + .align(Alignment.BottomEnd) + .padding(end = 16.dp) + .padding( + insets + .only(WindowInsetsSides.End) + .asPaddingValues() + ) +) { + val transition = updateTransition(state, "") + val bottomPaddingValues = insets.only(WindowInsetsSides.Bottom).asPaddingValues() + val coroutineScope = rememberCoroutineScope() + + onScrollToTop?.let { + transition.AnimatedVisibility( + visible = { it != null && it.isScrollingDown == reverse && it.isFar }, + enter = slideInVertically(tween(500, if (icon == null) 0 else 100)) { it }, + exit = slideOutVertically(tween(500, 0)) { it } + ) { + SecondaryButton( + onClick = { + coroutineScope.launch { onScrollToTop() } + }, + iconId = scrollIcon ?: R.drawable.chevron_up, + modifier = Modifier + .padding(bottom = 16.dp) + .padding(bottomPaddingValues) + ) + } + } + + icon?.let { + onClick?.let { + transition.AnimatedVisibility( + visible = { it?.isScrollingDown == false }, + enter = slideInVertically( + animationSpec = tween(durationMillis = 500, delayMillis = 0), + initialOffsetY = { it } + ), + exit = slideOutVertically( + animationSpec = tween(durationMillis = 500, delayMillis = 100), + targetOffsetY = { it } + ) + ) { + PrimaryButton( + icon = icon, + onClick = onClick, + enabled = transition.targetState?.isScrollingDown == false, + modifier = Modifier + .padding(bottom = 16.dp) + .padding(bottomPaddingValues) + ) + } + } + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/components/themed/Header.kt b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/Header.kt new file mode 100644 index 0000000..87ed06c --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/Header.kt @@ -0,0 +1,92 @@ +package app.vimusic.android.ui.components.themed + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import app.vimusic.android.ui.components.FadingRow +import app.vimusic.android.utils.medium +import app.vimusic.core.ui.Dimensions +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.shimmer +import kotlin.random.Random + +@Composable +fun Header( + title: String, + modifier: Modifier = Modifier, + actionsContent: @Composable RowScope.() -> Unit = {} +) = Header( + modifier = modifier, + titleContent = { + FadingRow { + BasicText( + text = title, + style = LocalAppearance.current.typography.xxl.medium, + maxLines = 1 + ) + } + }, + actionsContent = actionsContent +) + +@Composable +fun Header( + titleContent: @Composable () -> Unit, + actionsContent: @Composable RowScope.() -> Unit, + modifier: Modifier = Modifier +) = Box( + contentAlignment = Alignment.CenterEnd, + modifier = modifier + .padding(horizontal = 16.dp) + .height(Dimensions.items.headerHeight) + .fillMaxWidth() +) { + titleContent() + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .align(Alignment.BottomEnd) + .heightIn(min = 48.dp), + content = actionsContent + ) +} + +@Composable +fun HeaderPlaceholder(modifier: Modifier = Modifier) = Box( + contentAlignment = Alignment.CenterEnd, + modifier = modifier + .padding(horizontal = 16.dp) + .height(Dimensions.items.headerHeight) + .fillMaxWidth() +) { + val (colorPalette, typography) = LocalAppearance.current + val text = remember { List(Random.nextInt(4, 16)) { " " }.joinToString(separator = "") } + + Box( + modifier = Modifier + .background(colorPalette.shimmer) + .fillMaxWidth(remember { 0.25f + Random.nextFloat() * 0.5f }) + ) { + BasicText( + text = text, + style = typography.xxl.medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/components/themed/IconButton.kt b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/IconButton.kt new file mode 100644 index 0000000..d994771 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/IconButton.kt @@ -0,0 +1,99 @@ +package app.vimusic.android.ui.components.themed + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.Indication +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import app.vimusic.core.ui.LocalAppearance + +@Composable +fun HeaderIconButton( + onClick: () -> Unit, + @DrawableRes icon: Int, + modifier: Modifier = Modifier, + enabled: Boolean = true, + indication: Indication? = ripple(bounded = false) +) { + val (colorPalette) = LocalAppearance.current + + HeaderIconButton( + onClick = onClick, + icon = icon, + modifier = modifier, + indication = indication, + enabled = true, + color = if (enabled) colorPalette.text else colorPalette.textDisabled + ) +} + +@Composable +fun HeaderIconButton( + onClick: () -> Unit, + @DrawableRes icon: Int, + color: Color, + modifier: Modifier = Modifier, + enabled: Boolean = true, + indication: Indication? = ripple(bounded = false) +) = IconButton( + icon = icon, + color = color, + onClick = onClick, + enabled = enabled, + indication = indication, + modifier = modifier + .padding(all = 4.dp) + .size(18.dp) +) + +@Composable +fun IconButton( + onClick: () -> Unit, + @DrawableRes icon: Int, + modifier: Modifier = Modifier, + enabled: Boolean = true, + indication: Indication? = ripple(bounded = false) +) { + val (colorPalette) = LocalAppearance.current + + IconButton( + onClick = onClick, + icon = icon, + modifier = modifier, + indication = indication, + enabled = true, + color = if (enabled) colorPalette.text else colorPalette.textDisabled + ) +} + +@Composable +fun IconButton( + onClick: () -> Unit, + @DrawableRes icon: Int, + color: Color, + modifier: Modifier = Modifier, + enabled: Boolean = true, + indication: Indication? = ripple(bounded = false) +) = Image( + painter = painterResource(icon), + contentDescription = null, + colorFilter = ColorFilter.tint(color), + modifier = Modifier + .clickable( + indication = indication, + interactionSource = remember { MutableInteractionSource() }, + enabled = enabled, + onClick = onClick + ) + .then(modifier) +) diff --git a/app/src/main/kotlin/app/vimusic/android/ui/components/themed/LayoutWithAdaptiveThumbnail.kt b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/LayoutWithAdaptiveThumbnail.kt new file mode 100644 index 0000000..7f9baaf --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/LayoutWithAdaptiveThumbnail.kt @@ -0,0 +1,67 @@ +package app.vimusic.android.ui.components.themed + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.dp +import app.vimusic.android.utils.thumbnail +import app.vimusic.core.ui.Dimensions +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.shimmer +import app.vimusic.core.ui.utils.isLandscape +import app.vimusic.core.ui.utils.px +import coil3.compose.AsyncImage +import com.valentinilk.shimmer.shimmer + +@Composable +inline fun LayoutWithAdaptiveThumbnail( + thumbnailContent: @Composable () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) = if (isLandscape) Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier +) { + thumbnailContent() + content() +} else Box(modifier = modifier) { content() } + +fun adaptiveThumbnailContent( + isLoading: Boolean, + url: String?, + modifier: Modifier = Modifier, + shape: Shape? = null +): @Composable () -> Unit = { + BoxWithConstraints( + contentAlignment = Alignment.Center, + modifier = modifier.padding(horizontal = 8.dp, vertical = 16.dp) + ) { + val (colorPalette, _, _, thumbnailShape) = LocalAppearance.current + val thumbnailSize = + if (isLandscape) (maxHeight - 96.dp - Dimensions.items.collapsedPlayerHeight) + else maxWidth + + val innerModifier = Modifier + .clip(shape ?: thumbnailShape) + .size(thumbnailSize) + + if (isLoading) Spacer( + modifier = innerModifier + .shimmer() + .background(colorPalette.shimmer) + ) else AsyncImage( + model = url?.thumbnail(thumbnailSize.px), + contentDescription = null, + modifier = innerModifier.background(colorPalette.background1) + ) + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/components/themed/MediaItemMenu.kt b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/MediaItemMenu.kt new file mode 100644 index 0000000..5ed3e79 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/MediaItemMenu.kt @@ -0,0 +1,821 @@ +package app.vimusic.android.ui.components.themed + +import android.content.Intent +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Left +import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Right +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandIn +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.media3.common.MediaItem +import app.vimusic.android.Database +import app.vimusic.android.LocalPlayerServiceBinder +import app.vimusic.android.R +import app.vimusic.android.models.Info +import app.vimusic.android.models.Playlist +import app.vimusic.android.models.Song +import app.vimusic.android.models.SongPlaylistMap +import app.vimusic.android.query +import app.vimusic.android.service.PrecacheService +import app.vimusic.android.service.isLocal +import app.vimusic.android.transaction +import app.vimusic.android.ui.items.SongItem +import app.vimusic.android.ui.screens.albumRoute +import app.vimusic.android.ui.screens.artistRoute +import app.vimusic.android.ui.screens.home.HideSongDialog +import app.vimusic.android.utils.addNext +import app.vimusic.android.utils.asMediaItem +import app.vimusic.android.utils.enqueue +import app.vimusic.android.utils.forcePlay +import app.vimusic.android.utils.formatAsDuration +import app.vimusic.android.utils.isCached +import app.vimusic.android.utils.launchYouTubeMusic +import app.vimusic.android.utils.medium +import app.vimusic.android.utils.semiBold +import app.vimusic.android.utils.toast +import app.vimusic.core.data.enums.PlaylistSortBy +import app.vimusic.core.data.enums.SortOrder +import app.vimusic.core.ui.Dimensions +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.favoritesIcon +import app.vimusic.core.ui.utils.px +import app.vimusic.core.ui.utils.roundedShape +import app.vimusic.core.ui.utils.songBundle +import app.vimusic.providers.innertube.models.NavigationEndpoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@Composable +fun InHistoryMediaItemMenu( + onDismiss: () -> Unit, + song: Song, + modifier: Modifier = Modifier +) { + var isHiding by rememberSaveable { mutableStateOf(false) } + + if (isHiding) HideSongDialog( + song = song, + onDismiss = { isHiding = false }, + onConfirm = onDismiss + ) + + InHistoryMediaItemMenu( + onDismiss = onDismiss, + song = song, + onHideFromDatabase = { isHiding = true }, + modifier = modifier + ) +} + +@Composable +fun InHistoryMediaItemMenu( + onDismiss: () -> Unit, + song: Song, + onHideFromDatabase: () -> Unit, + modifier: Modifier = Modifier +) = NonQueuedMediaItemMenu( + mediaItem = song.asMediaItem, + onDismiss = onDismiss, + onHideFromDatabase = onHideFromDatabase, + modifier = modifier +) + +@Composable +fun InPlaylistMediaItemMenu( + onDismiss: () -> Unit, + playlistId: Long, + positionInPlaylist: Int, + song: Song, + modifier: Modifier = Modifier +) = NonQueuedMediaItemMenu( + mediaItem = song.asMediaItem, + onDismiss = onDismiss, + onRemoveFromPlaylist = { + transaction { + Database.move(playlistId, positionInPlaylist, Int.MAX_VALUE) + Database.delete(SongPlaylistMap(song.id, playlistId, Int.MAX_VALUE)) + } + }, + modifier = modifier +) + +@Composable +fun NonQueuedMediaItemMenu( + onDismiss: () -> Unit, + mediaItem: MediaItem, + modifier: Modifier = Modifier, + onRemoveFromPlaylist: (() -> Unit)? = null, + onHideFromDatabase: (() -> Unit)? = null, + onRemoveFromQuickPicks: (() -> Unit)? = null +) { + val binder = LocalPlayerServiceBinder.current + + BaseMediaItemMenu( + mediaItem = mediaItem, + onDismiss = onDismiss, + onStartRadio = { + binder?.stopRadio() + binder?.player?.forcePlay(mediaItem) + binder?.setupRadio( + NavigationEndpoint.Endpoint.Watch( + videoId = mediaItem.mediaId, + playlistId = mediaItem.mediaMetadata.extras?.getString("playlistId") + ) + ) + }, + onPlayNext = { binder?.player?.addNext(mediaItem) }, + onEnqueue = { binder?.player?.enqueue(mediaItem) }, + onRemoveFromPlaylist = onRemoveFromPlaylist, + onHideFromDatabase = onHideFromDatabase, + onRemoveFromQuickPicks = onRemoveFromQuickPicks, + modifier = modifier + ) +} + +@Composable +fun QueuedMediaItemMenu( + onDismiss: () -> Unit, + mediaItem: MediaItem, + indexInQueue: Int?, + modifier: Modifier = Modifier +) { + val binder = LocalPlayerServiceBinder.current + + BaseMediaItemMenu( + mediaItem = mediaItem, + onDismiss = onDismiss, + onRemoveFromQueue = indexInQueue?.let { index -> { binder?.player?.removeMediaItem(index) } }, + modifier = modifier + ) +} + +@Composable +fun BaseMediaItemMenu( + onDismiss: () -> Unit, + mediaItem: MediaItem, + modifier: Modifier = Modifier, + onGoToEqualizer: (() -> Unit)? = null, + onShowSleepTimer: (() -> Unit)? = null, + onStartRadio: (() -> Unit)? = null, + onPlayNext: (() -> Unit)? = null, + onEnqueue: (() -> Unit)? = null, + onRemoveFromQueue: (() -> Unit)? = null, + onRemoveFromPlaylist: (() -> Unit)? = null, + onHideFromDatabase: (() -> Unit)? = null, + onRemoveFromQuickPicks: (() -> Unit)? = null, + onShowSpeedDialog: (() -> Unit)? = null, + onShowNormalizationDialog: (() -> Unit)? = null +) { + val context = LocalContext.current + + MediaItemMenu( + mediaItem = mediaItem, + onDismiss = onDismiss, + onGoToEqualizer = onGoToEqualizer, + onShowSleepTimer = onShowSleepTimer, + onStartRadio = onStartRadio, + onPlayNext = onPlayNext, + onEnqueue = onEnqueue, + onAddToPlaylist = { playlist, position -> + transaction { + Database.insert(mediaItem) + Database.insert( + SongPlaylistMap( + songId = mediaItem.mediaId, + playlistId = Database.insert(playlist).takeIf { it != -1L } ?: playlist.id, + position = position + ) + ) + } + }, + onHideFromDatabase = onHideFromDatabase, + onRemoveFromPlaylist = onRemoveFromPlaylist, + onRemoveFromQueue = onRemoveFromQueue, + onGoToAlbum = albumRoute::global, + onGoToArtist = artistRoute::global, + onShare = { + val sendIntent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra( + Intent.EXTRA_TEXT, + "https://music.youtube.com/watch?v=${mediaItem.mediaId}" + ) + } + + context.startActivity(Intent.createChooser(sendIntent, null)) + }, + onRemoveFromQuickPicks = onRemoveFromQuickPicks, + onShowSpeedDialog = onShowSpeedDialog, + onShowNormalizationDialog = onShowNormalizationDialog, + modifier = modifier + ) +} + +@Composable +fun MediaItemMenu( + mediaItem: MediaItem, + onDismiss: () -> Unit, + onShare: () -> Unit, + modifier: Modifier = Modifier, + onGoToEqualizer: (() -> Unit)? = null, + onShowSleepTimer: (() -> Unit)? = null, + onStartRadio: (() -> Unit)? = null, + onPlayNext: (() -> Unit)? = null, + onEnqueue: (() -> Unit)? = null, + onHideFromDatabase: (() -> Unit)? = null, + onRemoveFromQueue: (() -> Unit)? = null, + onRemoveFromPlaylist: (() -> Unit)? = null, + onAddToPlaylist: ((Playlist, Int) -> Unit)? = null, + onGoToAlbum: ((String) -> Unit)? = null, + onGoToArtist: ((String) -> Unit)? = null, + onRemoveFromQuickPicks: (() -> Unit)? = null, + onShowSpeedDialog: (() -> Unit)? = null, + onShowNormalizationDialog: (() -> Unit)? = null +) { + val (colorPalette, typography) = LocalAppearance.current + val density = LocalDensity.current + val uriHandler = LocalUriHandler.current + val binder = LocalPlayerServiceBinder.current + val context = LocalContext.current + + val isLocal by remember { derivedStateOf { mediaItem.isLocal } } + + var isViewingPlaylists by remember { mutableStateOf(false) } + var height by remember { mutableStateOf(0.dp) } + var likedAt by remember { mutableStateOf(null) } + var isBlacklisted by remember { mutableStateOf(false) } + + val extras = remember(mediaItem) { mediaItem.mediaMetadata.extras?.songBundle } + + var albumInfo by remember { + mutableStateOf( + extras?.albumId?.let { + Info(id = it, name = null) + } + ) + } + + var artistsInfo by remember { + mutableStateOf( + extras?.artistNames?.let { names -> + extras.artistIds?.let { ids -> + names.zip(ids) { name, id -> Info(id, name) } + } + } + ) + } + + LaunchedEffect(Unit) { + withContext(Dispatchers.IO) { + if (albumInfo == null) albumInfo = Database.songAlbumInfo(mediaItem.mediaId) + if (artistsInfo == null) artistsInfo = Database.songArtistInfo(mediaItem.mediaId) + + launch { + Database + .likedAt(mediaItem.mediaId) + .collect { likedAt = it } + } + launch { + Database + .blacklisted(mediaItem.mediaId) + .collect { isBlacklisted = it } + } + } + } + + AnimatedContent( + targetState = isViewingPlaylists, + transitionSpec = { + val animationSpec = tween(400) + val slideDirection = if (targetState) Left else Right + + slideIntoContainer(slideDirection, animationSpec) togetherWith + slideOutOfContainer(slideDirection, animationSpec) + }, + label = "" + ) { currentIsViewingPlaylists -> + if (currentIsViewingPlaylists) { + val playlistPreviews by remember { + Database.playlistPreviews( + sortBy = PlaylistSortBy.DateAdded, + sortOrder = SortOrder.Descending + ) + }.collectAsState(initial = emptyList(), context = Dispatchers.IO) + + var isCreatingNewPlaylist by rememberSaveable { mutableStateOf(false) } + + if (isCreatingNewPlaylist && onAddToPlaylist != null) TextFieldDialog( + hintText = stringResource(R.string.enter_playlist_name_prompt), + onDismiss = { isCreatingNewPlaylist = false }, + onAccept = { text -> + onDismiss() + onAddToPlaylist(Playlist(name = text), 0) + } + ) + + BackHandler { isViewingPlaylists = false } + + Menu(modifier = modifier.requiredHeight(height)) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + .fillMaxWidth() + ) { + IconButton( + onClick = { isViewingPlaylists = false }, + icon = R.drawable.chevron_back, + color = colorPalette.textSecondary, + modifier = Modifier + .padding(all = 4.dp) + .size(20.dp) + ) + + if (onAddToPlaylist != null) SecondaryTextButton( + text = stringResource(R.string.new_playlist), + onClick = { isCreatingNewPlaylist = true }, + alternative = true + ) + } + + onAddToPlaylist?.let { onAddToPlaylist -> + playlistPreviews.forEach { playlistPreview -> + MenuEntry( + icon = R.drawable.playlist, + text = playlistPreview.playlist.name, + secondaryText = pluralStringResource( + id = R.plurals.song_count_plural, + count = playlistPreview.songCount, + playlistPreview.songCount + ), + onClick = { + onDismiss() + onAddToPlaylist(playlistPreview.playlist, playlistPreview.songCount) + } + ) + } + } + } + } else Menu( + modifier = modifier.onPlaced { + height = it.size.height.px.dp(density) + } + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(end = 12.dp) + ) { + SongItem( + song = mediaItem, + thumbnailSize = Dimensions.thumbnails.song, + modifier = Modifier.weight(1f), + showDuration = false + ) + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + IconButton( + icon = if (likedAt == null) R.drawable.heart_outline else R.drawable.heart, + color = colorPalette.favoritesIcon, + onClick = { + query { + if ( + Database.like( + songId = mediaItem.mediaId, + likedAt = if (likedAt == null) System.currentTimeMillis() else null + ) != 0 + ) return@query + + Database.insert(mediaItem, Song::toggleLike) + } + }, + modifier = Modifier + .padding(all = 4.dp) + .size(18.dp) + ) + + if (!isLocal) IconButton( + icon = R.drawable.share_social, + color = colorPalette.text, + onClick = { + onDismiss() + onShare() + }, + modifier = Modifier + .padding(all = 4.dp) + .size(17.dp) + ) + } + } + + HorizontalDivider( + modifier = Modifier + .alpha(0.5f) + .padding(vertical = 8.dp) + ) + + onPlayNext?.let { + MenuEntry( + icon = R.drawable.play_skip_forward, + text = stringResource(R.string.play_next), + onClick = { + onDismiss() + onPlayNext() + } + ) + } + + onEnqueue?.let { + MenuEntry( + icon = R.drawable.enqueue, + text = stringResource(R.string.enqueue), + onClick = { + onDismiss() + onEnqueue() + } + ) + } + + if (!isLocal) onStartRadio?.let { + MenuEntry( + icon = R.drawable.radio, + text = stringResource(R.string.start_radio), + onClick = { + onDismiss() + onStartRadio() + } + ) + } + + onAddToPlaylist?.let { + MenuEntry( + icon = R.drawable.playlist, + text = stringResource(R.string.add_to_playlist), + onClick = { isViewingPlaylists = true }, + trailingContent = { + Image( + painter = painterResource(R.drawable.chevron_forward), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.textSecondary), + modifier = Modifier.size(16.dp) + ) + } + ) + } + + onGoToEqualizer?.let { + MenuEntry( + icon = R.drawable.equalizer, + text = stringResource(R.string.equalizer), + onClick = { + onDismiss() + onGoToEqualizer() + } + ) + } + + onShowSpeedDialog?.let { + MenuEntry( + icon = R.drawable.speed, + text = stringResource(R.string.playback_settings), + onClick = { + onDismiss() + onShowSpeedDialog() + } + ) + } + + onShowNormalizationDialog?.let { + MenuEntry( + icon = R.drawable.volume_up, + text = stringResource(R.string.volume_boost), + onClick = { + onDismiss() + onShowNormalizationDialog() + } + ) + } + + onShowSleepTimer?.let { + var isShowingSleepTimerDialog by remember { mutableStateOf(false) } + var sleepTimerMillisLeft by remember { mutableLongStateOf(0L) } + + LaunchedEffect(binder, binder?.sleepTimerMillisLeft) { + binder?.sleepTimerMillisLeft?.collectLatest { + sleepTimerMillisLeft = it ?: 0L + } ?: run { sleepTimerMillisLeft = 0L } + } + + val stopAfterSong = { + runCatching { + binder?.startSleepTimer( + binder.player.duration - binder.player.contentPosition + ) + } + isShowingSleepTimerDialog = false + } + + if (isShowingSleepTimerDialog) { + if (sleepTimerMillisLeft == 0L) DefaultDialog( + onDismiss = { isShowingSleepTimerDialog = false } + ) { + var amount by remember { mutableIntStateOf(1) } + + BasicText( + text = stringResource(R.string.set_sleep_timer), + style = typography.s.semiBold, + modifier = Modifier.padding(vertical = 8.dp, horizontal = 24.dp) + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy( + space = 16.dp, + alignment = Alignment.CenterHorizontally + ), + modifier = Modifier.padding(vertical = 8.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .alpha(if (amount <= 1) 0.5f else 1f) + .clip(CircleShape) + .clickable(enabled = amount > 1) { amount-- } + .size(48.dp) + .background(colorPalette.background0) + ) { + BasicText( + text = "-", + style = typography.xs.semiBold + ) + } + + Box(contentAlignment = Alignment.Center) { + BasicText( + text = "88h 88m", // invisible placeholder, no need to localize + style = typography.s.semiBold, + modifier = Modifier.alpha(0f) + ) + BasicText( + text = "${stringResource(R.string.format_hours, amount / 6)} ${ + stringResource( + R.string.format_minutes, + (amount % 6) * 10 + ) + }", + style = typography.s.semiBold + ) + } + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .alpha(if (amount >= 60) 0.5f else 1f) + .clip(CircleShape) + .clickable(enabled = amount < 60) { amount++ } + .size(48.dp) + .background(colorPalette.background0) + ) { + BasicText( + text = "+", + style = typography.xs.semiBold + ) + } + } + + SecondaryTextButton( + text = stringResource(R.string.sleep_timer_until_song_end), + onClick = stopAfterSong, + modifier = Modifier.padding(vertical = 12.dp) + ) + + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier.fillMaxWidth() + ) { + DialogTextButton( + text = stringResource(R.string.cancel), + onClick = { isShowingSleepTimerDialog = false } + ) + + DialogTextButton( + text = stringResource(R.string.set), + enabled = amount > 0, + primary = true, + onClick = { + binder?.startSleepTimer(amount * 10 * 60 * 1000L) + isShowingSleepTimerDialog = false + } + ) + } + } else ConfirmationDialog( + text = stringResource(R.string.stop_sleep_timer_prompt), + cancelText = stringResource(R.string.no), + confirmText = stringResource(R.string.stop), + onDismiss = { isShowingSleepTimerDialog = false }, + onConfirm = { binder?.cancelSleepTimer() } + ) + } + + MenuEntry( + icon = R.drawable.alarm, + text = stringResource(R.string.sleep_timer), + onClick = { isShowingSleepTimerDialog = true }, + onLongClick = stopAfterSong, + trailingContent = { + AnimatedVisibility( + visible = sleepTimerMillisLeft != 0L, + label = "", + enter = fadeIn() + expandIn(), + exit = fadeOut() + shrinkOut() + ) { + BasicText( + text = stringResource( + R.string.format_time_left, + formatAsDuration(sleepTimerMillisLeft) + ), + style = typography.xxs.medium, + modifier = Modifier + .background( + color = colorPalette.background0, + shape = 16.dp.roundedShape + ) + .padding(horizontal = 16.dp, vertical = 8.dp) + .animateContentSize() + ) + } + } + ) + } + + if (!isLocal) onGoToAlbum?.let { + albumInfo?.let { (albumId) -> + MenuEntry( + icon = R.drawable.disc, + text = stringResource(R.string.go_to_album), + onClick = { + onDismiss() + onGoToAlbum(albumId) + } + ) + } + } + + if (!isLocal) onGoToArtist?.let { + artistsInfo?.forEach { (id, name) -> + name?.let { + MenuEntry( + icon = R.drawable.person, + text = stringResource(R.string.format_go_to_artist, name), + onClick = { + onDismiss() + onGoToArtist(id) + } + ) + } + } + } + + if (!isLocal) MenuEntry( + icon = R.drawable.play, + text = stringResource(R.string.watch_on_youtube), + onClick = { + onDismiss() + binder?.player?.pause() + uriHandler.openUri("https://youtube.com/watch?v=${mediaItem.mediaId}") + } + ) + + if (!isLocal) MenuEntry( + icon = R.drawable.musical_notes, + text = stringResource(R.string.open_in_youtube_music), + onClick = { + onDismiss() + binder?.player?.pause() + if (!launchYouTubeMusic(context, "watch?v=${mediaItem.mediaId}")) + context.toast(context.getString(R.string.youtube_music_not_installed)) + } + ) + + if (!isLocal && !isCached(mediaItem.mediaId)) MenuEntry( + icon = R.drawable.download, + text = stringResource(R.string.pre_cache), + onClick = { + onDismiss() + runCatching { + PrecacheService.scheduleCache( + context = context.applicationContext, + mediaItem = mediaItem + ) + }.exceptionOrNull()?.printStackTrace() + } + ) + + if (!mediaItem.isLocal) AnimatedContent( + targetState = isBlacklisted, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "" + ) { blacklisted -> + MenuEntry( + icon = R.drawable.remove_circle_outline, + text = if (blacklisted) stringResource(R.string.remove_from_blacklist) + else stringResource(R.string.add_to_blacklist), + onClick = { + transaction { + Database.insert(mediaItem) + Database.toggleBlacklist(mediaItem.mediaId) + } + } + ) + } + + onRemoveFromQueue?.let { + MenuEntry( + icon = R.drawable.trash, + text = stringResource(R.string.remove_from_queue), + onClick = { + onDismiss() + onRemoveFromQueue() + } + ) + } + + onRemoveFromPlaylist?.let { + MenuEntry( + icon = R.drawable.trash, + text = stringResource(R.string.remove_from_playlist), + onClick = { + onDismiss() + onRemoveFromPlaylist() + } + ) + } + + onHideFromDatabase?.let { + MenuEntry( + icon = R.drawable.trash, + text = stringResource(R.string.hide), + onClick = onHideFromDatabase + ) + } + + if (!isLocal) onRemoveFromQuickPicks?.let { + MenuEntry( + icon = R.drawable.trash, + text = stringResource(R.string.hide_from_quick_picks), + onClick = { + onDismiss() + onRemoveFromQuickPicks() + } + ) + } + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Menu.kt b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/Menu.kt similarity index 67% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Menu.kt rename to app/src/main/kotlin/app/vimusic/android/ui/components/themed/Menu.kt index a7ae862..870af61 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Menu.kt +++ b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/Menu.kt @@ -1,9 +1,10 @@ -package it.vfsfitvnm.vimusic.ui.components.themed +package app.vimusic.android.ui.components.themed import androidx.annotation.DrawableRes +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope @@ -13,46 +14,49 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.utils.medium -import it.vfsfitvnm.vimusic.utils.secondary +import app.vimusic.android.utils.medium +import app.vimusic.android.utils.secondary +import app.vimusic.core.ui.LocalAppearance @Composable inline fun Menu( modifier: Modifier = Modifier, + shape: Shape = RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp), content: @Composable ColumnScope.() -> Unit -) { - val (colorPalette) = LocalAppearance.current - - Column( - modifier = modifier - .padding(top = 48.dp) - .verticalScroll(rememberScrollState()) - .fillMaxWidth() - .background(colorPalette.background1) - .padding(top = 2.dp) - .padding(vertical = 8.dp) - .navigationBarsPadding(), - content = content - ) -} +) = Column( + modifier = modifier + .fillMaxWidth() + .clip(shape) + .verticalScroll(rememberScrollState()) + .background(LocalAppearance.current.colorPalette.background1) + .padding(top = 2.dp) + .padding(vertical = 8.dp) + .navigationBarsPadding(), + content = content +) +@OptIn(ExperimentalFoundationApi::class) @Composable fun MenuEntry( @DrawableRes icon: Int, text: String, onClick: () -> Unit, + modifier: Modifier = Modifier, secondaryText: String? = null, enabled: Boolean = true, + onLongClick: (() -> Unit)? = null, trailingContent: (@Composable () -> Unit)? = null ) { val (colorPalette, typography) = LocalAppearance.current @@ -60,8 +64,12 @@ fun MenuEntry( Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(24.dp), - modifier = Modifier - .clickable(enabled = enabled, onClick = onClick) + modifier = modifier + .combinedClickable( + enabled = enabled, + onClick = onClick, + onLongClick = onLongClick + ) .fillMaxWidth() .alpha(if (enabled) 1f else 0.4f) .padding(horizontal = 24.dp) @@ -70,8 +78,7 @@ fun MenuEntry( painter = painterResource(icon), contentDescription = null, colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .size(15.dp) + modifier = Modifier.size(15.dp) ) Column( diff --git a/app/src/main/kotlin/app/vimusic/android/ui/components/themed/NavigationRail.kt b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/NavigationRail.kt new file mode 100644 index 0000000..7526243 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/NavigationRail.kt @@ -0,0 +1,348 @@ +package app.vimusic.android.ui.components.themed + +import android.os.Parcelable +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColor +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.layout +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEachIndexed +import app.vimusic.android.LocalPlayerAwareWindowInsets +import app.vimusic.android.R +import app.vimusic.android.ui.screens.settings.SwitchSettingsEntry +import app.vimusic.android.utils.center +import app.vimusic.android.utils.color +import app.vimusic.android.utils.semiBold +import app.vimusic.core.ui.Dimensions +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.utils.isLandscape +import app.vimusic.core.ui.utils.roundedShape +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize + +class TabsBuilder @PublishedApi internal constructor() { + companion object { + @Composable + inline fun rememberTabs(crossinline content: TabsBuilder.() -> Unit) = rememberSaveable( + saver = listSaver( + save = { it }, + restore = { it.toImmutableList() } + ) + ) { + TabsBuilder().apply(content).tabs.values.toImmutableList() + } + } + + @PublishedApi + internal val tabs = mutableMapOf() + + fun tab( + key: Int, + @StringRes + title: Int, + @DrawableRes + icon: Int, + canHide: Boolean = true + ): Tab = tab(key.toString(), title, icon, canHide) + + fun tab( + key: String, + @StringRes + title: Int, + @DrawableRes + icon: Int, + canHide: Boolean = true + ): Tab { + require(key.isNotBlank()) { "key cannot be blank" } + require(!tabs.containsKey(key)) { "key already exists" } + require(icon != 0) { "icon is 0" } + + val ret = Tab.ResourcesTab( + key = key, + titleRes = title, + icon = icon, + canHide = canHide + ) + tabs += key to ret + return ret + } + + fun tab( + key: Int, + title: String, + @DrawableRes + icon: Int, + canHide: Boolean = true + ): Tab = tab(key.toString(), title, icon, canHide) + + fun tab( + key: String, + title: String, + @DrawableRes + icon: Int, + canHide: Boolean = true + ): Tab { + require(key.isNotBlank()) { "key cannot be blank" } + require(title.isNotBlank()) { "title cannot be blank" } + require(!tabs.containsKey(key)) { "key already exists" } + require(icon != 0) { "icon is 0" } + + val ret = Tab.StaticTab( + key = key, + titleText = title, + icon = icon, + canHide = canHide + ) + tabs += key to ret + return ret + } +} + +@Parcelize +sealed class Tab : Parcelable { + abstract val key: String + + @IgnoredOnParcel + abstract val title: @Composable () -> String + + @get:DrawableRes + abstract val icon: Int + abstract val canHide: Boolean + + data class ResourcesTab( + override val key: String, + @StringRes + private val titleRes: Int, + @DrawableRes + override val icon: Int, + override val canHide: Boolean + ) : Tab() { + @IgnoredOnParcel + override val title: @Composable () -> String = { stringResource(titleRes) } + } + + data class StaticTab( + override val key: String, + private val titleText: String, + @DrawableRes + override val icon: Int, + override val canHide: Boolean + ) : Tab() { + @IgnoredOnParcel + override val title: @Composable () -> String = { titleText } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +inline fun NavigationRail( + topIconButtonId: Int, + noinline onTopIconButtonClick: () -> Unit, + tabIndex: Int, + crossinline onTabIndexChange: (Int) -> Unit, + hiddenTabs: ImmutableList, + crossinline setHiddenTabs: (List) -> Unit, + modifier: Modifier = Modifier, + crossinline content: TabsBuilder.() -> Unit +) { + val (colorPalette, typography) = LocalAppearance.current + + val tabs = TabsBuilder.rememberTabs(content) + + val isLandscape = isLandscape + + val paddingValues = LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical + WindowInsetsSides.Start) + .asPaddingValues() + + var editing by remember { mutableStateOf(false) } + + if (editing) DefaultDialog( + onDismiss = { editing = false }, + horizontalPadding = 0.dp + ) { + BasicText( + text = stringResource(R.string.tabs), + style = typography.s.center.semiBold, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + + Spacer(Modifier.height(12.dp)) + + LazyColumn { + items( + items = tabs, + key = { it.key } + ) { tab -> + SwitchSettingsEntry( + title = tab.title(), + text = null, + isChecked = tab.key !in hiddenTabs, + onCheckedChange = { + if (!it && hiddenTabs.size == tabs.size - 1) return@SwitchSettingsEntry + + setHiddenTabs(if (it) hiddenTabs - tab.key else hiddenTabs + tab.key) + }, + isEnabled = tab.canHide && (tab.key in hiddenTabs || hiddenTabs.size < tabs.size - 1) + ) + } + } + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .verticalScroll(rememberScrollState()) + .padding(paddingValues) + ) { + Box( + contentAlignment = Alignment.TopCenter, + modifier = Modifier + .size( + width = if (isLandscape) Dimensions.navigationRail.widthLandscape + else Dimensions.navigationRail.width, + height = Dimensions.items.headerHeight + ) + ) { + Image( + painter = painterResource(topIconButtonId), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.textSecondary), + modifier = Modifier + .offset( + x = if (isLandscape) 0.dp else Dimensions.navigationRail.iconOffset, + y = 48.dp + ) + .clip(CircleShape) + .clickable(onClick = onTopIconButtonClick) + .padding(all = 12.dp) + .size(22.dp) + ) + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .width(if (isLandscape) Dimensions.navigationRail.widthLandscape else Dimensions.navigationRail.width) + ) { + val transition = updateTransition(targetState = tabIndex, label = null) + + tabs.fastForEachIndexed { index, tab -> + AnimatedVisibility( + visible = tabIndex == index || tab.key !in hiddenTabs, + label = "" + ) { + val dothAlpha by transition.animateFloat(label = "") { + if (it == index) 1f else 0f + } + + val textColor by transition.animateColor(label = "") { + if (it == index) colorPalette.text else colorPalette.textDisabled + } + + val iconContent: @Composable () -> Unit = { + Image( + painter = painterResource(tab.icon), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .vertical(enabled = !isLandscape) + .graphicsLayer { + alpha = dothAlpha + translationX = (1f - dothAlpha) * -48.dp.toPx() + rotationZ = if (isLandscape) 0f else -90f + } + .size(Dimensions.navigationRail.iconOffset * 2) + ) + } + + val textContent: @Composable () -> Unit = { + BasicText( + text = tab.title(), + style = typography.xs.semiBold.center.color(textColor), + modifier = Modifier + .vertical(enabled = !isLandscape) + .rotate(if (isLandscape) 0f else -90f) + .padding(horizontal = 16.dp) + ) + } + + val contentModifier = Modifier + .clip(24.dp.roundedShape) + .combinedClickable( + onClick = { onTabIndexChange(index) }, + onLongClick = { editing = true } + ) + + if (isLandscape) Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = contentModifier.padding(vertical = 8.dp) + ) { + iconContent() + textContent() + } else Row( + verticalAlignment = Alignment.CenterVertically, + modifier = contentModifier.padding(horizontal = 8.dp) + ) { + iconContent() + textContent() + } + } + } + } + } +} + +fun Modifier.vertical(enabled: Boolean = true) = + if (enabled) + layout { measurable, constraints -> + val placeable = measurable.measure(constraints.copy(maxWidth = Int.MAX_VALUE)) + layout(placeable.height, placeable.width) { + placeable.place( + x = -(placeable.width / 2 - placeable.height / 2), + y = -(placeable.height / 2 - placeable.width / 2) + ) + } + } else this diff --git a/app/src/main/kotlin/app/vimusic/android/ui/components/themed/PlaylistInfo.kt b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/PlaylistInfo.kt new file mode 100644 index 0000000..8d6412d --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/PlaylistInfo.kt @@ -0,0 +1,75 @@ +package app.vimusic.android.ui.components.themed + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import app.vimusic.android.models.Album +import app.vimusic.android.utils.semiBold +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.providers.innertube.Innertube + +@Composable +fun PlaylistInfo( + description: String?, + year: String?, + otherInfo: String?, + modifier: Modifier = Modifier +) { + val (_, typography) = LocalAppearance.current + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier.padding(horizontal = 8.dp) + ) { + otherInfo?.let { info -> + BasicText( + text = info, + style = typography.s.semiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + softWrap = false + ) + } + + year?.let { year -> + BasicText( + text = year, + style = typography.s.semiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + softWrap = false + ) + } + + description?.let { description -> + Attribution(text = description) + } + } +} + +@Composable +fun PlaylistInfo( + playlist: Innertube.PlaylistOrAlbumPage?, + modifier: Modifier = Modifier +) = PlaylistInfo( + description = playlist?.description, + year = playlist?.year, + otherInfo = playlist?.otherInfo, + modifier = modifier +) + +@Composable +fun PlaylistInfo( + playlist: Album?, + modifier: Modifier = Modifier +) = PlaylistInfo( + description = playlist?.description, + year = playlist?.year, + otherInfo = playlist?.otherInfo, + modifier = modifier +) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/PrimaryButton.kt b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/PrimaryButton.kt similarity index 75% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/PrimaryButton.kt rename to app/src/main/kotlin/app/vimusic/android/ui/components/themed/PrimaryButton.kt index 6ad94f7..5611f95 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/PrimaryButton.kt +++ b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/PrimaryButton.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.vimusic.ui.components.themed +package app.vimusic.android.ui.components.themed import androidx.annotation.DrawableRes import androidx.compose.foundation.Image @@ -6,7 +6,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -14,27 +13,28 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.primaryButton +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.primaryButton +import app.vimusic.core.ui.utils.roundedShape @Composable fun PrimaryButton( onClick: () -> Unit, - @DrawableRes iconId: Int, + @DrawableRes icon: Int, modifier: Modifier = Modifier, - enabled: Boolean = true, + enabled: Boolean = true ) { val (colorPalette) = LocalAppearance.current Box( modifier = modifier - .clip(RoundedCornerShape(16.dp)) + .clip(16.dp.roundedShape) .clickable(enabled = enabled, onClick = onClick) .background(colorPalette.primaryButton) .size(62.dp) ) { Image( - painter = painterResource(iconId), + painter = painterResource(icon), contentDescription = null, colorFilter = ColorFilter.tint(colorPalette.text), modifier = Modifier diff --git a/app/src/main/kotlin/app/vimusic/android/ui/components/themed/ProgressIndicator.kt b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/ProgressIndicator.kt new file mode 100644 index 0000000..b239462 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/ProgressIndicator.kt @@ -0,0 +1,49 @@ +package app.vimusic.android.ui.components.themed + +import androidx.compose.material3.ProgressIndicatorDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.StrokeCap +import app.vimusic.core.ui.LocalAppearance + +@Composable +fun CircularProgressIndicator( + modifier: Modifier = Modifier, + progress: Float? = null, + strokeCap: StrokeCap? = null +) { + val (colorPalette) = LocalAppearance.current + + if (progress == null) androidx.compose.material3.CircularProgressIndicator( + modifier = modifier, + color = colorPalette.accent, + strokeCap = strokeCap ?: ProgressIndicatorDefaults.CircularIndeterminateStrokeCap + ) else androidx.compose.material3.CircularProgressIndicator( + modifier = modifier, + color = colorPalette.accent, + strokeCap = strokeCap ?: ProgressIndicatorDefaults.CircularDeterminateStrokeCap, + progress = { progress } + ) +} + +@Composable +fun LinearProgressIndicator( + modifier: Modifier = Modifier, + progress: Float? = null, + strokeCap: StrokeCap = ProgressIndicatorDefaults.LinearStrokeCap +) { + val (colorPalette) = LocalAppearance.current + + if (progress == null) androidx.compose.material3.LinearProgressIndicator( + modifier = modifier, + color = colorPalette.accent, + trackColor = colorPalette.background1, + strokeCap = strokeCap + ) else androidx.compose.material3.LinearProgressIndicator( + modifier = modifier, + color = colorPalette.accent, + trackColor = colorPalette.background1, + strokeCap = strokeCap, + progress = { progress } + ) +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/components/themed/ReorderHandle.kt b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/ReorderHandle.kt new file mode 100644 index 0000000..4de88d0 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/ReorderHandle.kt @@ -0,0 +1,28 @@ +package app.vimusic.android.ui.components.themed + +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import app.vimusic.android.R +import app.vimusic.compose.reordering.ReorderingState +import app.vimusic.compose.reordering.reorder +import app.vimusic.core.ui.LocalAppearance + +@Composable +fun ReorderHandle( + reorderingState: ReorderingState, + index: Int, + modifier: Modifier = Modifier +) = IconButton( + icon = R.drawable.reorder, + color = LocalAppearance.current.colorPalette.textDisabled, + indication = null, + onClick = {}, + modifier = modifier + .reorder( + reorderingState = reorderingState, + index = index + ) + .size(18.dp) +) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Scaffold.kt b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/Scaffold.kt similarity index 54% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Scaffold.kt rename to app/src/main/kotlin/app/vimusic/android/ui/components/themed/Scaffold.kt index 3f71da7..b6619f4 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Scaffold.kt +++ b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/Scaffold.kt @@ -1,34 +1,38 @@ -package it.vfsfitvnm.vimusic.ui.components.themed +package app.vimusic.android.ui.components.themed import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Down +import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Up import androidx.compose.animation.AnimatedVisibilityScope -import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.ContentTransform import androidx.compose.animation.core.Spring import androidx.compose.animation.core.VisibilityThreshold import androidx.compose.animation.core.spring -import androidx.compose.animation.with import androidx.compose.foundation.background -import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.IntOffset -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import app.vimusic.android.preferences.UIStatePreferences +import app.vimusic.core.ui.LocalAppearance +import kotlinx.collections.immutable.toImmutableList -@ExperimentalAnimationApi @Composable fun Scaffold( + key: String, topIconButtonId: Int, onTopIconButtonClick: () -> Unit, tabIndex: Int, - onTabChanged: (Int) -> Unit, - tabColumnContent: @Composable ColumnScope.(@Composable (Int, String, Int) -> Unit) -> Unit, + onTabChange: (Int) -> Unit, + tabColumnContent: TabsBuilder.() -> Unit, modifier: Modifier = Modifier, content: @Composable AnimatedVisibilityScope.(Int) -> Unit ) { val (colorPalette) = LocalAppearance.current + var hiddenTabs by UIStatePreferences.mutableTabStateOf(key) Row( modifier = modifier @@ -39,28 +43,30 @@ fun Scaffold( topIconButtonId = topIconButtonId, onTopIconButtonClick = onTopIconButtonClick, tabIndex = tabIndex, - onTabIndexChanged = onTabChanged, + onTabIndexChange = onTabChange, + hiddenTabs = hiddenTabs, + setHiddenTabs = { hiddenTabs = it.toImmutableList() }, content = tabColumnContent ) AnimatedContent( targetState = tabIndex, transitionSpec = { - val slideDirection = when (targetState > initialState) { - true -> AnimatedContentScope.SlideDirection.Up - false -> AnimatedContentScope.SlideDirection.Down - } - + val slideDirection = if (targetState > initialState) Up else Down val animationSpec = spring( dampingRatio = 0.9f, stiffness = Spring.StiffnessLow, visibilityThreshold = IntOffset.VisibilityThreshold ) - slideIntoContainer(slideDirection, animationSpec) with - slideOutOfContainer(slideDirection, animationSpec) + ContentTransform( + targetContentEnter = slideIntoContainer(slideDirection, animationSpec), + initialContentExit = slideOutOfContainer(slideDirection, animationSpec), + sizeTransform = null + ) }, - content = content + content = content, + label = "" ) } } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/SecondaryButton.kt b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/SecondaryButton.kt similarity index 87% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/SecondaryButton.kt rename to app/src/main/kotlin/app/vimusic/android/ui/components/themed/SecondaryButton.kt index 9f0fb22..330c6e4 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/SecondaryButton.kt +++ b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/SecondaryButton.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.vimusic.ui.components.themed +package app.vimusic.android.ui.components.themed import androidx.annotation.DrawableRes import androidx.compose.foundation.Image @@ -14,15 +14,15 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.primaryButton +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.primaryButton @Composable fun SecondaryButton( onClick: () -> Unit, @DrawableRes iconId: Int, modifier: Modifier = Modifier, - enabled: Boolean = true, + enabled: Boolean = true ) { val (colorPalette) = LocalAppearance.current diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/SecondaryTextButton.kt b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/SecondaryTextButton.kt similarity index 67% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/SecondaryTextButton.kt rename to app/src/main/kotlin/app/vimusic/android/ui/components/themed/SecondaryTextButton.kt index 240f3b6..d6bb6ab 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/SecondaryTextButton.kt +++ b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/SecondaryTextButton.kt @@ -1,17 +1,19 @@ -package it.vfsfitvnm.vimusic.ui.components.themed +package app.vimusic.android.ui.components.themed import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.primaryButton -import it.vfsfitvnm.vimusic.utils.medium +import app.vimusic.android.utils.center +import app.vimusic.android.utils.disabled +import app.vimusic.android.utils.medium +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.primaryButton +import app.vimusic.core.ui.utils.roundedShape @Composable fun SecondaryTextButton( @@ -25,9 +27,9 @@ fun SecondaryTextButton( BasicText( text = text, - style = typography.xxs.medium, + style = typography.xxs.medium.center.let { if (enabled) it else it.disabled }, modifier = modifier - .clip(RoundedCornerShape(16.dp)) + .clip(16.dp.roundedShape) .clickable(enabled = enabled, onClick = onClick) .background(if (alternative) colorPalette.background0 else colorPalette.primaryButton) .padding(all = 8.dp) diff --git a/app/src/main/kotlin/app/vimusic/android/ui/components/themed/Slider.kt b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/Slider.kt new file mode 100644 index 0000000..78effee --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/Slider.kt @@ -0,0 +1,33 @@ +package app.vimusic.android.ui.components.themed + +import androidx.annotation.IntRange +import androidx.compose.material3.SliderDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import app.vimusic.core.ui.LocalAppearance + +@Composable +fun Slider( + state: Float, + setState: (Float) -> Unit, + onSlideComplete: () -> Unit, + range: ClosedFloatingPointRange, + modifier: Modifier = Modifier, + @IntRange(from = 0) steps: Int = 0 +) { + val (colorPalette) = LocalAppearance.current + + androidx.compose.material3.Slider( + value = state, + onValueChange = setState, + onValueChangeFinished = onSlideComplete, + valueRange = range, + modifier = modifier, + steps = steps, + colors = SliderDefaults.colors( + thumbColor = colorPalette.onAccent, + activeTrackColor = colorPalette.accent, + inactiveTrackColor = colorPalette.text.copy(alpha = 0.75f) + ) + ) +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Switch.kt b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/Switch.kt similarity index 85% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Switch.kt rename to app/src/main/kotlin/app/vimusic/android/ui/components/themed/Switch.kt index 14b37e9..f461453 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Switch.kt +++ b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/Switch.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.vimusic.ui.components.themed +package app.vimusic.android.ui.components.themed import androidx.compose.animation.animateColor import androidx.compose.animation.core.animateDp @@ -14,13 +14,13 @@ import androidx.compose.ui.geometry.center import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.unit.dp -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.utils.drawCircle +import app.vimusic.android.utils.drawCircle +import app.vimusic.core.ui.LocalAppearance @Composable fun Switch( isChecked: Boolean, - modifier: Modifier = Modifier, + modifier: Modifier = Modifier ) { val (colorPalette) = LocalAppearance.current @@ -38,13 +38,10 @@ fun Switch( if (it) 36.dp else 12.dp } - Canvas( - modifier = modifier - .size(width = 48.dp, height = 24.dp) - ) { + Canvas(modifier = modifier.size(width = 48.dp, height = 24.dp)) { drawRoundRect( color = backgroundColor, - cornerRadius = CornerRadius(x = 12.dp.toPx(), y = 12.dp.toPx()), + cornerRadius = CornerRadius(x = 12.dp.toPx(), y = 12.dp.toPx()) ) drawCircle( diff --git a/app/src/main/kotlin/app/vimusic/android/ui/components/themed/TextField.kt b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/TextField.kt new file mode 100644 index 0000000..fa7f7c7 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/TextField.kt @@ -0,0 +1,140 @@ +package app.vimusic.android.ui.components.themed + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextOverflow +import app.vimusic.android.utils.secondary +import app.vimusic.android.utils.semiBold +import app.vimusic.core.ui.Appearance +import app.vimusic.core.ui.LocalAppearance + +@Composable +fun ColumnScope.TextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + appearance: Appearance = LocalAppearance.current, + textStyle: TextStyle = appearance.typography.xs.semiBold, + singleLine: Boolean = false, + keyboardActions: KeyboardActions = KeyboardActions.Default, + keyboardOptions: KeyboardOptions = KeyboardOptions( + imeAction = if (singleLine) ImeAction.Done else ImeAction.None + ), + minLines: Int = 1, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + visualTransformation: VisualTransformation = VisualTransformation.None, + onTextLayout: (TextLayoutResult) -> Unit = { }, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + hintText: String? = null +) = BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier, + enabled = enabled, + readOnly = readOnly, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + textStyle = textStyle, + singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, + visualTransformation = visualTransformation, + onTextLayout = onTextLayout, + interactionSource = interactionSource, + cursorBrush = SolidColor(appearance.colorPalette.text), + decorationBox = { innerTextField -> + hintText?.let { text -> + this@TextField.AnimatedVisibility( + visible = value.isEmpty(), + enter = fadeIn(tween(100)), + exit = fadeOut(tween(100)), + modifier = Modifier.weight(1f) + ) { + BasicText( + text = text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = textStyle.secondary + ) + } + } + + innerTextField() + } +) + +@Composable +fun RowScope.TextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + appearance: Appearance = LocalAppearance.current, + textStyle: TextStyle = appearance.typography.xs.semiBold, + singleLine: Boolean = false, + keyboardActions: KeyboardActions = KeyboardActions.Default, + keyboardOptions: KeyboardOptions = KeyboardOptions( + imeAction = if (singleLine) ImeAction.Done else ImeAction.None + ), + minLines: Int = 1, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + visualTransformation: VisualTransformation = VisualTransformation.None, + onTextLayout: (TextLayoutResult) -> Unit = { }, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + hintText: String? = null +) = BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier, + enabled = enabled, + readOnly = readOnly, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + textStyle = textStyle, + singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, + visualTransformation = visualTransformation, + onTextLayout = onTextLayout, + interactionSource = interactionSource, + cursorBrush = SolidColor(appearance.colorPalette.text), + decorationBox = { innerTextField -> + hintText?.let { text -> + this@TextField.AnimatedVisibility( + visible = value.isEmpty(), + enter = fadeIn(tween(100)), + exit = fadeOut(tween(100)), + modifier = Modifier.weight(1f) + ) { + BasicText( + text = text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = textStyle.secondary + ) + } + } + + innerTextField() + } +) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/TextPlaceholder.kt b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/TextPlaceholder.kt similarity index 53% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/TextPlaceholder.kt rename to app/src/main/kotlin/app/vimusic/android/ui/components/themed/TextPlaceholder.kt index f747e91..a44ed69 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/TextPlaceholder.kt +++ b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/TextPlaceholder.kt @@ -1,5 +1,6 @@ -package it.vfsfitvnm.vimusic.ui.components.themed +package app.vimusic.android.ui.components.themed +import androidx.annotation.FloatRange import androidx.compose.foundation.background import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -10,20 +11,20 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.shimmer +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.shimmer import kotlin.random.Random @Composable fun TextPlaceholder( modifier: Modifier = Modifier, - color: Color = LocalAppearance.current.colorPalette.shimmer -) { - Spacer( - modifier = modifier - .padding(vertical = 4.dp) - .background(color) - .fillMaxWidth(remember { 0.25f + Random.nextFloat() * 0.5f }) - .height(16.dp) - ) -} + color: Color = LocalAppearance.current.colorPalette.shimmer, + @FloatRange(from = 0.0, to = 1.0) + width: Float = remember { 0.25f + Random.nextFloat() * 0.5f } +) = Spacer( + modifier = modifier + .padding(vertical = 4.dp) + .background(color) + .fillMaxWidth(width) + .height(16.dp) +) diff --git a/app/src/main/kotlin/app/vimusic/android/ui/components/themed/TextToggle.kt b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/TextToggle.kt new file mode 100644 index 0000000..145cadd --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/components/themed/TextToggle.kt @@ -0,0 +1,68 @@ +package app.vimusic.android.ui.components.themed + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import app.vimusic.android.R +import app.vimusic.android.utils.medium +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.utils.roundedShape + +@Composable +fun TextToggle( + state: Boolean, + toggleState: () -> Unit, + name: String, + modifier: Modifier = Modifier, + onLabel: String = stringResource(R.string.on_label), + offLabel: String = stringResource(R.string.off_label) +) { + val (colorPalette, typography) = LocalAppearance.current + + Row( + modifier = modifier + .clip(16.dp.roundedShape) + .clickable(onClick = toggleState) + .background(colorPalette.background1) + .padding(horizontal = 16.dp, vertical = 8.dp) + .animateContentSize() + ) { + BasicText( + text = "$name ", + style = typography.xxs.medium + ) + + AnimatedContent( + targetState = state, + transitionSpec = { + val slideDirection = + if (targetState) AnimatedContentTransitionScope.SlideDirection.Up + else AnimatedContentTransitionScope.SlideDirection.Down + + ContentTransform( + targetContentEnter = slideIntoContainer(slideDirection) + fadeIn(), + initialContentExit = slideOutOfContainer(slideDirection) + fadeOut() + ) + }, + label = "" + ) { + BasicText( + text = if (it) onLabel else offLabel, + style = typography.xxs.medium + ) + } + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/items/AlbumItem.kt b/app/src/main/kotlin/app/vimusic/android/ui/items/AlbumItem.kt new file mode 100644 index 0000000..860f636 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/items/AlbumItem.kt @@ -0,0 +1,139 @@ +package app.vimusic.android.ui.items + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import app.vimusic.android.models.Album +import app.vimusic.android.ui.components.themed.TextPlaceholder +import app.vimusic.android.utils.secondary +import app.vimusic.android.utils.semiBold +import app.vimusic.android.utils.thumbnail +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.shimmer +import app.vimusic.core.ui.utils.px +import app.vimusic.providers.innertube.Innertube +import coil3.compose.AsyncImage + +@Composable +fun AlbumItem( + album: Album, + thumbnailSize: Dp, + modifier: Modifier = Modifier, + alternative: Boolean = false +) = AlbumItem( + thumbnailUrl = album.thumbnailUrl, + title = album.title, + authors = album.authorsText, + year = album.year, + thumbnailSize = thumbnailSize, + alternative = alternative, + modifier = modifier +) + +@Composable +fun AlbumItem( + album: Innertube.AlbumItem, + thumbnailSize: Dp, + modifier: Modifier = Modifier, + alternative: Boolean = false +) = AlbumItem( + thumbnailUrl = album.thumbnail?.url, + title = album.info?.name, + authors = album.authors?.joinToString("") { it.name.orEmpty() }, + year = album.year, + thumbnailSize = thumbnailSize, + alternative = alternative, + modifier = modifier +) + +@Composable +fun AlbumItem( + thumbnailUrl: String?, + title: String?, + authors: String?, + year: String?, + thumbnailSize: Dp, + modifier: Modifier = Modifier, + alternative: Boolean = false +) = ItemContainer( + alternative = alternative, + thumbnailSize = thumbnailSize, + modifier = Modifier.clip(LocalAppearance.current.thumbnailShape) then modifier +) { + val typography = LocalAppearance.current.typography + val thumbnailShape = LocalAppearance.current.thumbnailShape + + AsyncImage( + model = thumbnailUrl?.thumbnail(thumbnailSize.px), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .clip(thumbnailShape) + .size(thumbnailSize) + ) + + ItemInfoContainer { + title?.let { + BasicText( + text = title, + style = typography.xs.semiBold, + maxLines = if (alternative) 1 else 2, + overflow = TextOverflow.Ellipsis + ) + } + + if (!alternative) authors?.let { + BasicText( + text = authors, + style = typography.xs.semiBold.secondary, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + + year?.let { + BasicText( + text = year, + style = typography.xxs.semiBold.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 4.dp) + ) + } + } +} + +@Composable +fun AlbumItemPlaceholder( + thumbnailSize: Dp, + modifier: Modifier = Modifier, + alternative: Boolean = false +) = ItemContainer( + alternative = alternative, + thumbnailSize = thumbnailSize, + modifier = modifier +) { + val colorPalette = LocalAppearance.current.colorPalette + val thumbnailShape = LocalAppearance.current.thumbnailShape + + Spacer( + modifier = Modifier + .background(color = colorPalette.shimmer, shape = thumbnailShape) + .size(thumbnailSize) + ) + + ItemInfoContainer { + TextPlaceholder() + if (!alternative) TextPlaceholder() + TextPlaceholder(modifier = Modifier.padding(top = 4.dp)) + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/items/ArtistItem.kt b/app/src/main/kotlin/app/vimusic/android/ui/items/ArtistItem.kt new file mode 100644 index 0000000..09f1810 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/items/ArtistItem.kt @@ -0,0 +1,131 @@ +package app.vimusic.android.ui.items + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import app.vimusic.android.models.Artist +import app.vimusic.android.ui.components.themed.TextPlaceholder +import app.vimusic.android.utils.secondary +import app.vimusic.android.utils.semiBold +import app.vimusic.android.utils.thumbnail +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.shimmer +import app.vimusic.core.ui.utils.px +import app.vimusic.providers.innertube.Innertube +import coil3.compose.AsyncImage + +@Composable +fun ArtistItem( + artist: Artist, + thumbnailSize: Dp, + modifier: Modifier = Modifier, + alternative: Boolean = false +) = ArtistItem( + thumbnailUrl = artist.thumbnailUrl, + name = artist.name, + subscribersCount = null, + thumbnailSize = thumbnailSize, + modifier = modifier, + alternative = alternative +) + +@Composable +fun ArtistItem( + artist: Innertube.ArtistItem, + thumbnailSize: Dp, + modifier: Modifier = Modifier, + alternative: Boolean = false +) = ArtistItem( + thumbnailUrl = artist.thumbnail?.url, + name = artist.info?.name, + subscribersCount = artist.subscribersCountText, + thumbnailSize = thumbnailSize, + modifier = modifier, + alternative = alternative +) + +@Composable +fun ArtistItem( + thumbnailUrl: String?, + name: String?, + subscribersCount: String?, + thumbnailSize: Dp, + modifier: Modifier = Modifier, + alternative: Boolean = false +) = ItemContainer( + alternative = alternative, + thumbnailSize = thumbnailSize, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.clip(LocalAppearance.current.thumbnailShape) then modifier +) { + val (_, typography) = LocalAppearance.current + + AsyncImage( + model = thumbnailUrl?.thumbnail(thumbnailSize.px), + contentDescription = null, + modifier = Modifier + .clip(CircleShape) + .requiredSize(thumbnailSize) + ) + + ItemInfoContainer( + horizontalAlignment = if (alternative) Alignment.CenterHorizontally else Alignment.Start + ) { + BasicText( + text = name.orEmpty(), + style = typography.xs.semiBold, + maxLines = if (alternative) 1 else 2, + overflow = TextOverflow.Ellipsis + ) + + subscribersCount?.let { + BasicText( + text = subscribersCount, + style = typography.xxs.semiBold.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 4.dp) + ) + } + } +} + +@Composable +fun ArtistItemPlaceholder( + thumbnailSize: Dp, + modifier: Modifier = Modifier, + alternative: Boolean = false +) { + val (colorPalette) = LocalAppearance.current + + ItemContainer( + alternative = alternative, + thumbnailSize = thumbnailSize, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + ) { + Spacer( + modifier = Modifier + .background(color = colorPalette.shimmer, shape = CircleShape) + .size(thumbnailSize) + ) + + ItemInfoContainer( + horizontalAlignment = if (alternative) Alignment.CenterHorizontally else Alignment.Start + ) { + TextPlaceholder() + TextPlaceholder(modifier = Modifier.padding(top = 4.dp)) + } + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/items/ItemContainer.kt b/app/src/main/kotlin/app/vimusic/android/ui/items/ItemContainer.kt new file mode 100644 index 0000000..459eb4d --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/items/ItemContainer.kt @@ -0,0 +1,56 @@ +package app.vimusic.android.ui.items + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import app.vimusic.core.ui.Dimensions + +@Composable +inline fun ItemContainer( + alternative: Boolean, + thumbnailSize: Dp, + modifier: Modifier = Modifier, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, + content: @Composable (centeredModifier: Modifier) -> Unit +) = if (alternative) Column( + horizontalAlignment = horizontalAlignment, + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = modifier + .padding( + vertical = Dimensions.items.verticalPadding, + horizontal = Dimensions.items.horizontalPadding + ) + .width(thumbnailSize) +) { content(Modifier.align(Alignment.CenterHorizontally)) } +else Row( + verticalAlignment = verticalAlignment, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = modifier + .padding( + vertical = Dimensions.items.verticalPadding, + horizontal = Dimensions.items.horizontalPadding + ) + .fillMaxWidth() +) { content(Modifier.align(Alignment.CenterVertically)) } + +@Composable +inline fun ItemInfoContainer( + modifier: Modifier = Modifier, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + content: @Composable ColumnScope.() -> Unit +) = Column( + horizontalAlignment = horizontalAlignment, + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier, + content = content +) diff --git a/app/src/main/kotlin/app/vimusic/android/ui/items/PlaylistItem.kt b/app/src/main/kotlin/app/vimusic/android/ui/items/PlaylistItem.kt new file mode 100644 index 0000000..f3f8a74 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/items/PlaylistItem.kt @@ -0,0 +1,256 @@ +package app.vimusic.android.ui.items + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import app.vimusic.android.Database +import app.vimusic.android.models.PlaylistPreview +import app.vimusic.android.ui.components.themed.TextPlaceholder +import app.vimusic.android.utils.center +import app.vimusic.android.utils.color +import app.vimusic.android.utils.medium +import app.vimusic.android.utils.secondary +import app.vimusic.android.utils.semiBold +import app.vimusic.android.utils.thumbnail +import app.vimusic.core.ui.Dimensions +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.onOverlay +import app.vimusic.core.ui.overlay +import app.vimusic.core.ui.shimmer +import app.vimusic.core.ui.utils.px +import app.vimusic.core.ui.utils.roundedShape +import app.vimusic.providers.innertube.Innertube +import coil3.compose.AsyncImage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map + +@Composable +fun PlaylistItem( + @DrawableRes icon: Int, + colorTint: Color, + name: String?, + songCount: Int?, + thumbnailSize: Dp, + modifier: Modifier = Modifier, + alternative: Boolean = false +) = PlaylistItem( + thumbnailContent = { + Image( + painter = painterResource(icon), + contentDescription = null, + colorFilter = ColorFilter.tint(colorTint), + modifier = Modifier + .align(Alignment.Center) + .size(24.dp) + ) + }, + songCount = songCount, + name = name, + channelName = null, + thumbnailSize = thumbnailSize, + modifier = modifier, + alternative = alternative +) + +@Composable +fun PlaylistItem( + playlist: PlaylistPreview, + thumbnailSize: Dp, + modifier: Modifier = Modifier, + alternative: Boolean = false +) { + val thumbnailSizePx = thumbnailSize.px + val thumbnails by remember { + playlist.thumbnail?.let { flowOf(listOf(it)) } + ?: Database + .playlistThumbnailUrls(playlist.playlist.id) + .distinctUntilChanged() + .map { urls -> + urls.map { it.thumbnail(thumbnailSizePx / 2) } + } + }.collectAsState(initial = emptyList(), context = Dispatchers.IO) + + PlaylistItem( + thumbnailContent = { + if (thumbnails.toSet().size == 1) AsyncImage( + model = thumbnails.first().thumbnail(thumbnailSizePx), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = it + ) else Box(modifier = it.fillMaxSize()) { + listOf( + Alignment.TopStart, + Alignment.TopEnd, + Alignment.BottomStart, + Alignment.BottomEnd + ).forEachIndexed { index, alignment -> + AsyncImage( + model = thumbnails.getOrNull(index), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .align(alignment) + .size(thumbnailSize / 2) + ) + } + } + }, + songCount = playlist.songCount, + name = playlist.playlist.name, + channelName = null, + thumbnailSize = thumbnailSize, + modifier = modifier, + alternative = alternative + ) +} + +@Composable +fun PlaylistItem( + playlist: Innertube.PlaylistItem, + thumbnailSize: Dp, + modifier: Modifier = Modifier, + alternative: Boolean = false +) = PlaylistItem( + thumbnailUrl = playlist.thumbnail?.url, + songCount = playlist.songCount, + name = playlist.info?.name, + channelName = playlist.channel?.name, + thumbnailSize = thumbnailSize, + modifier = modifier, + alternative = alternative +) + +@Composable +fun PlaylistItem( + thumbnailUrl: String?, + songCount: Int?, + name: String?, + channelName: String?, + thumbnailSize: Dp, + modifier: Modifier = Modifier, + alternative: Boolean = false +) = PlaylistItem( + thumbnailContent = { + AsyncImage( + model = thumbnailUrl?.thumbnail(thumbnailSize.px), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = it + ) + }, + songCount = songCount, + name = name, + channelName = channelName, + thumbnailSize = thumbnailSize, + modifier = modifier, + alternative = alternative +) + +@Composable +fun PlaylistItem( + thumbnailContent: @Composable BoxScope.(modifier: Modifier) -> Unit, + songCount: Int?, + name: String?, + channelName: String?, + thumbnailSize: Dp, + modifier: Modifier = Modifier, + alternative: Boolean = false +) = ItemContainer( + alternative = alternative, + thumbnailSize = thumbnailSize, + modifier = Modifier.clip(LocalAppearance.current.thumbnailShape) then modifier +) { centeredModifier -> + val (colorPalette, typography, thumbnailShapeCorners) = LocalAppearance.current + + Box( + modifier = centeredModifier + .clip(thumbnailShapeCorners.roundedShape) + .background(color = colorPalette.background1) + .requiredSize(thumbnailSize) + ) { + thumbnailContent(Modifier.fillMaxSize()) + + songCount?.let { + BasicText( + text = "$songCount", + style = typography.xxs.medium.color(colorPalette.onOverlay), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(all = Dimensions.items.gap) + .background( + color = colorPalette.overlay, + shape = (thumbnailShapeCorners - Dimensions.items.gap).coerceAtLeast(0.dp).roundedShape + ) + .padding(horizontal = 4.dp, vertical = 2.dp) + .align(Alignment.BottomEnd) + ) + } + } + + ItemInfoContainer(modifier = if (alternative && channelName.isNullOrBlank()) centeredModifier else Modifier) { + BasicText( + text = name.orEmpty(), + style = typography.xs.semiBold.let { if (alternative && channelName.isNullOrBlank()) it.center else it }, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + if (channelName?.isNotBlank() == true) BasicText( + text = channelName, + style = typography.xs.semiBold.secondary, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +fun PlaylistItemPlaceholder( + thumbnailSize: Dp, + modifier: Modifier = Modifier, + alternative: Boolean = false +) = ItemContainer( + alternative = alternative, + thumbnailSize = thumbnailSize, + modifier = modifier +) { + val (colorPalette, _, _, thumbnailShape) = LocalAppearance.current + + Spacer( + modifier = Modifier + .background(color = colorPalette.shimmer, shape = thumbnailShape) + .size(thumbnailSize) + ) + + ItemInfoContainer( + horizontalAlignment = if (alternative) Alignment.CenterHorizontally else Alignment.Start + ) { + TextPlaceholder() + TextPlaceholder() + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/items/SongItem.kt b/app/src/main/kotlin/app/vimusic/android/ui/items/SongItem.kt new file mode 100644 index 0000000..2c85101 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/items/SongItem.kt @@ -0,0 +1,295 @@ +package app.vimusic.android.ui.items + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.media3.common.MediaItem +import app.vimusic.android.R +import app.vimusic.android.models.Song +import app.vimusic.android.preferences.AppearancePreferences +import app.vimusic.android.ui.components.themed.TextPlaceholder +import app.vimusic.android.utils.medium +import app.vimusic.android.utils.secondary +import app.vimusic.android.utils.semiBold +import app.vimusic.android.utils.thumbnail +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.shimmer +import app.vimusic.core.ui.utils.px +import app.vimusic.core.ui.utils.songBundle +import app.vimusic.providers.innertube.Innertube +import coil3.compose.AsyncImage + +@Composable +fun SongItem( + song: Innertube.SongItem, + thumbnailSize: Dp, + modifier: Modifier = Modifier, + showDuration: Boolean = true, + clip: Boolean = true, + hideExplicit: Boolean = AppearancePreferences.hideExplicit +) = SongItem( + modifier = modifier, + thumbnailUrl = song.thumbnail?.size(thumbnailSize.px), + title = song.info?.name, + authors = song.authors?.joinToString("") { it.name.orEmpty() }, + duration = song.durationText, + explicit = song.explicit, + thumbnailSize = thumbnailSize, + showDuration = showDuration, + clip = clip, + hideExplicit = hideExplicit +) + +@Composable +fun SongItem( + song: MediaItem, + thumbnailSize: Dp, + modifier: Modifier = Modifier, + onThumbnailContent: (@Composable BoxScope.() -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null, + showDuration: Boolean = true, + clip: Boolean = true, + hideExplicit: Boolean = AppearancePreferences.hideExplicit +) { + val extras = remember(song) { song.mediaMetadata.extras?.songBundle } + + SongItem( + modifier = modifier, + thumbnailUrl = song.mediaMetadata.artworkUri.thumbnail(thumbnailSize.px)?.toString(), + title = song.mediaMetadata.title?.toString(), + authors = song.mediaMetadata.artist?.toString(), + duration = extras?.durationText, + explicit = extras?.explicit == true, + thumbnailSize = thumbnailSize, + onThumbnailContent = onThumbnailContent, + trailingContent = trailingContent, + showDuration = showDuration, + clip = clip, + hideExplicit = hideExplicit + ) +} + +@Composable +fun SongItem( + song: Song, + thumbnailSize: Dp, + modifier: Modifier = Modifier, + index: Int? = null, + onThumbnailContent: @Composable (BoxScope.() -> Unit)? = null, + trailingContent: @Composable (() -> Unit)? = null, + showDuration: Boolean = true, + clip: Boolean = true, + hideExplicit: Boolean = AppearancePreferences.hideExplicit +) = SongItem( + modifier = modifier, + index = index, + thumbnailUrl = song.thumbnailUrl?.thumbnail(thumbnailSize.px), + title = song.title, + authors = song.artistsText, + duration = song.durationText, + explicit = song.explicit, + thumbnailSize = thumbnailSize, + onThumbnailContent = onThumbnailContent, + trailingContent = trailingContent, + showDuration = showDuration, + clip = clip, + hideExplicit = hideExplicit +) + +@Composable +fun SongItem( + thumbnailUrl: String?, + title: String?, + authors: String?, + duration: String?, + explicit: Boolean, + thumbnailSize: Dp, + modifier: Modifier = Modifier, + index: Int? = null, + onThumbnailContent: @Composable (BoxScope.() -> Unit)? = null, + trailingContent: @Composable (() -> Unit)? = null, + showDuration: Boolean = true, + clip: Boolean = true, + hideExplicit: Boolean = AppearancePreferences.hideExplicit +) { + val (colorPalette, typography, _, thumbnailShape) = LocalAppearance.current + + SongItem( + title = title, + authors = authors, + duration = duration, + explicit = explicit, + thumbnailSize = thumbnailSize, + thumbnailContent = { + Box( + modifier = Modifier + .clip(thumbnailShape) + .background(colorPalette.background1) + .fillMaxSize() + ) { + AsyncImage( + model = thumbnailUrl, + error = painterResource(id = R.drawable.ic_launcher_foreground), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + + if (index != null) { + Box( + modifier = Modifier + .background(color = Color.Black.copy(alpha = 0.75f)) + .fillMaxSize() + ) + BasicText( + text = "${index + 1}", + style = typography.xs.semiBold.copy(color = Color.White), + modifier = Modifier.align(Alignment.Center) + ) + } + } + + onThumbnailContent?.invoke(this) + }, + modifier = modifier, + trailingContent = trailingContent, + showDuration = showDuration, + clip = clip, + hideExplicit = hideExplicit + ) +} + +@Composable +fun SongItem( + title: String?, + authors: String?, + duration: String?, + explicit: Boolean, + thumbnailSize: Dp, + thumbnailContent: @Composable BoxScope.() -> Unit, + modifier: Modifier = Modifier, + trailingContent: @Composable (() -> Unit)? = null, + showDuration: Boolean = true, + clip: Boolean = true, + hideExplicit: Boolean = AppearancePreferences.hideExplicit +) = if (!(hideExplicit && explicit)) ItemContainer( + alternative = false, + thumbnailSize = thumbnailSize, + modifier = if (clip) Modifier.clip(LocalAppearance.current.thumbnailShape) then modifier + else modifier +) { + val (colorPalette, typography) = LocalAppearance.current + + Box( + modifier = Modifier.size(thumbnailSize), + content = thumbnailContent + ) + + ItemInfoContainer { + trailingContent?.let { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + BasicText( + text = title.orEmpty(), + style = typography.xs.semiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + + it() + } + } ?: BasicText( + text = title.orEmpty(), + style = typography.xs.semiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.weight(1f) + ) { + authors?.let { + BasicText( + text = authors, + style = typography.xs.semiBold.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight( + weight = 1f, + fill = false + ) + ) + } + + if (explicit) Image( + painter = painterResource(R.drawable.explicit), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier.size(15.dp) + ) + } + + if (showDuration) duration?.let { + BasicText( + text = duration, + style = typography.xxs.secondary.medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 4.dp) + ) + } + } + } +} else Unit + +@Composable +fun SongItemPlaceholder( + thumbnailSize: Dp, + modifier: Modifier = Modifier +) = ItemContainer( + alternative = false, + thumbnailSize = thumbnailSize, + modifier = modifier +) { + val (colorPalette, _, _, thumbnailShape) = LocalAppearance.current + + Spacer( + modifier = Modifier + .background(color = colorPalette.shimmer, shape = thumbnailShape) + .size(thumbnailSize) + ) + + ItemInfoContainer { + TextPlaceholder() + TextPlaceholder() + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/items/VideoItem.kt b/app/src/main/kotlin/app/vimusic/android/ui/items/VideoItem.kt new file mode 100644 index 0000000..f895b7e --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/items/VideoItem.kt @@ -0,0 +1,144 @@ +package app.vimusic.android.ui.items + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import app.vimusic.android.ui.components.themed.TextPlaceholder +import app.vimusic.android.utils.color +import app.vimusic.android.utils.medium +import app.vimusic.android.utils.secondary +import app.vimusic.android.utils.semiBold +import app.vimusic.core.ui.Dimensions +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.onOverlay +import app.vimusic.core.ui.overlay +import app.vimusic.core.ui.shimmer +import app.vimusic.core.ui.utils.roundedShape +import app.vimusic.providers.innertube.Innertube +import coil3.compose.AsyncImage + +@Composable +fun VideoItem( + video: Innertube.VideoItem, + thumbnailWidth: Dp, + thumbnailHeight: Dp, + modifier: Modifier = Modifier +) = VideoItem( + thumbnailUrl = video.thumbnail?.url, + duration = video.durationText, + title = video.info?.name, + uploader = video.authors?.joinToString("") { it.name.orEmpty() }, + views = video.viewsText, + thumbnailWidth = thumbnailWidth, + thumbnailHeight = thumbnailHeight, + modifier = modifier +) + +@Composable +fun VideoItem( + thumbnailUrl: String?, + duration: String?, + title: String?, + uploader: String?, + views: String?, + thumbnailWidth: Dp, + thumbnailHeight: Dp, + modifier: Modifier = Modifier +) = ItemContainer( + alternative = false, + thumbnailSize = 0.dp, + modifier = Modifier.clip(LocalAppearance.current.thumbnailShape) then modifier +) { + val (colorPalette, typography, thumbnailShapeCorners) = LocalAppearance.current + + Box { + AsyncImage( + model = thumbnailUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .clip(thumbnailShapeCorners.roundedShape) + .size(width = thumbnailWidth, height = thumbnailHeight) + ) + + duration?.let { + BasicText( + text = duration, + style = typography.xxs.medium.color(colorPalette.onOverlay), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(all = Dimensions.items.gap) + .background( + color = colorPalette.overlay, + shape = (thumbnailShapeCorners - Dimensions.items.gap).coerceAtLeast(0.dp).roundedShape + ) + .padding(horizontal = 4.dp, vertical = 2.dp) + .align(Alignment.BottomEnd) + ) + } + } + + ItemInfoContainer { + BasicText( + text = title.orEmpty(), + style = typography.xs.semiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + BasicText( + text = uploader.orEmpty(), + style = typography.xs.semiBold.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + views?.let { + BasicText( + text = views, + style = typography.xxs.medium.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 4.dp) + ) + } + } +} + +@Composable +fun VideoItemPlaceholder( + thumbnailWidth: Dp, + thumbnailHeight: Dp, + modifier: Modifier = Modifier +) = ItemContainer( + alternative = false, + thumbnailSize = 0.dp, + modifier = modifier +) { + val colorPalette = LocalAppearance.current.colorPalette + val thumbnailShape = LocalAppearance.current.thumbnailShape + + Spacer( + modifier = Modifier + .background(color = colorPalette.shimmer, shape = thumbnailShape) + .size(width = thumbnailWidth, height = thumbnailHeight) + ) + + ItemInfoContainer { + TextPlaceholder() + TextPlaceholder() + TextPlaceholder(modifier = Modifier.padding(top = 8.dp)) + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/modifiers/FadingEdge.kt b/app/src/main/kotlin/app/vimusic/android/ui/modifiers/FadingEdge.kt new file mode 100644 index 0000000..1d7192d --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/modifiers/FadingEdge.kt @@ -0,0 +1,46 @@ +package app.vimusic.android.ui.modifiers + +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer + +private fun Modifier.fadingEdge( + start: Boolean, + middle: Int, + end: Boolean, + alpha: Float, + isHorizontal: Boolean +) = this + .graphicsLayer(alpha = 0.99f) + .drawWithContent { + drawContent() + val gradient = buildList { + val transparentColor = Color(red = 0f, green = 0f, blue = 0f, alpha = 1f - alpha) + + add(if (start) transparentColor else Color.Black) + repeat(middle) { add(Color.Black) } + add(if (end) transparentColor else Color.Black) + } + drawRect( + brush = if (isHorizontal) Brush.horizontalGradient(gradient) + else Brush.verticalGradient(gradient), + blendMode = BlendMode.DstIn + ) + } + +fun Modifier.verticalFadingEdge( + top: Boolean = true, + middle: Int = 3, + bottom: Boolean = true, + alpha: Float = 1f +) = fadingEdge(start = top, middle = middle, end = bottom, alpha = alpha, isHorizontal = false) + +fun Modifier.horizontalFadingEdge( + left: Boolean = true, + middle: Int = 3, + right: Boolean = true, + alpha: Float = 1f +) = fadingEdge(start = left, middle = middle, end = right, alpha = alpha, isHorizontal = true) diff --git a/app/src/main/kotlin/app/vimusic/android/ui/modifiers/PinchToggle.kt b/app/src/main/kotlin/app/vimusic/android/ui/modifiers/PinchToggle.kt new file mode 100644 index 0000000..dc5b82a --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/modifiers/PinchToggle.kt @@ -0,0 +1,73 @@ +package app.vimusic.android.ui.modifiers + +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.calculateCentroidSize +import androidx.compose.foundation.gestures.calculateZoom +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChanged +import androidx.compose.ui.util.fastAny +import androidx.compose.ui.util.fastForEach +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.isActive +import kotlin.math.abs + +@JvmInline +value class PinchDirection private constructor(private val out: Boolean) { + companion object { + val Out = PinchDirection(out = true) + val In = PinchDirection(out = false) + } + + fun reachedThreshold( + value: Float, + threshold: Float + ) = when (this) { + Out -> value >= threshold + In -> value <= threshold + else -> error("Unreachable") + } +} + +fun Modifier.pinchToToggle( + direction: PinchDirection, + threshold: Float, + key: Any? = Unit, + onPinch: (scale: Float) -> Unit +) = this.pointerInput(key) { + coroutineScope { + awaitEachGesture { + val touchSlop = viewConfiguration.touchSlop / 2 + var scale = 1f + var touchSlopReached = false + + awaitFirstDown(requireUnconsumed = false) + + @Suppress("LoopWithTooManyJumpStatements") + while (isActive) { + val event = awaitPointerEvent() + if (event.changes.fastAny { it.isConsumed }) break + if (!event.changes.fastAny { it.pressed }) continue + + scale *= event.calculateZoom() + if (!touchSlopReached) { + val centroidSize = event.calculateCentroidSize(useCurrent = false) + if (abs(1 - scale) * centroidSize > touchSlop) touchSlopReached = true + } + + if (touchSlopReached) event.changes.fastForEach { if (it.positionChanged()) it.consume() } + + if ( + direction.reachedThreshold( + value = scale, + threshold = threshold + ) + ) { + onPinch(scale) + break + } + } + } + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/modifiers/Pressable.kt b/app/src/main/kotlin/app/vimusic/android/ui/modifiers/Pressable.kt new file mode 100644 index 0000000..fafb87c --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/modifiers/Pressable.kt @@ -0,0 +1,31 @@ +package app.vimusic.android.ui.modifiers + +import androidx.compose.foundation.Indication +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed + +fun Modifier.pressable( + onPress: () -> Unit = {}, + onCancel: () -> Unit = {}, + onRelease: () -> Unit = {}, + indication: Indication? = null +) = this.composed { + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + + LaunchedEffect(isPressed) { + if (isPressed) onPress() else onCancel() + } + + this.clickable( + interactionSource = interactionSource, + indication = indication, + onClick = onRelease + ) +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/modifiers/Swiping.kt b/app/src/main/kotlin/app/vimusic/android/ui/modifiers/Swiping.kt new file mode 100644 index 0000000..3fa7303 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/modifiers/Swiping.kt @@ -0,0 +1,246 @@ +package app.vimusic.android.ui.modifiers + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.DecayAnimationSpec +import androidx.compose.animation.core.calculateTargetValue +import androidx.compose.animation.core.spring +import androidx.compose.animation.splineBasedDecay +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.awaitHorizontalTouchSlopOrCancellation +import androidx.compose.foundation.gestures.awaitVerticalTouchSlopOrCancellation +import androidx.compose.foundation.gestures.horizontalDrag +import androidx.compose.foundation.gestures.verticalDrag +import androidx.compose.foundation.layout.offset +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange +import androidx.compose.ui.input.pointer.util.VelocityTracker +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import app.vimusic.core.ui.utils.px +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlin.time.Duration + +@Stable +@JvmInline +value class SwipeState @PublishedApi internal constructor( + private val offsetLazy: Lazy> = lazy { acquire() } +) { + internal val offset get() = offsetLazy.value + + private companion object { + private val animatables = mutableListOf>() + private val coroutineScope = CoroutineScope(Dispatchers.IO) + + fun acquire() = animatables.removeFirstOrNull() ?: Animatable(0f) + fun recycle(animatable: Animatable) { + coroutineScope.launch { + animatable.snapTo(0f) + animatables += animatable + } + } + } + + @Composable + fun calculateOffset(bounds: ClosedRange? = null) = + offset.value.px.dp.let { if (bounds == null) it else it.coerceIn(bounds) } + + @PublishedApi + internal fun recycle() = recycle(offset) +} + +@Suppress("NOTHING_TO_INLINE") +@Composable +inline fun rememberSwipeState(key: Any?): SwipeState { + val state = remember(key) { SwipeState() } + + DisposableEffect(key) { + onDispose { + state.recycle() + } + } + + return state +} + +fun Modifier.onSwipe( + state: SwipeState? = null, + key: Any = Unit, + animateOffset: Boolean = false, + orientation: Orientation = Orientation.Horizontal, + delay: Duration = Duration.ZERO, + decay: Density.() -> DecayAnimationSpec = { splineBasedDecay(this) }, + animationSpec: AnimationSpec = spring(), + bounds: ClosedRange? = null, + requireUnconsumed: Boolean = false, + onSwipeOut: suspend (animationJob: Job) -> Unit +) = onSwipe( + state = state, + key = key, + animateOffset = animateOffset, + onSwipeLeft = onSwipeOut, + onSwipeRight = onSwipeOut, + orientation = orientation, + delay = delay, + decay = decay, + animationSpec = animationSpec, + requireUnconsumed = requireUnconsumed, + bounds = bounds +) + +@Suppress("CyclomaticComplexMethod") +fun Modifier.onSwipe( + state: SwipeState? = null, + key: Any = Unit, + animateOffset: Boolean = false, + onSwipeLeft: suspend (animationJob: Job) -> Unit = { }, + onSwipeRight: suspend (animationJob: Job) -> Unit = { }, + orientation: Orientation = Orientation.Horizontal, + delay: Duration = Duration.ZERO, + decay: Density.() -> DecayAnimationSpec = { splineBasedDecay(this) }, + animationSpec: AnimationSpec = spring(), + bounds: ClosedRange? = null, + requireUnconsumed: Boolean = false +) = this.composed { + val swipeState = state ?: rememberSwipeState(key) + + pointerInput(key) { + coroutineScope { + val velocityTracker = VelocityTracker() + + // fling loop, doesn't really offset anything but simulates the animation beforehand + while (isActive) { + velocityTracker.resetTracking() + + awaitPointerEventScope { + val pointer = awaitFirstDown(requireUnconsumed = requireUnconsumed).id + launch { swipeState.offset.snapTo(0f) } + + val onDrag: (PointerInputChange) -> Unit = { + val change = + if (orientation == Orientation.Horizontal) it.positionChange().x + else it.positionChange().y + + launch { swipeState.offset.snapTo(swipeState.offset.value + change) } + + velocityTracker.addPosition(it.uptimeMillis, it.position) + if (change != 0f) it.consume() + } + + if (orientation == Orientation.Horizontal) { + awaitHorizontalTouchSlopOrCancellation(pointer) { change, _ -> onDrag(change) } + ?: return@awaitPointerEventScope + horizontalDrag(pointer, onDrag) + } else { + awaitVerticalTouchSlopOrCancellation(pointer) { change, _ -> onDrag(change) } + ?: return@awaitPointerEventScope + verticalDrag(pointer, onDrag) + } + } + + // drag completed, calculate velocity + val targetOffset = decay().calculateTargetValue( + initialValue = swipeState.offset.value, + initialVelocity = velocityTracker.calculateVelocity() + .let { if (orientation == Orientation.Horizontal) it.x else it.y } + ) + val size = if (orientation == Orientation.Horizontal) size.width else size.height + + launch animationEnd@{ + when { + targetOffset >= size / 2 -> { + val animationJob = launch { + swipeState.offset.animateTo( + targetValue = size.toFloat(), + animationSpec = animationSpec + ) + } + delay(delay) + onSwipeRight(animationJob) + } + + targetOffset <= -size / 2 -> { + val animationJob = launch { + swipeState.offset.animateTo( + targetValue = -size.toFloat(), + animationSpec = animationSpec + ) + } + delay(delay) + onSwipeLeft(animationJob) + } + } + swipeState.offset.animateTo( + targetValue = 0f, + animationSpec = animationSpec + ) + } + } + } + }.let { modifier -> + when { + animateOffset && orientation == Orientation.Horizontal -> + modifier.offset(x = swipeState.calculateOffset(bounds = bounds)) + + animateOffset && orientation == Orientation.Vertical -> + modifier.offset(y = swipeState.calculateOffset(bounds = bounds)) + + else -> modifier + } + } +} + +fun Modifier.swipeToClose( + key: Any = Unit, + state: SwipeState? = null, + delay: Duration = Duration.ZERO, + decay: Density.() -> DecayAnimationSpec = { splineBasedDecay(this) }, + requireUnconsumed: Boolean = false, + onClose: suspend (animationJob: Job) -> Unit +) = this.composed { + val swipeState = state ?: rememberSwipeState(key) + + val density = LocalDensity.current + + var currentWidth by remember { mutableIntStateOf(0) } + val currentWidthDp by remember { derivedStateOf { currentWidth.px.dp(density) } } + val bounds by remember { derivedStateOf { -currentWidthDp..0.dp } } + + this + .onSizeChanged { currentWidth = it.width } + .alpha((currentWidthDp + swipeState.calculateOffset(bounds = bounds)) / currentWidthDp) + .onSwipe( + state = swipeState, + key = key, + animateOffset = true, + onSwipeLeft = onClose, + orientation = Orientation.Horizontal, + delay = delay, + decay = decay, + requireUnconsumed = requireUnconsumed, + bounds = bounds + ) +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/Routes.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/Routes.kt new file mode 100644 index 0000000..6294a90 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/Routes.kt @@ -0,0 +1,121 @@ +package app.vimusic.android.ui.screens + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.core.net.toUri +import app.vimusic.android.Database +import app.vimusic.android.LocalPlayerServiceBinder +import app.vimusic.android.R +import app.vimusic.android.handleUrl +import app.vimusic.android.models.Mood +import app.vimusic.android.models.SearchQuery +import app.vimusic.android.preferences.DataPreferences +import app.vimusic.android.query +import app.vimusic.android.ui.screens.album.AlbumScreen +import app.vimusic.android.ui.screens.artist.ArtistScreen +import app.vimusic.android.ui.screens.pipedplaylist.PipedPlaylistScreen +import app.vimusic.android.ui.screens.playlist.PlaylistScreen +import app.vimusic.android.ui.screens.search.SearchScreen +import app.vimusic.android.ui.screens.searchresult.SearchResultScreen +import app.vimusic.android.ui.screens.settings.LogsScreen +import app.vimusic.android.ui.screens.settings.SettingsScreen +import app.vimusic.android.utils.toast +import app.vimusic.compose.routing.Route0 +import app.vimusic.compose.routing.Route1 +import app.vimusic.compose.routing.Route3 +import app.vimusic.compose.routing.Route4 +import app.vimusic.compose.routing.RouteHandlerScope +import app.vimusic.core.data.enums.BuiltInPlaylist +import io.ktor.http.Url +import java.util.UUID + +/** + * Marker class for linters that a composable is a route and should not be handled like a regular + * composable, but rather as an entrypoint. + */ +@Retention(AnnotationRetention.SOURCE) +@Target(AnnotationTarget.FUNCTION) +annotation class Route + +val albumRoute = Route1("albumRoute") +val artistRoute = Route1("artistRoute") +val builtInPlaylistRoute = Route1("builtInPlaylistRoute") +val localPlaylistRoute = Route1("localPlaylistRoute") +val logsRoute = Route0("logsRoute") +val pipedPlaylistRoute = Route3("pipedPlaylistRoute") +val playlistRoute = Route4("playlistRoute") +val moodRoute = Route1("moodRoute") +val searchResultRoute = Route1("searchResultRoute") +val searchRoute = Route1("searchRoute") +val settingsRoute = Route0("settingsRoute") + +@Composable +fun RouteHandlerScope.GlobalRoutes() { + val context = LocalContext.current + val binder = LocalPlayerServiceBinder.current + + albumRoute { browseId -> + AlbumScreen(browseId = browseId) + } + + artistRoute { browseId -> + ArtistScreen(browseId = browseId) + } + + logsRoute { + LogsScreen() + } + + pipedPlaylistRoute { apiBaseUrl, sessionToken, playlistId -> + PipedPlaylistScreen( + apiBaseUrl = runCatching { Url(apiBaseUrl) }.getOrNull() + ?: error("Invalid apiBaseUrl: $apiBaseUrl is not a valid Url"), + sessionToken = sessionToken, + playlistId = runCatching { + UUID.fromString(playlistId) + }.getOrNull() ?: error("Invalid playlistId: $playlistId is not a valid UUID") + ) + } + + playlistRoute { browseId, params, maxDepth, shouldDedup -> + PlaylistScreen( + browseId = browseId, + params = params, + maxDepth = maxDepth, + shouldDedup = shouldDedup + ) + } + + settingsRoute { + SettingsScreen() + } + + searchRoute { initialTextInput -> + SearchScreen( + initialTextInput = initialTextInput, + onSearch = { query -> + searchResultRoute(query) + + if (!DataPreferences.pauseSearchHistory) query { + Database.insert(SearchQuery(query = query)) + } + }, + onViewPlaylist = { url -> + with(context) { + runCatching { + handleUrl(url.toUri(), binder) + }.onFailure { + toast(getString(R.string.error_url, url)) + } + } + } + ) + } + + searchResultRoute { query -> + SearchResultScreen( + query = query, + onSearchAgain = { searchRoute(query) } + ) + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/album/AlbumScreen.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/album/AlbumScreen.kt new file mode 100644 index 0000000..916560a --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/album/AlbumScreen.kt @@ -0,0 +1,246 @@ +package app.vimusic.android.ui.screens.album + +import android.content.Intent +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Spacer +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import app.vimusic.android.Database +import app.vimusic.android.R +import app.vimusic.android.models.Album +import app.vimusic.android.models.Song +import app.vimusic.android.models.SongAlbumMap +import app.vimusic.android.query +import app.vimusic.android.transaction +import app.vimusic.android.ui.components.themed.Header +import app.vimusic.android.ui.components.themed.HeaderIconButton +import app.vimusic.android.ui.components.themed.HeaderPlaceholder +import app.vimusic.android.ui.components.themed.PlaylistInfo +import app.vimusic.android.ui.components.themed.Scaffold +import app.vimusic.android.ui.components.themed.adaptiveThumbnailContent +import app.vimusic.android.ui.items.AlbumItem +import app.vimusic.android.ui.items.AlbumItemPlaceholder +import app.vimusic.android.ui.screens.GlobalRoutes +import app.vimusic.android.ui.screens.Route +import app.vimusic.android.ui.screens.albumRoute +import app.vimusic.android.ui.screens.searchresult.ItemsPage +import app.vimusic.android.utils.asMediaItem +import app.vimusic.compose.persist.PersistMapCleanup +import app.vimusic.compose.persist.persist +import app.vimusic.compose.persist.persistList +import app.vimusic.compose.routing.RouteHandler +import app.vimusic.core.ui.Dimensions +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.utils.stateFlowSaver +import app.vimusic.providers.innertube.Innertube +import app.vimusic.providers.innertube.models.bodies.BrowseBody +import app.vimusic.providers.innertube.requests.albumPage +import com.valentinilk.shimmer.shimmer +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.cancellable +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext + +@Route +@Composable +fun AlbumScreen(browseId: String) { + val saveableStateHolder = rememberSaveableStateHolder() + + val tabIndexState = rememberSaveable(saver = stateFlowSaver()) { MutableStateFlow(0) } + val tabIndex by tabIndexState.collectAsState() + + var album by persist("album/$browseId/album") + var albumPage by persist("album/$browseId/albumPage") + var songs by persistList("album/$browseId/songs") + + PersistMapCleanup(prefix = "album/$browseId/") + + LaunchedEffect(Unit) { + Database + .albumSongs(browseId) + .distinctUntilChanged() + .combine( + Database + .album(browseId) + .distinctUntilChanged() + .cancellable() + ) { currentSongs, currentAlbum -> + album = currentAlbum + songs = currentSongs.toImmutableList() + + if (currentAlbum?.timestamp != null && currentSongs.isNotEmpty()) return@combine + + withContext(Dispatchers.IO) { + Innertube.albumPage(BrowseBody(browseId = browseId)) + ?.onSuccess { newAlbumPage -> + albumPage = newAlbumPage + + transaction { + Database.clearAlbum(browseId) + + Database.upsert( + album = Album( + id = browseId, + title = newAlbumPage.title, + description = newAlbumPage.description, + thumbnailUrl = newAlbumPage.thumbnail?.url, + year = newAlbumPage.year, + authorsText = newAlbumPage.authors + ?.joinToString("") { it.name.orEmpty() }, + shareUrl = newAlbumPage.url, + timestamp = System.currentTimeMillis(), + bookmarkedAt = album?.bookmarkedAt, + otherInfo = newAlbumPage.otherInfo + ), + songAlbumMaps = newAlbumPage + .songsPage + ?.items + ?.map { it.asMediaItem } + ?.onEach { Database.insert(it) } + ?.mapIndexed { position, mediaItem -> + SongAlbumMap( + songId = mediaItem.mediaId, + albumId = browseId, + position = position + ) + } ?: emptyList() + ) + } + }?.exceptionOrNull()?.printStackTrace() + } + }.collect() + } + + RouteHandler { + GlobalRoutes() + + Content { + val headerContent: @Composable ( + beforeContent: (@Composable () -> Unit)?, + afterContent: (@Composable () -> Unit)? + ) -> Unit = { beforeContent, afterContent -> + if (album?.timestamp == null) HeaderPlaceholder(modifier = Modifier.shimmer()) + else { + val (colorPalette) = LocalAppearance.current + val context = LocalContext.current + + Header(title = album?.title ?: stringResource(R.string.unknown)) { + beforeContent?.invoke() + + Spacer(modifier = Modifier.weight(1f)) + + afterContent?.invoke() + + HeaderIconButton( + icon = if (album?.bookmarkedAt == null) R.drawable.bookmark_outline + else R.drawable.bookmark, + color = colorPalette.accent, + onClick = { + val bookmarkedAt = + if (album?.bookmarkedAt == null) System.currentTimeMillis() else null + + query { + album + ?.copy(bookmarkedAt = bookmarkedAt) + ?.let(Database::update) + } + } + ) + + HeaderIconButton( + icon = R.drawable.share_social, + color = colorPalette.text, + onClick = { + album?.shareUrl?.let { url -> + val sendIntent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, url) + } + + context.startActivity( + Intent.createChooser(sendIntent, null) + ) + } + } + ) + } + } + } + + val thumbnailContent = adaptiveThumbnailContent( + isLoading = album?.timestamp == null, + url = album?.thumbnailUrl + ) + + Scaffold( + key = "album", + topIconButtonId = R.drawable.chevron_back, + onTopIconButtonClick = pop, + tabIndex = tabIndex, + onTabChange = { newTab -> tabIndexState.update { newTab } }, + tabColumnContent = { + tab(0, R.string.songs, R.drawable.musical_notes, canHide = false) + tab(1, R.string.other_versions, R.drawable.disc) + } + ) { currentTabIndex -> + saveableStateHolder.SaveableStateProvider(key = currentTabIndex) { + when (currentTabIndex) { + 0 -> AlbumSongs( + songs = songs, + headerContent = headerContent, + thumbnailContent = thumbnailContent, + afterHeaderContent = { + if (album == null) PlaylistInfo(playlist = albumPage) + else PlaylistInfo(playlist = album) + } + ) + + 1 -> { + ItemsPage( + tag = "album/$browseId/alternatives", + header = headerContent, + initialPlaceholderCount = 1, + continuationPlaceholderCount = 1, + emptyItemsText = stringResource(R.string.no_alternative_version), + provider = albumPage?.let { + { + Result.success( + Innertube.ItemsPage( + items = albumPage?.otherVersions, + continuation = null + ) + ) + } + }, + itemContent = { album -> + AlbumItem( + album = album, + thumbnailSize = Dimensions.thumbnails.album, + modifier = Modifier.clickable { albumRoute(album.key) } + ) + }, + itemPlaceholderContent = { + AlbumItemPlaceholder(thumbnailSize = Dimensions.thumbnails.album) + } + ) + } + } + } + } + } + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/album/AlbumSongs.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/album/AlbumSongs.kt new file mode 100644 index 0000000..6f3bf69 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/album/AlbumSongs.kt @@ -0,0 +1,150 @@ +package app.vimusic.android.ui.screens.album + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import app.vimusic.android.LocalPlayerAwareWindowInsets +import app.vimusic.android.LocalPlayerServiceBinder +import app.vimusic.android.R +import app.vimusic.android.models.Song +import app.vimusic.android.ui.components.LocalMenuState +import app.vimusic.android.ui.components.ShimmerHost +import app.vimusic.android.ui.components.themed.FloatingActionsContainerWithScrollToTop +import app.vimusic.android.ui.components.themed.LayoutWithAdaptiveThumbnail +import app.vimusic.android.ui.components.themed.NonQueuedMediaItemMenu +import app.vimusic.android.ui.components.themed.SecondaryTextButton +import app.vimusic.android.ui.items.SongItem +import app.vimusic.android.ui.items.SongItemPlaceholder +import app.vimusic.android.utils.PlaylistDownloadIcon +import app.vimusic.android.utils.asMediaItem +import app.vimusic.android.utils.enqueue +import app.vimusic.android.utils.forcePlayAtIndex +import app.vimusic.android.utils.forcePlayFromBeginning +import app.vimusic.core.ui.Dimensions +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.utils.isLandscape +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +// TODO: migrate to single impl for all 'song lists' +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun AlbumSongs( + songs: ImmutableList, + headerContent: @Composable ( + beforeContent: (@Composable () -> Unit)?, + afterContent: (@Composable () -> Unit)? + ) -> Unit, + thumbnailContent: @Composable () -> Unit, + modifier: Modifier = Modifier, + afterHeaderContent: (@Composable () -> Unit)? = null +) = LayoutWithAdaptiveThumbnail( + thumbnailContent = thumbnailContent, + modifier = modifier +) { + val (colorPalette) = LocalAppearance.current + val binder = LocalPlayerServiceBinder.current + val menuState = LocalMenuState.current + val lazyListState = rememberLazyListState() + + Box { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) + .asPaddingValues(), + modifier = Modifier + .background(colorPalette.background0) + .fillMaxSize() + ) { + item( + key = "header", + contentType = 0 + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + headerContent( + { + SecondaryTextButton( + text = stringResource(R.string.enqueue), + enabled = songs.isNotEmpty(), + onClick = { + binder?.player?.enqueue(songs.map(Song::asMediaItem)) + } + ) + }, + { + PlaylistDownloadIcon( + songs = songs.map(Song::asMediaItem).toImmutableList() + ) + } + ) + + if (!isLandscape) thumbnailContent() + afterHeaderContent?.invoke() + } + } + + itemsIndexed( + items = songs, + key = { _, song -> song.id } + ) { index, song -> + SongItem( + song = song, + index = index, + thumbnailSize = Dimensions.thumbnails.song, + modifier = Modifier.combinedClickable( + onLongClick = { + menuState.display { + NonQueuedMediaItemMenu( + onDismiss = menuState::hide, + mediaItem = song.asMediaItem + ) + } + }, + onClick = { + binder?.stopRadio() + binder?.player?.forcePlayAtIndex( + items = songs.map(Song::asMediaItem), + index = index + ) + } + ) + ) + } + + if (songs.isEmpty()) item(key = "loading") { + ShimmerHost(modifier = Modifier.fillParentMaxSize()) { + repeat(4) { + SongItemPlaceholder(thumbnailSize = Dimensions.thumbnails.song) + } + } + } + } + + FloatingActionsContainerWithScrollToTop( + lazyListState = lazyListState, + icon = R.drawable.shuffle, + onClick = { + if (songs.isNotEmpty()) { + binder?.stopRadio() + binder?.player?.forcePlayFromBeginning( + songs.shuffled().map(Song::asMediaItem) + ) + } + } + ) + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistLocalSongs.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/artist/ArtistLocalSongs.kt similarity index 55% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistLocalSongs.kt rename to app/src/main/kotlin/app/vimusic/android/ui/screens/artist/ArtistLocalSongs.kt index aad59e7..6500506 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistLocalSongs.kt +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/artist/ArtistLocalSongs.kt @@ -1,6 +1,5 @@ -package it.vfsfitvnm.vimusic.ui.screens.artist +package app.vimusic.android.ui.screens.artist -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable @@ -19,35 +18,36 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import it.vfsfitvnm.compose.persist.persist -import it.vfsfitvnm.vimusic.Database -import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets -import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.models.Song -import it.vfsfitvnm.vimusic.ui.components.LocalMenuState -import it.vfsfitvnm.vimusic.ui.components.ShimmerHost -import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop -import it.vfsfitvnm.vimusic.ui.components.themed.LayoutWithAdaptiveThumbnail -import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu -import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton -import it.vfsfitvnm.vimusic.ui.items.SongItem -import it.vfsfitvnm.vimusic.ui.items.SongItemPlaceholder -import it.vfsfitvnm.vimusic.ui.styling.Dimensions -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.px -import it.vfsfitvnm.vimusic.utils.asMediaItem -import it.vfsfitvnm.vimusic.utils.enqueue -import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex -import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning +import androidx.compose.ui.res.stringResource +import app.vimusic.android.Database +import app.vimusic.android.LocalPlayerAwareWindowInsets +import app.vimusic.android.LocalPlayerServiceBinder +import app.vimusic.android.R +import app.vimusic.android.models.Song +import app.vimusic.android.ui.components.LocalMenuState +import app.vimusic.android.ui.components.ShimmerHost +import app.vimusic.android.ui.components.themed.FloatingActionsContainerWithScrollToTop +import app.vimusic.android.ui.components.themed.LayoutWithAdaptiveThumbnail +import app.vimusic.android.ui.components.themed.NonQueuedMediaItemMenu +import app.vimusic.android.ui.components.themed.SecondaryTextButton +import app.vimusic.android.ui.items.SongItem +import app.vimusic.android.ui.items.SongItemPlaceholder +import app.vimusic.android.utils.asMediaItem +import app.vimusic.android.utils.enqueue +import app.vimusic.android.utils.forcePlayAtIndex +import app.vimusic.android.utils.forcePlayFromBeginning +import app.vimusic.compose.persist.persist +import app.vimusic.core.ui.Dimensions +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.utils.isLandscape -@ExperimentalFoundationApi -@ExperimentalAnimationApi +@OptIn(ExperimentalFoundationApi::class) @Composable fun ArtistLocalSongs( browseId: String, headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit, thumbnailContent: @Composable () -> Unit, + modifier: Modifier = Modifier ) { val binder = LocalPlayerServiceBinder.current val (colorPalette) = LocalAppearance.current @@ -59,17 +59,17 @@ fun ArtistLocalSongs( Database.artistSongs(browseId).collect { songs = it } } - val songThumbnailSizeDp = Dimensions.thumbnails.song - val songThumbnailSizePx = songThumbnailSizeDp.px - val lazyListState = rememberLazyListState() - LayoutWithAdaptiveThumbnail(thumbnailContent = thumbnailContent) { + LayoutWithAdaptiveThumbnail( + thumbnailContent = thumbnailContent, + modifier = modifier + ) { Box { LazyColumn( state = lazyListState, contentPadding = LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), modifier = Modifier .background(colorPalette.background0) .fillMaxSize() @@ -81,7 +81,7 @@ fun ArtistLocalSongs( Column(horizontalAlignment = Alignment.CenterHorizontally) { headerContent { SecondaryTextButton( - text = "Enqueue", + text = stringResource(R.string.enqueue), enabled = !songs.isNullOrEmpty(), onClick = { binder?.player?.enqueue(songs!!.map(Song::asMediaItem)) @@ -89,7 +89,7 @@ fun ArtistLocalSongs( ) } - thumbnailContent() + if (!isLandscape) thumbnailContent() } } @@ -99,33 +99,31 @@ fun ArtistLocalSongs( key = { _, song -> song.id } ) { index, song -> SongItem( - song = song, - thumbnailSizeDp = songThumbnailSizeDp, - thumbnailSizePx = songThumbnailSizePx, - modifier = Modifier - .combinedClickable( - onLongClick = { - menuState.display { - NonQueuedMediaItemMenu( - onDismiss = menuState::hide, - mediaItem = song.asMediaItem, - ) - } - }, - onClick = { - binder?.stopRadio() - binder?.player?.forcePlayAtIndex( - songs.map(Song::asMediaItem), - index + modifier = Modifier.combinedClickable( + onLongClick = { + menuState.display { + NonQueuedMediaItemMenu( + onDismiss = menuState::hide, + mediaItem = song.asMediaItem ) } - ) + }, + onClick = { + binder?.stopRadio() + binder?.player?.forcePlayAtIndex( + items = songs.map(Song::asMediaItem), + index = index + ) + } + ), + song = song, + thumbnailSize = Dimensions.thumbnails.song ) } } ?: item(key = "loading") { ShimmerHost { repeat(4) { - SongItemPlaceholder(thumbnailSizeDp = Dimensions.thumbnails.song) + SongItemPlaceholder(thumbnailSize = Dimensions.thumbnails.song) } } } @@ -133,7 +131,7 @@ fun ArtistLocalSongs( FloatingActionsContainerWithScrollToTop( lazyListState = lazyListState, - iconId = R.drawable.shuffle, + icon = R.drawable.shuffle, onClick = { songs?.let { songs -> if (songs.isNotEmpty()) { diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/artist/ArtistOverview.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/artist/ArtistOverview.kt new file mode 100644 index 0000000..c6a63ef --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/artist/ArtistOverview.kt @@ -0,0 +1,300 @@ +package app.vimusic.android.ui.screens.artist + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import app.vimusic.android.LocalPlayerAwareWindowInsets +import app.vimusic.android.LocalPlayerServiceBinder +import app.vimusic.android.R +import app.vimusic.android.ui.components.LocalMenuState +import app.vimusic.android.ui.components.ShimmerHost +import app.vimusic.android.ui.components.themed.Attribution +import app.vimusic.android.ui.components.themed.FloatingActionsContainerWithScrollToTop +import app.vimusic.android.ui.components.themed.LayoutWithAdaptiveThumbnail +import app.vimusic.android.ui.components.themed.NonQueuedMediaItemMenu +import app.vimusic.android.ui.components.themed.SecondaryTextButton +import app.vimusic.android.ui.components.themed.TextPlaceholder +import app.vimusic.android.ui.items.AlbumItem +import app.vimusic.android.ui.items.AlbumItemPlaceholder +import app.vimusic.android.ui.items.SongItem +import app.vimusic.android.ui.items.SongItemPlaceholder +import app.vimusic.android.utils.asMediaItem +import app.vimusic.android.utils.forcePlay +import app.vimusic.android.utils.medium +import app.vimusic.android.utils.secondary +import app.vimusic.android.utils.semiBold +import app.vimusic.core.ui.Dimensions +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.utils.isLandscape +import app.vimusic.providers.innertube.Innertube +import app.vimusic.providers.innertube.models.NavigationEndpoint + +private val sectionTextModifier = Modifier + .padding(horizontal = 16.dp) + .padding(top = 24.dp, bottom = 8.dp) + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ArtistOverview( + youtubeArtistPage: Innertube.ArtistPage?, + onViewAllSongsClick: () -> Unit, + onViewAllAlbumsClick: () -> Unit, + onViewAllSinglesClick: () -> Unit, + onAlbumClick: (String) -> Unit, + thumbnailContent: @Composable () -> Unit, + headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit, + modifier: Modifier = Modifier +) = LayoutWithAdaptiveThumbnail( + thumbnailContent = thumbnailContent, + modifier = modifier +) { + val (colorPalette, typography) = LocalAppearance.current + val binder = LocalPlayerServiceBinder.current + val menuState = LocalMenuState.current + val windowInsets = LocalPlayerAwareWindowInsets.current + + val endPaddingValues = windowInsets.only(WindowInsetsSides.End).asPaddingValues() + + val scrollState = rememberScrollState() + + Box { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .background(colorPalette.background0) + .fillMaxSize() + .verticalScroll(scrollState) + .padding( + windowInsets + .only(WindowInsetsSides.Vertical) + .asPaddingValues() + ) + ) { + Box(modifier = Modifier.padding(endPaddingValues)) { + headerContent { + youtubeArtistPage?.shuffleEndpoint?.let { endpoint -> + SecondaryTextButton( + text = stringResource(R.string.shuffle), + onClick = { + binder?.stopRadio() + binder?.playRadio(endpoint) + } + ) + } + youtubeArtistPage?.subscribersCountText?.let { subscribers -> + BasicText( + text = stringResource(R.string.format_subscribers, subscribers), + style = typography.xxs.medium + ) + } + } + } + + if (!isLandscape) thumbnailContent() + + youtubeArtistPage?.let { artist -> + artist.songs?.let { songs -> + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxSize() + .padding(endPaddingValues) + ) { + BasicText( + text = stringResource(R.string.songs), + style = typography.m.semiBold, + modifier = sectionTextModifier + ) + + artist.songsEndpoint?.let { + BasicText( + text = stringResource(R.string.view_all), + style = typography.xs.secondary, + modifier = sectionTextModifier.clickable(onClick = onViewAllSongsClick) + ) + } + } + + songs.forEach { song -> + SongItem( + song = song, + thumbnailSize = Dimensions.thumbnails.song, + modifier = Modifier + .combinedClickable( + onLongClick = { + menuState.display { + NonQueuedMediaItemMenu( + onDismiss = menuState::hide, + mediaItem = song.asMediaItem + ) + } + }, + onClick = { + val mediaItem = song.asMediaItem + binder?.stopRadio() + binder?.player?.forcePlay(mediaItem) + binder?.setupRadio( + NavigationEndpoint.Endpoint.Watch(videoId = mediaItem.mediaId) + ) + } + ) + .padding(endPaddingValues) + ) + } + } + + artist.albums?.let { albums -> + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxSize() + .padding(endPaddingValues) + ) { + BasicText( + text = stringResource(R.string.albums), + style = typography.m.semiBold, + modifier = sectionTextModifier + ) + + artist.albumsEndpoint?.let { + BasicText( + text = stringResource(R.string.view_all), + style = typography.xs.secondary, + modifier = sectionTextModifier.clickable(onClick = onViewAllAlbumsClick) + ) + } + } + + LazyRow( + contentPadding = endPaddingValues, + modifier = Modifier.fillMaxWidth() + ) { + items( + items = albums, + key = Innertube.AlbumItem::key + ) { album -> + AlbumItem( + album = album, + thumbnailSize = Dimensions.thumbnails.album, + alternative = true, + modifier = Modifier.clickable { + onAlbumClick(album.key) + } + ) + } + } + } + + artist.singles?.let { singles -> + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxSize() + .padding(endPaddingValues) + ) { + BasicText( + text = stringResource(R.string.singles), + style = typography.m.semiBold, + modifier = sectionTextModifier + ) + + artist.singlesEndpoint?.let { + BasicText( + text = stringResource(R.string.view_all), + style = typography.xs.secondary, + modifier = sectionTextModifier.clickable(onClick = onViewAllSinglesClick) + ) + } + } + + LazyRow( + contentPadding = endPaddingValues, + modifier = Modifier.fillMaxWidth() + ) { + items( + items = singles, + key = Innertube.AlbumItem::key + ) { album -> + AlbumItem( + album = album, + thumbnailSize = Dimensions.thumbnails.album, + alternative = true, + modifier = Modifier.clickable(onClick = { onAlbumClick(album.key) }) + ) + } + } + } + + artist.description?.let { description -> + Attribution( + text = description, + modifier = Modifier + .padding(top = 16.dp) + .padding(vertical = 16.dp, horizontal = 8.dp) + ) + } + + Unit + } ?: ArtistOverviewBodyPlaceholder() + } + + youtubeArtistPage?.radioEndpoint?.let { endpoint -> + FloatingActionsContainerWithScrollToTop( + scrollState = scrollState, + icon = R.drawable.radio, + onClick = { + binder?.stopRadio() + binder?.playRadio(endpoint) + } + ) + } + } +} + +@Composable +fun ArtistOverviewBodyPlaceholder(modifier: Modifier = Modifier) = ShimmerHost( + modifier = modifier +) { + TextPlaceholder(modifier = sectionTextModifier) + + repeat(5) { + SongItemPlaceholder(thumbnailSize = Dimensions.thumbnails.song) + } + + repeat(2) { + TextPlaceholder(modifier = sectionTextModifier) + + Row { + repeat(2) { + AlbumItemPlaceholder( + thumbnailSize = Dimensions.thumbnails.album, + alternative = true + ) + } + } + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/artist/ArtistScreen.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/artist/ArtistScreen.kt new file mode 100644 index 0000000..b8af9df --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/artist/ArtistScreen.kt @@ -0,0 +1,343 @@ +package app.vimusic.android.ui.screens.artist + +import android.content.Intent +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import app.vimusic.android.Database +import app.vimusic.android.LocalPlayerServiceBinder +import app.vimusic.android.R +import app.vimusic.android.models.Artist +import app.vimusic.android.preferences.UIStatePreferences +import app.vimusic.android.preferences.UIStatePreferences.artistScreenTabIndexProperty +import app.vimusic.android.query +import app.vimusic.android.ui.components.LocalMenuState +import app.vimusic.android.ui.components.themed.Header +import app.vimusic.android.ui.components.themed.HeaderIconButton +import app.vimusic.android.ui.components.themed.HeaderPlaceholder +import app.vimusic.android.ui.components.themed.NonQueuedMediaItemMenu +import app.vimusic.android.ui.components.themed.Scaffold +import app.vimusic.android.ui.components.themed.adaptiveThumbnailContent +import app.vimusic.android.ui.items.AlbumItem +import app.vimusic.android.ui.items.AlbumItemPlaceholder +import app.vimusic.android.ui.items.SongItem +import app.vimusic.android.ui.items.SongItemPlaceholder +import app.vimusic.android.ui.screens.GlobalRoutes +import app.vimusic.android.ui.screens.Route +import app.vimusic.android.ui.screens.albumRoute +import app.vimusic.android.ui.screens.searchresult.ItemsPage +import app.vimusic.android.utils.asMediaItem +import app.vimusic.android.utils.forcePlay +import app.vimusic.compose.persist.PersistMapCleanup +import app.vimusic.compose.persist.persist +import app.vimusic.compose.routing.RouteHandler +import app.vimusic.core.ui.Dimensions +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.providers.innertube.Innertube +import app.vimusic.providers.innertube.models.bodies.BrowseBody +import app.vimusic.providers.innertube.models.bodies.ContinuationBody +import app.vimusic.providers.innertube.requests.artistPage +import app.vimusic.providers.innertube.requests.itemsPage +import app.vimusic.providers.innertube.utils.from +import com.valentinilk.shimmer.shimmer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalFoundationApi::class) +@Route +@Composable +fun ArtistScreen(browseId: String) { + val binder = LocalPlayerServiceBinder.current + val menuState = LocalMenuState.current + + val saveableStateHolder = rememberSaveableStateHolder() + + PersistMapCleanup(prefix = "artist/$browseId/") + + var artist by persist("artist/$browseId/artist") + + var artistPage by persist("artist/$browseId/artistPage") + + LaunchedEffect(Unit) { + Database + .artist(browseId) + .combine( + flow = artistScreenTabIndexProperty.stateFlow.map { it != 4 }, + transform = ::Pair + ) + .distinctUntilChanged() + .collect { (currentArtist, mustFetch) -> + artist = currentArtist + + if (artistPage == null && (currentArtist?.timestamp == null || mustFetch)) + withContext(Dispatchers.IO) { + Innertube.artistPage(BrowseBody(browseId = browseId)) + ?.onSuccess { currentArtistPage -> + artistPage = currentArtistPage + + Database.upsert( + Artist( + id = browseId, + name = currentArtistPage.name, + thumbnailUrl = currentArtistPage.thumbnail?.url, + timestamp = System.currentTimeMillis(), + bookmarkedAt = currentArtist?.bookmarkedAt + ) + ) + } + } + } + } + + RouteHandler { + GlobalRoutes() + + Content { + val thumbnailContent = adaptiveThumbnailContent( + isLoading = artist?.timestamp == null, + url = artist?.thumbnailUrl, + shape = CircleShape + ) + + val headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit = + { textButton -> + if (artist?.timestamp == null) HeaderPlaceholder(modifier = Modifier.shimmer()) else { + val (colorPalette) = LocalAppearance.current + val context = LocalContext.current + + Header(title = artist?.name ?: stringResource(R.string.unknown)) { + textButton?.invoke() + + Spacer(modifier = Modifier.weight(1f)) + + HeaderIconButton( + icon = if (artist?.bookmarkedAt == null) R.drawable.bookmark_outline + else R.drawable.bookmark, + color = colorPalette.accent, + onClick = { + val bookmarkedAt = + if (artist?.bookmarkedAt == null) System.currentTimeMillis() else null + + query { + artist + ?.copy(bookmarkedAt = bookmarkedAt) + ?.let(Database::update) + } + } + ) + + HeaderIconButton( + icon = R.drawable.share_social, + color = colorPalette.text, + onClick = { + val sendIntent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra( + Intent.EXTRA_TEXT, + "https://music.youtube.com/channel/$browseId" + ) + } + + context.startActivity(Intent.createChooser(sendIntent, null)) + } + ) + } + } + } + + Scaffold( + key = "artist", + topIconButtonId = R.drawable.chevron_back, + onTopIconButtonClick = pop, + tabIndex = UIStatePreferences.artistScreenTabIndex, + onTabChange = { UIStatePreferences.artistScreenTabIndex = it }, + tabColumnContent = { + tab(0, R.string.overview, R.drawable.sparkles) + tab(1, R.string.songs, R.drawable.musical_notes) + tab(2, R.string.albums, R.drawable.disc) + tab(3, R.string.singles, R.drawable.disc) + tab(4, R.string.library, R.drawable.library) + } + ) { currentTabIndex -> + saveableStateHolder.SaveableStateProvider(key = currentTabIndex) { + when (currentTabIndex) { + 0 -> ArtistOverview( + youtubeArtistPage = artistPage, + thumbnailContent = thumbnailContent, + headerContent = headerContent, + onAlbumClick = { albumRoute(it) }, + onViewAllSongsClick = { UIStatePreferences.artistScreenTabIndex = 1 }, + onViewAllAlbumsClick = { UIStatePreferences.artistScreenTabIndex = 2 }, + onViewAllSinglesClick = { UIStatePreferences.artistScreenTabIndex = 3 } + ) + + 1 -> ItemsPage( + tag = "artist/$browseId/songs", + header = headerContent, + provider = artistPage?.let { + @Suppress("SpacingAroundCurly") + { continuation -> + continuation?.let { + Innertube.itemsPage( + body = ContinuationBody(continuation = continuation), + fromMusicResponsiveListItemRenderer = Innertube.SongItem::from + ) + } ?: artistPage + ?.songsEndpoint + ?.takeIf { it.browseId != null } + ?.let { endpoint -> + Innertube.itemsPage( + body = BrowseBody( + browseId = endpoint.browseId!!, + params = endpoint.params + ), + fromMusicResponsiveListItemRenderer = Innertube.SongItem::from + ) + } + ?: Result.success( + Innertube.ItemsPage( + items = artistPage?.songs, + continuation = null + ) + ) + } + }, + itemContent = { song -> + SongItem( + song = song, + thumbnailSize = Dimensions.thumbnails.song, + modifier = Modifier.combinedClickable( + onLongClick = { + menuState.display { + NonQueuedMediaItemMenu( + onDismiss = menuState::hide, + mediaItem = song.asMediaItem + ) + } + }, + onClick = { + binder?.stopRadio() + binder?.player?.forcePlay(song.asMediaItem) + binder?.setupRadio(song.info?.endpoint) + } + ) + ) + }, + itemPlaceholderContent = { + SongItemPlaceholder(thumbnailSize = Dimensions.thumbnails.song) + } + ) + + 2 -> ItemsPage( + tag = "artist/$browseId/albums", + header = headerContent, + emptyItemsText = stringResource(R.string.artist_has_no_albums), + provider = artistPage?.let { + @Suppress("SpacingAroundCurly") + { continuation -> + continuation?.let { + Innertube.itemsPage( + body = ContinuationBody(continuation = continuation), + fromMusicTwoRowItemRenderer = Innertube.AlbumItem::from + ) + } ?: artistPage + ?.albumsEndpoint + ?.takeIf { it.browseId != null } + ?.let { endpoint -> + Innertube.itemsPage( + body = BrowseBody( + browseId = endpoint.browseId!!, + params = endpoint.params + ), + fromMusicTwoRowItemRenderer = Innertube.AlbumItem::from + ) + } + ?: Result.success( + Innertube.ItemsPage( + items = artistPage?.albums, + continuation = null + ) + ) + } + }, + itemContent = { album -> + AlbumItem( + album = album, + thumbnailSize = Dimensions.thumbnails.album, + modifier = Modifier.clickable(onClick = { albumRoute(album.key) }) + ) + }, + itemPlaceholderContent = { + AlbumItemPlaceholder(thumbnailSize = Dimensions.thumbnails.album) + } + ) + + 3 -> ItemsPage( + tag = "artist/$browseId/singles", + header = headerContent, + emptyItemsText = stringResource(R.string.artist_has_no_singles), + provider = artistPage?.let { + @Suppress("SpacingAroundCurly") + { continuation -> + continuation?.let { + Innertube.itemsPage( + body = ContinuationBody(continuation = continuation), + fromMusicTwoRowItemRenderer = Innertube.AlbumItem::from + ) + } ?: artistPage + ?.singlesEndpoint + ?.takeIf { it.browseId != null } + ?.let { endpoint -> + Innertube.itemsPage( + body = BrowseBody( + browseId = endpoint.browseId!!, + params = endpoint.params + ), + fromMusicTwoRowItemRenderer = Innertube.AlbumItem::from + ) + } + ?: Result.success( + Innertube.ItemsPage( + items = artistPage?.singles, + continuation = null + ) + ) + } + }, + itemContent = { album -> + AlbumItem( + album = album, + thumbnailSize = Dimensions.thumbnails.album, + modifier = Modifier.clickable(onClick = { albumRoute(album.key) }) + ) + }, + itemPlaceholderContent = { + AlbumItemPlaceholder(thumbnailSize = Dimensions.thumbnails.album) + } + ) + + 4 -> ArtistLocalSongs( + browseId = browseId, + headerContent = headerContent, + thumbnailContent = thumbnailContent + ) + } + } + } + } + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/builtinplaylist/BuiltInPlaylistScreen.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/builtinplaylist/BuiltInPlaylistScreen.kt new file mode 100644 index 0000000..e194879 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/builtinplaylist/BuiltInPlaylistScreen.kt @@ -0,0 +1,53 @@ +package app.vimusic.android.ui.screens.builtinplaylist + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.ui.res.stringResource +import app.vimusic.android.R +import app.vimusic.android.preferences.DataPreferences +import app.vimusic.android.ui.components.themed.Scaffold +import app.vimusic.android.ui.screens.GlobalRoutes +import app.vimusic.android.ui.screens.Route +import app.vimusic.compose.persist.PersistMapCleanup +import app.vimusic.compose.routing.RouteHandler +import app.vimusic.core.data.enums.BuiltInPlaylist + +@Route +@Composable +fun BuiltInPlaylistScreen(builtInPlaylist: BuiltInPlaylist) { + val saveableStateHolder = rememberSaveableStateHolder() + val (tabIndex, onTabIndexChanged) = rememberSaveable { mutableIntStateOf(builtInPlaylist.ordinal) } + + PersistMapCleanup(prefix = "${builtInPlaylist.name}/") + + RouteHandler { + GlobalRoutes() + + Content { + val topTabTitle = stringResource(R.string.format_top_playlist, DataPreferences.topListLength) + + Scaffold( + key = "builtinplaylist", + topIconButtonId = R.drawable.chevron_back, + onTopIconButtonClick = pop, + tabIndex = tabIndex, + onTabChange = onTabIndexChanged, + tabColumnContent = { + tab(0, R.string.favorites, R.drawable.heart) + tab(1, R.string.offline, R.drawable.airplane) + tab(2, topTabTitle, R.drawable.trending_up) + tab(3, R.string.history, R.drawable.history) + } + ) { currentTabIndex -> + saveableStateHolder.SaveableStateProvider(key = currentTabIndex) { + BuiltInPlaylist + .entries + .getOrNull(currentTabIndex) + ?.let { BuiltInPlaylistSongs(builtInPlaylist = it) } + } + } + } + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/builtinplaylist/BuiltInPlaylistSongs.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/builtinplaylist/BuiltInPlaylistSongs.kt new file mode 100644 index 0000000..d4910ac --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/builtinplaylist/BuiltInPlaylistSongs.kt @@ -0,0 +1,239 @@ +package app.vimusic.android.ui.screens.builtinplaylist + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import app.vimusic.android.Database +import app.vimusic.android.LocalPlayerAwareWindowInsets +import app.vimusic.android.LocalPlayerServiceBinder +import app.vimusic.android.R +import app.vimusic.android.models.Song +import app.vimusic.android.preferences.DataPreferences +import app.vimusic.android.ui.components.LocalMenuState +import app.vimusic.android.ui.components.themed.FloatingActionsContainerWithScrollToTop +import app.vimusic.android.ui.components.themed.Header +import app.vimusic.android.ui.components.themed.InHistoryMediaItemMenu +import app.vimusic.android.ui.components.themed.NonQueuedMediaItemMenu +import app.vimusic.android.ui.components.themed.SecondaryTextButton +import app.vimusic.android.ui.components.themed.ValueSelectorDialog +import app.vimusic.android.ui.items.SongItem +import app.vimusic.android.ui.screens.home.HeaderSongSortBy +import app.vimusic.android.utils.PlaylistDownloadIcon +import app.vimusic.android.utils.asMediaItem +import app.vimusic.android.utils.enqueue +import app.vimusic.android.utils.forcePlayAtIndex +import app.vimusic.android.utils.forcePlayFromBeginning +import app.vimusic.compose.persist.persistList +import app.vimusic.core.data.enums.BuiltInPlaylist +import app.vimusic.core.data.enums.SongSortBy +import app.vimusic.core.data.enums.SortOrder +import app.vimusic.core.ui.Dimensions +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.utils.enumSaver +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.cancellable +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map + +@OptIn(ExperimentalFoundationApi::class, ExperimentalCoroutinesApi::class) +@Composable +fun BuiltInPlaylistSongs( + builtInPlaylist: BuiltInPlaylist, + modifier: Modifier = Modifier +) = with(DataPreferences) { + val (colorPalette) = LocalAppearance.current + val binder = LocalPlayerServiceBinder.current + val menuState = LocalMenuState.current + + var songs by persistList("${builtInPlaylist.name}/songs") + + var sortBy by rememberSaveable(stateSaver = enumSaver()) { mutableStateOf(SongSortBy.DateAdded) } + var sortOrder by rememberSaveable(stateSaver = enumSaver()) { mutableStateOf(SortOrder.Descending) } + + LaunchedEffect(binder, sortBy, sortOrder) { + when (builtInPlaylist) { + BuiltInPlaylist.Favorites -> Database.favorites( + sortBy = sortBy, + sortOrder = sortOrder + ) + + BuiltInPlaylist.Offline -> + Database + .songsWithContentLength( + sortBy = sortBy, + sortOrder = sortOrder + ) + .map { songs -> + songs.filter { binder?.isCached(it) ?: false }.map { it.song } + } + + BuiltInPlaylist.Top -> combine( + flow = topListPeriodProperty.stateFlow, + flow2 = topListLengthProperty.stateFlow + ) { period, length -> period to length }.flatMapLatest { (period, length) -> + if (period.duration == null) Database + .songsByPlayTimeDesc(limit = length) + .distinctUntilChanged() + .cancellable() + else Database + .trending( + limit = length, + period = period.duration.inWholeMilliseconds + ) + .distinctUntilChanged() + .cancellable() + } + + BuiltInPlaylist.History -> Database.history() + }.collect { songs = it.toImmutableList() } + } + + val lazyListState = rememberLazyListState() + + Box(modifier = modifier) { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) + .asPaddingValues(), + modifier = Modifier + .background(colorPalette.background0) + .fillMaxSize() + ) { + item( + key = "header", + contentType = 0 + ) { + Header( + title = when (builtInPlaylist) { + BuiltInPlaylist.Favorites -> stringResource(R.string.favorites) + BuiltInPlaylist.Offline -> stringResource(R.string.offline) + BuiltInPlaylist.Top -> stringResource( + R.string.format_my_top_playlist, + topListLength + ) + + BuiltInPlaylist.History -> stringResource(R.string.history) + }, + modifier = Modifier.padding(bottom = 8.dp) + ) { + SecondaryTextButton( + text = stringResource(R.string.enqueue), + enabled = songs.isNotEmpty(), + onClick = { + binder?.player?.enqueue(songs.map(Song::asMediaItem)) + } + ) + + Spacer(modifier = Modifier.weight(1f)) + + if (builtInPlaylist != BuiltInPlaylist.Offline) PlaylistDownloadIcon( + songs = songs.map(Song::asMediaItem).toImmutableList() + ) + + if (builtInPlaylist.sortable) HeaderSongSortBy( + sortBy = sortBy, + setSortBy = { sortBy = it }, + sortOrder = sortOrder, + setSortOrder = { sortOrder = it } + ) + + if (builtInPlaylist == BuiltInPlaylist.Top) { + var dialogShowing by rememberSaveable { mutableStateOf(false) } + + SecondaryTextButton( + text = topListPeriod.displayName(), + onClick = { dialogShowing = true } + ) + + if (dialogShowing) ValueSelectorDialog( + onDismiss = { dialogShowing = false }, + title = stringResource( + R.string.format_view_top_of_header, + topListLength + ), + selectedValue = topListPeriod, + values = DataPreferences.TopListPeriod.entries.toImmutableList(), + onValueSelect = { topListPeriod = it }, + valueText = { it.displayName() } + ) + } + } + } + + itemsIndexed( + items = songs, + key = { _, song -> song.id }, + contentType = { _, song -> song } + ) { index, song -> + SongItem( + modifier = Modifier + .combinedClickable( + onLongClick = { + menuState.display { + when (builtInPlaylist) { + BuiltInPlaylist.Offline -> InHistoryMediaItemMenu( + song = song, + onDismiss = menuState::hide + ) + + BuiltInPlaylist.Favorites, + BuiltInPlaylist.Top, + BuiltInPlaylist.History -> NonQueuedMediaItemMenu( + mediaItem = song.asMediaItem, + onDismiss = menuState::hide + ) + } + } + }, + onClick = { + binder?.stopRadio() + binder?.player?.forcePlayAtIndex( + items = songs.map(Song::asMediaItem), + index = index + ) + } + ) + .animateItem(), + song = song, + index = if (builtInPlaylist == BuiltInPlaylist.Top) index else null, + thumbnailSize = Dimensions.thumbnails.song + ) + } + } + + FloatingActionsContainerWithScrollToTop( + lazyListState = lazyListState, + icon = R.drawable.shuffle, + onClick = { + if (songs.isEmpty()) return@FloatingActionsContainerWithScrollToTop + binder?.stopRadio() + binder?.player?.forcePlayFromBeginning( + songs.shuffled().map(Song::asMediaItem) + ) + } + ) + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeAlbums.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/home/HomeAlbums.kt similarity index 50% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeAlbums.kt rename to app/src/main/kotlin/app/vimusic/android/ui/screens/home/HomeAlbums.kt index 5321ec9..ee26efa 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeAlbums.kt +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/home/HomeAlbums.kt @@ -1,10 +1,8 @@ -package it.vfsfitvnm.vimusic.ui.screens.home +package app.vimusic.android.ui.screens.home -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -15,59 +13,50 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import it.vfsfitvnm.compose.persist.persist -import it.vfsfitvnm.vimusic.Database -import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.enums.AlbumSortBy -import it.vfsfitvnm.vimusic.enums.SortOrder -import it.vfsfitvnm.vimusic.models.Album -import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop -import it.vfsfitvnm.vimusic.ui.components.themed.Header -import it.vfsfitvnm.vimusic.ui.components.themed.HeaderIconButton -import it.vfsfitvnm.vimusic.ui.items.AlbumItem -import it.vfsfitvnm.vimusic.ui.styling.Dimensions -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.px -import it.vfsfitvnm.vimusic.utils.albumSortByKey -import it.vfsfitvnm.vimusic.utils.albumSortOrderKey -import it.vfsfitvnm.vimusic.utils.rememberPreference +import app.vimusic.android.Database +import app.vimusic.android.LocalPlayerAwareWindowInsets +import app.vimusic.android.R +import app.vimusic.android.models.Album +import app.vimusic.android.preferences.OrderPreferences +import app.vimusic.android.ui.components.themed.FloatingActionsContainerWithScrollToTop +import app.vimusic.android.ui.components.themed.Header +import app.vimusic.android.ui.components.themed.HeaderIconButton +import app.vimusic.android.ui.items.AlbumItem +import app.vimusic.android.ui.screens.Route +import app.vimusic.compose.persist.persist +import app.vimusic.core.data.enums.AlbumSortBy +import app.vimusic.core.data.enums.SortOrder +import app.vimusic.core.ui.Dimensions +import app.vimusic.core.ui.LocalAppearance -@ExperimentalFoundationApi -@ExperimentalAnimationApi +@Route @Composable fun HomeAlbums( onAlbumClick: (Album) -> Unit, - onSearchClick: () -> Unit, -) { + onSearchClick: () -> Unit +) = with(OrderPreferences) { val (colorPalette) = LocalAppearance.current - var sortBy by rememberPreference(albumSortByKey, AlbumSortBy.DateAdded) - var sortOrder by rememberPreference(albumSortOrderKey, SortOrder.Descending) - var items by persist>(tag = "home/albums", emptyList()) - LaunchedEffect(sortBy, sortOrder) { - Database.albums(sortBy, sortOrder).collect { items = it } + LaunchedEffect(albumSortBy, albumSortOrder) { + Database.albums(albumSortBy, albumSortOrder).collect { items = it } } - val thumbnailSizeDp = Dimensions.thumbnails.song * 2 - val thumbnailSizePx = thumbnailSizeDp.px - val sortOrderIconRotation by animateFloatAsState( - targetValue = if (sortOrder == SortOrder.Ascending) 0f else 180f, - animationSpec = tween(durationMillis = 400, easing = LinearEasing) + targetValue = if (albumSortOrder == SortOrder.Ascending) 0f else 180f, + animationSpec = tween(durationMillis = 400, easing = LinearEasing), + label = "" ) val lazyListState = rememberLazyListState() @@ -85,36 +74,32 @@ fun HomeAlbums( key = "header", contentType = 0 ) { - Header(title = "Albums") { + Header(title = stringResource(R.string.albums)) { HeaderIconButton( icon = R.drawable.calendar, - color = if (sortBy == AlbumSortBy.Year) colorPalette.text else colorPalette.textDisabled, - onClick = { sortBy = AlbumSortBy.Year } + enabled = albumSortBy == AlbumSortBy.Year, + onClick = { albumSortBy = AlbumSortBy.Year } ) HeaderIconButton( icon = R.drawable.text, - color = if (sortBy == AlbumSortBy.Title) colorPalette.text else colorPalette.textDisabled, - onClick = { sortBy = AlbumSortBy.Title } + enabled = albumSortBy == AlbumSortBy.Title, + onClick = { albumSortBy = AlbumSortBy.Title } ) HeaderIconButton( icon = R.drawable.time, - color = if (sortBy == AlbumSortBy.DateAdded) colorPalette.text else colorPalette.textDisabled, - onClick = { sortBy = AlbumSortBy.DateAdded } + enabled = albumSortBy == AlbumSortBy.DateAdded, + onClick = { albumSortBy = AlbumSortBy.DateAdded } ) - Spacer( - modifier = Modifier - .width(2.dp) - ) + Spacer(modifier = Modifier.width(2.dp)) HeaderIconButton( icon = R.drawable.arrow_up, color = colorPalette.text, - onClick = { sortOrder = !sortOrder }, - modifier = Modifier - .graphicsLayer { rotationZ = sortOrderIconRotation } + onClick = { albumSortOrder = !albumSortOrder }, + modifier = Modifier.graphicsLayer { rotationZ = sortOrderIconRotation } ) } } @@ -125,18 +110,17 @@ fun HomeAlbums( ) { album -> AlbumItem( album = album, - thumbnailSizePx = thumbnailSizePx, - thumbnailSizeDp = thumbnailSizeDp, + thumbnailSize = Dimensions.thumbnails.album, modifier = Modifier .clickable(onClick = { onAlbumClick(album) }) - .animateItemPlacement() + .animateItem() ) } } FloatingActionsContainerWithScrollToTop( lazyListState = lazyListState, - iconId = R.drawable.search, + icon = R.drawable.search, onClick = onSearchClick ) } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeArtists.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/home/HomeArtists.kt similarity index 50% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeArtists.kt rename to app/src/main/kotlin/app/vimusic/android/ui/screens/home/HomeArtists.kt index 4eeba41..bfb7998 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeArtists.kt +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/home/HomeArtists.kt @@ -1,10 +1,8 @@ -package it.vfsfitvnm.vimusic.ui.screens.home +package app.vimusic.android.ui.screens.home -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -24,52 +22,50 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import it.vfsfitvnm.compose.persist.persistList -import it.vfsfitvnm.vimusic.Database -import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.enums.ArtistSortBy -import it.vfsfitvnm.vimusic.enums.SortOrder -import it.vfsfitvnm.vimusic.models.Artist -import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop -import it.vfsfitvnm.vimusic.ui.components.themed.Header -import it.vfsfitvnm.vimusic.ui.components.themed.HeaderIconButton -import it.vfsfitvnm.vimusic.ui.items.ArtistItem -import it.vfsfitvnm.vimusic.ui.styling.Dimensions -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.px -import it.vfsfitvnm.vimusic.utils.artistSortByKey -import it.vfsfitvnm.vimusic.utils.artistSortOrderKey -import it.vfsfitvnm.vimusic.utils.rememberPreference +import app.vimusic.android.Database +import app.vimusic.android.LocalPlayerAwareWindowInsets +import app.vimusic.android.R +import app.vimusic.android.models.Artist +import app.vimusic.android.preferences.OrderPreferences +import app.vimusic.android.ui.components.themed.FloatingActionsContainerWithScrollToTop +import app.vimusic.android.ui.components.themed.Header +import app.vimusic.android.ui.components.themed.HeaderIconButton +import app.vimusic.android.ui.items.ArtistItem +import app.vimusic.android.ui.screens.Route +import app.vimusic.compose.persist.persistList +import app.vimusic.core.data.enums.ArtistSortBy +import app.vimusic.core.data.enums.SortOrder +import app.vimusic.core.ui.Dimensions +import app.vimusic.core.ui.LocalAppearance +import kotlinx.collections.immutable.toImmutableList -@ExperimentalFoundationApi -@ExperimentalAnimationApi +@Route @Composable fun HomeArtistList( onArtistClick: (Artist) -> Unit, - onSearchClick: () -> Unit, -) { + onSearchClick: () -> Unit +) = with(OrderPreferences) { val (colorPalette) = LocalAppearance.current - var sortBy by rememberPreference(artistSortByKey, ArtistSortBy.DateAdded) - var sortOrder by rememberPreference(artistSortOrderKey, SortOrder.Descending) - var items by persistList("home/artists") - LaunchedEffect(sortBy, sortOrder) { - Database.artists(sortBy, sortOrder).collect { items = it } + LaunchedEffect(artistSortBy, artistSortOrder) { + Database + .artists(artistSortBy, artistSortOrder) + .collect { items = it.toImmutableList() } } - val thumbnailSizeDp = Dimensions.thumbnails.song * 2 - val thumbnailSizePx = thumbnailSizeDp.px - val sortOrderIconRotation by animateFloatAsState( - targetValue = if (sortOrder == SortOrder.Ascending) 0f else 180f, - animationSpec = tween(durationMillis = 400, easing = LinearEasing) + targetValue = if (artistSortOrder == SortOrder.Ascending) 0f else 180f, + animationSpec = tween( + durationMillis = 400, + easing = LinearEasing + ), + label = "" ) val lazyGridState = rememberLazyGridState() @@ -77,14 +73,11 @@ fun HomeArtistList( Box { LazyVerticalGrid( state = lazyGridState, - columns = GridCells.Adaptive(Dimensions.thumbnails.song * 2 + Dimensions.itemsVerticalPadding * 2), + columns = GridCells.Adaptive(Dimensions.thumbnails.song * 2 + Dimensions.items.verticalPadding * 2), contentPadding = LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), - verticalArrangement = Arrangement.spacedBy(Dimensions.itemsVerticalPadding * 2), - horizontalArrangement = Arrangement.spacedBy( - space = Dimensions.itemsVerticalPadding * 2, - alignment = Alignment.CenterHorizontally - ), + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) + .asPaddingValues(), + horizontalArrangement = Arrangement.Center, modifier = Modifier .background(colorPalette.background0) .fillMaxSize() @@ -94,30 +87,26 @@ fun HomeArtistList( contentType = 0, span = { GridItemSpan(maxLineSpan) } ) { - Header(title = "Artists") { + Header(title = stringResource(R.string.artists)) { HeaderIconButton( icon = R.drawable.text, - color = if (sortBy == ArtistSortBy.Name) colorPalette.text else colorPalette.textDisabled, - onClick = { sortBy = ArtistSortBy.Name } + enabled = artistSortBy == ArtistSortBy.Name, + onClick = { artistSortBy = ArtistSortBy.Name } ) HeaderIconButton( icon = R.drawable.time, - color = if (sortBy == ArtistSortBy.DateAdded) colorPalette.text else colorPalette.textDisabled, - onClick = { sortBy = ArtistSortBy.DateAdded } + enabled = artistSortBy == ArtistSortBy.DateAdded, + onClick = { artistSortBy = ArtistSortBy.DateAdded } ) - Spacer( - modifier = Modifier - .width(2.dp) - ) + Spacer(modifier = Modifier.width(2.dp)) HeaderIconButton( icon = R.drawable.arrow_up, color = colorPalette.text, - onClick = { sortOrder = !sortOrder }, - modifier = Modifier - .graphicsLayer { rotationZ = sortOrderIconRotation } + onClick = { artistSortOrder = !artistSortOrder }, + modifier = Modifier.graphicsLayer { rotationZ = sortOrderIconRotation } ) } } @@ -125,19 +114,18 @@ fun HomeArtistList( items(items = items, key = Artist::id) { artist -> ArtistItem( artist = artist, - thumbnailSizePx = thumbnailSizePx, - thumbnailSizeDp = thumbnailSizeDp, + thumbnailSize = Dimensions.thumbnails.song * 2, alternative = true, modifier = Modifier .clickable(onClick = { onArtistClick(artist) }) - .animateItemPlacement() + .animateItem(fadeInSpec = null, fadeOutSpec = null) ) } } FloatingActionsContainerWithScrollToTop( lazyGridState = lazyGridState, - iconId = R.drawable.search, + icon = R.drawable.search, onClick = onSearchClick ) } diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/home/HomeDiscovery.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/home/HomeDiscovery.kt new file mode 100644 index 0000000..8d5ba70 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/home/HomeDiscovery.kt @@ -0,0 +1,401 @@ +package app.vimusic.android.ui.screens.home + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.times +import app.vimusic.android.LocalPlayerAwareWindowInsets +import app.vimusic.android.LocalPlayerServiceBinder +import app.vimusic.android.R +import app.vimusic.android.ui.components.FadingRow +import app.vimusic.android.ui.components.LocalMenuState +import app.vimusic.android.ui.components.ShimmerHost +import app.vimusic.android.ui.components.themed.FloatingActionsContainerWithScrollToTop +import app.vimusic.android.ui.components.themed.Header +import app.vimusic.android.ui.components.themed.NonQueuedMediaItemMenu +import app.vimusic.android.ui.components.themed.SecondaryTextButton +import app.vimusic.android.ui.components.themed.TextPlaceholder +import app.vimusic.android.ui.items.AlbumItem +import app.vimusic.android.ui.items.AlbumItemPlaceholder +import app.vimusic.android.ui.items.SongItem +import app.vimusic.android.ui.screens.Route +import app.vimusic.android.utils.asMediaItem +import app.vimusic.android.utils.center +import app.vimusic.android.utils.color +import app.vimusic.android.utils.forcePlay +import app.vimusic.android.utils.rememberSnapLayoutInfo +import app.vimusic.android.utils.secondary +import app.vimusic.android.utils.semiBold +import app.vimusic.compose.persist.persist +import app.vimusic.core.ui.Dimensions +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.shimmer +import app.vimusic.core.ui.utils.isLandscape +import app.vimusic.providers.innertube.Innertube +import app.vimusic.providers.innertube.models.NavigationEndpoint +import app.vimusic.providers.innertube.requests.discoverPage + +// TODO: a lot of duplicate code all around the codebase, especially for discover + +@OptIn(ExperimentalFoundationApi::class) +@Route +@Composable +fun HomeDiscovery( + onMoodClick: (mood: Innertube.Mood.Item) -> Unit, + onNewReleaseAlbumClick: (String) -> Unit, + onSearchClick: () -> Unit, + onMoreMoodsClick: () -> Unit, + onMoreAlbumsClick: () -> Unit, + onPlaylistClick: (browseId: String) -> Unit +) { + val (colorPalette, typography) = LocalAppearance.current + val windowInsets = LocalPlayerAwareWindowInsets.current + val menuState = LocalMenuState.current + val binder = LocalPlayerServiceBinder.current + + val scrollState = rememberScrollState() + val moodGridState = rememberLazyGridState() + + val endPaddingValues = windowInsets + .only(WindowInsetsSides.End) + .asPaddingValues() + + val sectionTextModifier = Modifier + .padding(horizontal = 16.dp) + .padding(top = 24.dp, bottom = 8.dp) + .padding(endPaddingValues) + + var discoverPage by persist>("home/discovery") + + LaunchedEffect(Unit) { + if (discoverPage?.isSuccess != true) discoverPage = Innertube.discoverPage() + } + + BoxWithConstraints { + val widthFactor = if (isLandscape && maxWidth * 0.475f >= 320.dp) 0.475f else 0.75f + val moodSnapLayoutInfoProvider = rememberSnapLayoutInfo( + lazyGridState = moodGridState, + positionInLayout = { layoutSize, itemSize -> + layoutSize * widthFactor / 2f - itemSize / 2f + } + ) + val itemWidth = maxWidth * widthFactor + + Column( + modifier = Modifier + .background(colorPalette.background0) + .fillMaxSize() + .verticalScroll(scrollState) + .padding( + windowInsets + .only(WindowInsetsSides.Vertical) + .asPaddingValues() + ) + ) { + Header( + title = stringResource(R.string.discover), + modifier = Modifier.padding(endPaddingValues) + ) + + discoverPage?.getOrNull()?.let { page -> + if (page.moods.isNotEmpty()) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + FadingRow( + modifier = Modifier.weight( + weight = 1f, + fill = false + ) + ) { + BasicText( + text = stringResource(R.string.moods_and_genres), + style = typography.m.semiBold, + modifier = sectionTextModifier + ) + } + + SecondaryTextButton( + text = stringResource(R.string.more), + onClick = onMoreMoodsClick, + modifier = sectionTextModifier + ) + } + + LazyHorizontalGrid( + state = moodGridState, + rows = GridCells.Fixed(4), + flingBehavior = rememberSnapFlingBehavior(moodSnapLayoutInfoProvider), + contentPadding = endPaddingValues, + modifier = Modifier + .fillMaxWidth() + .height((4 * (64 + 4)).dp) + ) { + items( + items = page.moods.sortedBy { it.title }, + key = { it.endpoint.params ?: it.title } + ) { + MoodItem( + mood = it, + onClick = { it.endpoint.browseId?.let { _ -> onMoodClick(it) } }, + modifier = Modifier + .width(itemWidth) + .padding(4.dp) + ) + } + } + } + + if (page.newReleaseAlbums.isNotEmpty()) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + FadingRow( + modifier = Modifier.weight( + weight = 1f, + fill = false + ) + ) { + BasicText( + text = stringResource(R.string.new_released_albums), + style = typography.m.semiBold, + modifier = sectionTextModifier + ) + } + + SecondaryTextButton( + text = stringResource(R.string.more), + onClick = onMoreAlbumsClick, + modifier = sectionTextModifier + ) + } + + LazyRow(contentPadding = endPaddingValues) { + items(items = page.newReleaseAlbums, key = { it.key }) { + AlbumItem( + album = it, + thumbnailSize = Dimensions.thumbnails.album, + alternative = true, + modifier = Modifier.clickable(onClick = { onNewReleaseAlbumClick(it.key) }) + ) + } + } + } + + if (page.trending.songs.isNotEmpty()) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + FadingRow( + modifier = Modifier.weight( + weight = 1f, + fill = false + ) + ) { + BasicText( + text = stringResource(R.string.trending), + style = typography.m.semiBold, + modifier = sectionTextModifier + ) + } + + page.trending.endpoint?.browseId?.let { browseId -> + SecondaryTextButton( + text = stringResource(R.string.more), + onClick = { onPlaylistClick(browseId) }, + modifier = sectionTextModifier + ) + } + } + + val trendingGridState = rememberLazyGridState() + val trendingSnapLayoutInfoProvider = rememberSnapLayoutInfo( + lazyGridState = trendingGridState, + positionInLayout = { layoutSize, itemSize -> + (layoutSize * widthFactor / 2f - itemSize / 2f) + } + ) + + LazyHorizontalGrid( + state = trendingGridState, + rows = GridCells.Fixed(4), + flingBehavior = rememberSnapFlingBehavior(trendingSnapLayoutInfoProvider), + contentPadding = endPaddingValues, + modifier = Modifier + .fillMaxWidth() + .height((Dimensions.thumbnails.song + Dimensions.items.verticalPadding * 2) * 4) + ) { + items( + items = page.trending.songs, + key = Innertube.SongItem::key + ) { song -> + SongItem( + song = song, + thumbnailSize = Dimensions.thumbnails.song, + modifier = Modifier + .combinedClickable( + onLongClick = { + menuState.display { + NonQueuedMediaItemMenu( + onDismiss = menuState::hide, + mediaItem = song.asMediaItem + ) + } + }, + onClick = { + val mediaItem = song.asMediaItem + binder?.stopRadio() + binder?.player?.forcePlay(mediaItem) + binder?.setupRadio( + NavigationEndpoint.Endpoint.Watch(videoId = mediaItem.mediaId) + ) + } + ) + .animateItem(fadeInSpec = null, fadeOutSpec = null) + .width(itemWidth), + showDuration = false + ) + } + } + } + } ?: discoverPage?.exceptionOrNull()?.let { + BasicText( + text = stringResource(R.string.error_message), + style = typography.s.secondary.center, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(all = 16.dp) + ) + } ?: ShimmerHost { + TextPlaceholder(modifier = sectionTextModifier) + LazyHorizontalGrid( + state = moodGridState, + rows = GridCells.Fixed(4), + flingBehavior = rememberSnapFlingBehavior(moodSnapLayoutInfoProvider), + contentPadding = endPaddingValues, + modifier = Modifier + .fillMaxWidth() + .height(4 * (Dimensions.items.moodHeight + 4.dp)) + ) { + items(16) { + MoodItemPlaceholder( + width = itemWidth, + modifier = Modifier.padding(4.dp) + ) + } + } + TextPlaceholder(modifier = sectionTextModifier) + Row { + repeat(2) { + AlbumItemPlaceholder( + thumbnailSize = Dimensions.thumbnails.album, + alternative = true + ) + } + } + } + } + + FloatingActionsContainerWithScrollToTop( + scrollState = scrollState, + icon = R.drawable.search, + onClick = onSearchClick + ) + } +} + +@Composable +fun MoodItem( + mood: Innertube.Mood.Item, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val typography = LocalAppearance.current.typography + val thumbnailShape = LocalAppearance.current.thumbnailShape + + val color by remember { derivedStateOf { Color(mood.stripeColor) } } + + ElevatedCard( + modifier = modifier.height(Dimensions.items.moodHeight), + shape = thumbnailShape, + colors = CardDefaults.elevatedCardColors(containerColor = color) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .clickable { onClick() }, + contentAlignment = Alignment.CenterStart + ) { + BasicText( + text = mood.title, + style = typography.xs.semiBold.color( + if (color.luminance() >= 0.5f) Color.Black else Color.White + ), + modifier = Modifier.padding(start = 24.dp) + ) + } + } +} + +@Composable +fun MoodItemPlaceholder( + width: Dp, + modifier: Modifier = Modifier +) = Spacer( + modifier = modifier + .background( + color = LocalAppearance.current.colorPalette.shimmer, + shape = LocalAppearance.current.thumbnailShape + ) + .size( + width = width, + height = Dimensions.items.moodHeight + ) +) diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/home/HomeLocalSongs.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/home/HomeLocalSongs.kt new file mode 100644 index 0000000..28b8f09 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/home/HomeLocalSongs.kt @@ -0,0 +1,157 @@ +package app.vimusic.android.ui.screens.home + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.MediaStore +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import app.vimusic.android.Database +import app.vimusic.android.R +import app.vimusic.android.models.Song +import app.vimusic.android.preferences.OrderPreferences +import app.vimusic.android.service.LOCAL_KEY_PREFIX +import app.vimusic.android.transaction +import app.vimusic.android.ui.components.themed.SecondaryTextButton +import app.vimusic.android.ui.screens.Route +import app.vimusic.android.utils.AudioMediaCursor +import app.vimusic.android.utils.hasPermission +import app.vimusic.android.utils.medium +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.utils.isAtLeastAndroid13 +import app.vimusic.core.ui.utils.isCompositionLaunched +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.isActive +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +private val permission = if (isAtLeastAndroid13) Manifest.permission.READ_MEDIA_AUDIO +else Manifest.permission.READ_EXTERNAL_STORAGE + +@Route +@Composable +fun HomeLocalSongs(onSearchClick: () -> Unit) = with(OrderPreferences) { + val context = LocalContext.current + val (_, typography) = LocalAppearance.current + + var hasPermission by remember(isCompositionLaunched()) { + mutableStateOf(context.applicationContext.hasPermission(permission)) + } + + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + onResult = { hasPermission = it } + ) + + LaunchedEffect(hasPermission) { + if (hasPermission) context.musicFilesAsFlow().collect() + } + + if (hasPermission) HomeSongs( + onSearchClick = onSearchClick, + songProvider = { + Database.songs( + sortBy = localSongSortBy, + sortOrder = localSongSortOrder, + isLocal = true + ).map { songs -> songs.filter { it.durationText != "0:00" } } + }, + sortBy = localSongSortBy, + setSortBy = { localSongSortBy = it }, + sortOrder = localSongSortOrder, + setSortOrder = { localSongSortOrder = it }, + title = stringResource(R.string.local) + ) else { + LaunchedEffect(Unit) { launcher.launch(permission) } + + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally + ) { + BasicText( + text = stringResource(R.string.media_permission_declined), + modifier = Modifier.fillMaxWidth(0.75f), + style = typography.m.medium + ) + Spacer(modifier = Modifier.height(12.dp)) + SecondaryTextButton( + text = stringResource(R.string.open_settings), + onClick = { + context.startActivity( + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + setData(Uri.fromParts("package", context.packageName, null)) + } + ) + } + ) + } + } +} + +private val mediaScope = CoroutineScope(Dispatchers.IO + CoroutineName("MediaStore worker")) +fun Context.musicFilesAsFlow(): StateFlow> = flow { + var version: String? = null + + while (currentCoroutineContext().isActive) { + val newVersion = MediaStore.getVersion(applicationContext) + + if (version != newVersion) { + version = newVersion + + AudioMediaCursor.query(contentResolver) { + buildList { + while (next()) { + if (!isMusic || duration == 0) continue + add( + Song( + id = "$LOCAL_KEY_PREFIX$id", + title = name, + artistsText = artist, + durationText = duration.milliseconds.toComponents { minutes, seconds, _ -> + "$minutes:${seconds.toString().padStart(2, '0')}" + }, + thumbnailUrl = albumUri.toString() + ) + ) + } + } + }?.let { emit(it) } + } + delay(5.seconds) + } +}.distinctUntilChanged() + .onEach { songs -> transaction { songs.forEach(Database::insert) } } + .stateIn(mediaScope, SharingStarted.Eagerly, listOf()) diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/home/HomePlaylists.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/home/HomePlaylists.kt new file mode 100644 index 0000000..c962ac8 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/home/HomePlaylists.kt @@ -0,0 +1,270 @@ +package app.vimusic.android.ui.screens.home + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import app.vimusic.android.Database +import app.vimusic.android.LocalPlayerAwareWindowInsets +import app.vimusic.android.R +import app.vimusic.android.models.PipedSession +import app.vimusic.android.models.Playlist +import app.vimusic.android.models.PlaylistPreview +import app.vimusic.android.preferences.DataPreferences +import app.vimusic.android.preferences.OrderPreferences +import app.vimusic.android.query +import app.vimusic.android.ui.components.themed.FloatingActionsContainerWithScrollToTop +import app.vimusic.android.ui.components.themed.Header +import app.vimusic.android.ui.components.themed.HeaderIconButton +import app.vimusic.android.ui.components.themed.SecondaryTextButton +import app.vimusic.android.ui.components.themed.TextFieldDialog +import app.vimusic.android.ui.items.PlaylistItem +import app.vimusic.android.ui.screens.Route +import app.vimusic.android.ui.screens.settings.SettingsEntryGroupText +import app.vimusic.android.ui.screens.settings.SettingsGroupSpacer +import app.vimusic.compose.persist.persist +import app.vimusic.compose.persist.persistList +import app.vimusic.core.data.enums.BuiltInPlaylist +import app.vimusic.core.data.enums.PlaylistSortBy +import app.vimusic.core.data.enums.SortOrder +import app.vimusic.core.ui.Dimensions +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.providers.piped.Piped +import app.vimusic.providers.piped.models.Session +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.async +import app.vimusic.providers.piped.models.PlaylistPreview as PipedPlaylistPreview + +@Route +@Composable +fun HomePlaylists( + onBuiltInPlaylist: (BuiltInPlaylist) -> Unit, + onPlaylistClick: (Playlist) -> Unit, + onPipedPlaylistClick: (Session, PipedPlaylistPreview) -> Unit, + onSearchClick: () -> Unit +) = with(OrderPreferences) { + val (colorPalette) = LocalAppearance.current + + var isCreatingANewPlaylist by rememberSaveable { mutableStateOf(false) } + + if (isCreatingANewPlaylist) TextFieldDialog( + hintText = stringResource(R.string.enter_playlist_name_prompt), + onDismiss = { isCreatingANewPlaylist = false }, + onAccept = { text -> + query { + Database.insert(Playlist(name = text)) + } + } + ) + var items by persistList("home/playlists") + var pipedSessions by persist?>>("home/piped") + + LaunchedEffect(playlistSortBy, playlistSortOrder) { + Database + .playlistPreviews(playlistSortBy, playlistSortOrder) + .collect { items = it.toImmutableList() } + } + + LaunchedEffect(Unit) { + Database.pipedSessions().collect { sessions -> + pipedSessions = sessions.associateWith { session -> + async { + Piped.playlist.list(session = session.toApiSession())?.getOrNull() + } + }.mapValues { (_, value) -> value.await() } + } + } + + val sortOrderIconRotation by animateFloatAsState( + targetValue = if (playlistSortOrder == SortOrder.Ascending) 0f else 180f, + animationSpec = tween(durationMillis = 400, easing = LinearEasing), + label = "" + ) + + val lazyGridState = rememberLazyGridState() + + Box { + LazyVerticalGrid( + state = lazyGridState, + columns = GridCells.Adaptive(Dimensions.thumbnails.song * 2 + Dimensions.items.verticalPadding * 2), + contentPadding = LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) + .asPaddingValues(), + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxSize() + .background(colorPalette.background0) + ) { + item(key = "header", contentType = 0, span = { GridItemSpan(maxLineSpan) }) { + Header(title = stringResource(R.string.playlists)) { + SecondaryTextButton( + text = stringResource(R.string.new_playlist), + onClick = { isCreatingANewPlaylist = true } + ) + + Spacer(modifier = Modifier.weight(1f)) + + HeaderIconButton( + icon = R.drawable.medical, + enabled = playlistSortBy == PlaylistSortBy.SongCount, + onClick = { playlistSortBy = PlaylistSortBy.SongCount } + ) + + HeaderIconButton( + icon = R.drawable.text, + enabled = playlistSortBy == PlaylistSortBy.Name, + onClick = { playlistSortBy = PlaylistSortBy.Name } + ) + + HeaderIconButton( + icon = R.drawable.time, + enabled = playlistSortBy == PlaylistSortBy.DateAdded, + onClick = { playlistSortBy = PlaylistSortBy.DateAdded } + ) + + Spacer(modifier = Modifier.width(2.dp)) + + HeaderIconButton( + icon = R.drawable.arrow_up, + color = colorPalette.text, + onClick = { playlistSortOrder = !playlistSortOrder }, + modifier = Modifier.graphicsLayer { rotationZ = sortOrderIconRotation } + ) + } + } + + item(key = "favorites") { + PlaylistItem( + icon = R.drawable.heart, + colorTint = colorPalette.red, + name = stringResource(R.string.favorites), + songCount = null, + thumbnailSize = Dimensions.thumbnails.playlist, + alternative = true, + modifier = Modifier.clickable { onBuiltInPlaylist(BuiltInPlaylist.Favorites) } + ) + } + + item(key = "offline") { + PlaylistItem( + icon = R.drawable.airplane, + colorTint = colorPalette.blue, + name = stringResource(R.string.offline), + songCount = null, + thumbnailSize = Dimensions.thumbnails.playlist, + alternative = true, + modifier = Modifier.clickable { onBuiltInPlaylist(BuiltInPlaylist.Offline) } + ) + } + + item(key = "top") { + PlaylistItem( + icon = R.drawable.trending, + colorTint = colorPalette.red, + name = stringResource( + R.string.format_my_top_playlist, + DataPreferences.topListLength + ), + songCount = null, + thumbnailSize = Dimensions.thumbnails.playlist, + alternative = true, + modifier = Modifier.clickable { onBuiltInPlaylist(BuiltInPlaylist.Top) } + ) + } + + item(key = "history") { + PlaylistItem( + icon = R.drawable.history, + colorTint = colorPalette.textDisabled, + name = stringResource(R.string.history), + songCount = null, + thumbnailSize = Dimensions.thumbnails.playlist, + alternative = true, + modifier = Modifier.clickable { onBuiltInPlaylist(BuiltInPlaylist.History) } + ) + } + + items( + items = items, + key = { it.playlist.id } + ) { playlistPreview -> + PlaylistItem( + playlist = playlistPreview, + thumbnailSize = Dimensions.thumbnails.playlist, + alternative = true, + modifier = Modifier + .clickable(onClick = { onPlaylistClick(playlistPreview.playlist) }) + .animateItem(fadeInSpec = null, fadeOutSpec = null) + ) + } + + pipedSessions + ?.ifEmpty { null } + ?.filter { it.value?.isNotEmpty() == true } + ?.forEach { (session, playlists) -> + item( + key = "piped-header-${session.username}", + contentType = 0, + span = { GridItemSpan(maxLineSpan) } + ) { + SettingsGroupSpacer() + SettingsEntryGroupText(title = session.username) + } + + playlists?.let { + items( + items = playlists, + key = { "piped-${session.username}-${it.id}" } + ) { playlist -> + PlaylistItem( + name = playlist.name, + songCount = playlist.videoCount, + channelName = null, + thumbnailUrl = playlist.thumbnailUrl.toString(), + thumbnailSize = Dimensions.thumbnails.playlist, + alternative = true, + modifier = Modifier + .clickable(onClick = { + onPipedPlaylistClick( + session.toApiSession(), + playlist + ) + }) + .animateItem(fadeInSpec = null, fadeOutSpec = null) + ) + } + } + } + } + + FloatingActionsContainerWithScrollToTop( + lazyGridState = lazyGridState, + icon = R.drawable.search, + onClick = onSearchClick + ) + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/QuickPicks.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/home/HomeQuickPicks.kt similarity index 64% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/QuickPicks.kt rename to app/src/main/kotlin/app/vimusic/android/ui/screens/home/HomeQuickPicks.kt index 5ac3b4a..a466e73 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/QuickPicks.kt +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/home/HomeQuickPicks.kt @@ -1,6 +1,5 @@ -package it.vfsfitvnm.vimusic.ui.screens.home +package app.vimusic.android.ui.screens.home -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -31,58 +30,59 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import it.vfsfitvnm.compose.persist.persist -import it.vfsfitvnm.innertube.Innertube -import it.vfsfitvnm.innertube.models.NavigationEndpoint -import it.vfsfitvnm.innertube.models.bodies.NextBody -import it.vfsfitvnm.innertube.requests.relatedPage -import it.vfsfitvnm.vimusic.Database -import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets -import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.models.Song -import it.vfsfitvnm.vimusic.query -import it.vfsfitvnm.vimusic.ui.components.LocalMenuState -import it.vfsfitvnm.vimusic.ui.components.ShimmerHost -import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop -import it.vfsfitvnm.vimusic.ui.components.themed.Header -import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu -import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder -import it.vfsfitvnm.vimusic.ui.items.AlbumItem -import it.vfsfitvnm.vimusic.ui.items.AlbumItemPlaceholder -import it.vfsfitvnm.vimusic.ui.items.ArtistItem -import it.vfsfitvnm.vimusic.ui.items.ArtistItemPlaceholder -import it.vfsfitvnm.vimusic.ui.items.PlaylistItem -import it.vfsfitvnm.vimusic.ui.items.PlaylistItemPlaceholder -import it.vfsfitvnm.vimusic.ui.items.SongItem -import it.vfsfitvnm.vimusic.ui.items.SongItemPlaceholder -import it.vfsfitvnm.vimusic.ui.styling.Dimensions -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.px -import it.vfsfitvnm.vimusic.utils.SnapLayoutInfoProvider -import it.vfsfitvnm.vimusic.utils.asMediaItem -import it.vfsfitvnm.vimusic.utils.center -import it.vfsfitvnm.vimusic.utils.forcePlay -import it.vfsfitvnm.vimusic.utils.isLandscape -import it.vfsfitvnm.vimusic.utils.secondary -import it.vfsfitvnm.vimusic.utils.semiBold +import app.vimusic.android.Database +import app.vimusic.android.LocalPlayerAwareWindowInsets +import app.vimusic.android.LocalPlayerServiceBinder +import app.vimusic.android.R +import app.vimusic.android.models.Song +import app.vimusic.android.preferences.DataPreferences +import app.vimusic.android.query +import app.vimusic.android.ui.components.LocalMenuState +import app.vimusic.android.ui.components.ShimmerHost +import app.vimusic.android.ui.components.themed.FloatingActionsContainerWithScrollToTop +import app.vimusic.android.ui.components.themed.Header +import app.vimusic.android.ui.components.themed.NonQueuedMediaItemMenu +import app.vimusic.android.ui.components.themed.TextPlaceholder +import app.vimusic.android.ui.items.AlbumItem +import app.vimusic.android.ui.items.AlbumItemPlaceholder +import app.vimusic.android.ui.items.ArtistItem +import app.vimusic.android.ui.items.ArtistItemPlaceholder +import app.vimusic.android.ui.items.PlaylistItem +import app.vimusic.android.ui.items.PlaylistItemPlaceholder +import app.vimusic.android.ui.items.SongItem +import app.vimusic.android.ui.items.SongItemPlaceholder +import app.vimusic.android.ui.screens.Route +import app.vimusic.android.utils.asMediaItem +import app.vimusic.android.utils.center +import app.vimusic.android.utils.forcePlay +import app.vimusic.android.utils.rememberSnapLayoutInfo +import app.vimusic.android.utils.secondary +import app.vimusic.android.utils.semiBold +import app.vimusic.compose.persist.persist +import app.vimusic.core.ui.Dimensions +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.utils.isLandscape +import app.vimusic.providers.innertube.Innertube +import app.vimusic.providers.innertube.models.NavigationEndpoint +import app.vimusic.providers.innertube.models.bodies.NextBody +import app.vimusic.providers.innertube.requests.relatedPage import kotlinx.coroutines.flow.distinctUntilChanged -@ExperimentalFoundationApi -@ExperimentalAnimationApi +@OptIn(ExperimentalFoundationApi::class) +@Route @Composable fun QuickPicks( - onAlbumClick: (String) -> Unit, - onArtistClick: (String) -> Unit, - onPlaylistClick: (String) -> Unit, - onSearchClick: () -> Unit, + onAlbumClick: (Innertube.AlbumItem) -> Unit, + onArtistClick: (Innertube.ArtistItem) -> Unit, + onPlaylistClick: (Innertube.PlaylistItem) -> Unit, + onSearchClick: () -> Unit ) { val (colorPalette, typography) = LocalAppearance.current val binder = LocalPlayerServiceBinder.current @@ -93,24 +93,44 @@ fun QuickPicks( var relatedPageResult by persist?>(tag = "home/relatedPageResult") - LaunchedEffect(Unit) { - Database.trending().distinctUntilChanged().collect { song -> - if ((song == null && relatedPageResult == null) || trending?.id != song?.id) { - relatedPageResult = - Innertube.relatedPage(NextBody(videoId = (song?.id ?: "J7p4bzqLvCw"))) + LaunchedEffect(relatedPageResult, DataPreferences.shouldCacheQuickPicks) { + if (DataPreferences.shouldCacheQuickPicks) + relatedPageResult?.getOrNull()?.let { DataPreferences.cachedQuickPicks = it } + else DataPreferences.cachedQuickPicks = Innertube.RelatedPage() + } + + LaunchedEffect(DataPreferences.quickPicksSource) { + if ( + DataPreferences.shouldCacheQuickPicks && !DataPreferences.cachedQuickPicks.let { + it.albums.isNullOrEmpty() && + it.artists.isNullOrEmpty() && + it.playlists.isNullOrEmpty() && + it.songs.isNullOrEmpty() } + ) relatedPageResult = Result.success(DataPreferences.cachedQuickPicks) + + suspend fun handleSong(song: Song?) { + if (relatedPageResult == null || trending?.id != song?.id) relatedPageResult = + Innertube.relatedPage( + body = NextBody(videoId = (song?.id ?: "J7p4bzqLvCw")) + ) trending = song } - } - val songThumbnailSizeDp = Dimensions.thumbnails.song - val songThumbnailSizePx = songThumbnailSizeDp.px - val albumThumbnailSizeDp = 108.dp - val albumThumbnailSizePx = albumThumbnailSizeDp.px - val artistThumbnailSizeDp = 92.dp - val artistThumbnailSizePx = artistThumbnailSizeDp.px - val playlistThumbnailSizeDp = 108.dp - val playlistThumbnailSizePx = playlistThumbnailSizeDp.px + when (DataPreferences.quickPicksSource) { + DataPreferences.QuickPicksSource.Trending -> + Database + .trending() + .distinctUntilChanged() + .collect { handleSong(it.firstOrNull()) } + + DataPreferences.QuickPicksSource.LastInteraction -> + Database + .events() + .distinctUntilChanged() + .collect { handleSong(it.firstOrNull()?.song) } + } + } val scrollState = rememberScrollState() val quickPicksLazyGridState = rememberLazyGridState() @@ -123,20 +143,15 @@ fun QuickPicks( .padding(endPaddingValues) BoxWithConstraints { - val quickPicksLazyGridItemWidthFactor = if (isLandscape && maxWidth * 0.475f >= 320.dp) { - 0.475f - } else { - 0.9f - } + val quickPicksLazyGridItemWidthFactor = + if (isLandscape && maxWidth * 0.475f >= 320.dp) 0.475f else 0.75f - val snapLayoutInfoProvider = remember(quickPicksLazyGridState) { - SnapLayoutInfoProvider( - lazyGridState = quickPicksLazyGridState, - positionInLayout = { layoutSize, itemSize -> - (layoutSize * quickPicksLazyGridItemWidthFactor / 2f - itemSize / 2f) - } - ) - } + val snapLayoutInfoProvider = rememberSnapLayoutInfo( + lazyGridState = quickPicksLazyGridState, + positionInLayout = { layoutSize, itemSize -> + (layoutSize * quickPicksLazyGridItemWidthFactor / 2f - itemSize / 2f) + } + ) val itemInHorizontalGridWidth = maxWidth * quickPicksLazyGridItemWidthFactor @@ -152,9 +167,8 @@ fun QuickPicks( ) ) { Header( - title = "Quick picks", - modifier = Modifier - .padding(endPaddingValues) + title = stringResource(R.string.quick_picks), + modifier = Modifier.padding(endPaddingValues) ) relatedPageResult?.getOrNull()?.let { related -> @@ -165,23 +179,11 @@ fun QuickPicks( contentPadding = endPaddingValues, modifier = Modifier .fillMaxWidth() - .height((songThumbnailSizeDp + Dimensions.itemsVerticalPadding * 2) * 4) + .height((Dimensions.thumbnails.song + Dimensions.items.verticalPadding * 2) * 4) ) { trending?.let { song -> item { SongItem( - song = song, - thumbnailSizePx = songThumbnailSizePx, - thumbnailSizeDp = songThumbnailSizeDp, - trailingContent = { - Image( - painter = painterResource(R.drawable.star), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.accent), - modifier = Modifier - .size(16.dp) - ) - }, modifier = Modifier .combinedClickable( onLongClick = { @@ -206,8 +208,19 @@ fun QuickPicks( ) } ) - .animateItemPlacement() - .width(itemInHorizontalGridWidth) + .animateItem(fadeInSpec = null, fadeOutSpec = null) + .width(itemInHorizontalGridWidth), + song = song, + thumbnailSize = Dimensions.thumbnails.song, + trailingContent = { + Image( + painter = painterResource(R.drawable.star), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.accent), + modifier = Modifier.size(16.dp) + ) + }, + showDuration = false ) } } @@ -219,8 +232,7 @@ fun QuickPicks( ) { song -> SongItem( song = song, - thumbnailSizePx = songThumbnailSizePx, - thumbnailSizeDp = songThumbnailSizeDp, + thumbnailSize = Dimensions.thumbnails.song, modifier = Modifier .combinedClickable( onLongClick = { @@ -240,15 +252,16 @@ fun QuickPicks( ) } ) - .animateItemPlacement() - .width(itemInHorizontalGridWidth) + .animateItem(fadeInSpec = null, fadeOutSpec = null) + .width(itemInHorizontalGridWidth), + showDuration = false ) } } related.albums?.let { albums -> BasicText( - text = "関連アルバム", + text = stringResource(R.string.related_albums), style = typography.m.semiBold, modifier = sectionTextModifier ) @@ -260,11 +273,9 @@ fun QuickPicks( ) { album -> AlbumItem( album = album, - thumbnailSizePx = albumThumbnailSizePx, - thumbnailSizeDp = albumThumbnailSizeDp, + thumbnailSize = Dimensions.thumbnails.album, alternative = true, - modifier = Modifier - .clickable(onClick = { onAlbumClick(album.key) }) + modifier = Modifier.clickable { onAlbumClick(album) } ) } } @@ -272,7 +283,7 @@ fun QuickPicks( related.artists?.let { artists -> BasicText( - text = "似たようなアーティスト", + text = stringResource(R.string.similar_artists), style = typography.m.semiBold, modifier = sectionTextModifier ) @@ -280,15 +291,13 @@ fun QuickPicks( LazyRow(contentPadding = endPaddingValues) { items( items = artists, - key = Innertube.ArtistItem::key, + key = Innertube.ArtistItem::key ) { artist -> ArtistItem( artist = artist, - thumbnailSizePx = artistThumbnailSizePx, - thumbnailSizeDp = artistThumbnailSizeDp, + thumbnailSize = Dimensions.thumbnails.artist, alternative = true, - modifier = Modifier - .clickable(onClick = { onArtistClick(artist.key) }) + modifier = Modifier.clickable { onArtistClick(artist) } ) } } @@ -296,7 +305,7 @@ fun QuickPicks( related.playlists?.let { playlists -> BasicText( - text = "こちらもいかがですか?", + text = stringResource(R.string.recommended_playlists), style = typography.m.semiBold, modifier = Modifier .padding(horizontal = 16.dp) @@ -306,15 +315,13 @@ fun QuickPicks( LazyRow(contentPadding = endPaddingValues) { items( items = playlists, - key = Innertube.PlaylistItem::key, + key = Innertube.PlaylistItem::key ) { playlist -> PlaylistItem( playlist = playlist, - thumbnailSizePx = playlistThumbnailSizePx, - thumbnailSizeDp = playlistThumbnailSizeDp, + thumbnailSize = Dimensions.thumbnails.playlist, alternative = true, - modifier = Modifier - .clickable(onClick = { onPlaylistClick(playlist.key) }) + modifier = Modifier.clickable { onPlaylistClick(playlist) } ) } } @@ -323,7 +330,7 @@ fun QuickPicks( Unit } ?: relatedPageResult?.exceptionOrNull()?.let { BasicText( - text = "エラーが発生しました。", + text = stringResource(R.string.error_message), style = typography.s.secondary.center, modifier = Modifier .align(Alignment.CenterHorizontally) @@ -331,9 +338,7 @@ fun QuickPicks( ) } ?: ShimmerHost { repeat(4) { - SongItemPlaceholder( - thumbnailSizeDp = songThumbnailSizeDp, - ) + SongItemPlaceholder(thumbnailSize = Dimensions.thumbnails.song) } TextPlaceholder(modifier = sectionTextModifier) @@ -341,7 +346,7 @@ fun QuickPicks( Row { repeat(2) { AlbumItemPlaceholder( - thumbnailSizeDp = albumThumbnailSizeDp, + thumbnailSize = Dimensions.thumbnails.album, alternative = true ) } @@ -352,7 +357,7 @@ fun QuickPicks( Row { repeat(2) { ArtistItemPlaceholder( - thumbnailSizeDp = albumThumbnailSizeDp, + thumbnailSize = Dimensions.thumbnails.album, alternative = true ) } @@ -363,7 +368,7 @@ fun QuickPicks( Row { repeat(2) { PlaylistItemPlaceholder( - thumbnailSizeDp = albumThumbnailSizeDp, + thumbnailSize = Dimensions.thumbnails.album, alternative = true ) } @@ -373,7 +378,7 @@ fun QuickPicks( FloatingActionsContainerWithScrollToTop( scrollState = scrollState, - iconId = R.drawable.search, + icon = R.drawable.search, onClick = onSearchClick ) } diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/home/HomeScreen.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/home/HomeScreen.kt new file mode 100644 index 0000000..6204e92 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/home/HomeScreen.kt @@ -0,0 +1,140 @@ +package app.vimusic.android.ui.screens.home + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import app.vimusic.android.R +import app.vimusic.android.models.toUiMood +import app.vimusic.android.preferences.UIStatePreferences +import app.vimusic.android.ui.components.themed.Scaffold +import app.vimusic.android.ui.screens.GlobalRoutes +import app.vimusic.android.ui.screens.Route +import app.vimusic.android.ui.screens.albumRoute +import app.vimusic.android.ui.screens.artistRoute +import app.vimusic.android.ui.screens.builtInPlaylistRoute +import app.vimusic.android.ui.screens.builtinplaylist.BuiltInPlaylistScreen +import app.vimusic.android.ui.screens.localPlaylistRoute +import app.vimusic.android.ui.screens.localplaylist.LocalPlaylistScreen +import app.vimusic.android.ui.screens.mood.MoodScreen +import app.vimusic.android.ui.screens.mood.MoreAlbumsScreen +import app.vimusic.android.ui.screens.mood.MoreMoodsScreen +import app.vimusic.android.ui.screens.moodRoute +import app.vimusic.android.ui.screens.pipedPlaylistRoute +import app.vimusic.android.ui.screens.playlistRoute +import app.vimusic.android.ui.screens.searchRoute +import app.vimusic.android.ui.screens.settingsRoute +import app.vimusic.compose.persist.PersistMapCleanup +import app.vimusic.compose.routing.Route0 +import app.vimusic.compose.routing.RouteHandler + +private val moreMoodsRoute = Route0("moreMoodsRoute") +private val moreAlbumsRoute = Route0("moreAlbumsRoute") + +@Route +@Composable +fun HomeScreen() { + val saveableStateHolder = rememberSaveableStateHolder() + + PersistMapCleanup("home/") + + RouteHandler { + GlobalRoutes() + + localPlaylistRoute { playlistId -> + LocalPlaylistScreen(playlistId = playlistId) + } + + builtInPlaylistRoute { builtInPlaylist -> + BuiltInPlaylistScreen(builtInPlaylist = builtInPlaylist) + } + + moodRoute { mood -> + MoodScreen(mood = mood) + } + + moreMoodsRoute { + MoreMoodsScreen() + } + + moreAlbumsRoute { + MoreAlbumsScreen() + } + + Content { + Scaffold( + key = "home", + topIconButtonId = R.drawable.settings, + onTopIconButtonClick = { settingsRoute() }, + tabIndex = UIStatePreferences.homeScreenTabIndex, + onTabChange = { UIStatePreferences.homeScreenTabIndex = it }, + tabColumnContent = { + tab(0, R.string.quick_picks, R.drawable.sparkles) + tab(1, R.string.discover, R.drawable.globe) + tab(2, R.string.songs, R.drawable.musical_notes) + tab(3, R.string.playlists, R.drawable.playlist) + tab(4, R.string.artists, R.drawable.person) + tab(5, R.string.albums, R.drawable.disc) + tab(6, R.string.local, R.drawable.download) + } + ) { currentTabIndex -> + saveableStateHolder.SaveableStateProvider(key = currentTabIndex) { + val onSearchClick = { searchRoute("") } + when (currentTabIndex) { + 0 -> QuickPicks( + onAlbumClick = { albumRoute(it.key) }, + onArtistClick = { artistRoute(it.key) }, + onPlaylistClick = { + playlistRoute( + p0 = it.key, + p1 = null, + p2 = null, + p3 = it.channel?.name == "YouTube Music" + ) + }, + onSearchClick = onSearchClick + ) + + 1 -> HomeDiscovery( + onMoodClick = { mood -> moodRoute(mood.toUiMood()) }, + onNewReleaseAlbumClick = { albumRoute(it) }, + onSearchClick = onSearchClick, + onMoreMoodsClick = { moreMoodsRoute() }, + onMoreAlbumsClick = { moreAlbumsRoute() }, + onPlaylistClick = { playlistRoute(it, null, null, true) } + ) + + 2 -> HomeSongs( + onSearchClick = onSearchClick + ) + + 3 -> HomePlaylists( + onBuiltInPlaylist = { builtInPlaylistRoute(it) }, + onPlaylistClick = { localPlaylistRoute(it.id) }, + onPipedPlaylistClick = { session, playlist -> + pipedPlaylistRoute( + p0 = session.apiBaseUrl.toString(), + p1 = session.token, + p2 = playlist.id.toString() + ) + }, + onSearchClick = onSearchClick + ) + + 4 -> HomeArtistList( + onArtistClick = { artistRoute(it.id) }, + onSearchClick = onSearchClick + ) + + 5 -> HomeAlbums( + onAlbumClick = { albumRoute(it.id) }, + onSearchClick = onSearchClick + ) + + 6 -> HomeLocalSongs( + onSearchClick = onSearchClick + ) + } + } + } + } + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/home/HomeSongs.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/home/HomeSongs.kt new file mode 100644 index 0000000..4765380 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/home/HomeSongs.kt @@ -0,0 +1,384 @@ +package app.vimusic.android.ui.screens.home + +import androidx.annotation.OptIn +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.media3.common.util.UnstableApi +import app.vimusic.android.Database +import app.vimusic.android.LocalPlayerAwareWindowInsets +import app.vimusic.android.LocalPlayerServiceBinder +import app.vimusic.android.R +import app.vimusic.android.models.Song +import app.vimusic.android.preferences.AppearancePreferences +import app.vimusic.android.preferences.OrderPreferences +import app.vimusic.android.query +import app.vimusic.android.service.isLocal +import app.vimusic.android.transaction +import app.vimusic.android.ui.components.LocalMenuState +import app.vimusic.android.ui.components.themed.ConfirmationDialog +import app.vimusic.android.ui.components.themed.FloatingActionsContainerWithScrollToTop +import app.vimusic.android.ui.components.themed.Header +import app.vimusic.android.ui.components.themed.HeaderIconButton +import app.vimusic.android.ui.components.themed.InHistoryMediaItemMenu +import app.vimusic.android.ui.components.themed.TextField +import app.vimusic.android.ui.items.SongItem +import app.vimusic.android.ui.modifiers.swipeToClose +import app.vimusic.android.ui.screens.Route +import app.vimusic.android.utils.asMediaItem +import app.vimusic.android.utils.center +import app.vimusic.android.utils.color +import app.vimusic.android.utils.forcePlayAtIndex +import app.vimusic.android.utils.formatted +import app.vimusic.android.utils.secondary +import app.vimusic.android.utils.semiBold +import app.vimusic.compose.persist.persistList +import app.vimusic.core.data.enums.SongSortBy +import app.vimusic.core.data.enums.SortOrder +import app.vimusic.core.ui.Dimensions +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.onOverlay +import app.vimusic.core.ui.overlay +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlin.time.Duration.Companion.milliseconds + +private val Song.formattedTotalPlayTime @Composable get() = totalPlayTimeMs.milliseconds.formatted + +@Composable +fun HomeSongs( + onSearchClick: () -> Unit +) = with(OrderPreferences) { + HomeSongs( + onSearchClick = onSearchClick, + songProvider = { + Database.songs(songSortBy, songSortOrder) + .map { songs -> songs.filter { it.totalPlayTimeMs > 0L } } + }, + sortBy = songSortBy, + setSortBy = { songSortBy = it }, + sortOrder = songSortOrder, + setSortOrder = { songSortOrder = it }, + title = stringResource(R.string.songs) + ) +} + +@kotlin.OptIn(ExperimentalFoundationApi::class) +@OptIn(UnstableApi::class) +@Route +@Composable +fun HomeSongs( + onSearchClick: () -> Unit, + songProvider: () -> Flow>, + sortBy: SongSortBy, + setSortBy: (SongSortBy) -> Unit, + sortOrder: SortOrder, + setSortOrder: (SortOrder) -> Unit, + title: String +) { + val (colorPalette, typography, _, thumbnailShape) = LocalAppearance.current + + val binder = LocalPlayerServiceBinder.current + val menuState = LocalMenuState.current + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + + var filter: String? by rememberSaveable { mutableStateOf(null) } + var items by persistList("home/songs") + val filteredItems by remember { + derivedStateOf { + filter?.lowercase()?.ifBlank { null }?.let { f -> + items.filter { + f in it.title.lowercase() || f in it.artistsText?.lowercase().orEmpty() + }.sortedBy { it.title } + } ?: items + } + } + var hidingSong: String? by rememberSaveable { mutableStateOf(null) } + + LaunchedEffect(sortBy, sortOrder, songProvider) { + songProvider().collect { items = it.toPersistentList() } + } + + val lazyListState = rememberLazyListState() + + Box( + modifier = Modifier + .background(colorPalette.background0) + .fillMaxSize() + ) { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) + .asPaddingValues() + ) { + item( + key = "header", + contentType = 0 + ) { + Header(title = title) { + var searching by rememberSaveable { mutableStateOf(false) } + + AnimatedContent( + targetState = searching, + label = "" + ) { state -> + if (state) { + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + TextField( + value = filter.orEmpty(), + onValueChange = { filter = it }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { + if (filter.isNullOrBlank()) filter = "" + focusManager.clearFocus() + }), + hintText = stringResource(R.string.filter_placeholder), + modifier = Modifier + .focusRequester(focusRequester) + .onFocusChanged { + if (!it.hasFocus) { + keyboardController?.hide() + if (filter?.isBlank() == true) { + filter = null + searching = false + } + } + } + ) + } else Row(verticalAlignment = Alignment.CenterVertically) { + HeaderIconButton( + onClick = { searching = true }, + icon = R.drawable.search, + color = colorPalette.text + ) + + Spacer(modifier = Modifier.width(8.dp)) + + if (items.isNotEmpty()) BasicText( + text = pluralStringResource( + R.plurals.song_count_plural, + items.size, + items.size + ), + style = typography.xs.secondary.semiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + Spacer(modifier = Modifier.weight(1f)) + + HeaderSongSortBy(sortBy, setSortBy, sortOrder, setSortOrder) + } + } + + items( + items = filteredItems, + key = { song -> song.id } + ) { song -> + if (hidingSong == song.id) HideSongDialog( + song = song, + onDismiss = { hidingSong = null }, + onConfirm = { + hidingSong = null + menuState.hide() + } + ) + + SongItem( + modifier = Modifier + .combinedClickable( + onLongClick = { + keyboardController?.hide() + menuState.display { + InHistoryMediaItemMenu( + song = song, + onDismiss = menuState::hide, + onHideFromDatabase = { hidingSong = song.id } + ) + } + }, + onClick = { + keyboardController?.hide() + binder?.stopRadio() + binder?.player?.forcePlayAtIndex( + items.map(Song::asMediaItem), + items.indexOf(song) + ) + } + ) + .animateItem() + .let { + if (AppearancePreferences.swipeToHideSong) it.swipeToClose( + key = filteredItems, + requireUnconsumed = true + ) { animationJob -> + if (AppearancePreferences.swipeToHideSongConfirm) + hidingSong = song.id + else { + if (!song.isLocal) binder?.cache?.removeResource(song.id) + transaction { Database.delete(song) } + } + animationJob.join() + } else it + }, + song = song, + thumbnailSize = Dimensions.thumbnails.song, + onThumbnailContent = if (sortBy == SongSortBy.PlayTime) { + { + BasicText( + text = song.formattedTotalPlayTime, + style = typography.xxs.semiBold.center.color(colorPalette.onOverlay), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .fillMaxWidth() + .background( + brush = Brush.verticalGradient( + colors = listOf(Color.Transparent, colorPalette.overlay) + ), + shape = thumbnailShape.copy( + topStart = CornerSize(0.dp), + topEnd = CornerSize(0.dp) + ) + ) + .padding(horizontal = 8.dp, vertical = 4.dp) + .align(Alignment.BottomCenter) + ) + } + } else null + ) + } + } + + FloatingActionsContainerWithScrollToTop( + lazyListState = lazyListState, + icon = R.drawable.search, + onClick = onSearchClick + ) + } +} + +@OptIn(UnstableApi::class) +@Composable +fun HideSongDialog( + song: Song, + onDismiss: () -> Unit, + onConfirm: () -> Unit, + modifier: Modifier = Modifier +) { + val binder = LocalPlayerServiceBinder.current + + ConfirmationDialog( + text = stringResource(R.string.confirm_hide_song), + onDismiss = onDismiss, + onConfirm = { + onConfirm() + query { + runCatching { + if (!song.isLocal) binder?.cache?.removeResource(song.id) + Database.delete(song) + } + } + }, + modifier = modifier + ) +} + +// Row content, for convenience, doesn't need modifier/receiver +@Suppress("UnusedReceiverParameter", "ModifierMissing") +@Composable +fun RowScope.HeaderSongSortBy( + sortBy: SongSortBy, + setSortBy: (SongSortBy) -> Unit, + sortOrder: SortOrder, + setSortOrder: (SortOrder) -> Unit +) { + val (colorPalette) = LocalAppearance.current + + val sortOrderIconRotation by animateFloatAsState( + targetValue = if (sortOrder == SortOrder.Ascending) 0f else 180f, + animationSpec = tween(durationMillis = 400, easing = LinearEasing), + label = "" + ) + + HeaderIconButton( + icon = R.drawable.trending, + enabled = sortBy == SongSortBy.PlayTime, + onClick = { setSortBy(SongSortBy.PlayTime) } + ) + + HeaderIconButton( + icon = R.drawable.text, + enabled = sortBy == SongSortBy.Title, + onClick = { setSortBy(SongSortBy.Title) } + ) + + HeaderIconButton( + icon = R.drawable.time, + enabled = sortBy == SongSortBy.DateAdded, + onClick = { setSortBy(SongSortBy.DateAdded) } + ) + + HeaderIconButton( + icon = R.drawable.arrow_up, + color = colorPalette.text, + onClick = { setSortOrder(!sortOrder) }, + modifier = Modifier.graphicsLayer { rotationZ = sortOrderIconRotation } + ) +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/localplaylist/LocalPlaylistScreen.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/localplaylist/LocalPlaylistScreen.kt new file mode 100644 index 0000000..c99b757 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/localplaylist/LocalPlaylistScreen.kt @@ -0,0 +1,89 @@ +package app.vimusic.android.ui.screens.localplaylist + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.runtime.setValue +import app.vimusic.android.Database +import app.vimusic.android.R +import app.vimusic.android.models.Playlist +import app.vimusic.android.models.Song +import app.vimusic.android.ui.components.themed.Scaffold +import app.vimusic.android.ui.components.themed.adaptiveThumbnailContent +import app.vimusic.android.ui.screens.GlobalRoutes +import app.vimusic.android.ui.screens.Route +import app.vimusic.compose.persist.PersistMapCleanup +import app.vimusic.compose.persist.persist +import app.vimusic.compose.persist.persistList +import app.vimusic.compose.routing.RouteHandler +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull + +@Route +@Composable +fun LocalPlaylistScreen(playlistId: Long) { + val saveableStateHolder = rememberSaveableStateHolder() + + PersistMapCleanup(prefix = "localPlaylist/$playlistId/") + + RouteHandler { + GlobalRoutes() + + Content { + var playlist by persist("localPlaylist/$playlistId/playlist") + var songs by persistList("localPlaylist/$playlistId/songs") + + LaunchedEffect(Unit) { + Database + .playlist(playlistId) + .filterNotNull() + .distinctUntilChanged() + .collect { playlist = it } + } + + LaunchedEffect(Unit) { + Database + .playlistSongs(playlistId) + .filterNotNull() + .distinctUntilChanged() + .collect { songs = it.toImmutableList() } + } + + val thumbnailContent = remember(playlist) { + playlist?.thumbnail?.let { url -> + adaptiveThumbnailContent( + isLoading = false, + url = url + ) + } ?: { } + } + + Scaffold( + key = "localplaylist", + topIconButtonId = R.drawable.chevron_back, + onTopIconButtonClick = pop, + tabIndex = 0, + onTabChange = { }, + tabColumnContent = { + tab(0, R.string.songs, R.drawable.musical_notes) + } + ) { currentTabIndex -> + saveableStateHolder.SaveableStateProvider(currentTabIndex) { + playlist?.let { + when (currentTabIndex) { + 0 -> LocalPlaylistSongs( + playlist = it, + songs = songs, + thumbnailContent = thumbnailContent, + onDelete = pop + ) + } + } + } + } + } + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/localplaylist/LocalPlaylistSongs.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/localplaylist/LocalPlaylistSongs.kt new file mode 100644 index 0000000..5a2061c --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/localplaylist/LocalPlaylistSongs.kt @@ -0,0 +1,359 @@ +package app.vimusic.android.ui.screens.localplaylist + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.LookaheadScope +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import app.vimusic.android.Database +import app.vimusic.android.LocalPlayerAwareWindowInsets +import app.vimusic.android.LocalPlayerServiceBinder +import app.vimusic.android.R +import app.vimusic.android.models.Playlist +import app.vimusic.android.models.Song +import app.vimusic.android.models.SongPlaylistMap +import app.vimusic.android.preferences.DataPreferences +import app.vimusic.android.query +import app.vimusic.android.transaction +import app.vimusic.android.ui.components.LocalMenuState +import app.vimusic.android.ui.components.themed.CircularProgressIndicator +import app.vimusic.android.ui.components.themed.ConfirmationDialog +import app.vimusic.android.ui.components.themed.FloatingActionsContainerWithScrollToTop +import app.vimusic.android.ui.components.themed.Header +import app.vimusic.android.ui.components.themed.HeaderIconButton +import app.vimusic.android.ui.components.themed.InPlaylistMediaItemMenu +import app.vimusic.android.ui.components.themed.LayoutWithAdaptiveThumbnail +import app.vimusic.android.ui.components.themed.Menu +import app.vimusic.android.ui.components.themed.MenuEntry +import app.vimusic.android.ui.components.themed.ReorderHandle +import app.vimusic.android.ui.components.themed.SecondaryTextButton +import app.vimusic.android.ui.components.themed.TextFieldDialog +import app.vimusic.android.ui.items.SongItem +import app.vimusic.android.utils.PlaylistDownloadIcon +import app.vimusic.android.utils.asMediaItem +import app.vimusic.android.utils.completed +import app.vimusic.android.utils.enqueue +import app.vimusic.android.utils.forcePlayAtIndex +import app.vimusic.android.utils.forcePlayFromBeginning +import app.vimusic.android.utils.launchYouTubeMusic +import app.vimusic.android.utils.toast +import app.vimusic.compose.reordering.animateItemPlacement +import app.vimusic.compose.reordering.draggedItem +import app.vimusic.compose.reordering.rememberReorderingState +import app.vimusic.core.ui.Dimensions +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.utils.isLandscape +import app.vimusic.providers.innertube.Innertube +import app.vimusic.providers.innertube.models.bodies.BrowseBody +import app.vimusic.providers.innertube.requests.playlistPage +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LocalPlaylistSongs( + playlist: Playlist, + songs: ImmutableList, + onDelete: () -> Unit, + thumbnailContent: @Composable () -> Unit, + modifier: Modifier = Modifier +) = LayoutWithAdaptiveThumbnail( + thumbnailContent = thumbnailContent, + modifier = modifier +) { + val (colorPalette) = LocalAppearance.current + val binder = LocalPlayerServiceBinder.current + val menuState = LocalMenuState.current + val uriHandler = LocalUriHandler.current + val context = LocalContext.current + + val coroutineScope = rememberCoroutineScope() + val lazyListState = rememberLazyListState() + + var loading by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + if (DataPreferences.autoSyncPlaylists) playlist.browseId?.let { browseId -> + loading = true + sync(playlist, browseId) + loading = false + } + } + + val reorderingState = rememberReorderingState( + lazyListState = lazyListState, + key = songs, + onDragEnd = { fromIndex, toIndex -> + transaction { + Database.move(playlist.id, fromIndex, toIndex) + } + }, + extraItemCount = 1 + ) + + var isRenaming by rememberSaveable { mutableStateOf(false) } + + if (isRenaming) TextFieldDialog( + hintText = stringResource(R.string.enter_playlist_name_prompt), + initialTextInput = playlist.name, + onDismiss = { isRenaming = false }, + onAccept = { text -> + query { + Database.update(playlist.copy(name = text)) + } + } + ) + + var isDeleting by rememberSaveable { mutableStateOf(false) } + + if (isDeleting) ConfirmationDialog( + text = stringResource(R.string.confirm_delete_playlist), + onDismiss = { isDeleting = false }, + onConfirm = { + query { + Database.delete(playlist) + } + onDelete() + } + ) + + Box { + LookaheadScope { + LazyColumn( + state = reorderingState.lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) + .asPaddingValues(), + modifier = Modifier + .background(colorPalette.background0) + .fillMaxSize() + ) { + item( + key = "header", + contentType = 0 + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Header( + title = playlist.name, + modifier = Modifier.padding(bottom = 8.dp) + ) { + SecondaryTextButton( + text = stringResource(R.string.enqueue), + enabled = songs.isNotEmpty(), + onClick = { + binder?.player?.enqueue(songs.map { it.asMediaItem }) + } + ) + + Spacer(modifier = Modifier.weight(1f)) + + AnimatedVisibility(loading) { + CircularProgressIndicator(modifier = Modifier.size(18.dp)) + } + + PlaylistDownloadIcon( + songs = songs.map { it.asMediaItem }.toImmutableList() + ) + + HeaderIconButton( + icon = R.drawable.ellipsis_horizontal, + color = colorPalette.text, + onClick = { + menuState.display { + Menu { + playlist.browseId?.let { browseId -> + MenuEntry( + icon = R.drawable.sync, + text = stringResource(R.string.sync), + enabled = !loading, + onClick = { + menuState.hide() + coroutineScope.launch { + loading = true + sync(playlist, browseId) + loading = false + } + } + ) + + songs.firstOrNull()?.id?.let { firstSongId -> + MenuEntry( + icon = R.drawable.play, + text = stringResource(R.string.watch_playlist_on_youtube), + onClick = { + menuState.hide() + binder?.player?.pause() + uriHandler.openUri( + "https://youtube.com/watch?v=$firstSongId&list=${ + playlist.browseId.drop(2) + }" + ) + } + ) + + MenuEntry( + icon = R.drawable.musical_notes, + text = stringResource(R.string.open_in_youtube_music), + onClick = { + menuState.hide() + binder?.player?.pause() + if ( + !launchYouTubeMusic( + context = context, + endpoint = "watch?v=$firstSongId&list=${ + playlist.browseId.drop(2) + }" + ) + ) context.toast( + context.getString(R.string.youtube_music_not_installed) + ) + } + ) + } + } + + MenuEntry( + icon = R.drawable.pencil, + text = stringResource(R.string.rename), + onClick = { + menuState.hide() + isRenaming = true + } + ) + + MenuEntry( + icon = R.drawable.trash, + text = stringResource(R.string.delete), + onClick = { + menuState.hide() + isDeleting = true + } + ) + } + } + } + ) + } + + if (!isLandscape) thumbnailContent() + } + } + + itemsIndexed( + items = songs, + key = { _, song -> song.id }, + contentType = { _, song -> song } + ) { index, song -> + SongItem( + modifier = Modifier + .combinedClickable( + onLongClick = { + menuState.display { + InPlaylistMediaItemMenu( + playlistId = playlist.id, + positionInPlaylist = index, + song = song, + onDismiss = menuState::hide + ) + } + }, + onClick = { + binder?.stopRadio() + binder?.player?.forcePlayAtIndex( + items = songs.map { it.asMediaItem }, + index = index + ) + } + ) + .animateItemPlacement(reorderingState) + .draggedItem( + reorderingState = reorderingState, + index = index + ) + .background(colorPalette.background0), + song = song, + thumbnailSize = Dimensions.thumbnails.song, + trailingContent = { + ReorderHandle( + reorderingState = reorderingState, + index = index + ) + }, + clip = !reorderingState.isDragging + ) + } + } + } + + FloatingActionsContainerWithScrollToTop( + lazyListState = lazyListState, + icon = R.drawable.shuffle, + visible = !reorderingState.isDragging, + onClick = { + if (songs.isEmpty()) return@FloatingActionsContainerWithScrollToTop + + binder?.stopRadio() + binder?.player?.forcePlayFromBeginning( + songs.shuffled().map { it.asMediaItem } + ) + } + ) + } +} + +private suspend fun sync( + playlist: Playlist, + browseId: String +) = runCatching { + Innertube.playlistPage( + BrowseBody(browseId = browseId) + )?.completed()?.getOrNull()?.let { remotePlaylist -> + transaction { + Database.clearPlaylist(playlist.id) + + remotePlaylist.songsPage + ?.items + ?.map { it.asMediaItem } + ?.onEach { Database.insert(it) } + ?.mapIndexed { position, mediaItem -> + SongPlaylistMap( + songId = mediaItem.mediaId, + playlistId = playlist.id, + position = position + ) + } + ?.let(Database::insertSongPlaylistMaps) + } + } +}.onFailure { + if (it is CancellationException) throw it + it.printStackTrace() +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/mood/MoodList.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/mood/MoodList.kt new file mode 100644 index 0000000..c9b81c5 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/mood/MoodList.kt @@ -0,0 +1,188 @@ +package app.vimusic.android.ui.screens.mood + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import app.vimusic.android.LocalPlayerAwareWindowInsets +import app.vimusic.android.R +import app.vimusic.android.models.Mood +import app.vimusic.android.ui.components.ShimmerHost +import app.vimusic.android.ui.components.themed.Header +import app.vimusic.android.ui.components.themed.HeaderPlaceholder +import app.vimusic.android.ui.components.themed.TextPlaceholder +import app.vimusic.android.ui.items.AlbumItem +import app.vimusic.android.ui.items.AlbumItemPlaceholder +import app.vimusic.android.ui.items.ArtistItem +import app.vimusic.android.ui.items.PlaylistItem +import app.vimusic.android.ui.screens.albumRoute +import app.vimusic.android.ui.screens.artistRoute +import app.vimusic.android.ui.screens.playlistRoute +import app.vimusic.android.utils.center +import app.vimusic.android.utils.secondary +import app.vimusic.android.utils.semiBold +import app.vimusic.compose.persist.persist +import app.vimusic.core.ui.Dimensions +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.providers.innertube.Innertube +import app.vimusic.providers.innertube.models.bodies.BrowseBody +import app.vimusic.providers.innertube.requests.BrowseResult +import app.vimusic.providers.innertube.requests.browse +import com.valentinilk.shimmer.shimmer + +private const val DEFAULT_BROWSE_ID = "FEmusic_moods_and_genres_category" + +@Composable +fun MoodList( + mood: Mood, + modifier: Modifier = Modifier +) = Column(modifier = modifier) { + val (colorPalette, typography) = LocalAppearance.current + val windowInsets = LocalPlayerAwareWindowInsets.current + + val browseId = mood.browseId ?: DEFAULT_BROWSE_ID + var moodPage by persist>( + tag = "playlist/mood/$browseId${mood.params?.let { "/$it" }.orEmpty()}" + ) + + LaunchedEffect(Unit) { + if (moodPage?.isSuccess == true) return@LaunchedEffect + + moodPage = Innertube.browse(BrowseBody(browseId = browseId, params = mood.params)) + } + + val lazyListState = rememberLazyListState() + + val endPaddingValues = windowInsets + .only(WindowInsetsSides.End) + .asPaddingValues() + + val contentPadding = windowInsets + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) + .asPaddingValues() + + val sectionTextModifier = Modifier + .padding(horizontal = 16.dp) + .padding(top = 24.dp, bottom = 8.dp) + .padding(endPaddingValues) + + moodPage?.getOrNull()?.let { moodResult -> + LazyColumn( + state = lazyListState, + contentPadding = contentPadding, + modifier = Modifier + .background(colorPalette.background0) + .fillMaxSize() + ) { + item( + key = "header", + contentType = 0 + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Header(title = mood.name) + } + } + + moodResult.items.forEach { item -> + item { + BasicText( + text = item.title.orEmpty(), + style = typography.m.semiBold, + modifier = sectionTextModifier + ) + } + item { + LazyRow { + items( + items = item.items, + key = { it.key } + ) { childItem -> + if (childItem.key == DEFAULT_BROWSE_ID) return@items + + when (childItem) { + is Innertube.AlbumItem -> AlbumItem( + album = childItem, + thumbnailSize = Dimensions.thumbnails.album, + alternative = true, + modifier = Modifier.clickable { + childItem.info?.endpoint?.browseId?.let { + albumRoute.global(it) + } + } + ) + + is Innertube.ArtistItem -> ArtistItem( + artist = childItem, + thumbnailSize = Dimensions.thumbnails.album, + alternative = true, + modifier = Modifier.clickable { + childItem.info?.endpoint?.browseId?.let { + artistRoute.global(it) + } + } + ) + + is Innertube.PlaylistItem -> PlaylistItem( + playlist = childItem, + thumbnailSize = Dimensions.thumbnails.album, + alternative = true, + modifier = Modifier.clickable { + childItem.info?.endpoint?.let { endpoint -> + playlistRoute.global( + p0 = endpoint.browseId ?: return@clickable, + p1 = endpoint.params, + p2 = childItem.songCount?.let { it / 100 }, + p3 = true + ) + } + } + ) + + else -> {} + } + } + } + } + } + } + } ?: moodPage?.exceptionOrNull()?.let { + BasicText( + text = stringResource(R.string.error_message), + style = typography.s.secondary.center, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(all = 16.dp) + ) + } ?: ShimmerHost(modifier = Modifier.padding(contentPadding)) { + HeaderPlaceholder(modifier = Modifier.shimmer()) + repeat(4) { + TextPlaceholder(modifier = sectionTextModifier) + Row { + repeat(6) { + AlbumItemPlaceholder( + thumbnailSize = Dimensions.thumbnails.album, + alternative = true + ) + } + } + } + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/mood/MoodScreen.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/mood/MoodScreen.kt new file mode 100644 index 0000000..f27e332 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/mood/MoodScreen.kt @@ -0,0 +1,42 @@ +package app.vimusic.android.ui.screens.mood + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import app.vimusic.android.R +import app.vimusic.android.models.Mood +import app.vimusic.android.ui.components.themed.Scaffold +import app.vimusic.android.ui.screens.GlobalRoutes +import app.vimusic.android.ui.screens.Route +import app.vimusic.compose.persist.PersistMapCleanup +import app.vimusic.compose.routing.RouteHandler + +@Route +@Composable +fun MoodScreen(mood: Mood) { + val saveableStateHolder = rememberSaveableStateHolder() + + PersistMapCleanup(prefix = "playlist/mood/") + + RouteHandler { + GlobalRoutes() + + Content { + Scaffold( + key = "mood", + topIconButtonId = R.drawable.chevron_back, + onTopIconButtonClick = pop, + tabIndex = 0, + onTabChange = { }, + tabColumnContent = { + tab(0, R.string.mood, R.drawable.disc) + } + ) { currentTabIndex -> + saveableStateHolder.SaveableStateProvider(key = currentTabIndex) { + when (currentTabIndex) { + 0 -> MoodList(mood = mood) + } + } + } + } + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/mood/MoreAlbumsList.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/mood/MoreAlbumsList.kt new file mode 100644 index 0000000..29f9069 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/mood/MoreAlbumsList.kt @@ -0,0 +1,127 @@ +package app.vimusic.android.ui.screens.mood + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import app.vimusic.android.LocalPlayerAwareWindowInsets +import app.vimusic.android.R +import app.vimusic.android.ui.components.ShimmerHost +import app.vimusic.android.ui.components.themed.Header +import app.vimusic.android.ui.components.themed.HeaderPlaceholder +import app.vimusic.android.ui.items.AlbumItem +import app.vimusic.android.ui.items.AlbumItemPlaceholder +import app.vimusic.compose.persist.persist +import app.vimusic.core.ui.Dimensions +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.providers.innertube.Innertube +import app.vimusic.providers.innertube.models.bodies.BrowseBody +import app.vimusic.providers.innertube.requests.BrowseResult +import app.vimusic.providers.innertube.requests.browse +import com.valentinilk.shimmer.shimmer +import kotlinx.collections.immutable.toImmutableList + +private const val DEFAULT_BROWSE_ID = "FEmusic_new_releases_albums" + +@Composable +fun MoreAlbumsList( + onAlbumClick: (browseId: String) -> Unit, + modifier: Modifier = Modifier +) { + val (colorPalette) = LocalAppearance.current + val windowInsets = LocalPlayerAwareWindowInsets.current + + val endPaddingValues = windowInsets.only(WindowInsetsSides.End).asPaddingValues() + + var albumsPage by persist(tag = "more_albums/list") + val data by remember { + derivedStateOf { + albumsPage + ?.items + ?.firstOrNull() + ?.items + ?.filterIsInstance() + ?.toImmutableList() + } + } + + LaunchedEffect(Unit) { + if (albumsPage != null) return@LaunchedEffect + + albumsPage = Innertube + .browse(BrowseBody(browseId = DEFAULT_BROWSE_ID)) + ?.also { it.exceptionOrNull()?.printStackTrace() } + ?.getOrNull() + } + + LazyVerticalGrid( + columns = GridCells.Adaptive(Dimensions.thumbnails.album + Dimensions.items.horizontalPadding), + contentPadding = windowInsets + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) + .asPaddingValues(), + modifier = modifier + .background(colorPalette.background0) + .fillMaxSize() + ) { + item( + key = "header", + contentType = 0, + span = { GridItemSpan(maxLineSpan) } + ) { + if (albumsPage == null) HeaderPlaceholder(modifier = Modifier.shimmer()) + else Header( + title = stringResource(R.string.new_released_albums), + modifier = Modifier.padding(endPaddingValues) + ) + } + + data?.let { page -> + itemsIndexed( + items = page, + key = { i, item -> "item:$i,${item.key}" } + ) { _, album -> + BoxWithConstraints { + AlbumItem( + album = album, + thumbnailSize = maxWidth - Dimensions.items.horizontalPadding * 2, + modifier = Modifier + .fillMaxWidth() + .clickable { + onAlbumClick(album.key) + }, + alternative = true + ) + } + } + } + + if (albumsPage == null) item( + key = "loading", + contentType = 0, + span = { GridItemSpan(maxLineSpan) } + ) { + ShimmerHost(modifier = Modifier.fillMaxWidth()) { + repeat(16) { + AlbumItemPlaceholder(thumbnailSize = Dimensions.thumbnails.album) + } + } + } + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/mood/MoreAlbumsScreen.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/mood/MoreAlbumsScreen.kt new file mode 100644 index 0000000..9986f6e --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/mood/MoreAlbumsScreen.kt @@ -0,0 +1,44 @@ +package app.vimusic.android.ui.screens.mood + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import app.vimusic.android.R +import app.vimusic.android.ui.components.themed.Scaffold +import app.vimusic.android.ui.screens.GlobalRoutes +import app.vimusic.android.ui.screens.Route +import app.vimusic.android.ui.screens.albumRoute +import app.vimusic.compose.persist.PersistMapCleanup +import app.vimusic.compose.routing.RouteHandler + +@Route +@Composable +fun MoreAlbumsScreen() { + val saveableStateHolder = rememberSaveableStateHolder() + + PersistMapCleanup(prefix = "more_albums/") + + RouteHandler { + GlobalRoutes() + + Content { + Scaffold( + key = "morealbums", + topIconButtonId = R.drawable.chevron_back, + onTopIconButtonClick = pop, + tabIndex = 0, + onTabChange = { }, + tabColumnContent = { + tab(0, R.string.albums, R.drawable.disc) + } + ) { currentTabIndex -> + saveableStateHolder.SaveableStateProvider(key = currentTabIndex) { + when (currentTabIndex) { + 0 -> MoreAlbumsList( + onAlbumClick = { albumRoute(it) } + ) + } + } + } + } + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/mood/MoreMoodsList.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/mood/MoreMoodsList.kt new file mode 100644 index 0000000..ef3b904 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/mood/MoreMoodsList.kt @@ -0,0 +1,141 @@ +package app.vimusic.android.ui.screens.mood + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEachIndexed +import app.vimusic.android.LocalPlayerAwareWindowInsets +import app.vimusic.android.R +import app.vimusic.android.ui.components.ShimmerHost +import app.vimusic.android.ui.components.themed.Header +import app.vimusic.android.ui.components.themed.HeaderPlaceholder +import app.vimusic.android.ui.items.SongItemPlaceholder +import app.vimusic.android.ui.screens.home.MoodItem +import app.vimusic.android.utils.semiBold +import app.vimusic.compose.persist.persist +import app.vimusic.core.ui.Dimensions +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.providers.innertube.Innertube +import app.vimusic.providers.innertube.models.bodies.BrowseBody +import app.vimusic.providers.innertube.requests.BrowseResult +import app.vimusic.providers.innertube.requests.browse +import com.valentinilk.shimmer.shimmer +import kotlinx.collections.immutable.toImmutableList + +private const val DEFAULT_BROWSE_ID = "FEmusic_moods_and_genres" + +@Composable +fun MoreMoodsList( + onMoodClick: (mood: Innertube.Mood.Item) -> Unit, + modifier: Modifier = Modifier, + columns: Int = 2 +) { + val (colorPalette, typography) = LocalAppearance.current + val windowInsets = LocalPlayerAwareWindowInsets.current + + val endPaddingValues = windowInsets.only(WindowInsetsSides.End).asPaddingValues() + + val sectionTextModifier = Modifier + .padding(horizontal = 16.dp) + .padding(top = 24.dp, bottom = 8.dp) + .padding(endPaddingValues) + + var moodsPage by persist(tag = "more_moods/list") + val data by remember { + derivedStateOf { + moodsPage?.items?.map { + it.title.orEmpty() to it.items.filterIsInstance().toImmutableList() + } + } + } + + LaunchedEffect(Unit) { + if (moodsPage != null) return@LaunchedEffect + + moodsPage = Innertube + .browse(BrowseBody(browseId = DEFAULT_BROWSE_ID)) + ?.also { it.exceptionOrNull()?.printStackTrace() } + ?.getOrNull() + } + + LazyVerticalGrid( + columns = GridCells.Fixed(columns), + contentPadding = windowInsets + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) + .asPaddingValues(), + modifier = modifier + .background(colorPalette.background0) + .fillMaxSize() + ) { + item( + key = "header", + contentType = 0, + span = { GridItemSpan(columns) } + ) { + if (moodsPage == null) HeaderPlaceholder(modifier = Modifier.shimmer()) + else Header( + title = stringResource(R.string.moods_and_genres), + modifier = Modifier.padding(endPaddingValues) + ) + } + + data?.let { page -> + if (page.isNotEmpty()) page.fastForEachIndexed { i, (title, moods) -> + item( + key = "header:$i,$title", + contentType = 0, + span = { GridItemSpan(columns) } + ) { + BasicText( + text = title, + style = typography.m.semiBold, + modifier = sectionTextModifier + ) + } + + itemsIndexed( + items = moods, + key = { j, item -> "item:$j,${item.key}" } + ) { _, mood -> + MoodItem( + mood = mood, + onClick = { mood.endpoint.browseId?.let { _ -> onMoodClick(mood) } }, + modifier = Modifier + .fillMaxWidth() + .padding(4.dp) + ) + } + } + } + + if (moodsPage == null) item( + key = "loading", + contentType = 0, + span = { GridItemSpan(columns) } + ) { + ShimmerHost(modifier = Modifier.fillMaxWidth()) { + repeat(4) { + SongItemPlaceholder(thumbnailSize = Dimensions.thumbnails.song) + } + } + } + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/mood/MoreMoodsScreen.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/mood/MoreMoodsScreen.kt new file mode 100644 index 0000000..6e4790e --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/mood/MoreMoodsScreen.kt @@ -0,0 +1,49 @@ +package app.vimusic.android.ui.screens.mood + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import app.vimusic.android.R +import app.vimusic.android.models.toUiMood +import app.vimusic.android.ui.components.themed.Scaffold +import app.vimusic.android.ui.screens.GlobalRoutes +import app.vimusic.android.ui.screens.Route +import app.vimusic.android.ui.screens.moodRoute +import app.vimusic.compose.persist.PersistMapCleanup +import app.vimusic.compose.routing.RouteHandler + +@Route +@Composable +fun MoreMoodsScreen() { + val saveableStateHolder = rememberSaveableStateHolder() + + PersistMapCleanup(prefix = "more_moods/") + + RouteHandler { + GlobalRoutes() + + moodRoute { mood -> + MoodScreen(mood = mood) + } + + Content { + Scaffold( + key = "moremoods", + topIconButtonId = R.drawable.chevron_back, + onTopIconButtonClick = pop, + tabIndex = 0, + onTabChange = { }, + tabColumnContent = { + tab(0, R.string.moods_and_genres, R.drawable.playlist) + } + ) { currentTabIndex -> + saveableStateHolder.SaveableStateProvider(key = currentTabIndex) { + when (currentTabIndex) { + 0 -> MoreMoodsList( + onMoodClick = { mood -> moodRoute(mood.toUiMood()) } + ) + } + } + } + } + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/pipedplaylist/PipedPlaylistScreen.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/pipedplaylist/PipedPlaylistScreen.kt new file mode 100644 index 0000000..165a53f --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/pipedplaylist/PipedPlaylistScreen.kt @@ -0,0 +1,55 @@ +package app.vimusic.android.ui.screens.pipedplaylist + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import app.vimusic.android.R +import app.vimusic.android.ui.components.themed.Scaffold +import app.vimusic.android.ui.screens.GlobalRoutes +import app.vimusic.android.ui.screens.Route +import app.vimusic.compose.persist.PersistMapCleanup +import app.vimusic.compose.routing.RouteHandler +import app.vimusic.providers.piped.models.authenticatedWith +import io.ktor.http.Url +import java.util.UUID + +@Route +@Composable +fun PipedPlaylistScreen( + apiBaseUrl: Url, + sessionToken: String, + playlistId: UUID +) { + val saveableStateHolder = rememberSaveableStateHolder() + val session by remember { derivedStateOf { apiBaseUrl authenticatedWith sessionToken } } + + PersistMapCleanup(prefix = "pipedplaylist/$playlistId") + + RouteHandler { + GlobalRoutes() + + Content { + Scaffold( + key = "pipedplaylist", + topIconButtonId = R.drawable.chevron_back, + onTopIconButtonClick = pop, + tabIndex = 0, + onTabChange = { }, + tabColumnContent = { + tab(0, R.string.songs, R.drawable.musical_notes) + } + ) { currentTabIndex -> + saveableStateHolder.SaveableStateProvider(key = currentTabIndex) { + when (currentTabIndex) { + 0 -> PipedPlaylistSongList( + session = session, + playlistId = playlistId + ) + } + } + } + } + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/pipedplaylist/PipedPlaylistSongList.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/pipedplaylist/PipedPlaylistSongList.kt new file mode 100644 index 0000000..fa6f049 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/pipedplaylist/PipedPlaylistSongList.kt @@ -0,0 +1,176 @@ +package app.vimusic.android.ui.screens.pipedplaylist + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import app.vimusic.android.LocalPlayerAwareWindowInsets +import app.vimusic.android.LocalPlayerServiceBinder +import app.vimusic.android.R +import app.vimusic.android.ui.components.LocalMenuState +import app.vimusic.android.ui.components.ShimmerHost +import app.vimusic.android.ui.components.themed.FloatingActionsContainerWithScrollToTop +import app.vimusic.android.ui.components.themed.Header +import app.vimusic.android.ui.components.themed.HeaderPlaceholder +import app.vimusic.android.ui.components.themed.LayoutWithAdaptiveThumbnail +import app.vimusic.android.ui.components.themed.NonQueuedMediaItemMenu +import app.vimusic.android.ui.components.themed.SecondaryTextButton +import app.vimusic.android.ui.components.themed.adaptiveThumbnailContent +import app.vimusic.android.ui.items.SongItem +import app.vimusic.android.ui.items.SongItemPlaceholder +import app.vimusic.android.utils.PlaylistDownloadIcon +import app.vimusic.android.utils.asMediaItem +import app.vimusic.android.utils.enqueue +import app.vimusic.android.utils.forcePlayAtIndex +import app.vimusic.android.utils.forcePlayFromBeginning +import app.vimusic.compose.persist.persist +import app.vimusic.core.ui.Dimensions +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.utils.isLandscape +import app.vimusic.providers.piped.Piped +import app.vimusic.providers.piped.models.Playlist +import app.vimusic.providers.piped.models.Session +import com.valentinilk.shimmer.shimmer +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.UUID + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun PipedPlaylistSongList( + session: Session, + playlistId: UUID, + modifier: Modifier = Modifier +) { + val (colorPalette) = LocalAppearance.current + val binder = LocalPlayerServiceBinder.current + val menuState = LocalMenuState.current + + var playlist by persist(tag = "pipedplaylist/$playlistId/playlistPage") + val mediaItems = remember(playlist) { + playlist?.videos?.mapNotNull { it.asMediaItem }?.toImmutableList() + } + + LaunchedEffect(Unit) { + playlist = withContext(Dispatchers.IO) { + Piped.playlist.songs( + session = session, + id = playlistId + )?.getOrNull() + } + } + + val lazyListState = rememberLazyListState() + + val thumbnailContent = adaptiveThumbnailContent( + isLoading = playlist == null, + url = playlist?.thumbnailUrl?.toString() + ) + + LayoutWithAdaptiveThumbnail( + thumbnailContent = thumbnailContent, + modifier = modifier + ) { + Box { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), + modifier = Modifier + .background(colorPalette.background0) + .fillMaxSize() + ) { + item( + key = "header", + contentType = 0 + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + if (playlist == null) HeaderPlaceholder(modifier = Modifier.shimmer()) + else Header(title = playlist?.name ?: stringResource(R.string.unknown)) { + SecondaryTextButton( + text = stringResource(R.string.enqueue), + enabled = playlist?.videos?.isNotEmpty() == true, + onClick = { + mediaItems?.let { binder?.player?.enqueue(it) } + } + ) + + Spacer(modifier = Modifier.weight(1f)) + + mediaItems?.let { PlaylistDownloadIcon(it) } + } + + if (!isLandscape) thumbnailContent() + } + } + + itemsIndexed(items = playlist?.videos ?: emptyList()) { index, song -> + song.asMediaItem?.let { mediaItem -> + SongItem( + song = mediaItem, + thumbnailSize = Dimensions.thumbnails.song, + modifier = Modifier.combinedClickable( + onLongClick = { + menuState.display { + NonQueuedMediaItemMenu( + onDismiss = menuState::hide, + mediaItem = mediaItem + ) + } + }, + onClick = { + playlist?.videos?.mapNotNull(Playlist.Video::asMediaItem) + ?.let { mediaItems -> + binder?.stopRadio() + binder?.player?.forcePlayAtIndex(mediaItems, index) + } + } + ) + ) + } + } + + if (playlist == null) item(key = "loading") { + ShimmerHost(modifier = Modifier.fillParentMaxSize()) { + repeat(4) { + SongItemPlaceholder(thumbnailSize = Dimensions.thumbnails.song) + } + } + } + } + + FloatingActionsContainerWithScrollToTop( + lazyListState = lazyListState, + icon = R.drawable.shuffle, + onClick = { + playlist?.videos?.let { songs -> + if (songs.isNotEmpty()) { + binder?.stopRadio() + binder?.player?.forcePlayFromBeginning( + songs.shuffled().mapNotNull(Playlist.Video::asMediaItem) + ) + } + } + } + ) + } + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/player/AnimatedPlayPauseIcon.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/player/AnimatedPlayPauseIcon.kt new file mode 100644 index 0000000..0a55ad7 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/player/AnimatedPlayPauseIcon.kt @@ -0,0 +1,127 @@ +package app.vimusic.android.ui.screens.player + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.res.painterResource +import androidx.core.graphics.BlendModeColorFilterCompat +import androidx.core.graphics.BlendModeCompat +import app.vimusic.android.R +import app.vimusic.core.ui.LocalAppearance +import com.airbnb.lottie.LottieComposition +import com.airbnb.lottie.LottieProperty +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieCompositionResult +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.rememberLottieAnimatable +import com.airbnb.lottie.compose.rememberLottieComposition +import com.airbnb.lottie.compose.rememberLottieDynamicProperties +import com.airbnb.lottie.compose.rememberLottieDynamicProperty + +@Composable +fun AnimatedPlayPauseButton( + playing: Boolean, + modifier: Modifier = Modifier +) { + val (colorPalette) = LocalAppearance.current + + val result = rememberLottieComposition( + spec = LottieCompositionSpec.Asset("lottie/play_pause.json") + ) + val comp by result + val progress by comp.animateLottieProgressAsState( + targetState = playing, + speed = 2f + ) + + LottieAnimationWithPlaceholder( + lottieCompositionResult = result, + progress = progress, + tint = colorPalette.text, + placeholder = if (playing) R.drawable.play else R.drawable.pause, + modifier = modifier + ) +} + +@Composable +private fun LottieAnimationWithPlaceholder( + lottieCompositionResult: LottieCompositionResult, + progress: Float, + tint: Color, + @DrawableRes placeholder: Int, + modifier: Modifier = Modifier, + contentDescription: String? = null +) { + val colorFilter = remember(tint) { + BlendModeColorFilterCompat.createBlendModeColorFilterCompat( + /* color = */ tint.toArgb(), + /* blendModeCompat = */ BlendModeCompat.SRC_ATOP + ) + } + val dynamicProperties = rememberLottieDynamicProperties( + rememberLottieDynamicProperty( + property = LottieProperty.COLOR_FILTER, + value = colorFilter, + keyPath = arrayOf("**") + ) + ) + + val ready by produceState(initialValue = false) { + lottieCompositionResult.await() + value = true + } + + if (ready) LottieAnimation( + modifier = modifier, + composition = lottieCompositionResult.value, + progress = { progress }, + dynamicProperties = dynamicProperties + ) else Image( + modifier = modifier, + painter = painterResource(placeholder), + colorFilter = ColorFilter.tint(tint), + contentDescription = contentDescription + ) +} + +@Composable +private fun LottieComposition?.animateLottieProgressAsState( + targetState: Boolean, + speed: Float = 1f +): State { + val lottieProgress = rememberLottieAnimatable() + var first by remember { mutableStateOf(true) } + + LaunchedEffect(first) { + if (!first) return@LaunchedEffect + + lottieProgress.snapTo(progress = if (targetState) 1f else 0f) + first = false + } + + LaunchedEffect(targetState) { + val targetValue = if (targetState) 1f else 0f + + lottieProgress.animate( + composition = this@animateLottieProgressAsState, + speed = when { + lottieProgress.progress < targetValue -> speed + lottieProgress.progress > targetValue -> -speed + else -> return@LaunchedEffect + } + ) + } + + return lottieProgress +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/player/Controls.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/player/Controls.kt new file mode 100644 index 0000000..cef9823 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/player/Controls.kt @@ -0,0 +1,455 @@ +package app.vimusic.android.ui.screens.player + +import androidx.annotation.DrawableRes +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateDp +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEachIndexed +import androidx.media3.common.Player +import app.vimusic.android.Database +import app.vimusic.android.LocalPlayerServiceBinder +import app.vimusic.android.R +import app.vimusic.android.models.Info +import app.vimusic.android.models.ui.UiMedia +import app.vimusic.android.preferences.PlayerPreferences +import app.vimusic.android.service.PlayerService +import app.vimusic.android.ui.components.FadingRow +import app.vimusic.android.ui.components.SeekBar +import app.vimusic.android.ui.components.themed.BigIconButton +import app.vimusic.android.ui.components.themed.IconButton +import app.vimusic.android.ui.screens.artistRoute +import app.vimusic.android.utils.bold +import app.vimusic.android.utils.forceSeekToNext +import app.vimusic.android.utils.forceSeekToPrevious +import app.vimusic.android.utils.secondary +import app.vimusic.android.utils.semiBold +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.favoritesIcon +import app.vimusic.core.ui.utils.px +import app.vimusic.core.ui.utils.roundedShape +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +private val DefaultOffset = 24.dp + +@Composable +fun Controls( + media: UiMedia?, + binder: PlayerService.Binder?, + likedAt: Long?, + setLikedAt: (Long?) -> Unit, + shouldBePlaying: Boolean, + position: Long, + modifier: Modifier = Modifier, + layout: PlayerPreferences.PlayerLayout = PlayerPreferences.playerLayout +) { + val shouldBePlayingTransition = updateTransition( + targetState = shouldBePlaying, + label = "shouldBePlaying" + ) + + val playButtonRadius by shouldBePlayingTransition.animateDp( + transitionSpec = { tween(durationMillis = 100, easing = LinearEasing) }, + label = "playPauseRoundness", + targetValueByState = { if (it) 16.dp else 32.dp } + ) + + if (media != null && binder != null) when (layout) { + PlayerPreferences.PlayerLayout.Classic -> ClassicControls( + media = media, + binder = binder, + shouldBePlaying = shouldBePlaying, + position = position, + likedAt = likedAt, + setLikedAt = setLikedAt, + playButtonRadius = playButtonRadius, + modifier = modifier + ) + + PlayerPreferences.PlayerLayout.New -> ModernControls( + media = media, + binder = binder, + shouldBePlaying = shouldBePlaying, + position = position, + likedAt = likedAt, + setLikedAt = setLikedAt, + playButtonRadius = playButtonRadius, + modifier = modifier + ) + } +} + +@Composable +private fun ClassicControls( + media: UiMedia, + binder: PlayerService.Binder, + shouldBePlaying: Boolean, + position: Long, + likedAt: Long?, + setLikedAt: (Long?) -> Unit, + playButtonRadius: Dp, + modifier: Modifier = Modifier +) = with(PlayerPreferences) { + val (colorPalette) = LocalAppearance.current + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 32.dp) + ) { + Spacer(modifier = Modifier.weight(1f)) + MediaInfo(media) + Spacer(modifier = Modifier.weight(1f)) + SeekBar( + binder = binder, + position = position, + media = media, + alwaysShowDuration = true + ) + Spacer(modifier = Modifier.weight(1f)) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + IconButton( + icon = if (likedAt == null) R.drawable.heart_outline else R.drawable.heart, + color = colorPalette.favoritesIcon, + onClick = { + setLikedAt(if (likedAt == null) System.currentTimeMillis() else null) + }, + modifier = Modifier + .weight(1f) + .size(24.dp) + ) + + IconButton( + icon = R.drawable.play_skip_back, + color = colorPalette.text, + onClick = binder.player::forceSeekToPrevious, + modifier = Modifier + .weight(1f) + .size(24.dp) + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Box( + modifier = Modifier + .clip(playButtonRadius.roundedShape) + .clickable { + if (shouldBePlaying) binder.player.pause() + else { + if (binder.player.playbackState == Player.STATE_IDLE) binder.player.prepare() + binder.player.play() + } + } + .background(colorPalette.background2) + .size(64.dp) + ) { + AnimatedPlayPauseButton( + playing = shouldBePlaying, + modifier = Modifier + .align(Alignment.Center) + .size(32.dp) + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + IconButton( + icon = R.drawable.play_skip_forward, + color = colorPalette.text, + onClick = binder.player::forceSeekToNext, + modifier = Modifier + .weight(1f) + .size(24.dp) + ) + + IconButton( + icon = R.drawable.infinite, + enabled = trackLoopEnabled, + onClick = { trackLoopEnabled = !trackLoopEnabled }, + modifier = Modifier + .weight(1f) + .size(24.dp) + ) + } + + Spacer(modifier = Modifier.weight(1f)) + } +} + +@Composable +private fun ModernControls( + media: UiMedia, + binder: PlayerService.Binder, + shouldBePlaying: Boolean, + position: Long, + likedAt: Long?, + setLikedAt: (Long?) -> Unit, + playButtonRadius: Dp, + modifier: Modifier = Modifier, + controlHeight: Dp = 64.dp +) { + val previousButtonContent: @Composable RowScope.() -> Unit = { + SkipButton( + iconId = R.drawable.play_skip_back, + onClick = binder.player::forceSeekToPrevious, + modifier = Modifier.weight(1f), + offsetOnPress = -DefaultOffset + ) + } + + val likeButtonContent: @Composable RowScope.() -> Unit = { + BigIconButton( + iconId = if (likedAt == null) R.drawable.heart_outline else R.drawable.heart, + onClick = { + setLikedAt(if (likedAt == null) System.currentTimeMillis() else null) + }, + modifier = Modifier.weight(1f) + ) + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 32.dp) + ) { + Spacer(modifier = Modifier.weight(1f)) + MediaInfo(media) + Spacer(modifier = Modifier.weight(1f)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(if (PlayerPreferences.showLike) 4.dp else 8.dp) + ) { + if (PlayerPreferences.showLike) previousButtonContent() + PlayButton( + radius = playButtonRadius, + shouldBePlaying = shouldBePlaying, + modifier = Modifier + .height(controlHeight) + .weight(if (PlayerPreferences.showLike) 3f else 4f) + ) + SkipButton( + iconId = R.drawable.play_skip_forward, + onClick = binder.player::forceSeekToNext, + modifier = Modifier.weight(1f) + ) + } + Spacer(modifier = Modifier.weight(1f)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (PlayerPreferences.showLike) likeButtonContent() else previousButtonContent() + + Column(modifier = Modifier.weight(4f)) { + SeekBar( + binder = binder, + position = position, + media = media + ) + } + } + + Spacer(modifier = Modifier.weight(1f)) + } +} + +@Composable +private fun SkipButton( + @DrawableRes iconId: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, + offsetOnPress: Dp = DefaultOffset +) { + val interactionSource = remember { MutableInteractionSource() } + val pressed by interactionSource.collectIsPressedAsState() + val offset by animateDpAsState( + targetValue = if (pressed) offsetOnPress else 0.dp, + label = "" + ) + + BigIconButton( + iconId = iconId, + modifier = modifier + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick + ) + .offset { + IntOffset(x = offset.roundToPx(), y = 0) + } + ) +} + +@Composable +private fun PlayButton( + radius: Dp, + shouldBePlaying: Boolean, + modifier: Modifier = Modifier +) { + val (colorPalette) = LocalAppearance.current + val binder = LocalPlayerServiceBinder.current + + Box( + modifier = modifier + .clip(radius.roundedShape) + .clickable { + if (shouldBePlaying) binder?.player?.pause() else { + if (binder?.player?.playbackState == Player.STATE_IDLE) binder.player.prepare() + binder?.player?.play() + } + } + .background(colorPalette.accent) + ) { + AnimatedPlayPauseButton( + playing = shouldBePlaying, + modifier = Modifier + .align(Alignment.Center) + .size(32.dp) + ) + } +} + +@Composable +private fun MediaInfo(media: UiMedia) { + val (colorPalette, typography) = LocalAppearance.current + + var artistInfo: List? by remember { mutableStateOf(null) } + var maxHeight by rememberSaveable { mutableIntStateOf(0) } + + LaunchedEffect(media) { + withContext(Dispatchers.IO) { + artistInfo = Database + .songArtistInfo(media.id) + .takeIf { it.isNotEmpty() } + } + } + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + AnimatedContent( + targetState = media.title, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "" + ) { title -> + FadingRow(modifier = Modifier.fillMaxWidth(0.75f)) { + BasicText( + text = title, + style = typography.l.bold, + maxLines = 1 + ) + } + } + + AnimatedContent( + targetState = media to artistInfo, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "" + ) { (media, state) -> + state?.let { artists -> + FadingRow( + modifier = Modifier + .fillMaxWidth(0.75f) + .heightIn(maxHeight.px.dp), + verticalAlignment = Alignment.CenterVertically + ) { + artists.fastForEachIndexed { i, artist -> + if (i == artists.lastIndex && artists.size > 1) BasicText( + text = " & ", + style = typography.s.semiBold.secondary + ) + BasicText( + text = artist.name.orEmpty(), + style = typography.s.bold.secondary, + modifier = Modifier.clickable { artistRoute.global(artist.id) } + ) + if (i != artists.lastIndex && i + 1 != artists.lastIndex) BasicText( + text = ", ", + style = typography.s.semiBold.secondary + ) + } + if (media.explicit) { + Spacer(Modifier.width(4.dp)) + + Image( + painter = painterResource(R.drawable.explicit), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier.size(15.dp) + ) + } + } + } ?: FadingRow( + modifier = Modifier.fillMaxWidth(0.75f), + verticalAlignment = Alignment.CenterVertically + ) { + BasicText( + text = media.artist, + style = typography.s.semiBold.secondary, + maxLines = 1, + modifier = Modifier.onGloballyPositioned { maxHeight = it.size.height } + ) + if (media.explicit) { + Spacer(Modifier.width(4.dp)) + + Image( + painter = painterResource(R.drawable.explicit), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier.size(15.dp) + ) + } + } + } + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/player/Lyrics.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/player/Lyrics.kt new file mode 100644 index 0000000..1f94764 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/player/Lyrics.kt @@ -0,0 +1,711 @@ +package app.vimusic.android.ui.screens.player + +import android.app.SearchManager +import android.content.ActivityNotFoundException +import android.content.Intent +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.media3.common.C +import androidx.media3.common.MediaMetadata +import app.vimusic.android.Database +import app.vimusic.android.LocalPlayerServiceBinder +import app.vimusic.android.R +import app.vimusic.android.models.Lyrics +import app.vimusic.android.preferences.PlayerPreferences +import app.vimusic.android.query +import app.vimusic.android.service.LOCAL_KEY_PREFIX +import app.vimusic.android.transaction +import app.vimusic.android.ui.components.LocalMenuState +import app.vimusic.android.ui.components.themed.CircularProgressIndicator +import app.vimusic.android.ui.components.themed.DefaultDialog +import app.vimusic.android.ui.components.themed.Menu +import app.vimusic.android.ui.components.themed.MenuEntry +import app.vimusic.android.ui.components.themed.TextField +import app.vimusic.android.ui.components.themed.TextFieldDialog +import app.vimusic.android.ui.components.themed.TextPlaceholder +import app.vimusic.android.ui.components.themed.ValueSelectorDialogBody +import app.vimusic.android.ui.modifiers.verticalFadingEdge +import app.vimusic.android.utils.SynchronizedLyrics +import app.vimusic.android.utils.SynchronizedLyricsState +import app.vimusic.android.utils.center +import app.vimusic.android.utils.color +import app.vimusic.android.utils.isInPip +import app.vimusic.android.utils.medium +import app.vimusic.android.utils.semiBold +import app.vimusic.android.utils.toast +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.onOverlay +import app.vimusic.core.ui.onOverlayShimmer +import app.vimusic.core.ui.overlay +import app.vimusic.core.ui.utils.dp +import app.vimusic.providers.innertube.Innertube +import app.vimusic.providers.innertube.models.bodies.NextBody +import app.vimusic.providers.innertube.requests.lyrics +import app.vimusic.providers.kugou.KuGou +import app.vimusic.providers.lrclib.LrcLib +import app.vimusic.providers.lrclib.LrcParser +import app.vimusic.providers.lrclib.models.Track +import app.vimusic.providers.lrclib.toLrcFile +import com.valentinilk.shimmer.shimmer +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableMap +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.cancellable +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.withContext +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +private const val UPDATE_DELAY = 50L + +@Composable +fun Lyrics( + mediaId: String, + isDisplayed: Boolean, + onDismiss: () -> Unit, + mediaMetadataProvider: () -> MediaMetadata, + durationProvider: () -> Long, + ensureSongInserted: () -> Unit, + modifier: Modifier = Modifier, + onMenuLaunch: () -> Unit = { }, + onOpenDialog: (() -> Unit)? = null, + shouldShowSynchronizedLyrics: Boolean = PlayerPreferences.isShowingSynchronizedLyrics, + setShouldShowSynchronizedLyrics: (Boolean) -> Unit = { + PlayerPreferences.isShowingSynchronizedLyrics = it + }, + shouldKeepScreenAwake: Boolean = PlayerPreferences.lyricsKeepScreenAwake, + shouldUpdateLyrics: Boolean = true, + showControls: Boolean = true +) = AnimatedVisibility( + visible = isDisplayed, + enter = fadeIn(), + exit = fadeOut() +) { + val currentEnsureSongInserted by rememberUpdatedState(ensureSongInserted) + val currentMediaMetadataProvider by rememberUpdatedState(mediaMetadataProvider) + val currentDurationProvider by rememberUpdatedState(durationProvider) + + val (colorPalette, typography) = LocalAppearance.current + val context = LocalContext.current + val menuState = LocalMenuState.current + val binder = LocalPlayerServiceBinder.current + val density = LocalDensity.current + val view = LocalView.current + + val pip = isInPip() + + var lyrics by remember { mutableStateOf(null) } + + val showSynchronizedLyrics = remember(shouldShowSynchronizedLyrics, lyrics) { + shouldShowSynchronizedLyrics && lyrics?.synced?.isBlank() != true + } + + var editing by remember(mediaId, shouldShowSynchronizedLyrics) { mutableStateOf(false) } + var picking by remember(mediaId, shouldShowSynchronizedLyrics) { mutableStateOf(false) } + var error by remember(mediaId, shouldShowSynchronizedLyrics) { mutableStateOf(false) } + + val text = remember(lyrics, showSynchronizedLyrics) { + if (showSynchronizedLyrics) lyrics?.synced else lyrics?.fixed + } + var invalidLrc by remember(text) { mutableStateOf(false) } + + DisposableEffect(shouldKeepScreenAwake) { + view.keepScreenOn = shouldKeepScreenAwake + + onDispose { + view.keepScreenOn = false + } + } + + LaunchedEffect(mediaId, shouldShowSynchronizedLyrics) { + runCatching { + withContext(Dispatchers.IO) { + Database + .lyrics(mediaId) + .distinctUntilChanged() + .cancellable() + .collect { currentLyrics -> + if ( + !shouldUpdateLyrics || + (currentLyrics?.fixed != null && currentLyrics.synced != null) + ) lyrics = currentLyrics + else { + val mediaMetadata = currentMediaMetadataProvider() + var duration = + withContext(Dispatchers.Main) { currentDurationProvider() } + + while (duration == C.TIME_UNSET) { + delay(100) + duration = + withContext(Dispatchers.Main) { currentDurationProvider() } + } + + val album = mediaMetadata.albumTitle?.toString() + val artist = mediaMetadata.artist?.toString().orEmpty() + val title = mediaMetadata.title?.toString().orEmpty().let { + if (mediaId.startsWith(LOCAL_KEY_PREFIX)) it + .substringBeforeLast('.') + .trim() + else it + } + + lyrics = null + error = false + + val fixed = currentLyrics?.fixed ?: Innertube + .lyrics(NextBody(videoId = mediaId)) + ?.getOrNull() + ?: LrcLib.bestLyrics( + artist = artist, + title = title, + duration = duration.milliseconds, + album = album, + synced = false + )?.map { it?.text }?.getOrNull() + + val synced = currentLyrics?.synced ?: LrcLib.bestLyrics( + artist = artist, + title = title, + duration = duration.milliseconds, + album = album + )?.map { it?.text }?.getOrNull() ?: LrcLib.bestLyrics( + artist = artist, + title = title.split("(")[0].trim(), + duration = duration.milliseconds, + album = album + )?.map { it?.text }?.getOrNull() ?: KuGou.lyrics( + artist = artist, + title = title, + duration = duration / 1000 + )?.map { it?.value }?.getOrNull() + + Lyrics( + songId = mediaId, + fixed = fixed.orEmpty(), + synced = synced.orEmpty() + ).also { + ensureActive() + + transaction { + runCatching { + currentEnsureSongInserted() + Database.upsert(it) + } + } + } + } + + error = + (shouldShowSynchronizedLyrics && lyrics?.synced?.isBlank() == true) || + (!shouldShowSynchronizedLyrics && lyrics?.fixed?.isBlank() == true) + } + } + }.exceptionOrNull()?.let { + if (it is CancellationException) throw it + else it.printStackTrace() + } + } + + if (editing) TextFieldDialog( + hintText = stringResource(R.string.enter_lyrics), + initialTextInput = (if (shouldShowSynchronizedLyrics) lyrics?.synced else lyrics?.fixed).orEmpty(), + singleLine = false, + maxLines = 10, + isTextInputValid = { true }, + onDismiss = { editing = false }, + onAccept = { + transaction { + runCatching { + currentEnsureSongInserted() + + Database.upsert( + if (shouldShowSynchronizedLyrics) Lyrics( + songId = mediaId, + fixed = lyrics?.fixed, + synced = it + ) else Lyrics( + songId = mediaId, + fixed = it, + synced = lyrics?.synced + ) + ) + } + } + } + ) + + if (picking && shouldShowSynchronizedLyrics) { + var query by rememberSaveable { + mutableStateOf( + currentMediaMetadataProvider().title?.toString().orEmpty().let { + if (mediaId.startsWith(LOCAL_KEY_PREFIX)) it + .substringBeforeLast('.') + .trim() + else it + } + ) + } + + LrcLibSearchDialog( + query = query, + setQuery = { query = it }, + onDismiss = { picking = false }, + onPick = { + runCatching { + transaction { + Database.upsert( + Lyrics( + songId = mediaId, + fixed = lyrics?.fixed, + synced = it.syncedLyrics + ) + ) + } + } + } + ) + } + + BoxWithConstraints( + contentAlignment = Alignment.Center, + modifier = modifier + .pointerInput(Unit) { + detectTapGestures(onTap = { onDismiss() }) + } + .fillMaxSize() + .background(colorPalette.overlay) + ) { + val animatedHeight by animateDpAsState( + targetValue = maxHeight, + label = "" + ) + + AnimatedVisibility( + visible = error, + enter = slideInVertically { -it }, + exit = slideOutVertically { -it }, + modifier = Modifier.align(Alignment.TopCenter) + ) { + BasicText( + text = stringResource( + if (shouldShowSynchronizedLyrics) R.string.synchronized_lyrics_not_available + else R.string.lyrics_not_available + ), + style = typography.xs.center.medium.color(colorPalette.onOverlay), + modifier = Modifier + .background(Color.Black.copy(0.4f)) + .padding(all = 8.dp) + .fillMaxWidth(), + maxLines = if (pip) 1 else Int.MAX_VALUE, + overflow = TextOverflow.Ellipsis + ) + } + + AnimatedVisibility( + visible = !text.isNullOrBlank() && !error && invalidLrc && shouldShowSynchronizedLyrics, + enter = slideInVertically { -it }, + exit = slideOutVertically { -it }, + modifier = Modifier.align(Alignment.TopCenter) + ) { + BasicText( + text = stringResource(R.string.invalid_synchronized_lyrics), + style = typography.xs.center.medium.color(colorPalette.onOverlay), + modifier = Modifier + .background(Color.Black.copy(0.4f)) + .padding(all = 8.dp) + .fillMaxWidth(), + maxLines = if (pip) 1 else Int.MAX_VALUE, + overflow = TextOverflow.Ellipsis + ) + } + + val lyricsState = rememberSaveable(text) { + val file = lyrics?.synced?.takeIf { it.isNotBlank() }?.let { + LrcParser.parse(it)?.toLrcFile() + } + + SynchronizedLyricsState( + sentences = file?.lines, + offset = file?.offset?.inWholeMilliseconds ?: 0L + ) + } + + val synchronizedLyrics = remember(lyricsState) { + invalidLrc = lyricsState.sentences == null + lyricsState.sentences?.let { + SynchronizedLyrics(it.toImmutableMap()) { + binder?.player?.let { player -> + player.currentPosition + UPDATE_DELAY + lyricsState.offset - + (lyrics?.startTime ?: 0L) + } ?: 0L + } + } + } + + AnimatedContent( + targetState = showSynchronizedLyrics, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "" + ) { synchronized -> + val lazyListState = rememberLazyListState() + if (synchronized) { + LaunchedEffect(synchronizedLyrics, density, animatedHeight) { + val currentSynchronizedLyrics = synchronizedLyrics ?: return@LaunchedEffect + val centerOffset = with(density) { (-animatedHeight / 3).roundToPx() } + + lazyListState.animateScrollToItem( + index = currentSynchronizedLyrics.index + 1, + scrollOffset = centerOffset + ) + + while (true) { + delay(UPDATE_DELAY) + if (!currentSynchronizedLyrics.update()) continue + + lazyListState.animateScrollToItem( + index = currentSynchronizedLyrics.index + 1, + scrollOffset = centerOffset + ) + } + } + + if (synchronizedLyrics != null) LazyColumn( + state = lazyListState, + userScrollEnabled = false, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .verticalFadingEdge() + .fillMaxWidth() + ) { + item(key = "header", contentType = 0) { + Spacer(modifier = Modifier.height(maxHeight)) + } + itemsIndexed( + items = synchronizedLyrics.sentences.values.toImmutableList() + ) { index, sentence -> + val color by animateColorAsState( + if (index == synchronizedLyrics.index) Color.White + else colorPalette.textDisabled + ) + + if (sentence.isBlank()) Image( + painter = painterResource(R.drawable.musical_notes), + contentDescription = null, + colorFilter = ColorFilter.tint(color), + modifier = Modifier + .padding(vertical = 4.dp, horizontal = 32.dp) + .size(typography.xs.fontSize.dp) + ) else BasicText( + text = sentence, + style = typography.xs.center.medium.color(color), + modifier = Modifier.padding(vertical = 4.dp, horizontal = 32.dp) + ) + } + item(key = "footer", contentType = 0) { + Spacer(modifier = Modifier.height(maxHeight)) + } + } + } else BasicText( + text = lyrics?.fixed.orEmpty(), + style = typography.xs.center.medium.color(colorPalette.onOverlay), + modifier = Modifier + .verticalFadingEdge() + .verticalScroll(rememberScrollState()) + .fillMaxWidth() + .padding(vertical = maxHeight / 4, horizontal = 32.dp) + ) + } + + if (text == null && !error) Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.shimmer() + ) { + repeat(4) { + TextPlaceholder( + color = colorPalette.onOverlayShimmer, + modifier = Modifier.alpha(1f - it * 0.2f) + ) + } + } + + if (showControls) { + if (onOpenDialog != null) Image( + painter = painterResource(R.drawable.expand), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.onOverlay), + modifier = Modifier + .padding(all = 4.dp) + .clickable( + indication = ripple(bounded = false), + interactionSource = remember { MutableInteractionSource() }, + onClick = { + onOpenDialog() + } + ) + .padding(all = 8.dp) + .size(20.dp) + .align(Alignment.BottomStart) + ) + + Image( + painter = painterResource(R.drawable.ellipsis_horizontal), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.onOverlay), + modifier = Modifier + .padding(all = 4.dp) + .clickable( + indication = ripple(bounded = false), + interactionSource = remember { MutableInteractionSource() }, + onClick = { + onMenuLaunch() + menuState.display { + Menu { + MenuEntry( + icon = R.drawable.time, + text = stringResource( + if (shouldShowSynchronizedLyrics) R.string.show_unsynchronized_lyrics + else R.string.show_synchronized_lyrics + ), + secondaryText = if (shouldShowSynchronizedLyrics) null + else stringResource(R.string.provided_lyrics_by), + onClick = { + menuState.hide() + setShouldShowSynchronizedLyrics(!shouldShowSynchronizedLyrics) + } + ) + + MenuEntry( + icon = R.drawable.pencil, + text = stringResource(R.string.edit_lyrics), + onClick = { + menuState.hide() + editing = true + } + ) + + MenuEntry( + icon = R.drawable.search, + text = stringResource(R.string.search_lyrics_online), + onClick = { + menuState.hide() + val mediaMetadata = currentMediaMetadataProvider() + + try { + context.startActivity( + Intent(Intent.ACTION_WEB_SEARCH).apply { + putExtra( + SearchManager.QUERY, + "${mediaMetadata.title} ${mediaMetadata.artist} lyrics" + ) + } + ) + } catch (e: ActivityNotFoundException) { + context.toast(context.getString(R.string.no_browser_installed)) + } + } + ) + + MenuEntry( + icon = R.drawable.sync, + text = stringResource(R.string.refetch_lyrics), + enabled = lyrics != null, + onClick = { + menuState.hide() + + transaction { + runCatching { + currentEnsureSongInserted() + + Database.upsert( + if (shouldShowSynchronizedLyrics) Lyrics( + songId = mediaId, + fixed = lyrics?.fixed, + synced = null + ) else Lyrics( + songId = mediaId, + fixed = null, + synced = lyrics?.synced + ) + ) + } + } + } + ) + + if (shouldShowSynchronizedLyrics) { + MenuEntry( + icon = R.drawable.download, + text = stringResource(R.string.pick_from_lrclib), + onClick = { + menuState.hide() + picking = true + } + ) + MenuEntry( + icon = R.drawable.play_skip_forward, + text = stringResource(R.string.set_lyrics_start_offset), + secondaryText = stringResource( + R.string.set_lyrics_start_offset_description + ), + onClick = { + menuState.hide() + lyrics?.let { + val startTime = binder?.player?.currentPosition + query { + Database.upsert(it.copy(startTime = startTime)) + } + } + } + ) + } + } + } + } + ) + .padding(all = 8.dp) + .size(20.dp) + .align(Alignment.BottomEnd) + ) + } + } +} + +@Composable +fun LrcLibSearchDialog( + query: String, + setQuery: (String) -> Unit, + onDismiss: () -> Unit, + onPick: (Track) -> Unit, + modifier: Modifier = Modifier +) = DefaultDialog( + onDismiss = onDismiss, + horizontalPadding = 0.dp, + modifier = modifier +) { + val (_, typography) = LocalAppearance.current + + val tracks = remember { mutableStateListOf() } + var loading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(false) } + + LaunchedEffect(query) { + loading = true + error = false + + delay(1000) + + LrcLib.lyrics( + query = query, + synced = true + )?.onSuccess { newTracks -> + tracks.clear() + tracks.addAll(newTracks.filter { !it.syncedLyrics.isNullOrBlank() }) + loading = false + error = false + }?.onFailure { + loading = false + error = true + it.printStackTrace() + } ?: run { loading = false } + } + + TextField( + value = query, + onValueChange = setQuery, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + maxLines = 1, + singleLine = true + ) + Spacer(modifier = Modifier.height(8.dp)) + + when { + loading -> CircularProgressIndicator( + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + + error || tracks.isEmpty() -> BasicText( + text = stringResource(R.string.no_lyrics_found), + style = typography.s.semiBold.center, + modifier = Modifier + .padding(all = 24.dp) + .align(Alignment.CenterHorizontally) + ) + + else -> ValueSelectorDialogBody( + onDismiss = onDismiss, + title = stringResource(R.string.choose_lyric_track), + selectedValue = null, + values = tracks.toImmutableList(), + onValueSelect = { + transaction { + onPick(it) + onDismiss() + } + }, + valueText = { + "${it.artistName} - ${it.trackName} (${ + it.duration.seconds.toComponents { minutes, seconds, _ -> + "$minutes:${seconds.toString().padStart(2, '0')}" + } + })" + } + ) + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/player/LyricsDialog.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/player/LyricsDialog.kt new file mode 100644 index 0000000..6d2e375 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/player/LyricsDialog.kt @@ -0,0 +1,132 @@ +package app.vimusic.android.ui.screens.player + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import app.vimusic.android.Database +import app.vimusic.android.LocalPlayerServiceBinder +import app.vimusic.android.preferences.PlayerPreferences +import app.vimusic.android.ui.modifiers.PinchDirection +import app.vimusic.android.ui.modifiers.onSwipe +import app.vimusic.android.ui.modifiers.pinchToToggle +import app.vimusic.android.utils.FullScreenState +import app.vimusic.android.utils.forceSeekToNext +import app.vimusic.android.utils.forceSeekToPrevious +import app.vimusic.android.utils.thumbnail +import app.vimusic.android.utils.windowState +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.utils.px +import coil3.compose.AsyncImage + +@Composable +fun LyricsDialog( + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) = Dialog(onDismissRequest = onDismiss) { + val currentOnDismiss by rememberUpdatedState(onDismiss) + + FullScreenState(shown = PlayerPreferences.lyricsShowSystemBars) + + val (colorPalette, _, _, thumbnailShape) = LocalAppearance.current + + val player = LocalPlayerServiceBinder.current?.player ?: return@Dialog + val (window, error) = windowState() + + LaunchedEffect(window, error) { + if (window == null || error != null) currentOnDismiss() + } + + window ?: return@Dialog + + AnimatedContent( + targetState = window, + transitionSpec = { + if (initialState.mediaItem.mediaId == targetState.mediaItem.mediaId) + return@AnimatedContent ContentTransform( + targetContentEnter = EnterTransition.None, + initialContentExit = ExitTransition.None + ) + + val direction = if (targetState.firstPeriodIndex > initialState.firstPeriodIndex) + AnimatedContentTransitionScope.SlideDirection.Left + else AnimatedContentTransitionScope.SlideDirection.Right + + ContentTransform( + targetContentEnter = slideIntoContainer( + towards = direction, + animationSpec = tween(500) + ), + initialContentExit = slideOutOfContainer( + towards = direction, + animationSpec = tween(500) + ), + sizeTransform = null + ) + }, + label = "" + ) { currentWindow -> + BoxWithConstraints( + modifier = modifier + .padding(all = 36.dp) + .padding(vertical = 32.dp) + .clip(thumbnailShape) + .fillMaxSize() + .background(colorPalette.background1) + .pinchToToggle( + direction = PinchDirection.In, + threshold = 0.9f, + onPinch = { onDismiss() } + ) + .onSwipe( + onSwipeLeft = { + player.forceSeekToNext() + }, + onSwipeRight = { + player.seekToDefaultPosition() + player.forceSeekToPrevious() + } + ) + ) { + if (currentWindow.mediaItem.mediaMetadata.artworkUri != null) AsyncImage( + model = currentWindow.mediaItem.mediaMetadata.artworkUri.thumbnail((maxHeight - 64.dp).px), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .background(colorPalette.background0) + .blur(radius = 8.dp) + ) + + Lyrics( + mediaId = currentWindow.mediaItem.mediaId, + isDisplayed = true, + onDismiss = { }, + mediaMetadataProvider = currentWindow.mediaItem::mediaMetadata, + durationProvider = player::getDuration, + ensureSongInserted = { Database.insert(currentWindow.mediaItem) }, + onMenuLaunch = onDismiss, + modifier = Modifier.height(maxHeight), + shouldKeepScreenAwake = false, // otherwise the keepScreenOn flag resets after dialog closes + shouldUpdateLyrics = false + ) + } + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/player/PlaybackError.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/player/PlaybackError.kt new file mode 100644 index 0000000..32ce05e --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/player/PlaybackError.kt @@ -0,0 +1,83 @@ +package app.vimusic.android.ui.screens.player + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import app.vimusic.android.utils.center +import app.vimusic.android.utils.color +import app.vimusic.android.utils.isInPip +import app.vimusic.android.utils.medium +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.onOverlay +import app.vimusic.core.ui.overlay + +@Composable +fun PlaybackError( + isDisplayed: Boolean, + messageProvider: @Composable () -> String, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) = Box(modifier = modifier) { + val (colorPalette, typography) = LocalAppearance.current + val message by rememberUpdatedState(newValue = messageProvider()) + val pip = isInPip() + + AnimatedVisibility( + visible = isDisplayed, + enter = fadeIn(), + exit = fadeOut() + ) { + Spacer( + modifier = Modifier + .pointerInput(Unit) { + detectTapGestures(onTap = { onDismiss() }) + } + .fillMaxSize() + .background(Color.Black.copy(0.8f)) + ) + } + + AnimatedContent( + targetState = message.takeIf { isDisplayed }, + transitionSpec = { + ContentTransform( + targetContentEnter = slideInVertically { -it }, + initialContentExit = slideOutVertically { -it }, + sizeTransform = null + ) + }, + label = "", + modifier = Modifier.fillMaxWidth() + ) { currentMessage -> + if (currentMessage != null) BasicText( + text = currentMessage, + style = typography.xs.center.medium.color(colorPalette.onOverlay), + modifier = Modifier + .background(colorPalette.overlay.copy(alpha = 0.4f)) + .padding(all = 8.dp) + .fillMaxWidth(), + maxLines = if (pip) 1 else Int.MAX_VALUE, + overflow = TextOverflow.Ellipsis + ) + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/player/Player.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/player/Player.kt new file mode 100644 index 0000000..6daf00f --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/player/Player.kt @@ -0,0 +1,621 @@ +package app.vimusic.android.ui.screens.player + +import androidx.annotation.OptIn +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SnapshotMutationPolicy +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.neverEqualPolicy +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.coerceAtMost +import androidx.compose.ui.unit.dp +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import app.vimusic.android.Database +import app.vimusic.android.LocalPlayerServiceBinder +import app.vimusic.android.R +import app.vimusic.android.models.ui.toUiMedia +import app.vimusic.android.preferences.PlayerPreferences +import app.vimusic.android.query +import app.vimusic.android.service.PlayerService +import app.vimusic.android.transaction +import app.vimusic.android.ui.components.BottomSheet +import app.vimusic.android.ui.components.BottomSheetState +import app.vimusic.android.ui.components.LocalMenuState +import app.vimusic.android.ui.components.rememberBottomSheetState +import app.vimusic.android.ui.components.themed.BaseMediaItemMenu +import app.vimusic.android.ui.components.themed.IconButton +import app.vimusic.android.ui.components.themed.SecondaryTextButton +import app.vimusic.android.ui.components.themed.SliderDialog +import app.vimusic.android.ui.components.themed.SliderDialogBody +import app.vimusic.android.ui.modifiers.PinchDirection +import app.vimusic.android.ui.modifiers.onSwipe +import app.vimusic.android.ui.modifiers.pinchToToggle +import app.vimusic.android.utils.DisposableListener +import app.vimusic.android.utils.Pip +import app.vimusic.android.utils.forceSeekToNext +import app.vimusic.android.utils.forceSeekToPrevious +import app.vimusic.android.utils.positionAndDurationState +import app.vimusic.android.utils.rememberEqualizerLauncher +import app.vimusic.android.utils.rememberPipHandler +import app.vimusic.android.utils.seamlessPlay +import app.vimusic.android.utils.secondary +import app.vimusic.android.utils.semiBold +import app.vimusic.android.utils.shouldBePlaying +import app.vimusic.android.utils.thumbnail +import app.vimusic.compose.persist.PersistMapCleanup +import app.vimusic.compose.routing.OnGlobalRoute +import app.vimusic.core.ui.Dimensions +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.ThumbnailRoundness +import app.vimusic.core.ui.collapsedPlayerProgressBar +import app.vimusic.core.ui.utils.isLandscape +import app.vimusic.core.ui.utils.px +import app.vimusic.core.ui.utils.roundedShape +import app.vimusic.core.ui.utils.songBundle +import app.vimusic.providers.innertube.models.NavigationEndpoint +import coil3.compose.AsyncImage +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlin.math.absoluteValue + +@Composable +fun Player( + layoutState: BottomSheetState, + modifier: Modifier = Modifier, + shape: RoundedCornerShape = RoundedCornerShape( + topStart = 12.dp, + topEnd = 12.dp + ), + windowInsets: WindowInsets = WindowInsets.systemBars +) = with(PlayerPreferences) { + val menuState = LocalMenuState.current + val (colorPalette, typography, thumbnailCornerSize) = LocalAppearance.current + val binder = LocalPlayerServiceBinder.current + + val pipHandler = rememberPipHandler() + + PersistMapCleanup(prefix = "queue/suggestions") + + var mediaItem by remember(binder) { + mutableStateOf( + value = binder?.player?.currentMediaItem, + policy = neverEqualPolicy() + ) + } + var shouldBePlaying by remember(binder) { mutableStateOf(binder?.player?.shouldBePlaying == true) } + + var likedAt by remember(mediaItem) { + mutableStateOf( + value = null, + policy = object : SnapshotMutationPolicy { + override fun equivalent(a: Long?, b: Long?): Boolean { + mediaItem?.mediaId?.let { + query { + Database.like(it, b) + } + } + return a == b + } + } + ) + } + + LaunchedEffect(mediaItem) { + mediaItem?.mediaId?.let { mediaId -> + Database + .likedAt(mediaId) + .distinctUntilChanged() + .collect { likedAt = it } + } + } + + binder?.player.DisposableListener { + object : Player.Listener { + override fun onMediaItemTransition(newMediaItem: MediaItem?, reason: Int) { + mediaItem = newMediaItem + } + + override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { + shouldBePlaying = player.shouldBePlaying + } + + override fun onPlaybackStateChanged(playbackState: Int) { + shouldBePlaying = player.shouldBePlaying + } + } + } + + val (position, duration) = binder?.player.positionAndDurationState() + val metadata = remember(mediaItem) { mediaItem?.mediaMetadata } + val extras = remember(metadata) { metadata?.extras?.songBundle } + + val horizontalBottomPaddingValues = windowInsets + .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom) + .asPaddingValues() + + OnGlobalRoute { if (layoutState.expanded) layoutState.collapseSoft() } + + if (mediaItem != null) BottomSheet( + state = layoutState, + modifier = modifier.fillMaxSize(), + onDismiss = { + binder?.let { onDismiss(it) } + layoutState.dismissSoft() + }, + backHandlerEnabled = !menuState.isDisplayed, + collapsedContent = { innerModifier -> + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top, + modifier = Modifier + .let { modifier -> + if (horizontalSwipeToClose) modifier.onSwipe( + animateOffset = true, + onSwipeOut = { animationJob -> + binder?.let { onDismiss(it) } + animationJob.join() + layoutState.dismissSoft() + } + ) else modifier + } + .fillMaxSize() + .clip(shape) + .background(colorPalette.background1) + .drawBehind { + drawRect( + color = colorPalette.collapsedPlayerProgressBar, + topLeft = Offset.Zero, + size = Size( + width = runCatching { + size.width * (position.toFloat() / duration.absoluteValue) + }.getOrElse { 0f }, + height = size.height + ) + ) + } + .then(innerModifier) + .padding(horizontalBottomPaddingValues) + ) { + Spacer(modifier = Modifier.width(2.dp)) + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.height(Dimensions.items.collapsedPlayerHeight) + ) { + AsyncImage( + model = metadata?.artworkUri?.thumbnail(Dimensions.thumbnails.song.px), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .clip(thumbnailCornerSize.coerceAtMost(ThumbnailRoundness.Heavy.dp).roundedShape) + .background(colorPalette.background0) + .size(48.dp) + ) + } + + Column( + verticalArrangement = Arrangement.Center, + modifier = Modifier + .height(Dimensions.items.collapsedPlayerHeight) + .weight(1f) + ) { + AnimatedContent( + targetState = metadata?.title?.toString().orEmpty(), + label = "", + transitionSpec = { fadeIn() togetherWith fadeOut() } + ) { text -> + BasicText( + text = text, + style = typography.xs.semiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + AnimatedVisibility(visible = metadata?.artist != null) { + AnimatedContent( + targetState = metadata?.artist?.toString().orEmpty(), + label = "", + transitionSpec = { fadeIn() togetherWith fadeOut() } + ) { text -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + BasicText( + text = text, + style = typography.xs.semiBold.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + AnimatedVisibility(visible = extras?.explicit == true) { + Image( + painter = painterResource(R.drawable.explicit), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier.size(15.dp) + ) + } + } + } + } + } + + Spacer(modifier = Modifier.width(2.dp)) + + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.height(Dimensions.items.collapsedPlayerHeight) + ) { + AnimatedVisibility(visible = isShowingPrevButtonCollapsed) { + IconButton( + icon = R.drawable.play_skip_back, + color = colorPalette.text, + onClick = { binder?.player?.forceSeekToPrevious() }, + modifier = Modifier + .padding(horizontal = 4.dp, vertical = 8.dp) + .size(20.dp) + ) + } + + Box( + modifier = Modifier + .clickable( + onClick = { + if (shouldBePlaying) binder?.player?.pause() + else { + if (binder?.player?.playbackState == Player.STATE_IDLE) binder.player.prepare() + binder?.player?.play() + } + }, + indication = ripple(bounded = false), + interactionSource = remember { MutableInteractionSource() } + ) + .clip(CircleShape) + ) { + AnimatedPlayPauseButton( + playing = shouldBePlaying, + modifier = Modifier + .align(Alignment.Center) + .padding(horizontal = 4.dp, vertical = 8.dp) + .size(23.dp) + ) + } + + IconButton( + icon = R.drawable.play_skip_forward, + color = colorPalette.text, + onClick = { binder?.player?.forceSeekToNext() }, + modifier = Modifier + .padding(horizontal = 4.dp, vertical = 8.dp) + .size(20.dp) + ) + } + + Spacer(modifier = Modifier.width(2.dp)) + } + } + ) { + var isShowingStatsForNerds by rememberSaveable { mutableStateOf(false) } + var isShowingLyricsDialog by rememberSaveable { mutableStateOf(false) } + + if (isShowingLyricsDialog) LyricsDialog(onDismiss = { isShowingLyricsDialog = false }) + + val playerBottomSheetState = rememberBottomSheetState( + dismissedBound = 64.dp + horizontalBottomPaddingValues.calculateBottomPadding(), + expandedBound = layoutState.expandedBound + ) + + val containerModifier = Modifier + .clip(shape) + .background( + Brush.verticalGradient( + 0.5f to colorPalette.background1, + 1f to colorPalette.background0 + ) + ) + .padding( + windowInsets + .only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) + .asPaddingValues() + ) + .padding(bottom = playerBottomSheetState.collapsedBound) + + val thumbnailContent: @Composable (modifier: Modifier) -> Unit = { innerModifier -> + Pip( + numerator = 1, + denominator = 1, + modifier = innerModifier + ) { + Thumbnail( + isShowingLyrics = isShowingLyrics, + onShowLyrics = { isShowingLyrics = it }, + isShowingStatsForNerds = isShowingStatsForNerds, + onShowStatsForNerds = { isShowingStatsForNerds = it }, + onOpenDialog = { isShowingLyricsDialog = true }, + likedAt = likedAt, + setLikedAt = { likedAt = it }, + modifier = Modifier + .nestedScroll(layoutState.preUpPostDownNestedScrollConnection) + .pinchToToggle( + key = isShowingLyricsDialog, + direction = PinchDirection.Out, + threshold = 1.05f, + onPinch = { + if (isShowingLyrics) isShowingLyricsDialog = true + } + ) + .pinchToToggle( + key = isShowingLyricsDialog, + direction = PinchDirection.In, + threshold = .95f, + onPinch = { + pipHandler.enterPictureInPictureMode() + } + ) + ) + } + } + + val controlsContent: @Composable (modifier: Modifier) -> Unit = { innerModifier -> + Controls( + media = mediaItem?.toUiMedia(duration), + binder = binder, + likedAt = likedAt, + setLikedAt = { likedAt = it }, + shouldBePlaying = shouldBePlaying, + position = position, + modifier = innerModifier + ) + } + + if (isLandscape) Row( + verticalAlignment = Alignment.CenterVertically, + modifier = containerModifier.padding(top = 32.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .weight(0.66f) + .padding(bottom = 16.dp) + ) { + thumbnailContent(Modifier.padding(horizontal = 16.dp)) + } + + controlsContent( + Modifier + .padding(vertical = 8.dp) + .fillMaxHeight() + .weight(1f) + ) + } else Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = containerModifier.padding(top = 54.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.weight(1.25f) + ) { + thumbnailContent(Modifier.padding(horizontal = 32.dp, vertical = 8.dp)) + } + + controlsContent( + Modifier + .padding(vertical = 8.dp) + .fillMaxWidth() + .weight(1f) + ) + } + + var audioDialogOpen by rememberSaveable { mutableStateOf(false) } + + if (audioDialogOpen) SliderDialog( + onDismiss = { audioDialogOpen = false }, + title = stringResource(R.string.playback_settings) + ) { + SliderDialogBody( + provideState = { remember(speed) { mutableFloatStateOf(speed) } }, + onSlideComplete = { speed = it }, + min = 0f, + max = 2f, + toDisplay = { + if (it <= 0.01f) stringResource(R.string.minimum_speed_value) + else stringResource(R.string.format_multiplier, "%.2f".format(it)) + }, + label = stringResource(R.string.playback_speed) + ) + SliderDialogBody( + provideState = { remember(pitch) { mutableFloatStateOf(pitch) } }, + onSlideComplete = { pitch = it }, + min = 0f, + max = 2f, + toDisplay = { + if (it <= 0.01f) stringResource(R.string.minimum_speed_value) + else stringResource(R.string.format_multiplier, "%.2f".format(it)) + }, + label = stringResource(R.string.playback_pitch) + ) + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + SecondaryTextButton( + text = stringResource(R.string.reset), + onClick = { + speed = 1f + pitch = 1f + } + ) + } + } + + var boostDialogOpen by rememberSaveable { mutableStateOf(false) } + + if (boostDialogOpen) { + fun submit(state: Float) = transaction { + mediaItem?.mediaId?.let { mediaId -> + Database.setLoudnessBoost( + songId = mediaId, + loudnessBoost = state.takeUnless { it == 0f } + ) + } + } + + SliderDialog( + onDismiss = { boostDialogOpen = false }, + title = stringResource(R.string.volume_boost) + ) { + SliderDialogBody( + provideState = { + val state = remember { mutableFloatStateOf(0f) } + + LaunchedEffect(mediaItem) { + mediaItem?.mediaId?.let { mediaId -> + Database + .loudnessBoost(mediaId) + .distinctUntilChanged() + .collect { state.floatValue = it ?: 0f } + } + } + + state + }, + onSlideComplete = { submit(it) }, + min = -20f, + max = 20f, + toDisplay = { stringResource(R.string.format_db, "%.2f".format(it)) } + ) + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + SecondaryTextButton( + text = stringResource(R.string.reset), + onClick = { submit(0f) } + ) + } + } + } + + if (binder != null) Queue( + layoutState = playerBottomSheetState, + binder = binder, + beforeContent = { + if (playerLayout == PlayerPreferences.PlayerLayout.New) IconButton( + onClick = { trackLoopEnabled = !trackLoopEnabled }, + icon = R.drawable.infinite, + enabled = trackLoopEnabled, + modifier = Modifier + .padding(vertical = 8.dp) + .size(20.dp) + ) else Spacer(modifier = Modifier.width(20.dp)) + }, + afterContent = { + IconButton( + icon = R.drawable.ellipsis_horizontal, + color = colorPalette.text, + onClick = { + mediaItem?.let { + menuState.display { + PlayerMenu( + onDismiss = menuState::hide, + mediaItem = it, + binder = binder, + onShowSpeedDialog = { audioDialogOpen = true }, + onShowNormalizationDialog = { + boostDialogOpen = true + }.takeIf { volumeNormalization } + ) + } + } + }, + modifier = Modifier + .padding(vertical = 8.dp) + .size(20.dp) + ) + }, + modifier = Modifier.align(Alignment.BottomCenter), + shape = shape + ) + } +} + +@Composable +@OptIn(UnstableApi::class) +private fun PlayerMenu( + binder: PlayerService.Binder, + mediaItem: MediaItem, + onDismiss: () -> Unit, + onShowSpeedDialog: (() -> Unit)? = null, + onShowNormalizationDialog: (() -> Unit)? = null +) { + val launchEqualizer by rememberEqualizerLauncher(audioSessionId = { binder.player.audioSessionId }) + + BaseMediaItemMenu( + mediaItem = mediaItem, + onStartRadio = { + binder.stopRadio() + binder.player.seamlessPlay(mediaItem) + binder.setupRadio(NavigationEndpoint.Endpoint.Watch(videoId = mediaItem.mediaId)) + }, + onGoToEqualizer = launchEqualizer, + onShowSleepTimer = {}, + onDismiss = onDismiss, + onShowSpeedDialog = onShowSpeedDialog, + onShowNormalizationDialog = onShowNormalizationDialog + ) +} + +private fun onDismiss(binder: PlayerService.Binder) { + binder.stopRadio() + binder.player.clearMediaItems() +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/player/Queue.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/player/Queue.kt new file mode 100644 index 0000000..5b4861d --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/player/Queue.kt @@ -0,0 +1,585 @@ +package app.vimusic.android.ui.screens.player + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.LookaheadScope +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.Timeline +import app.vimusic.android.Database +import app.vimusic.android.R +import app.vimusic.android.models.Playlist +import app.vimusic.android.models.SongPlaylistMap +import app.vimusic.android.preferences.AppearancePreferences +import app.vimusic.android.preferences.PlayerPreferences +import app.vimusic.android.service.PlayerService +import app.vimusic.android.transaction +import app.vimusic.android.ui.components.BottomSheet +import app.vimusic.android.ui.components.BottomSheetState +import app.vimusic.android.ui.components.LocalMenuState +import app.vimusic.android.ui.components.MusicBars +import app.vimusic.android.ui.components.themed.BaseMediaItemMenu +import app.vimusic.android.ui.components.themed.FloatingActionsContainerWithScrollToTop +import app.vimusic.android.ui.components.themed.HorizontalDivider +import app.vimusic.android.ui.components.themed.IconButton +import app.vimusic.android.ui.components.themed.Menu +import app.vimusic.android.ui.components.themed.MenuEntry +import app.vimusic.android.ui.components.themed.QueuedMediaItemMenu +import app.vimusic.android.ui.components.themed.ReorderHandle +import app.vimusic.android.ui.components.themed.SecondaryTextButton +import app.vimusic.android.ui.components.themed.TextFieldDialog +import app.vimusic.android.ui.components.themed.TextToggle +import app.vimusic.android.ui.items.SongItem +import app.vimusic.android.ui.items.SongItemPlaceholder +import app.vimusic.android.ui.modifiers.swipeToClose +import app.vimusic.android.utils.DisposableListener +import app.vimusic.android.utils.addNext +import app.vimusic.android.utils.asMediaItem +import app.vimusic.android.utils.enqueue +import app.vimusic.android.utils.medium +import app.vimusic.android.utils.onFirst +import app.vimusic.android.utils.semiBold +import app.vimusic.android.utils.shouldBePlaying +import app.vimusic.android.utils.shuffleQueue +import app.vimusic.android.utils.smoothScrollToTop +import app.vimusic.android.utils.windows +import app.vimusic.compose.persist.persist +import app.vimusic.compose.reordering.animateItemPlacement +import app.vimusic.compose.reordering.draggedItem +import app.vimusic.compose.reordering.rememberReorderingState +import app.vimusic.core.data.enums.PlaylistSortBy +import app.vimusic.core.data.enums.SortOrder +import app.vimusic.core.ui.Dimensions +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.onOverlay +import app.vimusic.core.ui.utils.roundedShape +import app.vimusic.providers.innertube.Innertube +import app.vimusic.providers.innertube.models.bodies.NextBody +import app.vimusic.providers.innertube.requests.nextPage +import com.valentinilk.shimmer.shimmer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.time.Duration.Companion.milliseconds + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun Queue( + layoutState: BottomSheetState, + binder: PlayerService.Binder, + beforeContent: @Composable RowScope.() -> Unit, + afterContent: @Composable RowScope.() -> Unit, + modifier: Modifier = Modifier, + shape: RoundedCornerShape = RoundedCornerShape( + topStart = 12.dp, + topEnd = 12.dp + ), + scrollConnection: NestedScrollConnection = remember(layoutState::preUpPostDownNestedScrollConnection), + windowInsets: WindowInsets = WindowInsets.systemBars +) { + val (colorPalette, typography, _, thumbnailShape) = LocalAppearance.current + val menuState = LocalMenuState.current + + val horizontalBottomPaddingValues = windowInsets + .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom) + .asPaddingValues() + + var suggestions by persist?>?>(tag = "queue/suggestions") + + var mediaItemIndex by remember { + mutableIntStateOf(if (binder.player.mediaItemCount == 0) -1 else binder.player.currentMediaItemIndex) + } + + var windows by remember { mutableStateOf(binder.player.currentTimeline.windows) } + var shouldBePlaying by remember { mutableStateOf(binder.player.shouldBePlaying) } + + val lazyListState = rememberLazyListState() + val reorderingState = rememberReorderingState( + lazyListState = lazyListState, + key = windows, + onDragEnd = binder.player::moveMediaItem + ) + + val visibleSuggestions by remember { + derivedStateOf { + suggestions + ?.getOrNull() + .orEmpty() + .filter { windows.none { window -> window.mediaItem.mediaId == it.mediaId } } + } + } + + val shouldLoadSuggestions by remember { + derivedStateOf { + lazyListState.layoutInfo.visibleItemsInfo.any { it.key == "loading" } + } + } + + LaunchedEffect(mediaItemIndex, shouldLoadSuggestions) { + if (shouldLoadSuggestions) withContext(Dispatchers.IO) { + suggestions = runCatching { + Innertube.nextPage( + NextBody(videoId = windows[mediaItemIndex].mediaItem.mediaId) + )?.mapCatching { page -> + page.itemsPage?.items?.map { it.asMediaItem } + } + }.also { it.exceptionOrNull()?.printStackTrace() }.getOrNull() + } + } + + LaunchedEffect(mediaItemIndex) { + suggestions = null + } + + binder.player.DisposableListener { + object : Player.Listener { + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + mediaItemIndex = + if (binder.player.mediaItemCount == 0) -1 else binder.player.currentMediaItemIndex + } + + override fun onTimelineChanged(timeline: Timeline, reason: Int) { + windows = timeline.windows + mediaItemIndex = + if (binder.player.mediaItemCount == 0) -1 else binder.player.currentMediaItemIndex + } + + override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { + shouldBePlaying = binder.player.shouldBePlaying + } + + override fun onPlaybackStateChanged(playbackState: Int) { + shouldBePlaying = binder.player.shouldBePlaying + } + } + } + + BottomSheet( + state = layoutState, + modifier = modifier.fillMaxSize(), + collapsedContent = { innerModifier -> + Row( + modifier = Modifier + .clip(shape) + .background(colorPalette.background2) + .fillMaxSize() + .then(innerModifier) + .padding(horizontalBottomPaddingValues), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Spacer(modifier = Modifier.width(4.dp)) + beforeContent() + Spacer(modifier = Modifier.weight(1f)) + + Image( + painter = painterResource(R.drawable.playlist), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier.size(18.dp) + ) + + Spacer(modifier = Modifier.weight(1f)) + afterContent() + Spacer(modifier = Modifier.width(4.dp)) + } + } + ) { + val musicBarsTransition = updateTransition( + targetState = if (reorderingState.isDragging) -1L else mediaItemIndex, + label = "" + ) + + LaunchedEffect(Unit) { + lazyListState.scrollToItem(mediaItemIndex.coerceAtLeast(0)) + } + + Column { + Box( + modifier = Modifier + .clip(shape) + .background(colorPalette.background1) + .weight(1f) + ) { + LookaheadScope { + LazyColumn( + state = lazyListState, + contentPadding = windowInsets + .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top) + .asPaddingValues(), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.nestedScroll(scrollConnection) + ) { + itemsIndexed( + items = windows, + key = { _, window -> window.uid.hashCode() }, + contentType = { _, _ -> ContentType.Window } + ) { i, window -> + val isPlayingThisMediaItem = mediaItemIndex == window.firstPeriodIndex + + SongItem( + song = window.mediaItem, + thumbnailSize = Dimensions.thumbnails.song, + onThumbnailContent = { + musicBarsTransition.AnimatedVisibility( + visible = { it == window.firstPeriodIndex }, + enter = fadeIn(tween(800)), + exit = fadeOut(tween(800)) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .background( + color = Color.Black.copy(alpha = 0.25f), + shape = thumbnailShape + ) + .size(Dimensions.thumbnails.song) + ) { + if (shouldBePlaying) MusicBars( + color = colorPalette.onOverlay, + modifier = Modifier.height(24.dp) + ) else Image( + painter = painterResource(R.drawable.play), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.onOverlay), + modifier = Modifier.size(24.dp) + ) + } + } + }, + trailingContent = { + ReorderHandle( + reorderingState = reorderingState, + index = i + ) + }, + modifier = Modifier + .combinedClickable( + onLongClick = { + menuState.display { + QueuedMediaItemMenu( + mediaItem = window.mediaItem, + indexInQueue = if (isPlayingThisMediaItem) null + else window.firstPeriodIndex, + onDismiss = menuState::hide + ) + } + }, + onClick = { + if (isPlayingThisMediaItem) { + if (shouldBePlaying) binder.player.pause() else binder.player.play() + } else { + binder.player.seekToDefaultPosition(window.firstPeriodIndex) + binder.player.playWhenReady = true + } + } + ) + .animateItemPlacement(reorderingState) + .draggedItem( + reorderingState = reorderingState, + index = i + ) + .background(colorPalette.background1) + .let { + if (PlayerPreferences.horizontalSwipeToRemoveItem && !isPlayingThisMediaItem) + it.swipeToClose( + key = windows, + delay = 100.milliseconds, + requireUnconsumed = true + ) { + binder.player.removeMediaItem(window.firstPeriodIndex) + } + else it + }, + clip = !reorderingState.isDragging, + hideExplicit = !isPlayingThisMediaItem && AppearancePreferences.hideExplicit + ) + } + + item( + key = "divider", + contentType = { ContentType.Divider } + ) { + if (visibleSuggestions.isNotEmpty()) HorizontalDivider( + modifier = Modifier.padding(start = 28.dp + Dimensions.thumbnails.song) + ) + } + + items( + items = visibleSuggestions, + key = { "suggestion_${it.mediaId}" }, + contentType = { ContentType.Suggestion } + ) { + SongItem( + song = it, + thumbnailSize = Dimensions.thumbnails.song, + modifier = Modifier.clickable { + menuState.display { + BaseMediaItemMenu( + onDismiss = { menuState.hide() }, + mediaItem = it, + onEnqueue = { binder.player.enqueue(it) }, + onPlayNext = { binder.player.addNext(it) } + ) + } + }, + trailingContent = { + Row( + horizontalArrangement = Arrangement.spacedBy( + space = 12.dp, + alignment = Alignment.End + ) + ) { + IconButton( + icon = R.drawable.play_skip_forward, + color = colorPalette.text, + onClick = { + binder.player.addNext(it) + }, + modifier = Modifier.size(18.dp) + ) + IconButton( + icon = R.drawable.enqueue, + color = colorPalette.text, + onClick = { + binder.player.enqueue(it) + }, + modifier = Modifier.size(18.dp) + ) + } + } + ) + } + + item( + key = "loading", + contentType = { ContentType.Placeholder } + ) { + if (binder.isLoadingRadio || suggestions == null) + Column(modifier = Modifier.shimmer()) { + repeat(3) { index -> + SongItemPlaceholder( + thumbnailSize = Dimensions.thumbnails.song, + modifier = Modifier + .alpha(1f - index * 0.125f) + .fillMaxWidth() + ) + } + } + } + } + } + + FloatingActionsContainerWithScrollToTop( + lazyListState = lazyListState, + icon = R.drawable.shuffle, + visible = !reorderingState.isDragging, + insets = windowInsets.only(WindowInsetsSides.Horizontal), + onClick = { + reorderingState.coroutineScope.launch { + lazyListState.smoothScrollToTop() + }.invokeOnCompletion { + binder.player.shuffleQueue() + } + } + ) + } + + Row( + modifier = Modifier + .clickable(onClick = layoutState::collapseSoft) + .background(colorPalette.background2) + .fillMaxWidth() + .padding(horizontal = 12.dp) + .padding(horizontalBottomPaddingValues) + .height(64.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TextToggle( + state = PlayerPreferences.queueLoopEnabled, + toggleState = { + PlayerPreferences.queueLoopEnabled = !PlayerPreferences.queueLoopEnabled + }, + name = stringResource(R.string.queue_loop) + ) + + Spacer(modifier = Modifier.weight(1f)) + Image( + painter = painterResource(R.drawable.chevron_down), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.weight(1f)) + + BasicText( + text = pluralStringResource( + id = R.plurals.song_count_plural, + count = windows.size, + windows.size + ), + style = typography.xxs.medium, + modifier = Modifier + .clip(16.dp.roundedShape) + .clickable { + fun addToPlaylist(playlist: Playlist, index: Int) = transaction { + val playlistId = Database + .insert(playlist) + .takeIf { it != -1L } ?: playlist.id + + windows.forEachIndexed { i, window -> + val mediaItem = window.mediaItem + + Database.insert(mediaItem) + Database.insert( + SongPlaylistMap( + songId = mediaItem.mediaId, + playlistId = playlistId, + position = index + i + ) + ) + } + } + + menuState.display { + var isCreatingNewPlaylist by rememberSaveable { mutableStateOf(false) } + + val playlistPreviews by remember { + Database + .playlistPreviews( + sortBy = PlaylistSortBy.DateAdded, + sortOrder = SortOrder.Descending + ) + .onFirst { isCreatingNewPlaylist = it.isEmpty() } + }.collectAsState(initial = null, context = Dispatchers.IO) + + if (isCreatingNewPlaylist) TextFieldDialog( + hintText = stringResource(R.string.enter_playlist_name_prompt), + onDismiss = { isCreatingNewPlaylist = false }, + onAccept = { text -> + menuState.hide() + addToPlaylist(Playlist(name = text), 0) + } + ) + + Menu { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(horizontal = 24.dp, vertical = 8.dp) + .fillMaxWidth() + ) { + BasicText( + text = stringResource(R.string.add_queue_to_playlist), + style = typography.m.semiBold, + overflow = TextOverflow.Ellipsis, + maxLines = 2, + modifier = Modifier.weight(weight = 2f, fill = false) + ) + + Spacer(modifier = Modifier.width(8.dp)) + + SecondaryTextButton( + text = stringResource(R.string.new_playlist), + onClick = { isCreatingNewPlaylist = true }, + alternative = true, + modifier = Modifier.weight(weight = 1f, fill = false) + ) + } + + if (playlistPreviews?.isEmpty() == true) + Spacer(modifier = Modifier.height(160.dp)) + + playlistPreviews?.forEach { playlistPreview -> + MenuEntry( + icon = R.drawable.playlist, + text = playlistPreview.playlist.name, + secondaryText = pluralStringResource( + id = R.plurals.song_count_plural, + count = playlistPreview.songCount, + playlistPreview.songCount + ), + onClick = { + menuState.hide() + addToPlaylist( + playlistPreview.playlist, + playlistPreview.songCount + ) + } + ) + } + } + } + } + .background(colorPalette.background1) + .padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + } + } +} + +@JvmInline +private value class ContentType private constructor(val value: Int) { + companion object { + val Window = ContentType(value = 0) + val Divider = ContentType(value = 1) + val Suggestion = ContentType(value = 2) + val Placeholder = ContentType(value = 3) + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/player/StatsForNerds.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/player/StatsForNerds.kt new file mode 100644 index 0000000..d8e910d --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/player/StatsForNerds.kt @@ -0,0 +1,225 @@ +package app.vimusic.android.ui.screens.player + +import android.text.format.Formatter +import androidx.annotation.OptIn +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.cache.Cache +import androidx.media3.datasource.cache.CacheSpan +import app.vimusic.android.Database +import app.vimusic.android.LocalPlayerServiceBinder +import app.vimusic.android.R +import app.vimusic.android.models.Format +import app.vimusic.android.service.PlayerService +import app.vimusic.android.ui.components.themed.SecondaryTextButton +import app.vimusic.android.utils.color +import app.vimusic.android.utils.medium +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.onOverlay +import app.vimusic.core.ui.overlay +import app.vimusic.providers.innertube.Innertube +import app.vimusic.providers.innertube.models.bodies.PlayerBody +import app.vimusic.providers.innertube.requests.player +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.math.roundToInt + +@OptIn(UnstableApi::class) +@Composable +fun StatsForNerds( + mediaId: String, + isDisplayed: Boolean, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) = AnimatedVisibility( + visible = isDisplayed, + enter = fadeIn(), + exit = fadeOut() +) { + val (colorPalette, typography) = LocalAppearance.current + val context = LocalContext.current + val binder = LocalPlayerServiceBinder.current + + val coroutineScope = rememberCoroutineScope() + + var cachedBytes by remember(binder, mediaId) { + mutableLongStateOf(binder?.cache?.getCachedBytes(mediaId, 0, -1) ?: 0L) + } + + var format by remember { mutableStateOf(null) } + + var hasReloaded by rememberSaveable { mutableStateOf(false) } + + suspend fun reload(binder: PlayerService.Binder) { + binder.player.currentMediaItem + ?.takeIf { it.mediaId == mediaId } + ?.let { mediaItem -> + withContext(Dispatchers.IO) { + delay(2000) + + Innertube + .player(PlayerBody(videoId = mediaId)) + ?.onSuccess { response -> + response?.streamingData?.highestQualityFormat?.let { format -> + Database.insert(mediaItem) + Database.insert( + Format( + songId = mediaId, + itag = format.itag, + mimeType = format.mimeType, + bitrate = format.bitrate, + loudnessDb = response.playerConfig?.audioConfig?.normalizedLoudnessDb, + contentLength = format.contentLength, + lastModified = format.lastModified + ) + ) + } + } + } + } + } + + LaunchedEffect(binder, mediaId) { + val currentBinder = binder ?: return@LaunchedEffect + + Database + .format(mediaId) + .distinctUntilChanged() + .collectLatest { currentFormat -> + if (currentFormat?.itag != null) format = currentFormat + else reload(currentBinder) + } + } + + DisposableEffect(binder, mediaId) { + val currentBinder = binder ?: return@DisposableEffect onDispose { } + + val listener = object : Cache.Listener { + override fun onSpanAdded(cache: Cache, span: CacheSpan) { + cachedBytes += span.length + } + + override fun onSpanRemoved(cache: Cache, span: CacheSpan) { + cachedBytes -= span.length + } + + override fun onSpanTouched(cache: Cache, oldSpan: CacheSpan, newSpan: CacheSpan) = Unit + } + + currentBinder.cache.addListener(mediaId, listener) + + onDispose { + currentBinder.cache.removeListener(mediaId, listener) + } + } + + Column( + modifier = modifier + .pointerInput(Unit) { + detectTapGestures(onTap = { onDismiss() }) + } + .background(colorPalette.overlay) + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(all = 16.dp) + ) { + @Composable + fun Text(text: String) = BasicText( + text = text, + maxLines = 1, + style = typography.xs.medium.color(colorPalette.onOverlay) + ) + + Column(horizontalAlignment = Alignment.End) { + Text(text = stringResource(R.string.id)) + Text(text = stringResource(R.string.itag)) + Text(text = stringResource(R.string.bitrate)) + Text(text = stringResource(R.string.size)) + Text(text = stringResource(R.string.cached)) + Text(text = stringResource(R.string.loudness)) + } + + Column { + Text(text = mediaId) + Text(text = format?.itag?.toString() ?: stringResource(R.string.unknown)) + Text( + text = when (val rate = format?.bitrate) { + null, 0L -> stringResource(R.string.unknown) + else -> stringResource(R.string.format_kbps, rate / 1000) + } + ) + Text( + text = when (val length = format?.contentLength) { + null, 0L -> stringResource(R.string.unknown) + else -> Formatter.formatShortFileSize(context, length) + } + ) + Text( + text = buildString { + append(Formatter.formatShortFileSize(context, cachedBytes)) + + format?.contentLength?.let { + append(" (${(cachedBytes.toFloat() / it * 100).roundToInt()}%)") + } + } + ) + Text( + text = format?.loudnessDb?.let { + stringResource( + R.string.format_db, + "%.2f".format(it) + ) + } ?: stringResource(R.string.unknown) + ) + } + } + + binder?.let { + SecondaryTextButton( + text = stringResource(R.string.reload), + onClick = { + hasReloaded = true + + coroutineScope.launch { + reload(it) + } + }, + enabled = !hasReloaded + ) + } + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/player/Thumbnail.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/player/Thumbnail.kt new file mode 100644 index 0000000..3523ea2 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/player/Thumbnail.kt @@ -0,0 +1,263 @@ +package app.vimusic.android.ui.screens.player + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Left +import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Right +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.SeekableTransitionState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.rememberTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.media3.common.C +import app.vimusic.android.Database +import app.vimusic.android.LocalPlayerServiceBinder +import app.vimusic.android.R +import app.vimusic.android.preferences.PlayerPreferences +import app.vimusic.android.service.LoginRequiredException +import app.vimusic.android.service.PlayableFormatNotFoundException +import app.vimusic.android.service.RestrictedVideoException +import app.vimusic.android.service.UnplayableException +import app.vimusic.android.service.VideoIdMismatchException +import app.vimusic.android.service.isLocal +import app.vimusic.android.ui.modifiers.onSwipe +import app.vimusic.android.utils.forceSeekToNext +import app.vimusic.android.utils.forceSeekToPrevious +import app.vimusic.android.utils.thumbnail +import app.vimusic.android.utils.windowState +import app.vimusic.core.ui.Dimensions +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.utils.px +import coil3.compose.AsyncImage +import kotlinx.coroutines.launch +import java.net.UnknownHostException +import java.nio.channels.UnresolvedAddressException + +@Composable +fun Thumbnail( + isShowingLyrics: Boolean, + onShowLyrics: (Boolean) -> Unit, + isShowingStatsForNerds: Boolean, + onShowStatsForNerds: (Boolean) -> Unit, + onOpenDialog: () -> Unit, + likedAt: Long?, + setLikedAt: (Long?) -> Unit, + modifier: Modifier = Modifier, + contentScale: ContentScale = ContentScale.FillWidth, + shouldShowSynchronizedLyrics: Boolean = PlayerPreferences.isShowingSynchronizedLyrics, + setShouldShowSynchronizedLyrics: (Boolean) -> Unit = { + PlayerPreferences.isShowingSynchronizedLyrics = it + }, + showLyricsControls: Boolean = true +) { + val binder = LocalPlayerServiceBinder.current + val (colorPalette, _, _, thumbnailShape) = LocalAppearance.current + + val (window, error) = windowState() + + val coroutineScope = rememberCoroutineScope() + val transitionState = remember { SeekableTransitionState(false) } + val transition = rememberTransition(transitionState) + val opacity by transition.animateFloat(label = "") { if (it) 1f else 0f } + val scale by transition.animateFloat( + label = "", + transitionSpec = { + spring(dampingRatio = Spring.DampingRatioLowBouncy) + } + ) { if (it) 1f else 0f } + + AnimatedContent( + targetState = window, + transitionSpec = { + val duration = 500 + val initial = initialState + val target = targetState + + if (initial == null || target == null) return@AnimatedContent ContentTransform( + targetContentEnter = fadeIn(tween(duration)), + initialContentExit = fadeOut(tween(duration)), + sizeTransform = null + ) + + val sizeTransform = SizeTransform(clip = false) { _, _ -> + tween(durationMillis = duration, delayMillis = duration) + } + + val direction = if (target.firstPeriodIndex < initial.firstPeriodIndex) Right else Left + + ContentTransform( + targetContentEnter = slideIntoContainer(direction, tween(duration)) + + fadeIn(tween(duration)) + + scaleIn(tween(duration), 0.85f), + initialContentExit = slideOutOfContainer(direction, tween(duration)) + + fadeOut(tween(duration)) + + scaleOut(tween(duration), 0.85f), + sizeTransform = sizeTransform + ) + }, + modifier = modifier.onSwipe( + onSwipeLeft = { + binder?.player?.forceSeekToNext() + }, + onSwipeRight = { + binder?.player?.forceSeekToPrevious(seekToStart = false) + } + ), + contentAlignment = Alignment.Center, + label = "" + ) { currentWindow -> + val shadowElevation by animateDpAsState( + targetValue = if (window == currentWindow) 8.dp else 0.dp, + animationSpec = tween( + durationMillis = 500, + easing = LinearEasing + ), + label = "" + ) + val blurRadius by animateDpAsState( + targetValue = if (isShowingLyrics || error != null || isShowingStatsForNerds) 8.dp else 0.dp, + animationSpec = tween(500), + label = "" + ) + + if (currentWindow != null) Box( + modifier = Modifier + .fillMaxWidth() + .shadow( + elevation = shadowElevation, + shape = thumbnailShape, + clip = false + ) + .clip(thumbnailShape) + ) { + var height by remember { mutableIntStateOf(0) } + + AsyncImage( + model = currentWindow.mediaItem.mediaMetadata.artworkUri + ?.thumbnail((Dimensions.thumbnails.player.song - 64.dp).px), + error = painterResource(id = R.drawable.ic_launcher_foreground), + contentDescription = null, + contentScale = contentScale, + modifier = Modifier + .pointerInput(Unit) { + detectTapGestures( + onTap = { onShowLyrics(true) }, + onLongPress = { onShowStatsForNerds(true) }, + onDoubleTap = { + if (likedAt == null) setLikedAt(System.currentTimeMillis()) + + coroutineScope.launch { + val spec = tween(durationMillis = 500) + transitionState.animateTo(true, spec) + transitionState.animateTo(false, spec) + } + } + ) + } + .fillMaxWidth() + .animateContentSize() + .background(colorPalette.background0) + .let { + if (blurRadius == 0.dp) it else it.blur(radius = blurRadius) + } + .onGloballyPositioned { + height = it.size.height + } + ) + + Lyrics( + mediaId = currentWindow.mediaItem.mediaId, + isDisplayed = isShowingLyrics && error == null, + onDismiss = { onShowLyrics(false) }, + ensureSongInserted = { Database.insert(currentWindow.mediaItem) }, + mediaMetadataProvider = currentWindow.mediaItem::mediaMetadata, + durationProvider = { binder?.player?.duration ?: C.TIME_UNSET }, + onOpenDialog = onOpenDialog, + modifier = Modifier.height(height.px.dp), + shouldShowSynchronizedLyrics = shouldShowSynchronizedLyrics, + setShouldShowSynchronizedLyrics = setShouldShowSynchronizedLyrics, + showControls = showLyricsControls + ) + + StatsForNerds( + mediaId = currentWindow.mediaItem.mediaId, + isDisplayed = isShowingStatsForNerds && error == null, + onDismiss = { onShowStatsForNerds(false) }, + modifier = Modifier.height(height.px.dp) + ) + + Image( + painter = painterResource(R.drawable.heart), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.accent), + modifier = Modifier + .fillMaxSize(0.5f) + .aspectRatio(1f) + .align(Alignment.Center) + .graphicsLayer( + scaleX = scale, + scaleY = scale, + alpha = opacity, + shadowElevation = 8.dp.px.toFloat() + ) + ) + + PlaybackError( + isDisplayed = error != null, + messageProvider = { + if (currentWindow.mediaItem.isLocal) stringResource(R.string.error_local_music_deleted) + else when (error?.cause?.cause) { + is UnresolvedAddressException, is UnknownHostException -> + stringResource(R.string.error_network) + + is PlayableFormatNotFoundException -> stringResource(R.string.error_unplayable) + is UnplayableException -> stringResource(R.string.error_source_deleted) + is LoginRequiredException, is RestrictedVideoException -> + stringResource(R.string.error_server_restrictions) + + is VideoIdMismatchException -> stringResource(R.string.error_id_mismatch) + else -> stringResource(R.string.error_unknown_playback) + } + }, + onDismiss = { binder?.player?.prepare() }, + modifier = Modifier.height(height.px.dp) + ) + } + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/playlist/PlaylistScreen.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/playlist/PlaylistScreen.kt new file mode 100644 index 0000000..edb4fec --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/playlist/PlaylistScreen.kt @@ -0,0 +1,50 @@ +package app.vimusic.android.ui.screens.playlist + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import app.vimusic.android.R +import app.vimusic.android.ui.components.themed.Scaffold +import app.vimusic.android.ui.screens.GlobalRoutes +import app.vimusic.android.ui.screens.Route +import app.vimusic.compose.persist.PersistMapCleanup +import app.vimusic.compose.routing.RouteHandler + +@Route +@Composable +fun PlaylistScreen( + browseId: String, + params: String?, + shouldDedup: Boolean, + maxDepth: Int? = null +) { + val saveableStateHolder = rememberSaveableStateHolder() + PersistMapCleanup(prefix = "playlist/$browseId") + + RouteHandler { + GlobalRoutes() + + Content { + Scaffold( + key = "playlist", + topIconButtonId = R.drawable.chevron_back, + onTopIconButtonClick = pop, + tabIndex = 0, + onTabChange = { }, + tabColumnContent = { + tab(0, R.string.songs, R.drawable.musical_notes) + } + ) { currentTabIndex -> + saveableStateHolder.SaveableStateProvider(key = currentTabIndex) { + when (currentTabIndex) { + 0 -> PlaylistSongList( + browseId = browseId, + params = params, + maxDepth = maxDepth, + shouldDedup = shouldDedup + ) + } + } + } + } + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/playlist/PlaylistSongList.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/playlist/PlaylistSongList.kt new file mode 100644 index 0000000..5e81d3e --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/playlist/PlaylistSongList.kt @@ -0,0 +1,261 @@ +package app.vimusic.android.ui.screens.playlist + +import android.content.Intent +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import app.vimusic.android.Database +import app.vimusic.android.LocalPlayerAwareWindowInsets +import app.vimusic.android.LocalPlayerServiceBinder +import app.vimusic.android.R +import app.vimusic.android.models.Playlist +import app.vimusic.android.models.SongPlaylistMap +import app.vimusic.android.query +import app.vimusic.android.transaction +import app.vimusic.android.ui.components.LocalMenuState +import app.vimusic.android.ui.components.ShimmerHost +import app.vimusic.android.ui.components.themed.FloatingActionsContainerWithScrollToTop +import app.vimusic.android.ui.components.themed.Header +import app.vimusic.android.ui.components.themed.HeaderIconButton +import app.vimusic.android.ui.components.themed.HeaderPlaceholder +import app.vimusic.android.ui.components.themed.LayoutWithAdaptiveThumbnail +import app.vimusic.android.ui.components.themed.NonQueuedMediaItemMenu +import app.vimusic.android.ui.components.themed.PlaylistInfo +import app.vimusic.android.ui.components.themed.SecondaryTextButton +import app.vimusic.android.ui.components.themed.TextFieldDialog +import app.vimusic.android.ui.components.themed.adaptiveThumbnailContent +import app.vimusic.android.ui.items.SongItem +import app.vimusic.android.ui.items.SongItemPlaceholder +import app.vimusic.android.utils.PlaylistDownloadIcon +import app.vimusic.android.utils.asMediaItem +import app.vimusic.android.utils.completed +import app.vimusic.android.utils.enqueue +import app.vimusic.android.utils.forcePlayAtIndex +import app.vimusic.android.utils.forcePlayFromBeginning +import app.vimusic.compose.persist.persist +import app.vimusic.core.ui.Dimensions +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.utils.isLandscape +import app.vimusic.providers.innertube.Innertube +import app.vimusic.providers.innertube.models.bodies.BrowseBody +import app.vimusic.providers.innertube.requests.playlistPage +import com.valentinilk.shimmer.shimmer +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun PlaylistSongList( + browseId: String, + params: String?, + maxDepth: Int?, + shouldDedup: Boolean, + modifier: Modifier = Modifier +) { + val (colorPalette) = LocalAppearance.current + val binder = LocalPlayerServiceBinder.current + val context = LocalContext.current + val menuState = LocalMenuState.current + + var playlistPage by persist("playlist/$browseId/playlistPage") + + LaunchedEffect(Unit) { + if (playlistPage != null && playlistPage?.songsPage?.continuation == null) return@LaunchedEffect + + playlistPage = withContext(Dispatchers.IO) { + Innertube + .playlistPage(BrowseBody(browseId = browseId, params = params)) + ?.completed( + maxDepth = maxDepth ?: Int.MAX_VALUE, + shouldDedup = shouldDedup + ) + ?.getOrNull() + } + } + + var isImportingPlaylist by rememberSaveable { mutableStateOf(false) } + + if (isImportingPlaylist) TextFieldDialog( + hintText = stringResource(R.string.enter_playlist_name_prompt), + initialTextInput = playlistPage?.title.orEmpty(), + onDismiss = { isImportingPlaylist = false }, + onAccept = { text -> + query { + transaction { + val playlistId = Database.insert( + Playlist( + name = text, + browseId = browseId, + thumbnail = playlistPage?.thumbnail?.url + ) + ) + + playlistPage?.songsPage?.items + ?.map(Innertube.SongItem::asMediaItem) + ?.onEach(Database::insert) + ?.mapIndexed { index, mediaItem -> + SongPlaylistMap( + songId = mediaItem.mediaId, + playlistId = playlistId, + position = index + ) + }?.let(Database::insertSongPlaylistMaps) + } + } + } + ) + + val headerContent: @Composable () -> Unit = { + if (playlistPage == null) HeaderPlaceholder(modifier = Modifier.shimmer()) + else Header(title = playlistPage?.title ?: stringResource(R.string.unknown)) { + SecondaryTextButton( + text = stringResource(R.string.enqueue), + enabled = playlistPage?.songsPage?.items?.isNotEmpty() == true, + onClick = { + playlistPage?.songsPage?.items?.map(Innertube.SongItem::asMediaItem) + ?.let { mediaItems -> + binder?.player?.enqueue(mediaItems) + } + } + ) + + Spacer(modifier = Modifier.weight(1f)) + + playlistPage?.songsPage?.items?.map(Innertube.SongItem::asMediaItem) + ?.let { PlaylistDownloadIcon(songs = it.toImmutableList()) } + + HeaderIconButton( + icon = R.drawable.add, + color = colorPalette.text, + onClick = { isImportingPlaylist = true } + ) + + HeaderIconButton( + icon = R.drawable.share_social, + color = colorPalette.text, + onClick = { + ( + playlistPage?.url + ?: "https://music.youtube.com/playlist?list=${ + browseId.removePrefix( + "VL" + ) + }" + ).let { url -> + val sendIntent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, url) + } + + context.startActivity(Intent.createChooser(sendIntent, null)) + } + } + ) + } + } + + val thumbnailContent = adaptiveThumbnailContent( + isLoading = playlistPage == null, + url = playlistPage?.thumbnail?.url + ) + + val lazyListState = rememberLazyListState() + + LayoutWithAdaptiveThumbnail( + thumbnailContent = thumbnailContent, + modifier = modifier + ) { + Box { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) + .asPaddingValues(), + modifier = Modifier + .background(colorPalette.background0) + .fillMaxSize() + ) { + item( + key = "header", + contentType = 0 + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + headerContent() + if (!isLandscape) thumbnailContent() + PlaylistInfo(playlist = playlistPage) + } + } + + itemsIndexed(items = playlistPage?.songsPage?.items ?: emptyList()) { index, song -> + SongItem( + song = song, + thumbnailSize = Dimensions.thumbnails.song, + modifier = Modifier + .combinedClickable( + onLongClick = { + menuState.display { + NonQueuedMediaItemMenu( + onDismiss = menuState::hide, + mediaItem = song.asMediaItem + ) + } + }, + onClick = { + playlistPage?.songsPage?.items?.map(Innertube.SongItem::asMediaItem) + ?.let { mediaItems -> + binder?.stopRadio() + binder?.player?.forcePlayAtIndex(mediaItems, index) + } + } + ) + ) + } + + if (playlistPage == null) item(key = "loading") { + ShimmerHost(modifier = Modifier.fillParentMaxSize()) { + repeat(4) { + SongItemPlaceholder(thumbnailSize = Dimensions.thumbnails.song) + } + } + } + } + + FloatingActionsContainerWithScrollToTop( + lazyListState = lazyListState, + icon = R.drawable.shuffle, + onClick = { + playlistPage?.songsPage?.items?.let { songs -> + if (songs.isNotEmpty()) { + binder?.stopRadio() + binder?.player?.forcePlayFromBeginning( + songs.shuffled().map(Innertube.SongItem::asMediaItem) + ) + } + } + } + ) + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/LocalSongSearch.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/search/LocalSongSearch.kt similarity index 64% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/LocalSongSearch.kt rename to app/src/main/kotlin/app/vimusic/android/ui/screens/search/LocalSongSearch.kt index 4d32ea9..daf91c6 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/LocalSongSearch.kt +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/search/LocalSongSearch.kt @@ -1,6 +1,5 @@ -package it.vfsfitvnm.vimusic.ui.screens.search +package app.vimusic.android.ui.screens.search -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box @@ -19,36 +18,38 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign -import it.vfsfitvnm.compose.persist.persistList -import it.vfsfitvnm.innertube.models.NavigationEndpoint -import it.vfsfitvnm.vimusic.Database -import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets -import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder -import it.vfsfitvnm.vimusic.models.Song -import it.vfsfitvnm.vimusic.ui.components.LocalMenuState -import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop -import it.vfsfitvnm.vimusic.ui.components.themed.Header -import it.vfsfitvnm.vimusic.ui.components.themed.InHistoryMediaItemMenu -import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton -import it.vfsfitvnm.vimusic.ui.items.SongItem -import it.vfsfitvnm.vimusic.ui.styling.Dimensions -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.px -import it.vfsfitvnm.vimusic.utils.align -import it.vfsfitvnm.vimusic.utils.asMediaItem -import it.vfsfitvnm.vimusic.utils.forcePlay -import it.vfsfitvnm.vimusic.utils.medium +import app.vimusic.android.Database +import app.vimusic.android.LocalPlayerAwareWindowInsets +import app.vimusic.android.LocalPlayerServiceBinder +import app.vimusic.android.R +import app.vimusic.android.models.Song +import app.vimusic.android.ui.components.LocalMenuState +import app.vimusic.android.ui.components.themed.FloatingActionsContainerWithScrollToTop +import app.vimusic.android.ui.components.themed.Header +import app.vimusic.android.ui.components.themed.InHistoryMediaItemMenu +import app.vimusic.android.ui.components.themed.SecondaryTextButton +import app.vimusic.android.ui.items.SongItem +import app.vimusic.android.utils.align +import app.vimusic.android.utils.asMediaItem +import app.vimusic.android.utils.forcePlay +import app.vimusic.android.utils.medium +import app.vimusic.compose.persist.persistList +import app.vimusic.core.ui.Dimensions +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.providers.innertube.models.NavigationEndpoint +import kotlinx.collections.immutable.toImmutableList -@ExperimentalFoundationApi -@ExperimentalAnimationApi +@OptIn(ExperimentalFoundationApi::class) @Composable fun LocalSongSearch( textFieldValue: TextFieldValue, - onTextFieldValueChanged: (TextFieldValue) -> Unit, - decorationBox: @Composable (@Composable () -> Unit) -> Unit + onTextFieldValueChange: (TextFieldValue) -> Unit, + decorationBox: @Composable (@Composable () -> Unit) -> Unit, + modifier: Modifier = Modifier ) { val (colorPalette, typography) = LocalAppearance.current val binder = LocalPlayerServiceBinder.current @@ -57,23 +58,20 @@ fun LocalSongSearch( var items by persistList("search/local/songs") LaunchedEffect(textFieldValue.text) { - if (textFieldValue.text.length > 1) { - Database.search("%${textFieldValue.text}%").collect { items = it } - } + if (textFieldValue.text.length > 1) + Database + .search("%${textFieldValue.text}%") + .collect { items = it.toImmutableList() } } - val thumbnailSizeDp = Dimensions.thumbnails.song - val thumbnailSizePx = thumbnailSizeDp.px - val lazyListState = rememberLazyListState() - Box { + Box(modifier = modifier) { LazyColumn( state = lazyListState, contentPadding = LocalPlayerAwareWindowInsets.current .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), - modifier = Modifier - .fillMaxSize() + modifier = Modifier.fillMaxSize() ) { item( key = "header", @@ -83,7 +81,7 @@ fun LocalSongSearch( titleContent = { BasicTextField( value = textFieldValue, - onValueChange = onTextFieldValueChanged, + onValueChange = onTextFieldValueChange, textStyle = typography.xxl.medium.align(TextAlign.End), singleLine = true, maxLines = 1, @@ -93,24 +91,19 @@ fun LocalSongSearch( ) }, actionsContent = { - if (textFieldValue.text.isNotEmpty()) { - SecondaryTextButton( - text = "Clear", - onClick = { onTextFieldValueChanged(TextFieldValue()) } - ) - } + if (textFieldValue.text.isNotEmpty()) SecondaryTextButton( + text = stringResource(R.string.clear), + onClick = { onTextFieldValueChange(TextFieldValue()) } + ) } ) } items( items = items, - key = Song::id, + key = Song::id ) { song -> SongItem( - song = song, - thumbnailSizePx = thumbnailSizePx, - thumbnailSizeDp = thumbnailSizeDp, modifier = Modifier .combinedClickable( onLongClick = { @@ -130,7 +123,9 @@ fun LocalSongSearch( ) } ) - .animateItemPlacement() + .animateItem(), + song = song, + thumbnailSize = Dimensions.thumbnails.song ) } } diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/search/OnlineSearch.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/search/OnlineSearch.kt new file mode 100644 index 0000000..986db10 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/search/OnlineSearch.kt @@ -0,0 +1,319 @@ +package app.vimusic.android.ui.screens.search + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.paint +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.layout.LocalPinnableContainer +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import app.vimusic.android.Database +import app.vimusic.android.LocalPlayerAwareWindowInsets +import app.vimusic.android.R +import app.vimusic.android.models.SearchQuery +import app.vimusic.android.preferences.DataPreferences +import app.vimusic.android.query +import app.vimusic.android.ui.components.themed.FloatingActionsContainerWithScrollToTop +import app.vimusic.android.ui.components.themed.Header +import app.vimusic.android.ui.components.themed.SecondaryTextButton +import app.vimusic.android.utils.align +import app.vimusic.android.utils.center +import app.vimusic.android.utils.disabled +import app.vimusic.android.utils.medium +import app.vimusic.android.utils.secondary +import app.vimusic.compose.persist.persist +import app.vimusic.compose.persist.persistList +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.providers.innertube.Innertube +import app.vimusic.providers.innertube.models.bodies.SearchSuggestionsBody +import app.vimusic.providers.innertube.requests.searchSuggestions +import io.ktor.http.Url +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChanged + +@Composable +fun OnlineSearch( + textFieldValue: TextFieldValue, + onTextFieldValueChange: (TextFieldValue) -> Unit, + onSearch: (String) -> Unit, + onViewPlaylist: (String) -> Unit, + decorationBox: @Composable (@Composable () -> Unit) -> Unit, + focused: Boolean, + modifier: Modifier = Modifier +) = Box(modifier = modifier) { + val (colorPalette, typography) = LocalAppearance.current + + var history by persistList("search/online/history") + var suggestionsResult by persist?>?>("search/online/suggestionsResult") + + LaunchedEffect(textFieldValue.text) { + if (DataPreferences.pauseSearchHistory) return@LaunchedEffect + + Database.queries("%${textFieldValue.text}%") + .distinctUntilChanged { old, new -> old.size == new.size } + .collect { history = it.toImmutableList() } + } + + LaunchedEffect(textFieldValue.text) { + if (textFieldValue.text.isEmpty()) return@LaunchedEffect + + delay(500) + suggestionsResult = Innertube.searchSuggestions( + body = SearchSuggestionsBody(input = textFieldValue.text) + ) + } + + val playlistId = remember(textFieldValue.text) { + runCatching { + Url(textFieldValue.text).takeIf { + it.host.endsWith("youtube.com", ignoreCase = true) && + it.segments.lastOrNull()?.equals("playlist", ignoreCase = true) == true + }?.parameters?.get("list") + }.getOrNull() + } + + val focusRequester = remember { FocusRequester() } + val lazyListState = rememberLazyListState() + + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) + .asPaddingValues(), + modifier = Modifier.fillMaxSize() + ) { + item( + key = "header", + contentType = 0 + ) { + val container = LocalPinnableContainer.current + + DisposableEffect(Unit) { + val handle = container?.pin() + + onDispose { + handle?.release() + } + } + + LaunchedEffect(focused) { + if (!focused) return@LaunchedEffect + + delay(300) + focusRequester.requestFocus() + } + + Header( + titleContent = { + BasicTextField( + value = textFieldValue, + onValueChange = onTextFieldValueChange, + textStyle = typography.xxl.medium.align(TextAlign.End), + singleLine = true, + maxLines = 1, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions( + onSearch = { + if (textFieldValue.text.isNotEmpty()) onSearch(textFieldValue.text) + } + ), + cursorBrush = SolidColor(colorPalette.text), + decorationBox = decorationBox, + modifier = Modifier.focusRequester(focusRequester) + ) + }, + actionsContent = { + if (playlistId != null) { + val isAlbum = playlistId.startsWith("OLAK5uy_") + + SecondaryTextButton( + text = if (isAlbum) stringResource(R.string.view_album) + else stringResource(R.string.view_playlist), + onClick = { onViewPlaylist(textFieldValue.text) } + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + if (textFieldValue.text.isNotEmpty()) SecondaryTextButton( + text = stringResource(R.string.clear), + onClick = { onTextFieldValueChange(TextFieldValue()) } + ) + } + ) + } + + items( + items = history, + key = SearchQuery::id + ) { searchQuery -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable { onSearch(searchQuery.query) } + .fillMaxWidth() + .padding(all = 16.dp) + .animateItem() + ) { + Spacer( + modifier = Modifier + .padding(horizontal = 8.dp) + .size(20.dp) + .paint( + painter = painterResource(R.drawable.time), + colorFilter = ColorFilter.disabled + ) + ) + + BasicText( + text = searchQuery.query, + style = typography.s.secondary, + modifier = Modifier + .padding(horizontal = 8.dp) + .weight(1f) + ) + + Image( + painter = painterResource(R.drawable.close), + contentDescription = null, + colorFilter = ColorFilter.disabled, + modifier = Modifier + .clickable( + indication = ripple(bounded = false), + interactionSource = remember { MutableInteractionSource() }, + onClick = { + query { + Database.delete(searchQuery) + } + } + ) + .padding(horizontal = 8.dp) + .size(20.dp) + ) + + Image( + painter = painterResource(R.drawable.arrow_forward), + contentDescription = null, + colorFilter = ColorFilter.disabled, + modifier = Modifier + .clickable( + indication = ripple(bounded = false), + interactionSource = remember { MutableInteractionSource() }, + onClick = { + onTextFieldValueChange( + TextFieldValue( + text = searchQuery.query, + selection = TextRange(searchQuery.query.length) + ) + ) + } + ) + .rotate(225f) + .padding(horizontal = 8.dp) + .size(22.dp) + ) + } + } + + suggestionsResult?.getOrNull()?.let { suggestions -> + items(items = suggestions) { suggestion -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable { onSearch(suggestion) } + .fillMaxWidth() + .padding(all = 16.dp) + ) { + Spacer( + modifier = Modifier + .padding(horizontal = 8.dp) + .size(20.dp) + .paint( + painter = painterResource(R.drawable.search), + colorFilter = ColorFilter.disabled + ) + ) + + BasicText( + text = suggestion, + style = typography.s.secondary, + modifier = Modifier + .padding(horizontal = 8.dp) + .weight(1f) + ) + + Image( + painter = painterResource(R.drawable.arrow_forward), + contentDescription = null, + colorFilter = ColorFilter.disabled, + modifier = Modifier + .clickable( + indication = ripple(bounded = false), + interactionSource = remember { MutableInteractionSource() }, + onClick = { + onTextFieldValueChange( + TextFieldValue( + text = suggestion, + selection = TextRange(suggestion.length) + ) + ) + } + ) + .rotate(225f) + .padding(horizontal = 8.dp) + .size(22.dp) + ) + } + } + } ?: suggestionsResult?.exceptionOrNull()?.let { + item { + Box(modifier = Modifier.fillMaxSize()) { + BasicText( + text = stringResource(R.string.error_message), + style = typography.s.secondary.center, + modifier = Modifier.align(Alignment.Center) + ) + } + } + } + } + + FloatingActionsContainerWithScrollToTop(lazyListState = lazyListState) +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/SearchScreen.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/search/SearchScreen.kt similarity index 66% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/SearchScreen.kt rename to app/src/main/kotlin/app/vimusic/android/ui/screens/search/SearchScreen.kt index 49426c3..f4975bb 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/SearchScreen.kt +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/search/SearchScreen.kt @@ -1,31 +1,31 @@ -package it.vfsfitvnm.vimusic.ui.screens.search +package app.vimusic.android.ui.screens.search import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Box import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue -import it.vfsfitvnm.compose.persist.PersistMapCleanup -import it.vfsfitvnm.compose.routing.RouteHandler -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold -import it.vfsfitvnm.vimusic.ui.screens.globalRoutes -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.utils.secondary +import app.vimusic.android.R +import app.vimusic.android.ui.components.themed.Scaffold +import app.vimusic.android.ui.screens.GlobalRoutes +import app.vimusic.android.ui.screens.Route +import app.vimusic.android.utils.secondary +import app.vimusic.compose.persist.PersistMapCleanup +import app.vimusic.compose.routing.RouteHandler +import app.vimusic.core.ui.LocalAppearance -@ExperimentalFoundationApi -@ExperimentalAnimationApi +@Route @Composable fun SearchScreen( initialTextInput: String, @@ -34,9 +34,7 @@ fun SearchScreen( ) { val saveableStateHolder = rememberSaveableStateHolder() - val (tabIndex, onTabChanged) = rememberSaveable { - mutableStateOf(0) - } + val (tabIndex, onTabChanged) = rememberSaveable { mutableIntStateOf(0) } val (textFieldValue, onTextFieldValueChanged) = rememberSaveable( initialTextInput, @@ -50,23 +48,22 @@ fun SearchScreen( ) } - PersistMapCleanup(tagPrefix = "search/") + PersistMapCleanup(prefix = "search/") - RouteHandler(listenToGlobalEmitter = true) { - globalRoutes() + RouteHandler { + GlobalRoutes() - host { + Content { val decorationBox: @Composable (@Composable () -> Unit) -> Unit = { innerTextField -> Box { AnimatedVisibility( visible = textFieldValue.text.isEmpty(), enter = fadeIn(tween(300)), exit = fadeOut(tween(300)), - modifier = Modifier - .align(Alignment.CenterEnd) + modifier = Modifier.align(Alignment.CenterEnd) ) { BasicText( - text = "Enter a name", + text = stringResource(R.string.search_placeholder), maxLines = 1, style = LocalAppearance.current.typography.xxl.secondary ) @@ -77,28 +74,30 @@ fun SearchScreen( } Scaffold( + key = "search", topIconButtonId = R.drawable.chevron_back, onTopIconButtonClick = pop, tabIndex = tabIndex, - onTabChanged = onTabChanged, - tabColumnContent = { Item -> - Item(0, "Online", R.drawable.globe) - Item(1, "Library", R.drawable.library) + onTabChange = onTabChanged, + tabColumnContent = { + tab(0, R.string.online, R.drawable.globe, canHide = false) + tab(1, R.string.library, R.drawable.library) } ) { currentTabIndex -> saveableStateHolder.SaveableStateProvider(currentTabIndex) { when (currentTabIndex) { 0 -> OnlineSearch( textFieldValue = textFieldValue, - onTextFieldValueChanged = onTextFieldValueChanged, + onTextFieldValueChange = onTextFieldValueChanged, onSearch = onSearch, onViewPlaylist = onViewPlaylist, - decorationBox = decorationBox + decorationBox = decorationBox, + focused = child == null ) 1 -> LocalSongSearch( textFieldValue = textFieldValue, - onTextFieldValueChanged = onTextFieldValueChanged, + onTextFieldValueChange = onTextFieldValueChanged, decorationBox = decorationBox ) } diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/searchresult/ItemsPage.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/searchresult/ItemsPage.kt new file mode 100644 index 0000000..7d93059 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/searchresult/ItemsPage.kt @@ -0,0 +1,148 @@ +package app.vimusic.android.ui.screens.searchresult + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import app.vimusic.android.LocalPlayerAwareWindowInsets +import app.vimusic.android.R +import app.vimusic.android.ui.components.ShimmerHost +import app.vimusic.android.ui.components.themed.FloatingActionsContainerWithScrollToTop +import app.vimusic.android.utils.center +import app.vimusic.android.utils.secondary +import app.vimusic.compose.persist.persist +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.providers.innertube.Innertube +import app.vimusic.providers.innertube.utils.plus +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Composable +inline fun ItemsPage( + tag: String, + crossinline header: @Composable (textButton: (@Composable () -> Unit)?) -> Unit, + crossinline itemContent: @Composable LazyItemScope.(T) -> Unit, + noinline itemPlaceholderContent: @Composable () -> Unit, + modifier: Modifier = Modifier, + initialPlaceholderCount: Int = 8, + continuationPlaceholderCount: Int = 3, + emptyItemsText: String = stringResource(R.string.no_items_found), + noinline provider: (suspend (String?) -> Result?>?)? = null +) = ItemsPage( + tag = tag, + header = { before, _ -> header(before) }, + itemContent = itemContent, + itemPlaceholderContent = itemPlaceholderContent, + modifier = modifier, + initialPlaceholderCount = initialPlaceholderCount, + continuationPlaceholderCount = continuationPlaceholderCount, + emptyItemsText = emptyItemsText, + provider = provider +) + +@Composable +inline fun ItemsPage( + tag: String, + crossinline header: @Composable ( + beforeContent: (@Composable () -> Unit)?, + afterContent: (@Composable () -> Unit)? + ) -> Unit, + crossinline itemContent: @Composable LazyItemScope.(T) -> Unit, + noinline itemPlaceholderContent: @Composable () -> Unit, + modifier: Modifier = Modifier, + initialPlaceholderCount: Int = 8, + continuationPlaceholderCount: Int = 3, + emptyItemsText: String = stringResource(R.string.no_items_found), + noinline provider: (suspend (String?) -> Result?>?)? = null +) { + val (_, typography) = LocalAppearance.current + val updatedProvider by rememberUpdatedState(provider) + val lazyListState = rememberLazyListState() + var itemsPage by persist?>(tag) + + val shouldLoad by remember { + derivedStateOf { + lazyListState.layoutInfo.visibleItemsInfo.any { it.key == "loading" } + } + } + + LaunchedEffect(shouldLoad, updatedProvider) { + if (!shouldLoad) return@LaunchedEffect + val provideItems = updatedProvider ?: return@LaunchedEffect + + withContext(Dispatchers.IO) { + provideItems(itemsPage?.continuation) + }?.onSuccess { + if (it == null) { + if (itemsPage == null) itemsPage = Innertube.ItemsPage(null, null) + } else itemsPage += it + }?.onFailure { + itemsPage = itemsPage?.copy(continuation = null) + }?.exceptionOrNull()?.printStackTrace() + } + + Box(modifier = modifier) { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) + .asPaddingValues(), + modifier = Modifier.fillMaxSize() + ) { + item( + key = "header", + contentType = "header" + ) { + header(null, null) + } + + items( + items = itemsPage?.items ?: emptyList(), + key = Innertube.Item::key, + itemContent = itemContent + ) + + if (itemsPage != null && itemsPage?.items.isNullOrEmpty()) item(key = "empty") { + BasicText( + text = emptyItemsText, + style = typography.xs.secondary.center, + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 32.dp) + .fillMaxWidth() + ) + } + + if (!(itemsPage != null && itemsPage?.continuation == null)) item(key = "loading") { + val isFirstLoad = itemsPage?.items.isNullOrEmpty() + + ShimmerHost( + modifier = if (isFirstLoad) Modifier.fillParentMaxSize() else Modifier + ) { + repeat(if (isFirstLoad) initialPlaceholderCount else continuationPlaceholderCount) { + itemPlaceholderContent() + } + } + } + } + + FloatingActionsContainerWithScrollToTop(lazyListState = lazyListState) + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/searchresult/SearchResultScreen.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/searchresult/SearchResultScreen.kt new file mode 100644 index 0000000..bfb8c4a --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/searchresult/SearchResultScreen.kt @@ -0,0 +1,278 @@ +package app.vimusic.android.ui.screens.searchresult + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.runtime.Composable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import app.vimusic.android.LocalPlayerServiceBinder +import app.vimusic.android.R +import app.vimusic.android.preferences.UIStatePreferences +import app.vimusic.android.ui.components.LocalMenuState +import app.vimusic.android.ui.components.themed.Header +import app.vimusic.android.ui.components.themed.NonQueuedMediaItemMenu +import app.vimusic.android.ui.components.themed.Scaffold +import app.vimusic.android.ui.items.AlbumItem +import app.vimusic.android.ui.items.AlbumItemPlaceholder +import app.vimusic.android.ui.items.ArtistItem +import app.vimusic.android.ui.items.ArtistItemPlaceholder +import app.vimusic.android.ui.items.PlaylistItem +import app.vimusic.android.ui.items.PlaylistItemPlaceholder +import app.vimusic.android.ui.items.SongItem +import app.vimusic.android.ui.items.SongItemPlaceholder +import app.vimusic.android.ui.items.VideoItem +import app.vimusic.android.ui.items.VideoItemPlaceholder +import app.vimusic.android.ui.screens.GlobalRoutes +import app.vimusic.android.ui.screens.Route +import app.vimusic.android.ui.screens.albumRoute +import app.vimusic.android.ui.screens.artistRoute +import app.vimusic.android.ui.screens.playlistRoute +import app.vimusic.android.utils.asMediaItem +import app.vimusic.android.utils.forcePlay +import app.vimusic.compose.persist.LocalPersistMap +import app.vimusic.compose.persist.PersistMapCleanup +import app.vimusic.compose.routing.RouteHandler +import app.vimusic.core.ui.Dimensions +import app.vimusic.providers.innertube.Innertube +import app.vimusic.providers.innertube.models.bodies.ContinuationBody +import app.vimusic.providers.innertube.models.bodies.SearchBody +import app.vimusic.providers.innertube.requests.searchPage +import app.vimusic.providers.innertube.utils.from + +@OptIn(ExperimentalFoundationApi::class) +@Route +@Composable +fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) { + val persistMap = LocalPersistMap.current + val binder = LocalPlayerServiceBinder.current + val menuState = LocalMenuState.current + + val saveableStateHolder = rememberSaveableStateHolder() + + PersistMapCleanup(prefix = "searchResults/$query/") + + RouteHandler { + GlobalRoutes() + + Content { + val headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit = { + Header( + title = query, + modifier = Modifier.pointerInput(Unit) { + detectTapGestures { + persistMap?.clean("searchResults/$query/") + onSearchAgain() + } + } + ) + } + + Scaffold( + key = "searchresult", + topIconButtonId = R.drawable.chevron_back, + onTopIconButtonClick = pop, + tabIndex = UIStatePreferences.searchResultScreenTabIndex, + onTabChange = { UIStatePreferences.searchResultScreenTabIndex = it }, + tabColumnContent = { + tab(0, R.string.songs, R.drawable.musical_notes) + tab(1, R.string.albums, R.drawable.disc) + tab(2, R.string.artists, R.drawable.person) + tab(3, R.string.videos, R.drawable.film) + tab(4, R.string.playlists, R.drawable.playlist) + } + ) { tabIndex -> + saveableStateHolder.SaveableStateProvider(tabIndex) { + when (tabIndex) { + 0 -> ItemsPage( + tag = "searchResults/$query/songs", + provider = { continuation -> + if (continuation == null) Innertube.searchPage( + body = SearchBody( + query = query, + params = Innertube.SearchFilter.Song.value + ), + fromMusicShelfRendererContent = Innertube.SongItem.Companion::from + ) else Innertube.searchPage( + body = ContinuationBody(continuation = continuation), + fromMusicShelfRendererContent = Innertube.SongItem.Companion::from + ) + }, + emptyItemsText = stringResource(R.string.no_search_results), + header = headerContent, + itemContent = { song -> + SongItem( + song = song, + thumbnailSize = Dimensions.thumbnails.song, + modifier = Modifier.combinedClickable( + onLongClick = { + menuState.display { + NonQueuedMediaItemMenu( + onDismiss = menuState::hide, + mediaItem = song.asMediaItem + ) + } + }, + onClick = { + binder?.stopRadio() + binder?.player?.forcePlay(song.asMediaItem) + binder?.setupRadio(song.info?.endpoint) + } + ) + ) + }, + itemPlaceholderContent = { + SongItemPlaceholder(thumbnailSize = Dimensions.thumbnails.song) + } + ) + + 1 -> ItemsPage( + tag = "searchResults/$query/albums", + provider = { continuation -> + if (continuation == null) { + Innertube.searchPage( + body = SearchBody( + query = query, + params = Innertube.SearchFilter.Album.value + ), + fromMusicShelfRendererContent = Innertube.AlbumItem::from + ) + } else { + Innertube.searchPage( + body = ContinuationBody(continuation = continuation), + fromMusicShelfRendererContent = Innertube.AlbumItem::from + ) + } + }, + emptyItemsText = stringResource(R.string.no_search_results), + header = headerContent, + itemContent = { album -> + AlbumItem( + album = album, + thumbnailSize = Dimensions.thumbnails.album, + modifier = Modifier.clickable(onClick = { albumRoute(album.key) }) + ) + }, + itemPlaceholderContent = { + AlbumItemPlaceholder(thumbnailSize = Dimensions.thumbnails.album) + } + ) + + 2 -> ItemsPage( + tag = "searchResults/$query/artists", + provider = { continuation -> + if (continuation == null) { + Innertube.searchPage( + body = SearchBody( + query = query, + params = Innertube.SearchFilter.Artist.value + ), + fromMusicShelfRendererContent = Innertube.ArtistItem::from + ) + } else { + Innertube.searchPage( + body = ContinuationBody(continuation = continuation), + fromMusicShelfRendererContent = Innertube.ArtistItem::from + ) + } + }, + emptyItemsText = stringResource(R.string.no_search_results), + header = headerContent, + itemContent = { artist -> + ArtistItem( + artist = artist, + thumbnailSize = 64.dp, + modifier = Modifier + .clickable(onClick = { artistRoute(artist.key) }) + ) + }, + itemPlaceholderContent = { + ArtistItemPlaceholder(thumbnailSize = 64.dp) + } + ) + + 3 -> ItemsPage( + tag = "searchResults/$query/videos", + provider = { continuation -> + if (continuation == null) Innertube.searchPage( + body = SearchBody( + query = query, + params = Innertube.SearchFilter.Video.value + ), + fromMusicShelfRendererContent = Innertube.VideoItem::from + ) else Innertube.searchPage( + body = ContinuationBody(continuation = continuation), + fromMusicShelfRendererContent = Innertube.VideoItem::from + ) + }, + emptyItemsText = stringResource(R.string.no_search_results), + header = headerContent, + itemContent = { video -> + VideoItem( + video = video, + thumbnailWidth = 128.dp, + thumbnailHeight = 72.dp, + modifier = Modifier.combinedClickable( + onLongClick = { + menuState.display { + NonQueuedMediaItemMenu( + mediaItem = video.asMediaItem, + onDismiss = menuState::hide + ) + } + }, + onClick = { + binder?.stopRadio() + binder?.player?.forcePlay(video.asMediaItem) + binder?.setupRadio(video.info?.endpoint) + } + ) + ) + }, + itemPlaceholderContent = { + VideoItemPlaceholder( + thumbnailWidth = 128.dp, + thumbnailHeight = 72.dp + ) + } + ) + + 4 -> ItemsPage( + tag = "searchResults/$query/playlists", + provider = { continuation -> + if (continuation == null) Innertube.searchPage( + body = SearchBody( + query = query, + params = Innertube.SearchFilter.CommunityPlaylist.value + ), + fromMusicShelfRendererContent = Innertube.PlaylistItem::from + ) else Innertube.searchPage( + body = ContinuationBody(continuation = continuation), + fromMusicShelfRendererContent = Innertube.PlaylistItem::from + ) + }, + emptyItemsText = stringResource(R.string.no_search_results), + header = headerContent, + itemContent = { playlist -> + PlaylistItem( + playlist = playlist, + thumbnailSize = Dimensions.thumbnails.playlist, + modifier = Modifier.clickable { + playlistRoute(playlist.key, null, null, false) + } + ) + }, + itemPlaceholderContent = { + PlaylistItemPlaceholder(thumbnailSize = Dimensions.thumbnails.playlist) + } + ) + } + } + } + } + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/settings/About.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/settings/About.kt new file mode 100644 index 0000000..37524fa --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/settings/About.kt @@ -0,0 +1,292 @@ +package app.vimusic.android.ui.screens.settings + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.app.NotificationCompat +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import app.vimusic.android.BuildConfig +import app.vimusic.android.R +import app.vimusic.android.preferences.DataPreferences +import app.vimusic.android.service.ServiceNotifications +import app.vimusic.android.ui.components.themed.CircularProgressIndicator +import app.vimusic.android.ui.components.themed.DefaultDialog +import app.vimusic.android.ui.components.themed.SecondaryTextButton +import app.vimusic.android.ui.screens.Route +import app.vimusic.android.utils.bold +import app.vimusic.android.utils.center +import app.vimusic.android.utils.hasPermission +import app.vimusic.android.utils.pendingIntent +import app.vimusic.android.utils.semiBold +import app.vimusic.core.data.utils.Version +import app.vimusic.core.data.utils.version +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.utils.isAtLeastAndroid13 +import app.vimusic.core.ui.utils.isCompositionLaunched +import app.vimusic.providers.github.GitHub +import app.vimusic.providers.github.models.Release +import app.vimusic.providers.github.requests.releases +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlin.time.Duration +import kotlin.time.toJavaDuration + +private val VERSION_NAME = BuildConfig.VERSION_NAME.substringBeforeLast("-") +private const val REPO_OWNER = "haturatu" +private const val REPO_NAME = "ViMusic" + +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +private val permission = Manifest.permission.POST_NOTIFICATIONS + +class VersionCheckWorker( + context: Context, + params: WorkerParameters +) : CoroutineWorker(context, params) { + companion object { + private const val WORK_TAG = "version_check_worker" + + fun upsert(context: Context, period: Duration?) = runCatching { + val workManager = WorkManager.getInstance(context) + + if (period == null) { + workManager.cancelAllWorkByTag(WORK_TAG) + return@runCatching + } + + val request = PeriodicWorkRequestBuilder(period.toJavaDuration()) + .addTag(WORK_TAG) + .setConstraints( + Constraints( + requiredNetworkType = NetworkType.CONNECTED, + requiresBatteryNotLow = true + ) + ) + .build() + + workManager.enqueueUniquePeriodicWork( + /* uniqueWorkName = */ WORK_TAG, + /* existingPeriodicWorkPolicy = */ ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, + /* periodicWork = */ request + ) + + Unit + }.also { it.exceptionOrNull()?.printStackTrace() } + } + + override suspend fun doWork(): Result = with(applicationContext) { + if (isAtLeastAndroid13 && !hasPermission(permission)) return Result.retry() + + val result = withContext(Dispatchers.IO) { + VERSION_NAME.version + .getNewerVersion() + .also { it?.exceptionOrNull()?.printStackTrace() } + } + + result?.getOrNull()?.let { release -> + ServiceNotifications.version.sendNotification(applicationContext) { + this + .setSmallIcon(R.drawable.download) + .setContentTitle(getString(R.string.new_version_available)) + .setContentText(getString(R.string.redirect_github)) + .setContentIntent( + pendingIntent( + Intent( + /* action = */ Intent.ACTION_VIEW, + /* uri = */ Uri.parse(release.frontendUrl.toString()) + ) + ) + ) + .setAutoCancel(true) + .also { + it.setStyle( + NotificationCompat + .BigTextStyle(it) + .bigText(getString(R.string.new_version_available)) + ) + } + .setPriority(NotificationCompat.PRIORITY_HIGH) + } + } + + return when { + result == null || result.isFailure -> Result.retry() + result.isSuccess -> Result.success() + else -> Result.failure() // Unreachable + } + } +} + +private suspend fun Version.getNewerVersion( + repoOwner: String = REPO_OWNER, + repoName: String = REPO_NAME, + contentType: String = "application/vnd.android.package-archive" +) = GitHub.releases( + owner = repoOwner, + repo = repoName +)?.mapCatching { releases -> + releases + .sortedByDescending { it.publishedAt } + .firstOrNull { release -> + !release.draft && + !release.preRelease && + release.tag.version > this && + release.assets.any { + it.contentType == contentType && it.state == Release.Asset.State.Uploaded + } + } +} + +@Route +@Composable +fun About() = SettingsCategoryScreen( + title = stringResource(R.string.about), + description = stringResource( + R.string.format_version_credits, + VERSION_NAME + ) +) { + val (_, typography) = LocalAppearance.current + val uriHandler = LocalUriHandler.current + val context = LocalContext.current + + var hasPermission by remember(isCompositionLaunched()) { + mutableStateOf( + if (isAtLeastAndroid13) context.applicationContext.hasPermission(permission) + else true + ) + } + + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + onResult = { hasPermission = it } + ) + + SettingsGroup(title = stringResource(R.string.social)) { + SettingsEntry( + title = stringResource(R.string.github), + text = stringResource(R.string.view_source), + onClick = { + uriHandler.openUri("https://github.com/$REPO_OWNER/$REPO_NAME") + } + ) + } + + SettingsGroup(title = stringResource(R.string.contact)) { + SettingsEntry( + title = stringResource(R.string.report_bug), + text = stringResource(R.string.report_bug_description), + onClick = { + uriHandler.openUri( + @Suppress("MaximumLineLength") + "https://github.com/$REPO_OWNER/$REPO_NAME/issues/new?assignees=&labels=bug&template=bug_report.yaml" + ) + } + ) + + SettingsEntry( + title = stringResource(R.string.request_feature), + text = stringResource(R.string.redirect_github), + onClick = { + uriHandler.openUri( + @Suppress("MaximumLineLength") + "https://github.com/$REPO_OWNER/$REPO_NAME/issues/new?assignees=&labels=enhancement&template=feature_request.md" + ) + } + ) + } + + var newVersionDialogOpened by rememberSaveable { mutableStateOf(false) } + + SettingsGroup(title = stringResource(R.string.version)) { + SettingsEntry( + title = stringResource(R.string.check_new_version), + text = stringResource(R.string.current_version, VERSION_NAME), + onClick = { newVersionDialogOpened = true } + ) + + EnumValueSelectorSettingsEntry( + title = stringResource(R.string.version_check), + selectedValue = DataPreferences.versionCheckPeriod, + onValueSelect = onSelect@{ + DataPreferences.versionCheckPeriod = it + if (isAtLeastAndroid13 && it.period != null && !hasPermission) + launcher.launch(permission) + + VersionCheckWorker.upsert(context.applicationContext, it.period) + }, + valueText = { it.displayName() } + ) + } + + if (newVersionDialogOpened) DefaultDialog( + onDismiss = { newVersionDialogOpened = false } + ) { + var newerVersion: Result? by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + withContext(Dispatchers.IO) { + newerVersion = VERSION_NAME.version + .getNewerVersion() + ?.onFailure(Throwable::printStackTrace) + } + } + + newerVersion?.getOrNull()?.let { + BasicText( + text = stringResource(R.string.new_version_available), + style = typography.xs.semiBold.center + ) + + Spacer(modifier = Modifier.height(12.dp)) + + BasicText( + text = it.name ?: it.tag, + style = typography.m.bold.center + ) + + Spacer(modifier = Modifier.height(16.dp)) + + SecondaryTextButton( + text = stringResource(R.string.more_information), + onClick = { uriHandler.openUri(it.frontendUrl.toString()) } + ) + } ?: newerVersion?.exceptionOrNull()?.let { + BasicText( + text = stringResource(R.string.error_github), + style = typography.xs.semiBold.center, + modifier = Modifier.padding(all = 24.dp) + ) + } ?: if (newerVersion?.isSuccess == true) BasicText( + text = stringResource(R.string.up_to_date), + style = typography.xs.semiBold.center + ) else CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally)) + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/settings/AppearanceSettings.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/settings/AppearanceSettings.kt new file mode 100644 index 0000000..9514ddb --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/settings/AppearanceSettings.kt @@ -0,0 +1,270 @@ +package app.vimusic.android.ui.screens.settings + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import app.vimusic.android.R +import app.vimusic.android.preferences.AppearancePreferences +import app.vimusic.android.preferences.PlayerPreferences +import app.vimusic.android.ui.screens.Route +import app.vimusic.android.utils.currentLocale +import app.vimusic.android.utils.findActivity +import app.vimusic.android.utils.startLanguagePicker +import app.vimusic.core.ui.BuiltInFontFamily +import app.vimusic.core.ui.ColorMode +import app.vimusic.core.ui.ColorSource +import app.vimusic.core.ui.Darkness +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.ThumbnailRoundness +import app.vimusic.core.ui.googleFontsAvailable +import app.vimusic.core.ui.utils.isAtLeastAndroid13 + +@Route +@Composable +fun AppearanceSettings() = with(AppearancePreferences) { + val (colorPalette) = LocalAppearance.current + val context = LocalContext.current + val isDark = isSystemInDarkTheme() + + SettingsCategoryScreen(title = stringResource(R.string.appearance)) { + SettingsGroup(title = stringResource(R.string.colors)) { + EnumValueSelectorSettingsEntry( + title = stringResource(R.string.color_source), + selectedValue = colorSource, + onValueSelect = { colorSource = it }, + valueText = { it.nameLocalized } + ) + EnumValueSelectorSettingsEntry( + title = stringResource(R.string.color_mode), + selectedValue = colorMode, + onValueSelect = { colorMode = it }, + valueText = { it.nameLocalized } + ) + AnimatedVisibility(visible = colorMode == ColorMode.Dark || (colorMode == ColorMode.System && isDark)) { + EnumValueSelectorSettingsEntry( + title = stringResource(R.string.darkness), + selectedValue = darkness, + onValueSelect = { darkness = it }, + valueText = { it.nameLocalized } + ) + } + } + SettingsGroup(title = stringResource(R.string.shapes)) { + EnumValueSelectorSettingsEntry( + title = stringResource(R.string.thumbnail_roundness), + selectedValue = thumbnailRoundness, + onValueSelect = { thumbnailRoundness = it }, + trailingContent = { + Spacer( + modifier = Modifier + .border( + width = 1.dp, + color = colorPalette.accent, + shape = thumbnailRoundness.shape + ) + .background( + color = colorPalette.background1, + shape = thumbnailRoundness.shape + ) + .size(36.dp) + ) + }, + valueText = { it.nameLocalized } + ) + } + SettingsGroup(title = stringResource(R.string.text)) { + if (isAtLeastAndroid13) SettingsEntry( + title = stringResource(R.string.language), + text = currentLocale()?.displayLanguage + ?: stringResource(R.string.color_source_default), + onClick = { + context.findActivity().startLanguagePicker() + } + ) + + if (googleFontsAvailable()) EnumValueSelectorSettingsEntry( + title = stringResource(R.string.font), + selectedValue = fontFamily, + onValueSelect = { fontFamily = it }, + valueText = { + if (it == BuiltInFontFamily.System) stringResource(R.string.use_system_font) else it.name + } + ) else SwitchSettingsEntry( + title = stringResource(R.string.use_system_font), + text = stringResource(R.string.use_system_font_description), + isChecked = fontFamily == BuiltInFontFamily.System, + onCheckedChange = { + fontFamily = if (it) BuiltInFontFamily.System else BuiltInFontFamily.Poppins + } + ) + + SwitchSettingsEntry( + title = stringResource(R.string.apply_font_padding), + text = stringResource(R.string.apply_font_padding_description), + isChecked = applyFontPadding, + onCheckedChange = { applyFontPadding = it } + ) + } + if (!isAtLeastAndroid13) SettingsGroup(title = stringResource(R.string.lockscreen)) { + SwitchSettingsEntry( + title = stringResource(R.string.show_song_cover), + text = stringResource(R.string.show_song_cover_description), + isChecked = isShowingThumbnailInLockscreen, + onCheckedChange = { isShowingThumbnailInLockscreen = it } + ) + } + SettingsGroup(title = stringResource(R.string.player)) { + SwitchSettingsEntry( + title = stringResource(R.string.previous_button_while_collapsed), + text = stringResource(R.string.previous_button_while_collapsed_description), + isChecked = PlayerPreferences.isShowingPrevButtonCollapsed, + onCheckedChange = { PlayerPreferences.isShowingPrevButtonCollapsed = it } + ) + + SwitchSettingsEntry( + title = stringResource(R.string.swipe_horizontally_to_close), + text = stringResource(R.string.swipe_horizontally_to_close_description), + isChecked = PlayerPreferences.horizontalSwipeToClose, + onCheckedChange = { PlayerPreferences.horizontalSwipeToClose = it } + ) + + EnumValueSelectorSettingsEntry( + title = stringResource(R.string.player_layout), + selectedValue = PlayerPreferences.playerLayout, + onValueSelect = { PlayerPreferences.playerLayout = it }, + valueText = { it.displayName() } + ) + + AnimatedVisibility( + visible = PlayerPreferences.playerLayout == PlayerPreferences.PlayerLayout.New, + label = "" + ) { + SwitchSettingsEntry( + title = stringResource(R.string.show_like_button), + text = stringResource(R.string.show_like_button_description), + isChecked = PlayerPreferences.showLike, + onCheckedChange = { PlayerPreferences.showLike = it } + ) + } + + EnumValueSelectorSettingsEntry( + title = stringResource(R.string.seek_bar_style), + selectedValue = PlayerPreferences.seekBarStyle, + onValueSelect = { PlayerPreferences.seekBarStyle = it }, + valueText = { it.displayName() } + ) + + AnimatedVisibility( + visible = PlayerPreferences.seekBarStyle == PlayerPreferences.SeekBarStyle.Wavy, + label = "" + ) { + EnumValueSelectorSettingsEntry( + title = stringResource(R.string.seek_bar_quality), + selectedValue = PlayerPreferences.wavySeekBarQuality, + onValueSelect = { PlayerPreferences.wavySeekBarQuality = it }, + valueText = { it.displayName() } + ) + } + + SwitchSettingsEntry( + title = stringResource(R.string.swipe_to_remove_item), + text = stringResource(R.string.swipe_to_remove_item_description), + isChecked = PlayerPreferences.horizontalSwipeToRemoveItem, + onCheckedChange = { PlayerPreferences.horizontalSwipeToRemoveItem = it } + ) + + SwitchSettingsEntry( + title = stringResource(R.string.lyrics_keep_screen_awake), + text = stringResource(R.string.lyrics_keep_screen_awake_description), + isChecked = PlayerPreferences.lyricsKeepScreenAwake, + onCheckedChange = { PlayerPreferences.lyricsKeepScreenAwake = it } + ) + + SwitchSettingsEntry( + title = stringResource(R.string.lyrics_show_system_bars), + text = stringResource(R.string.lyrics_show_system_bars_description), + isChecked = PlayerPreferences.lyricsShowSystemBars, + onCheckedChange = { PlayerPreferences.lyricsShowSystemBars = it } + ) + + SwitchSettingsEntry( + title = stringResource(R.string.pip), + text = stringResource(R.string.pip_description), + isChecked = autoPip, + onCheckedChange = { autoPip = it } + ) + } + SettingsGroup(title = stringResource(R.string.songs)) { + SwitchSettingsEntry( + title = stringResource(R.string.swipe_to_hide_song), + text = stringResource(R.string.swipe_to_hide_song_description), + isChecked = swipeToHideSong, + onCheckedChange = { swipeToHideSong = it } + ) + AnimatedVisibility( + visible = swipeToHideSong, + label = "" + ) { + SwitchSettingsEntry( + title = stringResource(R.string.swipe_to_hide_song_confirm), + text = stringResource(R.string.swipe_to_hide_song_confirm_description), + isChecked = swipeToHideSongConfirm, + onCheckedChange = { swipeToHideSongConfirm = it } + ) + } + SwitchSettingsEntry( + title = stringResource(R.string.hide_explicit), + text = stringResource(R.string.hide_explicit_description), + isChecked = hideExplicit, + onCheckedChange = { hideExplicit = it } + ) + } + } +} + +val ColorSource.nameLocalized + @Composable get() = stringResource( + when (this) { + ColorSource.Default -> R.string.color_source_default + ColorSource.Dynamic -> R.string.color_source_dynamic + ColorSource.MaterialYou -> R.string.color_source_material_you + } + ) + +val ColorMode.nameLocalized + @Composable get() = stringResource( + when (this) { + ColorMode.System -> R.string.color_mode_system + ColorMode.Light -> R.string.color_mode_light + ColorMode.Dark -> R.string.color_mode_dark + } + ) + +val Darkness.nameLocalized + @Composable get() = stringResource( + when (this) { + Darkness.Normal -> R.string.darkness_normal + Darkness.AMOLED -> R.string.darkness_amoled + Darkness.PureBlack -> R.string.darkness_pureblack + } + ) + +val ThumbnailRoundness.nameLocalized + @Composable get() = stringResource( + when (this) { + ThumbnailRoundness.None -> R.string.none + ThumbnailRoundness.Light -> R.string.thumbnail_roundness_light + ThumbnailRoundness.Medium -> R.string.thumbnail_roundness_medium + ThumbnailRoundness.Heavy -> R.string.thumbnail_roundness_heavy + ThumbnailRoundness.Heavier -> R.string.thumbnail_roundness_heavier + ThumbnailRoundness.Heaviest -> R.string.thumbnail_roundness_heaviest + } + ) diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/settings/CacheSettings.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/settings/CacheSettings.kt new file mode 100644 index 0000000..8dcfa48 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/settings/CacheSettings.kt @@ -0,0 +1,114 @@ +package app.vimusic.android.ui.screens.settings + +import android.text.format.Formatter +import androidx.annotation.OptIn +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.media3.common.util.UnstableApi +import app.vimusic.android.LocalPlayerServiceBinder +import app.vimusic.android.R +import app.vimusic.android.preferences.DataPreferences +import app.vimusic.android.preferences.PlayerPreferences +import app.vimusic.android.ui.components.themed.LinearProgressIndicator +import app.vimusic.android.ui.screens.Route +import app.vimusic.core.data.enums.ExoPlayerDiskCacheSize +import coil3.imageLoader + +@OptIn(UnstableApi::class) +@Route +@Composable +fun CacheSettings() = with(DataPreferences) { + val context = LocalContext.current + val binder = LocalPlayerServiceBinder.current + + SettingsCategoryScreen(title = stringResource(R.string.cache)) { + SettingsDescription(text = stringResource(R.string.cache_description)) + + context.imageLoader.diskCache?.let { diskCache -> + val diskCacheSize by remember { derivedStateOf { diskCache.size } } + val formattedSize = remember(diskCacheSize) { + Formatter.formatShortFileSize(context, diskCacheSize) + } + val sizePercentage = remember(diskCacheSize, coilDiskCacheMaxSize) { + diskCacheSize.toFloat() / coilDiskCacheMaxSize.bytes.coerceAtLeast(1) + } + + SettingsGroup( + title = stringResource(R.string.image_cache), + description = stringResource( + R.string.format_cache_space_used_percentage, + formattedSize, + (sizePercentage * 100).toInt() + ) + ) { + LinearProgressIndicator( + progress = sizePercentage, + strokeCap = StrokeCap.Round, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp) + .padding(start = 32.dp, end = 16.dp) + ) + EnumValueSelectorSettingsEntry( + title = stringResource(R.string.max_size), + selectedValue = coilDiskCacheMaxSize, + onValueSelect = { coilDiskCacheMaxSize = it } + ) + } + } + binder?.cache?.let { cache -> + val diskCacheSize by remember { derivedStateOf { cache.cacheSpace } } + val formattedSize = remember(diskCacheSize) { + Formatter.formatShortFileSize(context, diskCacheSize) + } + val sizePercentage = remember(diskCacheSize, exoPlayerDiskCacheMaxSize) { + diskCacheSize.toFloat() / exoPlayerDiskCacheMaxSize.bytes.coerceAtLeast(1) + } + + SettingsGroup( + title = stringResource(R.string.song_cache), + description = if (exoPlayerDiskCacheMaxSize == ExoPlayerDiskCacheSize.Unlimited) stringResource( + R.string.format_cache_space_used, + formattedSize + ) + else stringResource( + R.string.format_cache_space_used_percentage, + formattedSize, + (sizePercentage * 100).toInt() + ) + ) { + AnimatedVisibility(visible = exoPlayerDiskCacheMaxSize != ExoPlayerDiskCacheSize.Unlimited) { + LinearProgressIndicator( + progress = sizePercentage, + strokeCap = StrokeCap.Round, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp) + .padding(start = 32.dp, end = 16.dp) + ) + } + EnumValueSelectorSettingsEntry( + title = stringResource(R.string.max_size), + selectedValue = exoPlayerDiskCacheMaxSize, + onValueSelect = { exoPlayerDiskCacheMaxSize = it } + ) + SwitchSettingsEntry( + title = stringResource(R.string.pause_song_cache), + text = stringResource(R.string.pause_song_cache_description), + isChecked = PlayerPreferences.pauseCache, + onCheckedChange = { PlayerPreferences.pauseCache = it } + ) + } + } + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/settings/DatabaseSettings.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/settings/DatabaseSettings.kt new file mode 100644 index 0000000..ffd1161 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/settings/DatabaseSettings.kt @@ -0,0 +1,176 @@ +package app.vimusic.android.ui.screens.settings + +import android.content.ActivityNotFoundException +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import app.vimusic.android.Database +import app.vimusic.android.R +import app.vimusic.android.internal +import app.vimusic.android.path +import app.vimusic.android.preferences.DataPreferences +import app.vimusic.android.query +import app.vimusic.android.service.PlayerService +import app.vimusic.android.transaction +import app.vimusic.android.ui.screens.Route +import app.vimusic.android.utils.intent +import app.vimusic.android.utils.toast +import kotlinx.coroutines.flow.distinctUntilChanged +import java.io.FileInputStream +import java.io.FileOutputStream +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import kotlin.system.exitProcess + +@Route +@Composable +fun DatabaseSettings() = with(DataPreferences) { + val context = LocalContext.current + + val eventsCount by remember { Database.eventsCount().distinctUntilChanged() } + .collectAsState(initial = 0) + + val blacklistLength by remember { Database.blacklistLength().distinctUntilChanged() } + .collectAsState(initial = 0) + + val backupLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument(mimeType = "application/vnd.sqlite3") + ) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + + query { + Database.checkpoint() + + context.applicationContext.contentResolver.openOutputStream(uri)?.use { output -> + FileInputStream(Database.internal.path).use { input -> input.copyTo(output) } + } + } + } + + val restoreLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + + query { + Database.checkpoint() + Database.internal.close() + + context.applicationContext.contentResolver.openInputStream(uri) + ?.use { inputStream -> + FileOutputStream(Database.internal.path).use { outputStream -> + inputStream.copyTo(outputStream) + } + } + + context.stopService(context.intent()) + exitProcess(0) + } + } + + SettingsCategoryScreen(title = stringResource(R.string.database)) { + SettingsGroup(title = stringResource(R.string.cleanup)) { + SwitchSettingsEntry( + title = stringResource(R.string.pause_playback_history), + text = stringResource(R.string.pause_playback_history_description), + isChecked = pauseHistory, + onCheckedChange = { pauseHistory = !pauseHistory } + ) + + AnimatedVisibility(visible = pauseHistory) { + SettingsDescription( + text = stringResource(R.string.pause_playback_history_warning), + important = true + ) + } + + AnimatedVisibility(visible = !(pauseHistory && eventsCount == 0)) { + SettingsEntry( + title = stringResource(R.string.reset_quick_picks), + text = if (eventsCount > 0) pluralStringResource( + R.plurals.format_reset_quick_picks_amount, + eventsCount, + eventsCount + ) + else stringResource(R.string.quick_picks_empty), + onClick = { query(Database::clearEvents) }, + isEnabled = eventsCount > 0 + ) + } + + SwitchSettingsEntry( + title = stringResource(R.string.pause_playback_time), + text = stringResource( + R.string.format_pause_playback_time_description, + topListLength + ), + isChecked = pausePlaytime, + onCheckedChange = { pausePlaytime = !pausePlaytime } + ) + + SettingsEntry( + title = stringResource(R.string.reset_blacklist), + text = if (blacklistLength > 0) pluralStringResource( + R.plurals.format_reset_blacklist_description, + blacklistLength, + blacklistLength + ) else stringResource(R.string.blacklist_empty), + isEnabled = blacklistLength > 0, + onClick = { + transaction { + Database.resetBlacklist() + } + } + ) + } + SettingsGroup( + title = stringResource(R.string.backup), + description = stringResource(R.string.backup_description) + ) { + SettingsEntry( + title = stringResource(R.string.backup), + text = stringResource(R.string.backup_action_description), + onClick = { + val dateFormat = SimpleDateFormat("yyyyMMddHHmmss", Locale.getDefault()) + + try { + backupLauncher.launch("ViMusic_backup_${dateFormat.format(Date())}.db") + } catch (e: ActivityNotFoundException) { + context.toast(context.getString(R.string.no_file_chooser_installed)) + } + } + ) + } + SettingsGroup( + title = stringResource(R.string.restore), + description = stringResource(R.string.restore_warning), + important = true + ) { + SettingsEntry( + title = stringResource(R.string.restore), + text = stringResource(R.string.restore_description), + onClick = { + try { + restoreLauncher.launch( + arrayOf( + "application/vnd.sqlite3", + "application/x-sqlite3", + "application/octet-stream" + ) + ) + } catch (e: ActivityNotFoundException) { + context.toast(context.getString(R.string.no_file_chooser_installed)) + } + } + ) + } + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/settings/LogsScreen.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/settings/LogsScreen.kt new file mode 100644 index 0000000..79d6f91 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/settings/LogsScreen.kt @@ -0,0 +1,243 @@ +package app.vimusic.android.ui.screens.settings + +import android.content.Intent +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.BasicText +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import app.vimusic.android.LocalPlayerAwareWindowInsets +import app.vimusic.android.R +import app.vimusic.android.ui.components.themed.FloatingActionsContainerWithScrollToTop +import app.vimusic.android.ui.components.themed.Scaffold +import app.vimusic.android.ui.screens.GlobalRoutes +import app.vimusic.android.ui.screens.Route +import app.vimusic.android.utils.Logcat +import app.vimusic.android.utils.color +import app.vimusic.android.utils.logcat +import app.vimusic.android.utils.semiBold +import app.vimusic.android.utils.smoothScrollToTop +import app.vimusic.compose.routing.RouteHandler +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.core.ui.primaryButton +import app.vimusic.core.ui.utils.ActivityIntentBundleAccessor +import kotlinx.coroutines.delay + +@Route +@Composable +fun LogsScreen() { + val saveableStateHolder = rememberSaveableStateHolder() + val (tabIndex, onTabChanged) = rememberSaveable { mutableIntStateOf(0) } + + RouteHandler { + GlobalRoutes() + + Content { + Scaffold( + key = "logs", + topIconButtonId = R.drawable.chevron_back, + onTopIconButtonClick = pop, + tabIndex = tabIndex, + onTabChange = onTabChanged, + tabColumnContent = { + tab(0, R.string.logs, R.drawable.library) + } + ) { currentTabIndex -> + saveableStateHolder.SaveableStateProvider(currentTabIndex) { + when (currentTabIndex) { + 0 -> LogsList() + } + } + } + } + } +} + +@Composable +fun LogsList(modifier: Modifier = Modifier) = Box(modifier = modifier.fillMaxSize()) { + val logs = logcat() + val state = rememberLazyListState() + + val (_, typography) = LocalAppearance.current + val context = LocalContext.current + + var initial by remember { mutableStateOf(true) } + val firstVisibleItemIndex by remember { derivedStateOf { state.firstVisibleItemIndex } } + + LaunchedEffect(logs.size) { + if (initial && logs.isNotEmpty()) { + delay(200) + state.scrollToItem(0) + initial = false + return@LaunchedEffect + } + if (logs.isEmpty() || firstVisibleItemIndex > 1) return@LaunchedEffect + + state.smoothScrollToTop() + } + + LazyColumn( + state = state, + modifier = Modifier.fillMaxSize(), + contentPadding = LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) + .asPaddingValues(), + reverseLayout = true + ) { + fun get(i: Int) = logs[logs.size - i - 1] + + items( + count = logs.size, + key = { get(it).id } + ) { i -> + when (val line = get(i)) { + is Logcat.FormattedLine -> FormattedLine( + line = line, + modifier = Modifier + .fillMaxWidth() + .padding(4.dp) + ) + + is Logcat.RawLine -> BasicText( + text = line.raw, + style = typography.xs, + modifier = Modifier.padding(16.dp) + ) + } + } + } + + FloatingActionsContainerWithScrollToTop( + lazyListState = state, + scrollIcon = R.drawable.chevron_down, + icon = R.drawable.share_social, + onClick = { + val extras = ActivityIntentBundleAccessor.bundle { + text = logs.joinToString(separator = "\n") { + when (it) { + is Logcat.FormattedLine -> + "[${it.timestamp}] ${it.level.name} (${it.pid}) ${it.tag} - ${it.message}" + + is Logcat.RawLine -> it.raw + } + } + } + + context.startActivity( + Intent(Intent.ACTION_SEND).apply { + putExtras(extras) + type = "text/plain" + }.let { Intent.createChooser(it, null) } + ) + } + ) +} + +@Composable +fun LazyItemScope.FormattedLine( + line: Logcat.FormattedLine, + modifier: Modifier = Modifier +) { + val (colorPalette, typography, _, thumbnailShape) = LocalAppearance.current + + val backgroundColor = remember(line, colorPalette) { + when (line.level) { + Logcat.FormattedLine.Level.Error -> colorPalette.red + Logcat.FormattedLine.Level.Warning -> colorPalette.yellow + Logcat.FormattedLine.Level.Debug -> colorPalette.blue + Logcat.FormattedLine.Level.Info -> colorPalette.primaryButton + Logcat.FormattedLine.Level.Unknown -> colorPalette.textDisabled + } + } + + var singleLine by remember { mutableStateOf(true) } + + Column( + modifier = modifier + .clip(thumbnailShape) + .clickable { singleLine = !singleLine } + .background(backgroundColor) + .padding(16.dp) + .animateItem() + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Image( + painter = painterResource( + when (line.level) { + Logcat.FormattedLine.Level.Error -> R.drawable.alert_circle + Logcat.FormattedLine.Level.Warning -> R.drawable.warning_outline + Logcat.FormattedLine.Level.Debug -> R.drawable.bug_outline + Logcat.FormattedLine.Level.Info -> R.drawable.information_circle_outline + Logcat.FormattedLine.Level.Unknown -> R.drawable.help_outline + } + ), + contentDescription = null, + colorFilter = ColorFilter.tint(contentColorFor(backgroundColor)) + ) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + BasicText( + text = line.timestamp.toString(), + style = typography.xxs.color(contentColorFor(backgroundColor)), + modifier = Modifier.padding(start = 8.dp) + ) + BasicText( + text = line.tag, + style = typography.xxs.semiBold.color( + contentColorFor( + backgroundColor + ) + ), + modifier = Modifier.padding(start = 8.dp) + ) + } + } + AnimatedContent( + targetState = singleLine, + label = "", + transitionSpec = { EnterTransition.None togetherWith fadeOut() } + ) { currentSingleLine -> + BasicText( + text = line.message, + style = typography.xs.color(contentColorFor(backgroundColor)), + modifier = Modifier.padding(top = 8.dp), + maxLines = if (currentSingleLine) 1 else Int.MAX_VALUE, + overflow = TextOverflow.Ellipsis + ) + } + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/settings/OtherSettings.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/settings/OtherSettings.kt new file mode 100644 index 0000000..57557bd --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/settings/OtherSettings.kt @@ -0,0 +1,338 @@ +package app.vimusic.android.ui.screens.settings + +import android.annotation.SuppressLint +import android.content.ActivityNotFoundException +import android.content.ComponentName +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SnapshotMutationPolicy +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import app.vimusic.android.Database +import app.vimusic.android.DatabaseInitializer +import app.vimusic.android.LocalPlayerServiceBinder +import app.vimusic.android.R +import app.vimusic.android.preferences.AppearancePreferences +import app.vimusic.android.preferences.DataPreferences +import app.vimusic.android.preferences.PlayerPreferences +import app.vimusic.android.query +import app.vimusic.android.service.PlayerMediaBrowserService +import app.vimusic.android.service.PrecacheService +import app.vimusic.android.ui.components.themed.SecondaryTextButton +import app.vimusic.android.ui.components.themed.SliderDialog +import app.vimusic.android.ui.components.themed.SliderDialogBody +import app.vimusic.android.ui.screens.Route +import app.vimusic.android.ui.screens.logsRoute +import app.vimusic.android.utils.findActivity +import app.vimusic.android.utils.intent +import app.vimusic.android.utils.isIgnoringBatteryOptimizations +import app.vimusic.android.utils.smoothScrollToBottom +import app.vimusic.android.utils.toast +import app.vimusic.core.ui.utils.isAtLeastAndroid12 +import app.vimusic.core.ui.utils.isAtLeastAndroid6 +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import kotlin.math.roundToInt +import kotlin.system.exitProcess + +@SuppressLint("BatteryLife") +@Route +@Composable +fun OtherSettings() { + val context = LocalContext.current + val binder = LocalPlayerServiceBinder.current + val uriHandler = LocalUriHandler.current + + val scrollState = rememberScrollState() + val coroutineScope = rememberCoroutineScope() + + var isAndroidAutoEnabled by remember { + val component = ComponentName(context, PlayerMediaBrowserService::class.java) + val disabledFlag = PackageManager.COMPONENT_ENABLED_STATE_DISABLED + val enabledFlag = PackageManager.COMPONENT_ENABLED_STATE_ENABLED + + mutableStateOf( + value = context.packageManager.getComponentEnabledSetting(component) == enabledFlag, + policy = object : SnapshotMutationPolicy { + override fun equivalent(a: Boolean, b: Boolean): Boolean { + context.packageManager.setComponentEnabledSetting( + component, + if (b) enabledFlag else disabledFlag, + PackageManager.DONT_KILL_APP + ) + return a == b + } + } + ) + } + + var isIgnoringBatteryOptimizations by remember { + mutableStateOf(context.isIgnoringBatteryOptimizations) + } + + val activityResultLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + onResult = { isIgnoringBatteryOptimizations = context.isIgnoringBatteryOptimizations } + ) + + val queriesCount by remember { + Database.queriesCount().distinctUntilChanged() + }.collectAsState(initial = 0) + + SettingsCategoryScreen( + title = stringResource(R.string.other), + scrollState = scrollState + ) { + SettingsGroup(title = stringResource(R.string.android_auto)) { + SwitchSettingsEntry( + title = stringResource(R.string.android_auto), + text = stringResource(R.string.android_auto_description), + isChecked = isAndroidAutoEnabled, + onCheckedChange = { isAndroidAutoEnabled = it } + ) + + AnimatedVisibility(visible = isAndroidAutoEnabled) { + SettingsDescription(text = stringResource(R.string.android_auto_warning)) + } + } + SettingsGroup(title = stringResource(R.string.search_history)) { + SwitchSettingsEntry( + title = stringResource(R.string.pause_search_history), + text = stringResource(R.string.pause_search_history_description), + isChecked = DataPreferences.pauseSearchHistory, + onCheckedChange = { DataPreferences.pauseSearchHistory = it } + ) + + AnimatedVisibility(visible = !(DataPreferences.pauseSearchHistory && queriesCount == 0)) { + SettingsEntry( + title = stringResource(R.string.clear_search_history), + text = if (queriesCount > 0) stringResource( + R.string.format_clear_search_history_amount, + queriesCount + ) + else stringResource(R.string.empty_history), + onClick = { query(Database::clearQueries) }, + isEnabled = queriesCount > 0 + ) + } + } + SettingsGroup(title = stringResource(R.string.playlists)) { + SwitchSettingsEntry( + title = stringResource(R.string.auto_sync_playlists), + text = stringResource(R.string.auto_sync_playlists_description), + isChecked = DataPreferences.autoSyncPlaylists, + onCheckedChange = { DataPreferences.autoSyncPlaylists = it } + ) + } + SettingsGroup(title = stringResource(R.string.built_in_playlists)) { + IntSettingsEntry( + title = stringResource(R.string.top_list_length), + text = stringResource(R.string.top_list_length_description), + currentValue = DataPreferences.topListLength, + setValue = { DataPreferences.topListLength = it }, + defaultValue = 10, + range = 1..500 + ) + } + SettingsGroup(title = stringResource(R.string.quick_picks)) { + EnumValueSelectorSettingsEntry( + title = stringResource(R.string.quick_picks_source), + selectedValue = DataPreferences.quickPicksSource, + onValueSelect = { DataPreferences.quickPicksSource = it }, + valueText = { it.displayName() } + ) + + SwitchSettingsEntry( + title = stringResource(R.string.quick_picks_cache), + text = stringResource(R.string.quick_picks_cache_description), + isChecked = DataPreferences.shouldCacheQuickPicks, + onCheckedChange = { DataPreferences.shouldCacheQuickPicks = it } + ) + } + SettingsGroup(title = stringResource(R.string.dynamic_thumbnails)) { + var selectingThumbnailSize by remember { mutableStateOf(false) } + SettingsEntry( + title = stringResource(R.string.max_dynamic_thumbnail_size), + text = stringResource(R.string.max_dynamic_thumbnail_size_description), + onClick = { selectingThumbnailSize = true } + ) + if (selectingThumbnailSize) SliderDialog( + onDismiss = { selectingThumbnailSize = false }, + title = stringResource(R.string.max_dynamic_thumbnail_size) + ) { + SliderDialogBody( + provideState = { + remember(AppearancePreferences.maxThumbnailSize) { + mutableFloatStateOf(AppearancePreferences.maxThumbnailSize.toFloat()) + } + }, + onSlideComplete = { AppearancePreferences.maxThumbnailSize = it.roundToInt() }, + min = 32f, + max = 1920f, + toDisplay = { stringResource(R.string.format_px, it.roundToInt()) }, + steps = 58 + ) + } + } + SettingsGroup(title = stringResource(R.string.service_lifetime)) { + AnimatedVisibility(visible = !isIgnoringBatteryOptimizations) { + SettingsDescription( + text = stringResource(R.string.service_lifetime_warning), + important = true + ) + } + + if (isAtLeastAndroid12) SettingsDescription( + text = stringResource(R.string.service_lifetime_warning_android_12) + ) + + SettingsEntry( + title = stringResource(R.string.ignore_battery_optimizations), + text = if (isIgnoringBatteryOptimizations) stringResource(R.string.ignoring_battery_optimizations) + else stringResource(R.string.ignore_battery_optimizations_action), + onClick = { + if (!isAtLeastAndroid6) return@SettingsEntry + + try { + activityResultLauncher.launch( + Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { + data = Uri.parse("package:${context.packageName}") + } + ) + } catch (e: ActivityNotFoundException) { + try { + activityResultLauncher.launch(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) + } catch (e: ActivityNotFoundException) { + context.toast(context.getString(R.string.no_battery_optimization_settings_found)) + } + } + }, + isEnabled = !isIgnoringBatteryOptimizations + ) + + AnimatedVisibility(!isAtLeastAndroid12 || isIgnoringBatteryOptimizations) { + SwitchSettingsEntry( + title = stringResource(R.string.invincible_service), + text = stringResource(R.string.invincible_service_description), + isChecked = PlayerPreferences.isInvincibilityEnabled, + onCheckedChange = { PlayerPreferences.isInvincibilityEnabled = it } + ) + } + + SettingsEntry( + title = stringResource(R.string.need_help), + text = stringResource(R.string.need_help_description), + onClick = { + uriHandler.openUri("https://dontkillmyapp.com/") + } + ) + + SettingsDescription(text = stringResource(R.string.service_lifetime_report_issue)) + } + + var showTroubleshoot by rememberSaveable { mutableStateOf(false) } + + AnimatedContent(showTroubleshoot, label = "") { show -> + if (show) SettingsGroup( + title = stringResource(R.string.troubleshooting), + description = stringResource(R.string.troubleshooting_warning), + important = true + ) { + val troubleshootScope = rememberCoroutineScope() + var reloading by rememberSaveable { mutableStateOf(false) } + + SecondaryTextButton( + text = stringResource(R.string.reload_app_internals), + onClick = { + if (!reloading) troubleshootScope.launch { + reloading = true + context.stopService(context.intent()) + binder?.restartForegroundOrStop() + DatabaseInitializer.reload() + reloading = false + } + }, + enabled = !reloading, + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp) + .padding(horizontal = 16.dp) + ) + + Spacer(modifier = Modifier.height(12.dp)) + + SecondaryTextButton( + text = stringResource(R.string.kill_app), + onClick = { + binder?.stopRadio() + binder?.invincible = false + context.findActivity().finishAndRemoveTask() + binder?.restartForegroundOrStop() + troubleshootScope.launch { + delay(500L) + Handler(Looper.getMainLooper()).postAtFrontOfQueue { exitProcess(0) } + } + }, + enabled = !reloading, + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp) + .padding(horizontal = 16.dp) + ) + + Spacer(modifier = Modifier.height(12.dp)) + + SecondaryTextButton( + text = stringResource(R.string.show_logs), + onClick = { + logsRoute.global() + }, + enabled = !reloading, + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp) + .padding(horizontal = 16.dp) + ) + } else SecondaryTextButton( + text = stringResource(R.string.show_troubleshoot_section), + onClick = { + coroutineScope.launch { + delay(500) + scrollState.smoothScrollToBottom() + } + showTroubleshoot = true + }, + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, bottom = 16.dp) + .padding(horizontal = 16.dp) + ) + } + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/settings/PlayerSettings.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/settings/PlayerSettings.kt new file mode 100644 index 0000000..3af31a7 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/settings/PlayerSettings.kt @@ -0,0 +1,209 @@ +package app.vimusic.android.ui.screens.settings + +import androidx.annotation.OptIn +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.media3.common.util.UnstableApi +import app.vimusic.android.LocalPlayerServiceBinder +import app.vimusic.android.R +import app.vimusic.android.preferences.PlayerPreferences +import app.vimusic.android.service.PlayerService +import app.vimusic.android.ui.components.themed.SecondaryTextButton +import app.vimusic.android.ui.screens.Route +import app.vimusic.android.utils.rememberEqualizerLauncher +import app.vimusic.core.ui.utils.isAtLeastAndroid6 + +@OptIn(UnstableApi::class) +@Route +@Composable +fun PlayerSettings() = with(PlayerPreferences) { + val binder = LocalPlayerServiceBinder.current + val launchEqualizer by rememberEqualizerLauncher(audioSessionId = { binder?.player?.audioSessionId }) + var changed by rememberSaveable { mutableStateOf(false) } + + SettingsCategoryScreen(title = stringResource(R.string.player)) { + SettingsGroup(title = stringResource(R.string.player)) { + SwitchSettingsEntry( + title = stringResource(R.string.persistent_queue), + text = stringResource(R.string.persistent_queue_description), + isChecked = persistentQueue, + onCheckedChange = { persistentQueue = it } + ) + + if (isAtLeastAndroid6) SwitchSettingsEntry( + title = stringResource(R.string.resume_playback), + text = stringResource(R.string.resume_playback_description), + isChecked = resumePlaybackWhenDeviceConnected, + onCheckedChange = { + resumePlaybackWhenDeviceConnected = it + } + ) + + SwitchSettingsEntry( + title = stringResource(R.string.stop_when_closed), + text = stringResource(R.string.stop_when_closed_description), + isChecked = stopWhenClosed, + onCheckedChange = { stopWhenClosed = it } + ) + + SwitchSettingsEntry( + title = stringResource(R.string.skip_on_error), + text = stringResource(R.string.skip_on_error_description), + isChecked = skipOnError, + onCheckedChange = { skipOnError = it } + ) + } + SettingsGroup(title = stringResource(R.string.audio)) { + AnimatedVisibility(visible = changed) { + RestartPlayerSettingsEntry( + onRestart = { changed = false } + ) + } + + SwitchSettingsEntry( + title = stringResource(R.string.skip_silence), + text = stringResource(R.string.skip_silence_description), + isChecked = skipSilence, + onCheckedChange = { + skipSilence = it + } + ) + + AnimatedVisibility(visible = skipSilence) { + val initialValue by remember { derivedStateOf { minimumSilence.toFloat() / 1000L } } + var newValue by remember(initialValue) { mutableFloatStateOf(initialValue) } + + Column { + SliderSettingsEntry( + title = stringResource(R.string.minimum_silence_length), + text = stringResource(R.string.minimum_silence_length_description), + state = newValue, + onSlide = { newValue = it }, + onSlideComplete = { + minimumSilence = newValue.toLong() * 1000L + changed = true + }, + toDisplay = { stringResource(R.string.format_ms, it.toLong()) }, + range = 1f..2000f + ) + } + } + + SwitchSettingsEntry( + title = stringResource(R.string.loudness_normalization), + text = stringResource(R.string.loudness_normalization_description), + isChecked = volumeNormalization, + onCheckedChange = { volumeNormalization = it } + ) + + AnimatedVisibility(visible = volumeNormalization) { + var newValue by remember(volumeNormalizationBaseGain) { + mutableFloatStateOf(volumeNormalizationBaseGain) + } + + SliderSettingsEntry( + title = stringResource(R.string.loudness_base_gain), + text = stringResource(R.string.loudness_base_gain_description), + state = newValue, + onSlide = { newValue = it }, + onSlideComplete = { volumeNormalizationBaseGain = newValue }, + toDisplay = { stringResource(R.string.format_db, "%.2f".format(it)) }, + range = -20f..20f, + steps = 79 + ) + } + + SwitchSettingsEntry( + title = stringResource(R.string.bass_boost), + text = stringResource(R.string.bass_boost_description), + isChecked = bassBoost, + onCheckedChange = { bassBoost = it } + ) + + AnimatedVisibility(visible = bassBoost) { + var newValue by remember(bassBoostLevel) { mutableFloatStateOf(bassBoostLevel.toFloat()) } + + SliderSettingsEntry( + title = stringResource(R.string.bass_boost_level), + text = stringResource(R.string.bass_boost_level_description), + state = newValue, + onSlide = { newValue = it }, + onSlideComplete = { bassBoostLevel = newValue.toInt() }, + toDisplay = { (it * 1000f).toInt().toString() }, + range = 0f..1f + ) + } + + SwitchSettingsEntry( + title = stringResource(R.string.sponsor_block), + text = stringResource(R.string.sponsor_block_description), + isChecked = sponsorBlockEnabled, + onCheckedChange = { + sponsorBlockEnabled = it + } + ) + + EnumValueSelectorSettingsEntry( + title = stringResource(R.string.reverb), + selectedValue = reverb, + onValueSelect = { reverb = it }, + valueText = { it.displayName() } + ) + + SwitchSettingsEntry( + title = stringResource(R.string.audio_focus), + text = stringResource(R.string.audio_focus_description), + isChecked = handleAudioFocus, + onCheckedChange = { + handleAudioFocus = it + changed = true + } + ) + + SettingsEntry( + title = stringResource(R.string.equalizer), + text = stringResource(R.string.equalizer_description), + onClick = launchEqualizer + ) + } + } +} + +@Composable +fun RestartPlayerSettingsEntry( + onRestart: () -> Unit, + modifier: Modifier = Modifier, + binder: PlayerService.Binder? = LocalPlayerServiceBinder.current +) = Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier +) { + SettingsDescription( + text = stringResource(R.string.minimum_silence_length_warning), + important = true, + modifier = Modifier.weight(2f) + ) + SecondaryTextButton( + text = stringResource(R.string.restart_service), + onClick = { + binder?.restartForegroundOrStop()?.let { onRestart() } + }, + modifier = Modifier + .weight(1f) + .padding(end = 24.dp) + ) +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/settings/SettingsScreen.kt new file mode 100644 index 0000000..77b56b8 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/settings/SettingsScreen.kt @@ -0,0 +1,378 @@ +@file:Suppress("TooManyFunctions") + +package app.vimusic.android.ui.screens.settings + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.text +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import app.vimusic.android.LocalPlayerAwareWindowInsets +import app.vimusic.android.R +import app.vimusic.android.ui.components.themed.Header +import app.vimusic.android.ui.components.themed.NumberFieldDialog +import app.vimusic.android.ui.components.themed.Scaffold +import app.vimusic.android.ui.components.themed.Slider +import app.vimusic.android.ui.components.themed.Switch +import app.vimusic.android.ui.components.themed.ValueSelectorDialog +import app.vimusic.android.ui.screens.GlobalRoutes +import app.vimusic.android.ui.screens.Route +import app.vimusic.android.utils.color +import app.vimusic.android.utils.secondary +import app.vimusic.android.utils.semiBold +import app.vimusic.compose.persist.PersistMapCleanup +import app.vimusic.compose.routing.RouteHandler +import app.vimusic.core.ui.LocalAppearance +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +@Route +@Composable +fun SettingsScreen() { + val saveableStateHolder = rememberSaveableStateHolder() + val (tabIndex, onTabChanged) = rememberSaveable { mutableIntStateOf(0) } + + PersistMapCleanup("settings/") + + RouteHandler { + GlobalRoutes() + + Content { + Scaffold( + key = "settings", + topIconButtonId = R.drawable.chevron_back, + onTopIconButtonClick = pop, + tabIndex = tabIndex, + onTabChange = onTabChanged, + tabColumnContent = { + tab(0, R.string.appearance, R.drawable.color_palette, canHide = false) + tab(1, R.string.player, R.drawable.play, canHide = false) + tab(2, R.string.cache, R.drawable.server, canHide = false) + tab(3, R.string.database, R.drawable.server, canHide = false) + tab(4, R.string.sync, R.drawable.sync, canHide = false) + tab(5, R.string.other, R.drawable.shapes, canHide = false) + tab(6, R.string.about, R.drawable.information, canHide = false) + } + ) { currentTabIndex -> + saveableStateHolder.SaveableStateProvider(currentTabIndex) { + when (currentTabIndex) { + 0 -> AppearanceSettings() + 1 -> PlayerSettings() + 2 -> CacheSettings() + 3 -> DatabaseSettings() + 4 -> SyncSettings() + 5 -> OtherSettings() + 6 -> About() + } + } + } + } + } +} + +@Composable +inline fun > EnumValueSelectorSettingsEntry( + title: String, + selectedValue: T, + noinline onValueSelect: (T) -> Unit, + modifier: Modifier = Modifier, + isEnabled: Boolean = true, + noinline valueText: @Composable (T) -> String = { it.name }, + noinline trailingContent: (@Composable () -> Unit)? = null +) = ValueSelectorSettingsEntry( + title = title, + selectedValue = selectedValue, + values = enumValues().toList().toImmutableList(), + onValueSelect = onValueSelect, + modifier = modifier, + isEnabled = isEnabled, + valueText = valueText, + trailingContent = trailingContent +) + +@Composable +fun ValueSelectorSettingsEntry( + title: String, + selectedValue: T, + values: ImmutableList, + onValueSelect: (T) -> Unit, + modifier: Modifier = Modifier, + text: String? = null, + isEnabled: Boolean = true, + usePadding: Boolean = true, + valueText: @Composable (T) -> String = { it.toString() }, + trailingContent: (@Composable () -> Unit)? = null +) { + var isShowingDialog by remember { mutableStateOf(false) } + + if (isShowingDialog) ValueSelectorDialog( + onDismiss = { isShowingDialog = false }, + title = title, + selectedValue = selectedValue, + values = values, + onValueSelect = onValueSelect, + valueText = valueText + ) + + SettingsEntry( + modifier = modifier, + title = title, + text = text ?: valueText(selectedValue), + onClick = { isShowingDialog = true }, + isEnabled = isEnabled, + trailingContent = trailingContent, + usePadding = usePadding + ) +} + +@Composable +fun SwitchSettingsEntry( + title: String, + text: String?, + isChecked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + isEnabled: Boolean = true, + usePadding: Boolean = true +) = SettingsEntry( + modifier = modifier, + title = title, + text = text, + onClick = { onCheckedChange(!isChecked) }, + isEnabled = isEnabled, + usePadding = usePadding +) { + Switch(isChecked = isChecked) +} + +@Composable +fun SliderSettingsEntry( + title: String, + text: String, + state: Float, + range: ClosedFloatingPointRange, + modifier: Modifier = Modifier, + onSlide: (Float) -> Unit = { }, + onSlideComplete: () -> Unit = { }, + toDisplay: @Composable (Float) -> String = { it.toString() }, + steps: Int = 0, + isEnabled: Boolean = true, + usePadding: Boolean = true +) = Column(modifier = modifier) { + SettingsEntry( + title = title, + text = "$text (${toDisplay(state)})", + onClick = {}, + isEnabled = isEnabled, + usePadding = usePadding + ) + + Slider( + state = state, + setState = onSlide, + onSlideComplete = onSlideComplete, + range = range, + steps = steps, + modifier = Modifier + .height(36.dp) + .alpha(if (isEnabled) 1f else 0.5f) + .let { if (usePadding) it.padding(start = 32.dp, end = 16.dp) else it } + .padding(vertical = 16.dp) + .fillMaxWidth() + ) +} + +@Composable +inline fun IntSettingsEntry( + title: String, + text: String, + currentValue: Int, + crossinline setValue: (Int) -> Unit, + range: IntRange, + modifier: Modifier = Modifier, + defaultValue: Int = 0, + isEnabled: Boolean = true, + usePadding: Boolean = true +) { + var isShowingDialog by remember { mutableStateOf(false) } + + if (isShowingDialog) NumberFieldDialog( + onDismiss = { isShowingDialog = false }, + onAccept = { + setValue(it) + isShowingDialog = false + }, + initialValue = currentValue, + defaultValue = defaultValue, + convert = { it.toIntOrNull() }, + range = range + ) + + SettingsEntry( + modifier = modifier, + title = title, + text = text, + onClick = { isShowingDialog = true }, + isEnabled = isEnabled, + usePadding = usePadding + ) +} + +@Composable +fun SettingsEntry( + title: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + text: String? = null, + isEnabled: Boolean = true, + usePadding: Boolean = true, + trailingContent: @Composable (() -> Unit)? = null +) = Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .clickable(enabled = isEnabled, onClick = onClick) + .alpha(if (isEnabled) 1f else 0.5f) + .let { if (usePadding) it.padding(start = 32.dp, end = 16.dp) else it } + .padding(vertical = 16.dp) + .fillMaxWidth() +) { + val (colorPalette, typography) = LocalAppearance.current + + Column(modifier = Modifier.weight(1f)) { + BasicText( + text = title, + style = typography.xs.semiBold.copy(color = colorPalette.text) + ) + + if (text != null) BasicText( + text = text, + style = typography.xs.semiBold.secondary + ) + } + + trailingContent?.invoke() +} + +@Composable +fun SettingsDescription( + text: String, + modifier: Modifier = Modifier, + important: Boolean = false +) { + val (colorPalette, typography) = LocalAppearance.current + + BasicText( + text = text, + style = if (important) typography.xxs.semiBold.color(colorPalette.red) + else typography.xxs.secondary, + modifier = modifier + .padding(start = 16.dp) + .padding(horizontal = 16.dp, vertical = 8.dp) + ) +} + +@Composable +fun SettingsEntryGroupText( + title: String, + modifier: Modifier = Modifier +) { + val (colorPalette, typography) = LocalAppearance.current + + BasicText( + text = title.uppercase(), + style = typography.xxs.semiBold.copy(colorPalette.accent), + modifier = modifier + .padding(start = 16.dp) + .padding(horizontal = 16.dp) + .semantics { text = AnnotatedString(text = title) } + ) +} + +@Composable +fun SettingsGroupSpacer(modifier: Modifier = Modifier) = Spacer(modifier = modifier.height(24.dp)) + +@Composable +fun SettingsCategoryScreen( + title: String, + modifier: Modifier = Modifier, + description: String? = null, + scrollState: ScrollState? = rememberScrollState(), + content: @Composable ColumnScope.() -> Unit +) { + val (colorPalette, typography) = LocalAppearance.current + + Column( + modifier = modifier + .background(colorPalette.background0) + .fillMaxSize() + .let { if (scrollState != null) it.verticalScroll(state = scrollState) else it } + .padding( + LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) + .asPaddingValues() + ) + ) { + Header(title = title) { + description?.let { description -> + BasicText( + text = description, + style = typography.s.secondary + ) + SettingsGroupSpacer() + } + } + + content() + } +} + +@Composable +fun SettingsGroup( + title: String, + modifier: Modifier = Modifier, + description: String? = null, + important: Boolean = false, + content: @Composable ColumnScope.() -> Unit +) = Column(modifier = modifier) { + SettingsEntryGroupText(title = title) + + description?.let { description -> + SettingsDescription( + text = description, + important = important + ) + } + + content() + + SettingsGroupSpacer() +} diff --git a/app/src/main/kotlin/app/vimusic/android/ui/screens/settings/SyncSettings.kt b/app/src/main/kotlin/app/vimusic/android/ui/screens/settings/SyncSettings.kt new file mode 100644 index 0000000..bcb3154 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/ui/screens/settings/SyncSettings.kt @@ -0,0 +1,298 @@ +package app.vimusic.android.ui.screens.settings + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.password +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEachIndexed +import androidx.credentials.CredentialManager +import app.vimusic.android.Database +import app.vimusic.android.LocalCredentialManager +import app.vimusic.android.R +import app.vimusic.android.models.PipedSession +import app.vimusic.android.transaction +import app.vimusic.android.ui.components.themed.CircularProgressIndicator +import app.vimusic.android.ui.components.themed.ConfirmationDialog +import app.vimusic.android.ui.components.themed.ConfirmationDialogBody +import app.vimusic.android.ui.components.themed.DefaultDialog +import app.vimusic.android.ui.components.themed.DialogTextButton +import app.vimusic.android.ui.components.themed.IconButton +import app.vimusic.android.ui.components.themed.TextField +import app.vimusic.android.ui.screens.Route +import app.vimusic.android.utils.center +import app.vimusic.android.utils.get +import app.vimusic.android.utils.semiBold +import app.vimusic.android.utils.upsert +import app.vimusic.compose.persist.persistList +import app.vimusic.core.ui.LocalAppearance +import app.vimusic.providers.piped.Piped +import app.vimusic.providers.piped.models.Instance +import io.ktor.http.Url +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.launch + +@Route +@Composable +fun SyncSettings( + credentialManager: CredentialManager = LocalCredentialManager.current +) { + val coroutineScope = rememberCoroutineScope() + + val (colorPalette, typography) = LocalAppearance.current + val uriHandler = LocalUriHandler.current + val context = LocalContext.current + + val pipedSessions by Database.pipedSessions().collectAsState(initial = listOf()) + + var linkingPiped by remember { mutableStateOf(false) } + if (linkingPiped) DefaultDialog( + onDismiss = { linkingPiped = false }, + horizontalAlignment = Alignment.CenterHorizontally + ) { + var isLoading by rememberSaveable { mutableStateOf(false) } + var hasError by rememberSaveable { mutableStateOf(false) } + var successful by remember { mutableStateOf(false) } + + when { + successful -> BasicText( + text = stringResource(R.string.piped_session_created_successfully), + style = typography.xs.semiBold.center, + modifier = Modifier.padding(all = 24.dp) + ) + + hasError -> ConfirmationDialogBody( + text = stringResource(R.string.error_piped_link), + onDismiss = { }, + onCancel = { linkingPiped = false }, + onConfirm = { hasError = false } + ) + + isLoading -> CircularProgressIndicator(modifier = Modifier.padding(all = 8.dp)) + + else -> Box(modifier = Modifier.fillMaxWidth()) { + var backgroundLoading by rememberSaveable { mutableStateOf(false) } + if (backgroundLoading) CircularProgressIndicator(modifier = Modifier.align(Alignment.TopEnd)) + + Column(modifier = Modifier.fillMaxWidth()) { + var instances by persistList(tag = "settings/sync/piped/instances") + var loadingInstances by rememberSaveable { mutableStateOf(true) } + var selectedInstance: Int? by rememberSaveable { mutableStateOf(null) } + var username by rememberSaveable { mutableStateOf("") } + var password by rememberSaveable { mutableStateOf("") } + var canSelect by rememberSaveable { mutableStateOf(false) } + var instancesUnavailable by rememberSaveable { mutableStateOf(false) } + var customInstance: String? by rememberSaveable { mutableStateOf(null) } + + LaunchedEffect(Unit) { + Piped.getInstances()?.getOrNull()?.let { + selectedInstance = null + instances = it.toImmutableList() + canSelect = true + } ?: run { instancesUnavailable = true } + loadingInstances = false + + backgroundLoading = true + runCatching { + credentialManager.get(context)?.let { + username = it.id + password = it.password + } + }.getOrNull() + backgroundLoading = false + } + + BasicText( + text = stringResource(R.string.piped), + style = typography.m.semiBold + ) + + if (customInstance == null) ValueSelectorSettingsEntry( + title = stringResource(R.string.instance), + selectedValue = selectedInstance, + values = instances.indices.toImmutableList(), + onValueSelect = { selectedInstance = it }, + valueText = { idx -> + idx?.let { instances.getOrNull(it)?.name } + ?: if (instancesUnavailable) stringResource(R.string.error_piped_instances_unavailable) + else stringResource(R.string.click_to_select) + }, + isEnabled = !instancesUnavailable && canSelect, + usePadding = false, + trailingContent = if (loadingInstances) { + { CircularProgressIndicator() } + } else null + ) + SwitchSettingsEntry( + title = stringResource(R.string.custom_instance), + text = null, + isChecked = customInstance != null, + onCheckedChange = { + customInstance = if (customInstance == null) "" else null + }, + usePadding = false + ) + customInstance?.let { instance -> + Spacer(modifier = Modifier.height(8.dp)) + TextField( + value = instance, + onValueChange = { customInstance = it }, + hintText = stringResource(R.string.base_api_url), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + modifier = Modifier.fillMaxWidth() + ) + } + Spacer(modifier = Modifier.height(8.dp)) + TextField( + value = username, + onValueChange = { username = it }, + hintText = stringResource(R.string.username), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(8.dp)) + + TextField( + value = password, + onValueChange = { password = it }, + hintText = stringResource(R.string.password), + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password + ), + modifier = Modifier + .fillMaxWidth() + .semantics { + password() + } + ) + Spacer(modifier = Modifier.height(16.dp)) + DialogTextButton( + text = stringResource(R.string.login), + primary = true, + enabled = (customInstance?.isNotBlank() == true || selectedInstance != null) && + username.isNotBlank() && password.isNotBlank(), + onClick = { + @Suppress("Wrapping") // thank you ktlint + (customInstance?.let { + runCatching { + Url(it) + }.getOrNull() ?: runCatching { + Url("https://$it") + }.getOrNull() + } ?: selectedInstance?.let { instances[it].apiBaseUrl })?.let { url -> + coroutineScope.launch { + isLoading = true + val session = Piped.login( + apiBaseUrl = url, + username = username, + password = password + )?.getOrNull() + isLoading = false + if (session == null) { + hasError = true + return@launch + } + + transaction { + Database.insert( + PipedSession( + apiBaseUrl = session.apiBaseUrl, + username = username, + token = session.token + ) + ) + } + + successful = true + + runCatching { + credentialManager.upsert( + context = context, + username = username, + password = password + ) + } + + linkingPiped = false + } + } + }, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + } + } + } + } + + var deletingPipedSession: Int? by rememberSaveable { mutableStateOf(null) } + if (deletingPipedSession != null) ConfirmationDialog( + text = stringResource(R.string.confirm_delete_piped_session), + onDismiss = { + deletingPipedSession = null + }, + onConfirm = { + deletingPipedSession?.let { + transaction { Database.delete(pipedSessions[it]) } + } + } + ) + + SettingsCategoryScreen(title = stringResource(R.string.sync)) { + SettingsDescription(text = stringResource(R.string.sync_description)) + + SettingsGroup(title = stringResource(R.string.piped)) { + SettingsEntry( + title = stringResource(R.string.add_account), + text = stringResource(R.string.add_account_description), + onClick = { linkingPiped = true } + ) + SettingsEntry( + title = stringResource(R.string.learn_more), + text = stringResource(R.string.learn_more_description), + onClick = { uriHandler.openUri("https://github.com/TeamPiped/Piped/blob/master/README.md") } + ) + } + SettingsGroup(title = stringResource(R.string.piped_sessions)) { + pipedSessions.fastForEachIndexed { i, session -> + SettingsEntry( + title = session.username, + text = session.apiBaseUrl.toString(), + onClick = { }, + trailingContent = { + IconButton( + onClick = { deletingPipedSession = i }, + icon = R.drawable.delete, + color = colorPalette.text + ) + } + ) + } + } + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/utils/ActionReceiver.kt b/app/src/main/kotlin/app/vimusic/android/utils/ActionReceiver.kt new file mode 100644 index 0000000..e9ad413 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/utils/ActionReceiver.kt @@ -0,0 +1,96 @@ +package app.vimusic.android.utils + +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.graphics.drawable.Icon +import androidx.core.content.ContextCompat +import app.vimusic.core.ui.utils.isAtLeastAndroid6 +import kotlin.properties.PropertyDelegateProvider +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KProperty + +abstract class ActionReceiver(private val base: String) : BroadcastReceiver() { + companion object { + const val REQUEST_CODE = 100 + } + + class Action internal constructor( + val value: String, + val icon: Icon?, + val title: String?, + val contentDescription: String?, + internal val onReceive: (Context, Intent) -> Unit + ) { + context(Context) + val pendingIntent: PendingIntent + get() = PendingIntent.getBroadcast( + /* context = */ this@Context, + /* requestCode = */ REQUEST_CODE, + /* intent = */ Intent(value).setPackage(packageName), + /* flags = */ PendingIntent.FLAG_UPDATE_CURRENT or + (if (isAtLeastAndroid6) PendingIntent.FLAG_IMMUTABLE else 0) + ) + } + + private val mutableActions = hashMapOf() + val all get() = mutableActions.toMap() + + val intentFilter + get() = IntentFilter().apply { + mutableActions.keys.forEach { addAction(it) } + } + + internal fun action( + icon: Icon? = null, + title: String? = null, + contentDescription: String? = null, + onReceive: (Context, Intent) -> Unit + ) = readOnlyProvider { thisRef, property -> + val name = "$base.${property.name}" + val action = Action( + value = name, + onReceive = onReceive, + icon = icon, + title = title, + contentDescription = contentDescription + ) + + thisRef.mutableActions += name to action + { _, _ -> action } + } + + override fun onReceive(context: Context, intent: Intent) { + mutableActions[intent.action]?.onReceive?.let { it(context, intent) } + } + + context(Context) + @JvmName("_register") + fun register( + @ContextCompat.RegisterReceiverFlags + flags: Int = ContextCompat.RECEIVER_NOT_EXPORTED + ) = register(this@Context, flags) + + fun register( + context: Context, + @ContextCompat.RegisterReceiverFlags + flags: Int = ContextCompat.RECEIVER_NOT_EXPORTED + ) = ContextCompat.registerReceiver( + /* context = */ context, + /* receiver = */ this@ActionReceiver, + /* filter = */ intentFilter, + /* flags = */ flags + ) +} + +private inline fun readOnlyProvider( + crossinline provide: ( + thisRef: ThisRef, + property: KProperty<*> + ) -> (thisRef: ThisRef, property: KProperty<*>) -> Return +) = PropertyDelegateProvider> { thisRef, property -> + val provider = provide(thisRef, property) + ReadOnlyProperty { innerThisRef, innerProperty -> provider(innerThisRef, innerProperty) } +} diff --git a/app/src/main/kotlin/app/vimusic/android/utils/BitmapState.kt b/app/src/main/kotlin/app/vimusic/android/utils/BitmapState.kt new file mode 100644 index 0000000..15430cb --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/utils/BitmapState.kt @@ -0,0 +1,25 @@ +package app.vimusic.android.utils + +import android.graphics.Bitmap +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import app.vimusic.android.service.PlayerService + +@Composable +fun PlayerService.Binder?.collectProvidedBitmapAsState( + key: Any = Unit +): Bitmap? { + var state by remember(this, key) { mutableStateOf(null) } + + LaunchedEffect(this, key) { + this@collectProvidedBitmapAsState?.setBitmapListener { + state = it + } + } + + return state +} diff --git a/app/src/main/kotlin/app/vimusic/android/utils/Cache.kt b/app/src/main/kotlin/app/vimusic/android/utils/Cache.kt new file mode 100644 index 0000000..e1b2e0c --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/utils/Cache.kt @@ -0,0 +1,39 @@ +@file:OptIn(UnstableApi::class) + +package app.vimusic.android.utils + +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.cache.Cache +import androidx.media3.datasource.cache.Cache.CacheException +import androidx.media3.datasource.cache.CacheSpan +import java.io.File + +class ReadOnlyException : CacheException("Cache is read-only") + +class ConditionalReadOnlyCache( + private val cache: Cache, + private val readOnly: () -> Boolean +) : Cache by cache { + private fun stub() = if (readOnly()) throw ReadOnlyException() else Unit + + override fun startFile(key: String, position: Long, length: Long): File { + stub() + return cache.startFile(key, position, length) + } + + override fun commitFile(file: File, length: Long) { + stub() + cache.commitFile(file, length) + } + + override fun releaseHoleSpan(holeSpan: CacheSpan) { + stub() + cache.releaseHoleSpan(holeSpan) + } +} + +fun Cache.readOnlyWhen(readOnly: () -> Boolean) = ConditionalReadOnlyCache( + cache = this, + readOnly = readOnly +) diff --git a/app/src/main/kotlin/app/vimusic/android/utils/CacheState.kt b/app/src/main/kotlin/app/vimusic/android/utils/CacheState.kt new file mode 100644 index 0000000..a00b295 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/utils/CacheState.kt @@ -0,0 +1,162 @@ +package app.vimusic.android.utils + +import androidx.annotation.OptIn +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastAll +import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.TransferListener +import androidx.media3.datasource.cache.CacheDataSource +import app.vimusic.android.Database +import app.vimusic.android.LocalPlayerServiceBinder +import app.vimusic.android.R +import app.vimusic.android.models.Format +import app.vimusic.android.service.LOCAL_KEY_PREFIX +import app.vimusic.android.service.PlayerService +import app.vimusic.android.service.PrecacheService +import app.vimusic.android.service.downloadState +import app.vimusic.android.ui.components.themed.CircularProgressIndicator +import app.vimusic.android.ui.components.themed.HeaderIconButton +import app.vimusic.core.ui.LocalAppearance +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty + +@Composable +fun PlaylistDownloadIcon( + songs: ImmutableList, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val (colorPalette) = LocalAppearance.current + + val isDownloading by downloadState.collectAsState() + + AnimatedContent( + targetState = isDownloading, + label = "", + transitionSpec = { fadeIn() togetherWith fadeOut() } + ) { currentIsDownloading -> + when { + currentIsDownloading -> CircularProgressIndicator(modifier = Modifier.size(18.dp)) + + !songs.map { it.mediaId }.fastAll { + isCached( + mediaId = it, + key = isDownloading + ) + } -> HeaderIconButton( + icon = R.drawable.download, + color = colorPalette.text, + onClick = { + songs.forEach { + PrecacheService.scheduleCache(context.applicationContext, it) + } + }, + modifier = modifier + ) + } + } +} + +@OptIn(UnstableApi::class) +@Composable +fun isCached( + mediaId: String, + key: Any? = Unit, + binder: PlayerService.Binder? = LocalPlayerServiceBinder.current +): Boolean { + if (mediaId.startsWith(LOCAL_KEY_PREFIX)) return true + + var format: Format? by remember { mutableStateOf(null) } + + LaunchedEffect(mediaId, key) { + Database + .format(mediaId) + .distinctUntilChanged() + .collect { format = it } + } + + return remember(mediaId, binder, format, key) { + format?.contentLength?.let { len -> + binder?.cache?.isCached(mediaId, 0, len) + } ?: false + } +} + +@OptIn(UnstableApi::class) +class ConditionalCacheDataSourceFactory( + private val cacheDataSourceFactory: CacheDataSource.Factory, + private val upstreamDataSourceFactory: DataSource.Factory, + private val shouldCache: (DataSpec) -> Boolean +) : DataSource.Factory { + init { + cacheDataSourceFactory.setUpstreamDataSourceFactory(upstreamDataSourceFactory) + } + + override fun createDataSource() = object : DataSource { + private lateinit var selectedFactory: DataSource.Factory + private val transferListeners = mutableListOf() + + private fun createSource(factory: DataSource.Factory = selectedFactory) = factory.createDataSource().apply { + transferListeners.forEach { addTransferListener(it) } + } + + private var source by object : ReadWriteProperty { + var s: DataSource? = null + + override fun getValue(thisRef: Any?, property: KProperty<*>) = s ?: run { + val newSource = createSource() + s = newSource + newSource + } + + override fun setValue(thisRef: Any?, property: KProperty<*>, value: DataSource) { + s = value + } + } + + override fun read(buffer: ByteArray, offset: Int, length: Int) = + source.read(buffer, offset, length) + + override fun addTransferListener(transferListener: TransferListener) { + if (::selectedFactory.isInitialized) source.addTransferListener(transferListener) + + transferListeners += transferListener + } + + override fun open(dataSpec: DataSpec): Long { + selectedFactory = + if (shouldCache(dataSpec)) cacheDataSourceFactory else upstreamDataSourceFactory + + return runCatching { + source.open(dataSpec) + }.getOrElse { + if (it is ReadOnlyException) { + source = createSource(upstreamDataSourceFactory) + source.open(dataSpec) + } else throw it + } + } + + override fun getUri() = source.uri + override fun close() = source.close() + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/utils/Context.kt b/app/src/main/kotlin/app/vimusic/android/utils/Context.kt new file mode 100644 index 0000000..a18bc12 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/utils/Context.kt @@ -0,0 +1,132 @@ +package app.vimusic.android.utils + +import android.app.Activity +import android.app.PendingIntent +import android.content.ActivityNotFoundException +import android.content.BroadcastReceiver +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.PowerManager +import android.widget.Toast +import androidx.annotation.OptIn +import androidx.core.app.PendingIntentCompat +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import androidx.media3.exoplayer.offline.DownloadService.sendAddDownload +import app.vimusic.android.BuildConfig +import app.vimusic.core.ui.utils.isAtLeastAndroid11 +import app.vimusic.core.ui.utils.isAtLeastAndroid6 + +inline fun Context.intent(): Intent = Intent(this@Context, T::class.java) + +inline fun Context.broadcastPendingIntent( + requestCode: Int = 0, + flags: Int = if (isAtLeastAndroid6) PendingIntent.FLAG_IMMUTABLE else 0 +): PendingIntent = PendingIntent.getBroadcast(this, requestCode, intent(), flags) + +inline fun Context.activityPendingIntent( + requestCode: Int = 0, + @PendingIntentCompat.Flags flags: Int = 0, + block: Intent.() -> Unit = { } +) = pendingIntent( + intent = intent().apply(block), + requestCode = requestCode, + flags = flags +) + +fun Context.pendingIntent( + intent: Intent, + requestCode: Int = 0, + @PendingIntentCompat.Flags flags: Int = 0 +): PendingIntent = PendingIntent.getActivity( + /* context = */ this, + /* requestCode = */ requestCode, + /* intent = */ intent, + /* flags = */ (if (isAtLeastAndroid6) PendingIntent.FLAG_IMMUTABLE else 0) or flags +) + +val Context.isIgnoringBatteryOptimizations + get() = !isAtLeastAndroid6 || + getSystemService()?.isIgnoringBatteryOptimizations(packageName) ?: true + +fun Context.toast( + message: String, + duration: ToastDuration = ToastDuration.Short +) = Toast.makeText(this, message, duration.length).show() + +@JvmInline +value class ToastDuration private constructor(internal val length: Int) { + companion object { + val Short = ToastDuration(length = Toast.LENGTH_SHORT) + val Long = ToastDuration(length = Toast.LENGTH_LONG) + } +} + +fun launchYouTubeMusic( + context: Context, + endpoint: String, + tryWithoutBrowser: Boolean = true +): Boolean { + return try { + val intent = Intent( + Intent.ACTION_VIEW, + Uri.parse("https://music.youtube.com/${endpoint.dropWhile { it == '/' }}") + ).apply { + if (tryWithoutBrowser && isAtLeastAndroid11) { + flags = Intent.FLAG_ACTIVITY_REQUIRE_NON_BROWSER + } + } + intent.`package` = + context.applicationContext.packageManager.queryIntentActivities(intent, 0) + .firstOrNull { + it?.activityInfo?.packageName != null && + BuildConfig.APPLICATION_ID !in it.activityInfo.packageName + }?.activityInfo?.packageName + ?: return false + context.startActivity(intent) + true + } catch (e: ActivityNotFoundException) { + if (tryWithoutBrowser) launchYouTubeMusic( + context = context, + endpoint = endpoint, + tryWithoutBrowser = false + ) else false + } +} + +fun Context.findActivity(): Activity { + var context = this + while (context is ContextWrapper) { + if (context is Activity) return context + context = context.baseContext + } + error("Should be called in the context of an Activity") +} + +fun Context.hasPermission(permission: String) = ContextCompat.checkSelfPermission( + applicationContext, + permission +) == PackageManager.PERMISSION_GRANTED + +@OptIn(UnstableApi::class) +inline fun Context.download(request: DownloadRequest) = runCatching { + sendAddDownload( + /* context = */ this, + /* clazz = */ T::class.java, + /* downloadRequest = */ request, + /* foreground = */ true + ) +}.recoverCatching { + sendAddDownload( + /* context = */ this, + /* clazz = */ T::class.java, + /* downloadRequest = */ request, + /* foreground = */ false + ) +} diff --git a/app/src/main/kotlin/app/vimusic/android/utils/Coroutines.kt b/app/src/main/kotlin/app/vimusic/android/utils/Coroutines.kt new file mode 100644 index 0000000..5866739 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/utils/Coroutines.kt @@ -0,0 +1,8 @@ +package app.vimusic.android.utils + +import android.os.CancellationSignal +import kotlinx.coroutines.CancellableContinuation + +val CancellableContinuation.asCancellationSignal get() = CancellationSignal().also { + it.setOnCancelListener { cancel() } +} diff --git a/app/src/main/kotlin/app/vimusic/android/utils/Credentials.kt b/app/src/main/kotlin/app/vimusic/android/utils/Credentials.kt new file mode 100644 index 0000000..6c359d7 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/utils/Credentials.kt @@ -0,0 +1,85 @@ +package app.vimusic.android.utils + +import android.content.Context +import androidx.credentials.CreatePasswordRequest +import androidx.credentials.CredentialManager +import androidx.credentials.CredentialManagerCallback +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetPasswordOption +import androidx.credentials.PasswordCredential +import androidx.credentials.exceptions.CreateCredentialCancellationException +import androidx.credentials.exceptions.GetCredentialCancellationException +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import java.util.concurrent.CancellationException +import java.util.concurrent.Executors +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +private val executor = Executors.newCachedThreadPool() +private val coroutineScope = CoroutineScope( + executor.asCoroutineDispatcher() + SupervisorJob() + CoroutineName("androidx-credentials-util") +) + +private suspend inline fun wrapper( + crossinline block: (cont: CancellableContinuation) -> Unit +): T = withContext(coroutineScope.coroutineContext) { + runCatching { + suspendCancellableCoroutine { cont -> + runCatching { + block(cont) + }.exceptionOrNull()?.let { + if (it is CancellationException) cont.cancel() else cont.resumeWithException(it) + } + } + }.let { + it.exceptionOrNull()?.printStackTrace() + it.getOrThrow() + } +} + +private inline fun < + reified Response : Any, + reified Exception : Throwable, + reified CancellationException : Exception + > callback( + cont: CancellableContinuation +) = object : CredentialManagerCallback { + override fun onError(e: Exception) { + if (e is CancellationException) cont.cancel(e) else cont.resumeWithException(e) + } + + override fun onResult(result: Response) = cont.resume(result) +} + +suspend fun CredentialManager.upsert( + context: Context, + username: String, + password: String +) = wrapper { cont -> + createCredentialAsync( + context = context, + request = CreatePasswordRequest( + id = username, + password = password + ), + cancellationSignal = cont.asCancellationSignal, + executor = executor, + callback = callback<_, _, CreateCredentialCancellationException>(cont) + ) +} + +suspend fun CredentialManager.get(context: Context) = wrapper { cont -> + getCredentialAsync( + context = context, + request = GetCredentialRequest(listOf(GetPasswordOption())), + cancellationSignal = cont.asCancellationSignal, + executor = executor, + callback = callback<_, _, GetCredentialCancellationException>(cont) + ) +}.let { runCatching { it.credential as? PasswordCredential }.getOrNull() } diff --git a/app/src/main/kotlin/app/vimusic/android/utils/Cursor.kt b/app/src/main/kotlin/app/vimusic/android/utils/Cursor.kt new file mode 100644 index 0000000..bd4c92f --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/utils/Cursor.kt @@ -0,0 +1,186 @@ +package app.vimusic.android.utils + +import android.content.ContentResolver +import android.content.ContentUris +import android.database.CharArrayBuffer +import android.database.ContentObserver +import android.database.Cursor +import android.database.DataSetObserver +import android.net.Uri +import android.os.Bundle +import android.provider.MediaStore +import android.provider.MediaStore.Audio.Media.ALBUM_ID +import android.provider.MediaStore.Audio.Media.ARTIST +import android.provider.MediaStore.Audio.Media.DISPLAY_NAME +import android.provider.MediaStore.Audio.Media.DURATION +import android.provider.MediaStore.Audio.Media.IS_MUSIC +import android.provider.MediaStore.Audio.Media._ID +import app.vimusic.core.ui.utils.isAtLeastAndroid10 +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KProperty + +typealias CursorAccessor = ReadOnlyProperty + +abstract class CursorDao(private val cursor: Cursor) { + private val mutableProjection = mutableListOf() + val projection get() = mutableProjection.toTypedArray() + + private inline fun column( + column: String, + crossinline get: Cursor.(Int) -> T + ): CursorAccessor { + mutableProjection += column + + return object : ReadOnlyProperty { + private val idx by lazy { cursor[column] } + + override fun getValue(thisRef: Any?, property: KProperty<*>) = cursor.get(idx) + } + } + + private inline fun nullableColumn( + column: String, + crossinline get: Cursor.(Int) -> T + ): CursorAccessor { + mutableProjection += column + + return object : ReadOnlyProperty { + private val idx by lazy { cursor[column] } + + override fun getValue(thisRef: Any?, property: KProperty<*>) = + if (cursor.isNull(idx)) null else cursor.get(idx) + } + } + + fun next() = cursor.moveToNext() + + fun string(col: String) = column(col) { getString(it) } + fun nullableString(col: String) = nullableColumn(col) { getString(it) } + + fun long(col: String) = column(col) { getLong(it) } + fun nullableLong(col: String) = nullableColumn(col) { getLong(it) } + + fun int(col: String) = column(col) { getInt(it) } + fun nullableInt(col: String) = nullableColumn(col) { getInt(it) } + + fun byteArray(col: String) = column(col) { getBlob(it) } + fun nullableByteArray(col: String) = nullableColumn(col) { getBlob(it) } + + fun double(col: String) = column(col) { getDouble(it) } + fun nullableDouble(col: String) = nullableColumn(col) { getDouble(it) } + + fun float(col: String) = column(col) { getFloat(it) } + fun nullableFloat(col: String) = nullableColumn(col) { getFloat(it) } + + fun short(col: String) = column(col) { getShort(it) } + fun nullableShort(col: String) = nullableColumn(col) { getShort(it) } + + // pseudo-boolean + fun boolean(col: String) = column(col) { getInt(it) != 0 } + fun nullableBoolean(col: String) = column(col) { getInt(it) != 0 } +} + +abstract class CursorDaoCompanion { + enum class SortOrder(val sql: String) { + Ascending("ASC"), + Descending("DESC") + } + + internal abstract fun order(order: SortOrder): String + internal abstract fun new(cursor: Cursor): T + + internal abstract val uri: Uri + internal abstract val projection: Array + + fun query( + contentResolver: ContentResolver, + order: SortOrder = SortOrder.Ascending, + block: T.() -> R + ) = contentResolver.query( + /* uri = */ uri, + /* projection = */ projection, + /* selection = */ null, + /* selectionArgs = */ null, + /* sortOrder = */ order(order) + )?.use { cursor -> + new(cursor).block() + } +} + +operator fun Cursor.get(column: String): Int = getColumnIndexOrThrow(column) + +object NoOpCursor : Cursor { + override fun close() = Unit + override fun getCount() = 0 + override fun getPosition() = 0 + override fun move(offset: Int) = false + override fun moveToPosition(position: Int) = false + override fun moveToFirst() = false + override fun moveToLast() = false + override fun moveToNext() = false + override fun moveToPrevious() = false + override fun isFirst() = false + override fun isLast() = false + override fun isBeforeFirst() = false + override fun isAfterLast() = false + override fun getColumnIndex(columnName: String?) = 0 + override fun getColumnIndexOrThrow(columnName: String?) = 0 + override fun getColumnName(columnIndex: Int) = "" + override fun getColumnNames() = arrayOf() + override fun getColumnCount() = 0 + override fun getBlob(columnIndex: Int) = ByteArray(0) + override fun getString(columnIndex: Int) = "" + override fun copyStringToBuffer(columnIndex: Int, buffer: CharArrayBuffer?) = Unit + override fun getShort(columnIndex: Int): Short = 0 + override fun getInt(columnIndex: Int) = 0 + override fun getLong(columnIndex: Int) = 0L + override fun getFloat(columnIndex: Int) = 0f + override fun getDouble(columnIndex: Int) = 0.0 + override fun getType(columnIndex: Int) = Cursor.FIELD_TYPE_NULL + override fun isNull(columnIndex: Int) = true + + @Deprecated("Deprecated in Java", ReplaceWith("Unit")) + override fun deactivate() = Unit + + @Deprecated("Deprecated in Java", ReplaceWith("false")) + override fun requery() = false + + override fun isClosed() = true + override fun registerContentObserver(observer: ContentObserver?) = Unit + override fun unregisterContentObserver(observer: ContentObserver?) = Unit + override fun registerDataSetObserver(observer: DataSetObserver?) = Unit + override fun unregisterDataSetObserver(observer: DataSetObserver?) = Unit + override fun setNotificationUri(cr: ContentResolver?, uri: Uri?) = Unit + override fun getNotificationUri(): Uri = Uri.EMPTY + override fun getWantsAllOnMoveCalls() = false + override fun setExtras(extras: Bundle?) = Unit + override fun getExtras(): Bundle = Bundle.EMPTY + override fun respond(extras: Bundle?): Bundle = Bundle.EMPTY +} + +class AudioMediaCursor(cursor: Cursor) : CursorDao(cursor) { + companion object : CursorDaoCompanion() { + val ALBUM_URI_BASE: Uri = Uri.parse("content://media/external/audio/albumart") + + override fun order(order: SortOrder) = "$DISPLAY_NAME ${order.sql}" + override fun new(cursor: Cursor) = AudioMediaCursor(cursor) + + override val uri by lazy { + if (isAtLeastAndroid10) MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL) + else MediaStore.Audio.Media.EXTERNAL_CONTENT_URI + } + + override val projection by lazy { + AudioMediaCursor(NoOpCursor).projection + } + } + + val isMusic by boolean(IS_MUSIC) + val id by long(_ID) + val name by string(DISPLAY_NAME) + val duration by int(DURATION) + val artist by string(ARTIST) + private val albumId by long(ALBUM_ID) + + val albumUri get() = ContentUris.withAppendedId(ALBUM_URI_BASE, albumId) +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/DrawScope.kt b/app/src/main/kotlin/app/vimusic/android/utils/DrawScope.kt similarity index 55% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/DrawScope.kt rename to app/src/main/kotlin/app/vimusic/android/utils/DrawScope.kt index 0e62796..9b3edf7 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/DrawScope.kt +++ b/app/src/main/kotlin/app/vimusic/android/utils/DrawScope.kt @@ -1,12 +1,19 @@ -package it.vfsfitvnm.vimusic.utils +package app.vimusic.android.utils import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.PaintingStyle +import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb fun DrawScope.drawCircle( color: Color, - shadow: Shadow, + shadow: Shadow = Shadow.None, radius: Float = size.minDimension / 2.0f, center: Offset = this.center, alpha: Float = 1.0f, @@ -14,17 +21,17 @@ fun DrawScope.drawCircle( colorFilter: ColorFilter? = null, blendMode: BlendMode = DrawScope.DefaultBlendMode ) = drawContext.canvas.nativeCanvas.drawCircle( - center.x, - center.y, - radius, - Paint().also { + /* cx = */ center.x, + /* cy = */ center.y, + /* radius = */ radius, + /* paint = */ Paint().also { it.color = color it.alpha = alpha it.blendMode = blendMode it.colorFilter = colorFilter it.style = style }.asFrameworkPaint().also { - it.setShadowLayer( + if (shadow != Shadow.None) it.setShadowLayer( shadow.blurRadius, shadow.offset.x, shadow.offset.y, diff --git a/app/src/main/kotlin/app/vimusic/android/utils/ExoPlayer.kt b/app/src/main/kotlin/app/vimusic/android/utils/ExoPlayer.kt new file mode 100644 index 0000000..19b82db --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/utils/ExoPlayer.kt @@ -0,0 +1,83 @@ +@file:OptIn(UnstableApi::class) + +package app.vimusic.android.utils + +import android.content.Context +import androidx.annotation.OptIn +import androidx.media3.common.C +import androidx.media3.common.PlaybackException +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.DefaultHttpDataSource +import androidx.media3.datasource.HttpDataSource.InvalidResponseCodeException +import androidx.media3.datasource.cache.Cache +import androidx.media3.datasource.cache.CacheDataSource +import java.io.EOFException + +class RangeHandlerDataSourceFactory(private val parent: DataSource.Factory) : DataSource.Factory { + class Source(private val parent: DataSource) : DataSource by parent { + override fun open(dataSpec: DataSpec) = runCatching { + parent.open(dataSpec) + }.getOrElse { e -> + if ( + e.findCause() != null || + e.findCause()?.responseCode == 416 + ) parent.open( + dataSpec + .buildUpon() + .setHttpRequestHeaders( + dataSpec.httpRequestHeaders.filter { + it.key.equals("range", ignoreCase = true) + } + ) + .setLength(C.LENGTH_UNSET.toLong()) + .build() + ) + else throw e + } + } + + override fun createDataSource() = Source(parent.createDataSource()) +} + +class CatchingDataSourceFactory( + private val parent: DataSource.Factory, + private val onError: ((Throwable) -> Unit)? +) : DataSource.Factory { + inner class Source(private val parent: DataSource) : DataSource by parent { + override fun open(dataSpec: DataSpec) = runCatching { + parent.open(dataSpec) + }.getOrElse { ex -> + ex.printStackTrace() + + if (ex is PlaybackException) throw ex + else throw PlaybackException( + /* message = */ "Unknown playback error", + /* cause = */ ex, + /* errorCode = */ PlaybackException.ERROR_CODE_UNSPECIFIED + ).also { onError?.invoke(it) } + } + } + + override fun createDataSource() = Source(parent.createDataSource()) +} + +fun DataSource.Factory.handleRangeErrors(): DataSource.Factory = RangeHandlerDataSourceFactory(this) +fun DataSource.Factory.handleUnknownErrors( + onError: ((Throwable) -> Unit)? = null +): DataSource.Factory = CatchingDataSourceFactory( + parent = this, + onError = onError +) + +val Cache.asDataSource get() = CacheDataSource.Factory().setCache(this) + +val Context.defaultDataSource + get() = DefaultDataSource.Factory( + this, + DefaultHttpDataSource.Factory().setConnectTimeoutMs(16000) + .setReadTimeoutMs(8000) + .setUserAgent("Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0") + ) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/InvincibleService.kt b/app/src/main/kotlin/app/vimusic/android/utils/InvincibleService.kt similarity index 64% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/InvincibleService.kt rename to app/src/main/kotlin/app/vimusic/android/utils/InvincibleService.kt index acfcd27..47100fc 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/InvincibleService.kt +++ b/app/src/main/kotlin/app/vimusic/android/utils/InvincibleService.kt @@ -1,6 +1,5 @@ -package it.vfsfitvnm.vimusic.utils +package app.vimusic.android.utils -import android.app.Notification import android.app.Service import android.content.BroadcastReceiver import android.content.Context @@ -9,6 +8,8 @@ import android.content.IntentFilter import android.os.Binder import android.os.Handler import android.os.Looper +import androidx.core.content.ContextCompat +import app.vimusic.core.ui.utils.isAtLeastAndroid12 // https://stackoverflow.com/q/53502244/16885569 // I found four ways to make the system not kill the stopped foreground service: e.g. when @@ -21,7 +22,6 @@ abstract class InvincibleService : Service() { protected val handler = Handler(Looper.getMainLooper()) protected abstract val isInvincibilityEnabled: Boolean - protected abstract val notificationId: Int private var invincibility: Invincibility? = null @@ -42,9 +42,8 @@ abstract class InvincibleService : Service() { } override fun onUnbind(intent: Intent?): Boolean { - if (isInvincibilityEnabled && isAllowedToStartForegroundServices) { + if (isInvincibilityEnabled && isAllowedToStartForegroundServices) invincibility = Invincibility() - } return true } @@ -55,16 +54,15 @@ abstract class InvincibleService : Service() { } protected fun makeInvincible(isInvincible: Boolean = true) { - if (isInvincible) { - invincibility?.start() - } else { - invincibility?.stop() - } + if (isInvincible) invincibility?.start() else invincibility?.stop() } protected abstract fun shouldBeInvincible(): Boolean - protected abstract fun notification(): Notification? + /** + * Should strictly be called on the main thread! + */ + protected abstract fun startForeground() private inner class Invincibility : BroadcastReceiver(), Runnable { private var isStarted = false @@ -73,42 +71,49 @@ abstract class InvincibleService : Service() { override fun onReceive(context: Context?, intent: Intent?) { when (intent?.action) { Intent.ACTION_SCREEN_ON -> handler.post(this) - Intent.ACTION_SCREEN_OFF -> notification()?.let { notification -> + Intent.ACTION_SCREEN_OFF -> { handler.removeCallbacks(this) - startForeground(notificationId, notification) + startForeground() } } } @Synchronized fun start() { - if (!isStarted) { - isStarted = true - handler.postDelayed(this, intervalMs) - registerReceiver(this, IntentFilter().apply { - addAction(Intent.ACTION_SCREEN_ON) - addAction(Intent.ACTION_SCREEN_OFF) - }) + if (isStarted) return + + isStarted = true + handler.postDelayed(this, intervalMs) + + val filter = IntentFilter().apply { + addAction(Intent.ACTION_SCREEN_ON) + addAction(Intent.ACTION_SCREEN_OFF) } + ContextCompat.registerReceiver( + /* context = */ this@InvincibleService, + /* receiver = */ this, + /* filter = */ filter, + /* flags = */ ContextCompat.RECEIVER_NOT_EXPORTED + ) } @Synchronized fun stop() { - if (isStarted) { - handler.removeCallbacks(this) - unregisterReceiver(this) - isStarted = false - } + if (!isStarted) return + + handler.removeCallbacks(this) + unregisterReceiver(this) + isStarted = false } override fun run() { - if (shouldBeInvincible() && isAllowedToStartForegroundServices) { - notification()?.let { notification -> - startForeground(notificationId, notification) - stopForeground(false) - handler.postDelayed(this, intervalMs) - } - } + if (!shouldBeInvincible() || !isAllowedToStartForegroundServices) return + + startForeground() + @Suppress("DEPRECATION") + stopForeground(false) + + handler.postDelayed(this, intervalMs) } } } diff --git a/app/src/main/kotlin/app/vimusic/android/utils/Language.kt b/app/src/main/kotlin/app/vimusic/android/utils/Language.kt new file mode 100644 index 0000000..fcdfebe --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/utils/Language.kt @@ -0,0 +1,39 @@ +package app.vimusic.android.utils + +import android.app.Activity +import android.app.LocaleManager +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.annotation.RequiresApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.getSystemService +import app.vimusic.core.ui.utils.isCompositionLaunched +import java.util.Locale + +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +@Composable +fun currentLocale(): Locale? { + val context = LocalContext.current + var locale: Locale? by remember { mutableStateOf(null) } + LaunchedEffect(isCompositionLaunched()) { + locale = runCatching { + context.getSystemService()?.applicationLocales?.get(0) + }.getOrNull() + } + return locale +} + +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +fun Activity.startLanguagePicker() = startActivity( + Intent(Settings.ACTION_APP_LOCALE_SETTINGS).apply { + data = Uri.fromParts("package", packageName, null) + } +) diff --git a/app/src/main/kotlin/app/vimusic/android/utils/Logcat.kt b/app/src/main/kotlin/app/vimusic/android/utils/Logcat.kt new file mode 100644 index 0000000..74c91eb --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/utils/Logcat.kt @@ -0,0 +1,164 @@ +package app.vimusic.android.utils + +import android.os.Parcel +import android.os.Parcelable +import android.os.Process +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.saveable.rememberSaveable +import app.vimusic.core.ui.utils.stateListSaver +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.isActive +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.format.char +import kotlinx.datetime.toInstant +import kotlinx.parcelize.Parceler +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.WriteWith +import java.io.IOException + +private val logcatDateTimeFormat = LocalDateTime.Format { + date( + LocalDate.Format { + year() + char('-') + monthNumber() + char('-') + dayOfMonth() + } + ) + + char(' ') + + time( + LocalTime.Format { + hour() + char(':') + minute() + char(':') + second() + char('.') + secondFraction(3) + } + ) +} + +@Immutable +sealed interface Logcat : Parcelable { + val id: Int + + companion object { + // @formatter:off + @Suppress("MaximumLineLength") + private val regex = "^(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}.\\d{3})\\s+(\\w)/(.+?)\\(\\s*(\\d+)\\): (.*)$".toRegex() + // @formatter:on + + private fun String.toLine(id: Int) = runCatching { + val results = regex.find(this)?.groups ?: return@runCatching null + val (timestamp, level, tag, pid, message) = results.drop(1).take(5) + .mapNotNull { it?.value } + + FormattedLine( + timestamp = LocalDateTime.parse( + input = timestamp, + format = logcatDateTimeFormat + ).toInstant(TimeZone.UTC), + level = FormattedLine.Level.codeLut[level.firstOrNull()] + ?: FormattedLine.Level.Unknown, + tag = tag, + pid = pid.toLongOrNull() ?: 0, + message = message, + id = id + ) + }.getOrNull() ?: RawLine( + raw = this, + id = id + ) + + fun logAsFlow() = flow { + val proc = + Runtime.getRuntime() + .exec("/system/bin/logcat -v time,year --pid=${Process.myPid()}") + val reader = proc.inputStream.bufferedReader() + val ctx = currentCoroutineContext() + + var id = 0 + + @Suppress("LoopWithTooManyJumpStatements", "SwallowedException") + while (ctx.isActive) { + try { + emit((reader.readLine() ?: break).toLine(id++)) + } catch (e: IOException) { + break + } + } + + proc.destroy() + }.flowOn(Dispatchers.IO) + } + + @Immutable + @Parcelize + data class FormattedLine( + val timestamp: @WriteWith Instant, + val level: Level, + val tag: String, + val pid: Long, + val message: String, + override val id: Int + ) : Logcat { + @Parcelize + enum class Level(val code: Char?) : Parcelable { + Error('E'), + Warning('W'), + Debug('D'), + Info('I'), + Unknown(null); + + companion object { + internal val codeLut = entries.associateBy { it.code } + } + } + } + + @Immutable + @Parcelize + data class RawLine( + val raw: String, + override val id: Int + ) : Logcat +} + +@Composable +fun logcat(): ImmutableList { + val lines = rememberSaveable(saver = stateListSaver()) { mutableStateListOf() } + + LaunchedEffect(Unit) { + Logcat.logAsFlow().onFirst { + lines.clear() + }.collect { + lines += it + if (lines.size > 8192) lines.removeAt(0) + } + } + + return lines.toList().toImmutableList() +} + +object InstantParceler : Parceler { + override fun Instant.write(parcel: Parcel, flags: Int) = + parcel.writeLong(toEpochMilliseconds()) + + override fun create(parcel: Parcel) = Instant.fromEpochMilliseconds(parcel.readLong()) +} diff --git a/app/src/main/kotlin/app/vimusic/android/utils/MonetCompat.kt b/app/src/main/kotlin/app/vimusic/android/utils/MonetCompat.kt new file mode 100644 index 0000000..794a4f6 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/utils/MonetCompat.kt @@ -0,0 +1,32 @@ +package app.vimusic.android.utils + +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.toArgb +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import app.vimusic.core.ui.ColorPalette +import app.vimusic.core.ui.defaultLightPalette +import com.kieronquinn.monetcompat.core.MonetCompat +import kotlinx.coroutines.launch + +val LocalMonetCompat = staticCompositionLocalOf { MonetCompat.getInstance() } + +context(LifecycleOwner) +inline fun MonetCompat.invokeOnReady( + state: Lifecycle.State = Lifecycle.State.CREATED, + crossinline block: () -> Unit +) = lifecycleScope.launch { + repeatOnLifecycle(state) { + awaitMonetReady() + block() + } +} + +fun MonetCompat.setDefaultPalette(palette: ColorPalette = defaultLightPalette) { + defaultAccentColor = palette.accent.toArgb() + defaultBackgroundColor = palette.background0.toArgb() + defaultPrimaryColor = palette.background1.toArgb() + defaultSecondaryColor = palette.background2.toArgb() +} diff --git a/app/src/main/kotlin/app/vimusic/android/utils/Nothing.kt b/app/src/main/kotlin/app/vimusic/android/utils/Nothing.kt new file mode 100644 index 0000000..9f5e63c --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/utils/Nothing.kt @@ -0,0 +1,133 @@ +package app.vimusic.android.utils + +import android.content.ComponentName +import android.content.Context +import android.util.Log +import com.nothing.ketchum.Common +import com.nothing.ketchum.Glyph +import com.nothing.ketchum.GlyphException +import com.nothing.ketchum.GlyphFrame +import com.nothing.ketchum.GlyphManager +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.concurrent.atomic.AtomicBoolean + +const val TAG = "GlyphInterface" + +enum class NothingDevice(val tag: String, val progressChannel: Int) { + Phone1(tag = Common.DEVICE_20111, progressChannel = Glyph.Code_20111.D1_1), + Phone2(tag = Common.DEVICE_22111, progressChannel = Glyph.Code_22111.C1_1), + Phone2a(tag = Common.DEVICE_23111, progressChannel = Glyph.Code_23111.C_1) +} + +val nothingDevice + get() = when { + Common.is20111() -> NothingDevice.Phone1 + Common.is22111() -> NothingDevice.Phone2 + Common.is23111() -> NothingDevice.Phone2a + else -> null + } + +class GlyphInterface(context: Context) : AutoCloseable { + private val bound = AtomicBoolean() + val isBound get() = bound.get() + + private val shouldOpenSession = AtomicBoolean() + + private val coroutineScope = CoroutineScope( + Dispatchers.Main + + SupervisorJob() + + CoroutineName(TAG) + ) + private val mutex = Mutex() + + private val callback: GlyphManager.Callback = object : GlyphManager.Callback { + override fun onServiceConnected(p0: ComponentName?) { + bound.set(true) + manager?.register(nothingDevice?.tag ?: return) + } + + override fun onServiceDisconnected(p0: ComponentName?) { + bound.set(false) + } + } + + private val manager by lazy { + GlyphManager.getInstance(context.applicationContext).takeIf { + runCatching { it.init(callback) }.also { + it.exceptionOrNull()?.printStackTrace() + }.isSuccess + } + } + + private fun openSession() { + if (!isBound || manager == null) return shouldOpenSession.set(false) + if (shouldOpenSession.getAndSet(true)) return + + runCatching { manager?.openSession() }.exceptionOrNull()?.let { + shouldOpenSession.set(false) + if (it is GlyphException) Log.e(TAG, it.message.orEmpty()) + it.printStackTrace() + } + } + + private fun closeSession() { + if (!isBound || manager == null || shouldOpenSession.getAndSet(false)) return + + runCatching { manager?.closeSession() }.exceptionOrNull()?.let { + if (it is GlyphException) Log.e(TAG, it.message.orEmpty()) + it.printStackTrace() + } + } + + fun tryInit() = manager != null + + fun glyph(block: suspend GlyphManager.() -> Unit): Job? { + if (!isBound) return null + + return coroutineScope.launch { + mutex.withLock { + openSession() + runCatching { + manager?.block() + }.exceptionOrNull()?.let { + if (it is GlyphException) Log.e(TAG, it.message.orEmpty()) + it.printStackTrace() + } + closeSession() + } + } + } + + override fun close() { + if (!bound.getAndSet(false)) return + + manager?.let { + closeSession() + it.unInit() + } + } +} + +fun GlyphManager.newProgressFrame(): GlyphFrame? { + return glyphFrameBuilder?.buildChannel(nothingDevice?.progressChannel ?: return null)?.build() +} + +fun GlyphInterface.progress(state: Flow) = glyph { + val frame = newProgressFrame() + state + .distinctUntilChanged() + .collectLatest { + displayProgress(frame, it) + } + turnOff() +} diff --git a/app/src/main/kotlin/app/vimusic/android/utils/PIP.kt b/app/src/main/kotlin/app/vimusic/android/utils/PIP.kt new file mode 100644 index 0000000..f87351e --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/utils/PIP.kt @@ -0,0 +1,216 @@ +package app.vimusic.android.utils + +import android.app.Activity +import android.app.PictureInPictureParams +import android.app.RemoteAction +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Rect +import android.graphics.drawable.Icon +import android.util.Log +import android.util.Rational +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toAndroidRectF +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext +import androidx.core.app.OnPictureInPictureModeChangedProvider +import androidx.core.app.PictureInPictureModeChangedInfo +import androidx.core.graphics.toRect +import app.vimusic.android.R +import app.vimusic.android.preferences.AppearancePreferences +import app.vimusic.compose.persist.findActivityNullable +import app.vimusic.core.ui.utils.isAtLeastAndroid12 +import app.vimusic.core.ui.utils.isAtLeastAndroid7 +import app.vimusic.core.ui.utils.isAtLeastAndroid8 + +private fun logError(throwable: Throwable) = Log.e("PipHandler", "An error occurred", throwable) + +@Suppress("DEPRECATION") +fun Activity.maybeEnterPip() = when { + !isAtLeastAndroid7 -> false + !packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) -> false + else -> runCatching { + if (isAtLeastAndroid8) enterPictureInPictureMode(PictureInPictureParams.Builder().build()) + else enterPictureInPictureMode() + }.onFailure(::logError).isSuccess +} + +fun Activity.setAutoEnterPip(autoEnterIfPossible: Boolean) = if (isAtLeastAndroid12) setPictureInPictureParams( + PictureInPictureParams.Builder() + .setAutoEnterEnabled(autoEnterIfPossible) + .build() +) else Unit + +fun Activity.setPipParams( + rect: Rect, + targetNumerator: Int, + targetDenominator: Int, + autoEnterIfPossible: Boolean = AppearancePreferences.autoPip, + block: PictureInPictureParams.Builder.() -> PictureInPictureParams.Builder = { this } +) { + if (isAtLeastAndroid8) setPictureInPictureParams( + PictureInPictureParams.Builder() + .block() + .setSourceRectHint(rect) + .setAspectRatio(Rational(targetNumerator, targetDenominator)) + .let { + if (isAtLeastAndroid12) it + .setAutoEnterEnabled(autoEnterIfPossible) + .setSeamlessResizeEnabled(false) + else it + } + .build() + ) +} + +fun Activity.maybeExitPip() = when { + !isAtLeastAndroid7 -> false + !isInPictureInPictureMode -> false + else -> runCatching { + moveTaskToBack(false) + application.startActivity( + Intent(this, javaClass).addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) + ) + }.onFailure(::logError).isSuccess +} + +@Composable +fun rememberPipHandler(key: Any = Unit): PipHandler { + val context = LocalContext.current + val activity = remember(context) { context.findActivityNullable() } + return remember(activity, key) { + PipHandler( + enterPip = { activity?.maybeEnterPip() }, + exitPip = { activity?.maybeExitPip() } + ) + } +} + +@Immutable +data class PipHandler internal constructor( + private val enterPip: () -> Boolean?, + private val exitPip: () -> Boolean? +) { + fun enterPictureInPictureMode() = enterPip() == true + fun exitPictureInPictureMode() = exitPip() == true +} + +private val Activity?.pip get() = if (isAtLeastAndroid7) this?.isInPictureInPictureMode == true else false + +@Composable +fun isInPip( + onChange: (Boolean) -> Unit = { } +): Boolean { + val context = LocalContext.current + val activity = remember(context) { context.findActivityNullable() } + val currentOnChange by rememberUpdatedState(onChange) + var pip by rememberSaveable { mutableStateOf(activity.pip) } + + DisposableEffect(activity, currentOnChange) { + if (activity !is OnPictureInPictureModeChangedProvider) return@DisposableEffect onDispose { } + + val listener: (PictureInPictureModeChangedInfo) -> Unit = { + pip = it.isInPictureInPictureMode + currentOnChange(pip) + } + activity.addOnPictureInPictureModeChangedListener(listener) + + onDispose { + activity.removeOnPictureInPictureModeChangedListener(listener) + } + } + + return pip +} + +fun Modifier.pip( + activity: Activity, + targetNumerator: Int, + targetDenominator: Int, + actions: ActionReceiver? = null +) = this.onGloballyPositioned { layoutCoordinates -> + activity.setPipParams( + rect = layoutCoordinates.boundsInWindow().toAndroidRectF().toRect(), + targetNumerator = targetNumerator, + targetDenominator = targetDenominator + ) { + if (actions != null) setActions( + actions.all.values.map { + RemoteAction( + it.icon ?: Icon.createWithResource(activity, R.drawable.ic_launcher_foreground), + it.title.orEmpty(), + it.contentDescription.orEmpty(), + with(activity) { it.pendingIntent } + ) + } + ) else this + } +} + +@Composable +fun Pip( + numerator: Int, + denominator: Int, + modifier: Modifier = Modifier, + actions: ActionReceiver? = null, + content: @Composable BoxScope.() -> Unit +) { + val context = LocalContext.current + val activity = remember(context) { context.findActivity() } + + DisposableEffect(context, actions) { + val currentActions = actions ?: return@DisposableEffect onDispose { } + currentActions.register(context) + onDispose { + context.unregisterReceiver(currentActions) + activity.setAutoEnterPip(false) + } + } + + Box( + modifier = modifier.pip( + activity = activity, + targetNumerator = numerator, + targetDenominator = denominator, + actions = actions + ), + content = content + ) +} + +@Composable +fun KeyedCrossfade( + state: T, + modifier: Modifier = Modifier, + content: @Composable (T) -> Unit +) { + val saveableStateHolder = rememberSaveableStateHolder() + + AnimatedContent( + targetState = state, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "", + modifier = modifier + ) { currentState -> + saveableStateHolder.SaveableStateProvider(key = currentState) { + content(currentState) + } + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/utils/Player.kt b/app/src/main/kotlin/app/vimusic/android/utils/Player.kt new file mode 100644 index 0000000..810a68f --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/utils/Player.kt @@ -0,0 +1,121 @@ +package app.vimusic.android.utils + +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.Player +import androidx.media3.common.Timeline +import app.vimusic.android.preferences.AppearancePreferences +import app.vimusic.core.ui.utils.songBundle +import kotlin.time.Duration + +val Player.currentWindow: Timeline.Window? + get() = if (mediaItemCount == 0) null else currentTimeline[currentMediaItemIndex] + +inline val Timeline.windows: List + get() = List(windowCount) { this[it] } + +inline val Timeline.mediaItems: List + get() = windows.map { it.mediaItem } + +val Player.shouldBePlaying: Boolean + get() = !(playbackState == Player.STATE_ENDED || !playWhenReady) + +fun Player.removeMediaItems(range: IntRange) = removeMediaItems(range.first, range.last + 1) + +fun Player.safeClearQueue() { + if (currentMediaItemIndex > 0) removeMediaItems(0 until currentMediaItemIndex) + if (currentMediaItemIndex < mediaItemCount - 1) + removeMediaItems(currentMediaItemIndex + 1 until mediaItemCount) +} + +fun Player.seamlessPlay(mediaItem: MediaItem) = + if (mediaItem.mediaId == currentMediaItem?.mediaId) safeClearQueue() else forcePlay(mediaItem) + +fun Player.shuffleQueue() { + val mediaItems = currentTimeline + .mediaItems + .toMutableList() + .apply { removeAt(currentMediaItemIndex) } + .shuffled() + + safeClearQueue() + addMediaItems(mediaItems) +} + +fun Player.forcePlay(mediaItem: MediaItem) { + setMediaItem(mediaItem, true) + playWhenReady = true + prepare() +} + +fun Player.forcePlayAtIndex( + items: List, + index: Int +) { + if (items.isEmpty()) return + + setMediaItems(items, index, C.TIME_UNSET) + playWhenReady = true + prepare() +} + +fun Player.forcePlayFromBeginning(items: List) = forcePlayAtIndex(items, 0) + +fun Player.forceSeekToPrevious( + hideExplicit: Boolean = AppearancePreferences.hideExplicit, + seekToStart: Boolean = true +): Unit = when { + seekToStart && currentPosition > maxSeekToPreviousPosition -> seekToPrevious() + hideExplicit -> if (mediaItemCount <= 1) forceSeekToPrevious(hideExplicit = false) + else { + var i = currentMediaItemIndex - 1 + while ( + i !in (0 until mediaItemCount) || + getMediaItemAt(i).mediaMetadata.extras?.songBundle?.explicit == true + ) { + if (i <= 0) i = mediaItemCount - 1 else i-- + } + seekTo(i, C.TIME_UNSET) + } + // fall back to default behavior if there is only a single song + + hasPreviousMediaItem() -> seekToPreviousMediaItem() + mediaItemCount > 0 -> seekTo(mediaItemCount - 1, C.TIME_UNSET) + else -> {} +} + +fun Player.forceSeekToNext() = + if (hasNextMediaItem()) seekToNext() else seekTo(0, C.TIME_UNSET) + +fun Player.addNext(mediaItem: MediaItem) = when (playbackState) { + Player.STATE_IDLE, Player.STATE_ENDED -> forcePlay(mediaItem) + else -> addMediaItem(currentMediaItemIndex + 1, mediaItem) +} + +fun Player.enqueue(mediaItem: MediaItem) = when (playbackState) { + Player.STATE_IDLE, Player.STATE_ENDED -> forcePlay(mediaItem) + else -> addMediaItem(mediaItemCount, mediaItem) +} + +fun Player.enqueue(mediaItems: List) = when (playbackState) { + Player.STATE_IDLE, Player.STATE_ENDED -> forcePlayFromBeginning(mediaItems) + else -> addMediaItems(mediaItemCount, mediaItems) +} + +fun Player.findNextMediaItemById(mediaId: String): MediaItem? = runCatching { + for (i in currentMediaItemIndex until mediaItemCount) { + if (getMediaItemAt(i).mediaId == mediaId) return getMediaItemAt(i) + } + return null +}.getOrNull() + +fun Player.setPlaybackPitch(pitch: Float) { + playbackParameters = PlaybackParameters(playbackParameters.speed, pitch) +} + +operator fun Timeline.get( + index: Int, + window: Timeline.Window = Timeline.Window(), + positionProjection: Duration = Duration.ZERO +) = getWindow(index, window, positionProjection.inWholeMicroseconds) diff --git a/app/src/main/kotlin/app/vimusic/android/utils/PlayerState.kt b/app/src/main/kotlin/app/vimusic/android/utils/PlayerState.kt new file mode 100644 index 0000000..0b54dd6 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/utils/PlayerState.kt @@ -0,0 +1,156 @@ +package app.vimusic.android.utils + +import android.content.ActivityNotFoundException +import android.content.Intent +import android.media.audiofx.AudioEffect +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SnapshotMutationPolicy +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.Timeline +import app.vimusic.android.LocalPlayerServiceBinder +import app.vimusic.android.R +import app.vimusic.android.service.PlayerService +import app.vimusic.core.ui.utils.EqualizerIntentBundleAccessor +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +@JvmInline +value class PlayerScope internal constructor(val player: Player) + +@Composable +fun Player?.DisposableListener( + key: Any? = Unit, + listenerProvider: PlayerScope.() -> Player.Listener +) { + val currentListenerProvider by rememberUpdatedState(listenerProvider) + + DisposableEffect(key, currentListenerProvider, this) { + this@DisposableListener?.run { + val listener = PlayerScope(this).currentListenerProvider() + + addListener(listener) + listener.onMediaItemTransition( + /* mediaItem = */ currentMediaItem, + /* reason = */ Player.MEDIA_ITEM_TRANSITION_REASON_AUTO + ) + onDispose { removeListener(listener) } + } ?: onDispose { } + } +} + +@Composable +fun Player?.positionAndDurationState( + delay: Duration = 500.milliseconds +): Pair { + var state by remember { + mutableStateOf(this?.let { currentPosition to duration } ?: (0L to 1L)) + } + + DisposableListener { + object : Player.Listener { + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + state = player.currentPosition to state.second + } + + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int + ) { + if (reason != Player.DISCONTINUITY_REASON_SEEK) return + state = player.currentPosition to player.duration + } + } + } + + LaunchedEffect(this) { + while (isActive) { + delay(delay) + this@positionAndDurationState?.run { + state = currentPosition to duration + } + } + } + + return state +} + +typealias WindowState = Pair + +@Composable +fun windowState( + binder: PlayerService.Binder? = LocalPlayerServiceBinder.current +): WindowState { + var window by remember { mutableStateOf(binder?.player?.currentWindow) } + var error by remember { mutableStateOf(binder?.player?.playerError) } + val state by remember { + derivedStateOf( + policy = object : SnapshotMutationPolicy { + override fun equivalent(a: WindowState, b: WindowState) = + a.first === b.first && a.second == b.second + } + ) { + window to error + } + } + + binder?.player.DisposableListener { + object : Player.Listener { + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + window = player.currentWindow + } + + override fun onPlaybackStateChanged(playbackState: Int) { + error = player.playerError + } + + override fun onPlayerError(playbackException: PlaybackException) { + error = playbackException + } + } + } + + return state +} + +@Composable +fun rememberEqualizerLauncher( + audioSessionId: () -> Int?, + contentType: Int = AudioEffect.CONTENT_TYPE_MUSIC +): State<() -> Unit> { + val context = LocalContext.current + val launcher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {} + + return rememberUpdatedState { + try { + launcher.launch( + Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL).replaceExtras( + EqualizerIntentBundleAccessor.bundle { + audioSessionId()?.let { audioSession = it } + packageName = context.packageName + this.contentType = contentType + } + ) + ) + } catch (e: ActivityNotFoundException) { + context.toast(context.getString(R.string.no_equalizer_installed)) + } + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/utils/ScrollingInfo.kt b/app/src/main/kotlin/app/vimusic/android/utils/ScrollingInfo.kt new file mode 100644 index 0000000..ebe67c0 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/utils/ScrollingInfo.kt @@ -0,0 +1,83 @@ +package app.vimusic.android.utils + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue + +data class ScrollingInfo( + val isScrollingDown: Boolean = false, + val isFar: Boolean = false, + val isReversed: Boolean = false +) + +@Composable +fun LazyListState.scrollingInfo(key: Any = Unit): ScrollingInfo { + var previousIndex by remember(this) { mutableIntStateOf(firstVisibleItemIndex) } + var previousScrollOffset by remember(this) { mutableIntStateOf(firstVisibleItemScrollOffset) } + + return remember(this, key) { + derivedStateOf { + val isScrollingDown = + if (previousIndex == firstVisibleItemIndex) firstVisibleItemScrollOffset > previousScrollOffset + else firstVisibleItemIndex > previousIndex + val isFar = firstVisibleItemIndex > layoutInfo.visibleItemsInfo.size + + previousIndex = firstVisibleItemIndex + previousScrollOffset = firstVisibleItemScrollOffset + + ScrollingInfo( + isScrollingDown = isScrollingDown, + isFar = isFar, + isReversed = layoutInfo.reverseLayout + ) + } + }.value +} + +@Composable +fun LazyGridState.scrollingInfo(key: Any = Unit): ScrollingInfo { + var previousIndex by remember(this) { mutableIntStateOf(firstVisibleItemIndex) } + var previousScrollOffset by remember(this) { mutableIntStateOf(firstVisibleItemScrollOffset) } + + return remember(this, key) { + derivedStateOf { + val isScrollingDown = + if (previousIndex == firstVisibleItemIndex) firstVisibleItemScrollOffset > previousScrollOffset + else firstVisibleItemIndex > previousIndex + val isFar = firstVisibleItemIndex > layoutInfo.visibleItemsInfo.size + + previousIndex = firstVisibleItemIndex + previousScrollOffset = firstVisibleItemScrollOffset + + ScrollingInfo( + isScrollingDown = isScrollingDown, + isFar = isFar, + isReversed = layoutInfo.reverseLayout + ) + } + }.value +} + +@Composable +fun ScrollState.scrollingInfo(key: Any = Unit): ScrollingInfo { + var previousValue by remember(this) { mutableIntStateOf(value) } + + return remember(this, key) { + derivedStateOf { + val isScrollingDown = value > previousValue + previousValue = value + + ScrollingInfo( + isScrollingDown = isScrollingDown, + isFar = false, + isReversed = false + ) + } + }.value +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/SmoothScrollToTop.kt b/app/src/main/kotlin/app/vimusic/android/utils/SmoothScroll.kt similarity index 61% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/SmoothScrollToTop.kt rename to app/src/main/kotlin/app/vimusic/android/utils/SmoothScroll.kt index 02a75ff..a114962 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/SmoothScrollToTop.kt +++ b/app/src/main/kotlin/app/vimusic/android/utils/SmoothScroll.kt @@ -1,24 +1,20 @@ -package it.vfsfitvnm.vimusic.utils +package app.vimusic.android.utils +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.grid.LazyGridState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue suspend fun LazyGridState.smoothScrollToTop() { - if (firstVisibleItemIndex > layoutInfo.visibleItemsInfo.size) { + if (firstVisibleItemIndex > layoutInfo.visibleItemsInfo.size) scrollToItem(layoutInfo.visibleItemsInfo.size) - } animateScrollToItem(0) } suspend fun LazyListState.smoothScrollToTop() { - if (firstVisibleItemIndex > layoutInfo.visibleItemsInfo.size) { + if (firstVisibleItemIndex > layoutInfo.visibleItemsInfo.size) scrollToItem(layoutInfo.visibleItemsInfo.size) - } animateScrollToItem(0) } + +suspend fun ScrollState.smoothScrollToTop() = animateScrollTo(0) +suspend fun ScrollState.smoothScrollToBottom() = animateScrollTo(maxValue) diff --git a/app/src/main/kotlin/app/vimusic/android/utils/SnapLayoutInfoProvider.kt b/app/src/main/kotlin/app/vimusic/android/utils/SnapLayoutInfoProvider.kt new file mode 100644 index 0000000..12e8468 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/utils/SnapLayoutInfoProvider.kt @@ -0,0 +1,88 @@ +package app.vimusic.android.utils + +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider +import androidx.compose.foundation.lazy.grid.LazyGridItemInfo +import androidx.compose.foundation.lazy.grid.LazyGridLayoutInfo +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density +import androidx.compose.ui.util.fastForEach + +private val LazyGridLayoutInfo.singleAxisViewportSize: Int + get() = if (orientation == Orientation.Vertical) viewportSize.height else viewportSize.width + +fun interface PositionInLayout { + companion object { + val Default = PositionInLayout { layoutSize, itemSize -> layoutSize / 2f - itemSize / 2f } + } + + fun calculate(layoutSize: Float, itemSize: Float): Float +} + +class GridSnapLayoutInfoProvider( + private val lazyGridState: LazyGridState, + private val positionInLayout: PositionInLayout = PositionInLayout.Default +) : SnapLayoutInfoProvider { + override fun calculateApproachOffset(velocity: Float, decayOffset: Float) = 0f + + override fun calculateSnapOffset(velocity: Float): Float { + var lowerBoundOffset = Float.NEGATIVE_INFINITY + var upperBoundOffset = Float.POSITIVE_INFINITY + + val layoutInfo = lazyGridState.layoutInfo + + layoutInfo.visibleItemsInfo.fastForEach { item -> + val offset = calculateDistanceToDesiredSnapPosition( + layoutInfo = layoutInfo, + item = item, + positionInLayout = positionInLayout + ) + + // Find item that is closest to the center + if (offset <= 0 && offset > lowerBoundOffset) lowerBoundOffset = offset + // Find item that is closest to center, but after it + if (offset >= 0 && offset < upperBoundOffset) upperBoundOffset = offset + } + + return if (lowerBoundOffset * -1f > upperBoundOffset) upperBoundOffset else lowerBoundOffset + } +} + +private fun calculateDistanceToDesiredSnapPosition( + layoutInfo: LazyGridLayoutInfo, + item: LazyGridItemInfo, + positionInLayout: PositionInLayout +): Float { + val containerSize = with(layoutInfo) { + singleAxisViewportSize - beforeContentPadding - afterContentPadding + } + + val desiredDistance = positionInLayout.calculate( + layoutSize = containerSize.toFloat(), + itemSize = item.size.width.toFloat() + ) + val itemCurrentPosition = item.offset.x.toFloat() + + return itemCurrentPosition - desiredDistance +} + +@Composable +fun rememberSnapLayoutInfo( + lazyGridState: LazyGridState, + positionInLayout: Density.(layoutSize: Float, itemSize: Float) -> Float = + { layoutSize, itemSize -> PositionInLayout.Default.calculate(layoutSize, itemSize) } +): SnapLayoutInfoProvider { + val density = LocalDensity.current + + return remember(lazyGridState, density) { + GridSnapLayoutInfoProvider( + lazyGridState = lazyGridState, + positionInLayout = { layoutSize, itemSize -> + density.positionInLayout(layoutSize, itemSize) + } + ) + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/utils/SynchronizedLyrics.kt b/app/src/main/kotlin/app/vimusic/android/utils/SynchronizedLyrics.kt new file mode 100644 index 0000000..9824fcf --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/utils/SynchronizedLyrics.kt @@ -0,0 +1,44 @@ +package app.vimusic.android.utils + +import android.os.Parcelable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.setValue +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.parcelize.Parcelize + +@Stable +class SynchronizedLyrics( + val sentences: ImmutableMap, + private val positionProvider: () -> Long +) { + var index by mutableIntStateOf(currentIndex) + private set + + private val currentIndex: Int + get() { + var index = -1 + for ((key) in sentences) { + if (key >= positionProvider()) break + index++ + } + return if (index == -1) 0 else index + } + + fun update(): Boolean { + val newIndex = currentIndex + return if (newIndex != index) { + index = newIndex + true + } else false + } +} + +@Parcelize +@Immutable +data class SynchronizedLyricsState( + val sentences: Map?, + val offset: Long +) : Parcelable diff --git a/app/src/main/kotlin/app/vimusic/android/utils/SystemBars.kt b/app/src/main/kotlin/app/vimusic/android/utils/SystemBars.kt new file mode 100644 index 0000000..d7341d3 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/utils/SystemBars.kt @@ -0,0 +1,28 @@ +package app.vimusic.android.utils + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import app.vimusic.compose.persist.findActivityNullable + +@Composable +fun FullScreenState( + shown: Boolean, + type: Int = WindowInsetsCompat.Type.systemBars() +) { + val view = LocalView.current + + DisposableEffect(view, shown, type) { + val window = view.context.findActivityNullable()?.window + ?: return@DisposableEffect onDispose { } + val insetsController = WindowCompat.getInsetsController(window, view) + + if (shown) insetsController.show(type) else insetsController.hide(type) + + onDispose { + insetsController.show(type) + } + } +} diff --git a/app/src/main/kotlin/app/vimusic/android/utils/TextStyle.kt b/app/src/main/kotlin/app/vimusic/android/utils/TextStyle.kt new file mode 100644 index 0000000..447c76b --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/utils/TextStyle.kt @@ -0,0 +1,41 @@ +package app.vimusic.android.utils + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import app.vimusic.core.ui.LocalAppearance + +fun TextStyle.style(style: FontStyle) = copy(fontStyle = style) +fun TextStyle.weight(weight: FontWeight) = copy(fontWeight = weight) +fun TextStyle.align(align: TextAlign) = copy(textAlign = align) +fun TextStyle.color(color: Color) = copy(color = color) + +inline val TextStyle.medium get() = weight(FontWeight.Medium) +inline val TextStyle.semiBold get() = weight(FontWeight.SemiBold) +inline val TextStyle.bold get() = weight(FontWeight.Bold) +inline val TextStyle.center get() = align(TextAlign.Center) + +inline val TextStyle.primary: TextStyle + @Composable + @ReadOnlyComposable + get() = color(LocalAppearance.current.colorPalette.onAccent) + +inline val TextStyle.secondary: TextStyle + @Composable + @ReadOnlyComposable + get() = color(LocalAppearance.current.colorPalette.textSecondary) + +inline val TextStyle.disabled: TextStyle + @Composable + @ReadOnlyComposable + get() = color(LocalAppearance.current.colorPalette.textDisabled) + +inline val ColorFilter.Companion.disabled + @Composable + @ReadOnlyComposable + get() = tint(LocalAppearance.current.colorPalette.textDisabled) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/TimerJob.kt b/app/src/main/kotlin/app/vimusic/android/utils/TimerJob.kt similarity index 70% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/TimerJob.kt rename to app/src/main/kotlin/app/vimusic/android/utils/TimerJob.kt index 381743a..d3aff27 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/TimerJob.kt +++ b/app/src/main/kotlin/app/vimusic/android/utils/TimerJob.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.vimusic.utils +package app.vimusic.android.utils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay @@ -14,22 +14,18 @@ interface TimerJob { } fun CoroutineScope.timer(delayMillis: Long, onCompletion: () -> Unit): TimerJob { + val end = System.currentTimeMillis() + delayMillis val millisLeft = MutableStateFlow(delayMillis) val job = launch { while (isActive && millisLeft.value != null) { delay(1000) - millisLeft.emit(millisLeft.value?.minus(1000)?.takeIf { it > 0 }) - } - } - val disposableHandle = job.invokeOnCompletion { - if (it == null) { - onCompletion() + millisLeft.emit((end - System.currentTimeMillis()).takeIf { it > 0 }) } } + val disposableHandle = job.invokeOnCompletion { if (it == null) onCompletion() } return object : TimerJob { - override val millisLeft: StateFlow - get() = millisLeft.asStateFlow() + override val millisLeft get() = millisLeft.asStateFlow() override fun cancel() { millisLeft.value = null diff --git a/app/src/main/kotlin/app/vimusic/android/utils/Utils.kt b/app/src/main/kotlin/app/vimusic/android/utils/Utils.kt new file mode 100644 index 0000000..2816e57 --- /dev/null +++ b/app/src/main/kotlin/app/vimusic/android/utils/Utils.kt @@ -0,0 +1,219 @@ +@file:OptIn(UnstableApi::class) + +package app.vimusic.android.utils + +import android.content.ContentUris +import android.net.Uri +import android.provider.MediaStore +import android.text.format.DateUtils +import androidx.annotation.OptIn +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.core.net.toUri +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.util.UnstableApi +import app.vimusic.android.R +import app.vimusic.android.models.Song +import app.vimusic.android.preferences.AppearancePreferences +import app.vimusic.android.service.LOCAL_KEY_PREFIX +import app.vimusic.android.service.isLocal +import app.vimusic.core.ui.utils.SongBundleAccessor +import app.vimusic.providers.innertube.Innertube +import app.vimusic.providers.innertube.models.bodies.ContinuationBody +import app.vimusic.providers.innertube.requests.playlistPage +import app.vimusic.providers.piped.models.Playlist +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.isActive +import kotlin.time.Duration + +val Innertube.SongItem.asMediaItem: MediaItem + get() = MediaItem.Builder() + .setMediaId(key) + .setUri(key) + .setCustomCacheKey(key) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(info?.name) + .setArtist(authors?.joinToString("") { it.name.orEmpty() }) + .setAlbumTitle(album?.name) + .setArtworkUri(thumbnail?.url?.toUri()) + .setExtras( + SongBundleAccessor.bundle { + albumId = album?.endpoint?.browseId + durationText = this@asMediaItem.durationText + artistNames = authors + ?.filter { it.endpoint != null } + ?.mapNotNull { it.name } + artistIds = authors?.mapNotNull { it.endpoint?.browseId } + explicit = this@asMediaItem.explicit + } + ) + .build() + ) + .build() + +val Innertube.VideoItem.asMediaItem: MediaItem + get() = MediaItem.Builder() + .setMediaId(key) + .setUri(key) + .setCustomCacheKey(key) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(info?.name) + .setArtist(authors?.joinToString("") { it.name.orEmpty() }) + .setArtworkUri(thumbnail?.url?.toUri()) + .setExtras( + SongBundleAccessor.bundle { + durationText = this@asMediaItem.durationText + artistNames = if (isOfficialMusicVideo) authors + ?.filter { it.endpoint != null } + ?.mapNotNull { it.name } + else null + artistIds = if (isOfficialMusicVideo) authors + ?.mapNotNull { it.endpoint?.browseId } + else null + } + ) + .build() + ) + .build() + +val Playlist.Video.asMediaItem: MediaItem? + get() { + val key = id ?: return null + + return MediaItem.Builder() + .setMediaId(key) + .setUri(key) + .setCustomCacheKey(key) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(title) + .setArtist(uploaderName) + .setArtworkUri(Uri.parse(thumbnailUrl.toString())) + .setExtras( + SongBundleAccessor.bundle { + durationText = duration.toComponents { minutes, seconds, _ -> + "$minutes:${seconds.toString().padStart(2, '0')}" + } + artistNames = listOf(uploaderName) + artistIds = uploaderId?.let { listOf(it) } + } + ) + .build() + ) + .build() + } + +val Song.asMediaItem: MediaItem + get() = MediaItem.Builder() + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(title) + .setArtist(artistsText) + .setArtworkUri(thumbnailUrl?.toUri()) + .setExtras( + SongBundleAccessor.bundle { + durationText = this@asMediaItem.durationText + explicit = this@asMediaItem.explicit + } + ) + .build() + ) + .setMediaId(id) + .setUri( + if (isLocal) ContentUris.withAppendedId( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, + id.substringAfter(LOCAL_KEY_PREFIX).toLong() + ) else id.toUri() + ) + .setCustomCacheKey(id) + .build() + +val Duration.formatted + @Composable get() = toComponents { hours, minutes, _, _ -> + when { + hours == 0L -> stringResource(id = R.string.format_minutes, minutes) + hours < 24L -> stringResource(id = R.string.format_hours, hours) + else -> stringResource(id = R.string.format_days, hours / 24) + } + } + +fun String?.thumbnail( + size: Int, + maxSize: Int = AppearancePreferences.maxThumbnailSize +): String? { + val actualSize = size.coerceAtMost(maxSize) + return when { + this?.startsWith("https://lh3.googleusercontent.com") == true -> "$this-w$actualSize-h$actualSize" + this?.startsWith("https://yt3.ggpht.com") == true -> "$this-w$actualSize-h$actualSize-s$actualSize" + else -> this + } +} + +fun Uri?.thumbnail(size: Int) = toString().thumbnail(size)?.toUri() + +fun formatAsDuration(millis: Long) = DateUtils.formatElapsedTime(millis / 1000).removePrefix("0") + +@Suppress("LoopWithTooManyJumpStatements") +suspend fun Result.completed( + maxDepth: Int = Int.MAX_VALUE, + shouldDedup: Boolean = false +) = runCatching { + val page = getOrThrow() + val songs = page.songsPage?.items.orEmpty().toMutableList() + + if (songs.isEmpty()) return@runCatching page + + var continuation = page.songsPage?.continuation + var depth = 0 + + val context = currentCoroutineContext() + + while (continuation != null && depth++ < maxDepth && context.isActive) { + val newSongs = Innertube + .playlistPage( + body = ContinuationBody(continuation = continuation) + ) + ?.getOrNull() + ?.takeUnless { it.items.isNullOrEmpty() } ?: break + + if (shouldDedup && newSongs.items?.any { it in songs } != false) break + + newSongs.items?.let { songs += it } + continuation = newSongs.continuation + } + + page.copy( + songsPage = Innertube.ItemsPage( + items = songs, + continuation = null + ) + ) +}.also { it.exceptionOrNull()?.printStackTrace() } + +fun Flow.onFirst(block: suspend (T) -> Unit): Flow { + var isFirst = true + + return onEach { + if (!isFirst) return@onEach + + block(it) + isFirst = false + } +} + +inline fun Throwable.findCause(): T? { + if (this is T) return this + + var th = cause + while (th != null) { + if (th is T) return th + th = th.cause + } + + return null +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/YoutubeRadio.kt b/app/src/main/kotlin/app/vimusic/android/utils/YouTubeRadio.kt similarity index 83% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/YoutubeRadio.kt rename to app/src/main/kotlin/app/vimusic/android/utils/YouTubeRadio.kt index 67f1746..0da4c94 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/YoutubeRadio.kt +++ b/app/src/main/kotlin/app/vimusic/android/utils/YouTubeRadio.kt @@ -1,10 +1,10 @@ -package it.vfsfitvnm.vimusic.utils +package app.vimusic.android.utils import androidx.media3.common.MediaItem -import it.vfsfitvnm.innertube.Innertube -import it.vfsfitvnm.innertube.models.bodies.ContinuationBody -import it.vfsfitvnm.innertube.models.bodies.NextBody -import it.vfsfitvnm.innertube.requests.nextPage +import app.vimusic.providers.innertube.Innertube +import app.vimusic.providers.innertube.models.bodies.ContinuationBody +import app.vimusic.providers.innertube.models.bodies.NextBody +import app.vimusic.providers.innertube.requests.nextPage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -23,7 +23,7 @@ data class YouTubeRadio( val continuation = nextContinuation if (continuation == null) { - Innertube.nextPage( + Innertube.nextPage( NextBody( videoId = videoId, playlistId = playlistId, @@ -43,7 +43,6 @@ data class YouTubeRadio( mediaItems = songsPage.items?.map(Innertube.SongItem::asMediaItem) songsPage.continuation?.takeUnless { nextContinuation == it } } - } return mediaItems ?: emptyList() diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt deleted file mode 100644 index 2e00f3f..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt +++ /dev/null @@ -1,684 +0,0 @@ -package it.vfsfitvnm.vimusic - -import android.content.ContentValues -import android.content.Context -import android.database.SQLException -import android.database.sqlite.SQLiteDatabase.CONFLICT_IGNORE -import android.os.Parcel -import androidx.core.database.getFloatOrNull -import androidx.media3.common.MediaItem -import androidx.room.AutoMigration -import androidx.room.Dao -import androidx.room.Delete -import androidx.room.DeleteColumn -import androidx.room.DeleteTable -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import androidx.room.RawQuery -import androidx.room.RenameColumn -import androidx.room.RenameTable -import androidx.room.RewriteQueriesToDropUnusedColumns -import androidx.room.Room -import androidx.room.RoomDatabase -import androidx.room.Transaction -import androidx.room.TypeConverter -import androidx.room.TypeConverters -import androidx.room.Update -import androidx.room.Upsert -import androidx.room.migration.AutoMigrationSpec -import androidx.room.migration.Migration -import androidx.sqlite.db.SimpleSQLiteQuery -import androidx.sqlite.db.SupportSQLiteDatabase -import androidx.sqlite.db.SupportSQLiteQuery -import it.vfsfitvnm.vimusic.enums.AlbumSortBy -import it.vfsfitvnm.vimusic.enums.ArtistSortBy -import it.vfsfitvnm.vimusic.enums.PlaylistSortBy -import it.vfsfitvnm.vimusic.enums.SongSortBy -import it.vfsfitvnm.vimusic.enums.SortOrder -import it.vfsfitvnm.vimusic.models.Album -import it.vfsfitvnm.vimusic.models.Artist -import it.vfsfitvnm.vimusic.models.SongWithContentLength -import it.vfsfitvnm.vimusic.models.Event -import it.vfsfitvnm.vimusic.models.Format -import it.vfsfitvnm.vimusic.models.Info -import it.vfsfitvnm.vimusic.models.Lyrics -import it.vfsfitvnm.vimusic.models.Playlist -import it.vfsfitvnm.vimusic.models.PlaylistPreview -import it.vfsfitvnm.vimusic.models.PlaylistWithSongs -import it.vfsfitvnm.vimusic.models.QueuedMediaItem -import it.vfsfitvnm.vimusic.models.SearchQuery -import it.vfsfitvnm.vimusic.models.Song -import it.vfsfitvnm.vimusic.models.SongAlbumMap -import it.vfsfitvnm.vimusic.models.SongArtistMap -import it.vfsfitvnm.vimusic.models.SongPlaylistMap -import it.vfsfitvnm.vimusic.models.SortedSongPlaylistMap -import kotlin.jvm.Throws -import kotlinx.coroutines.flow.Flow - -@Dao -interface Database { - companion object : Database by DatabaseInitializer.Instance.database - - @Transaction - @Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY ROWID ASC") - @RewriteQueriesToDropUnusedColumns - fun songsByRowIdAsc(): Flow> - - @Transaction - @Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY ROWID DESC") - @RewriteQueriesToDropUnusedColumns - fun songsByRowIdDesc(): Flow> - - @Transaction - @Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY title ASC") - @RewriteQueriesToDropUnusedColumns - fun songsByTitleAsc(): Flow> - - @Transaction - @Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY title DESC") - @RewriteQueriesToDropUnusedColumns - fun songsByTitleDesc(): Flow> - - @Transaction - @Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY totalPlayTimeMs ASC") - @RewriteQueriesToDropUnusedColumns - fun songsByPlayTimeAsc(): Flow> - - @Transaction - @Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY totalPlayTimeMs DESC") - @RewriteQueriesToDropUnusedColumns - fun songsByPlayTimeDesc(): Flow> - - fun songs(sortBy: SongSortBy, sortOrder: SortOrder): Flow> { - return when (sortBy) { - SongSortBy.PlayTime -> when (sortOrder) { - SortOrder.Ascending -> songsByPlayTimeAsc() - SortOrder.Descending -> songsByPlayTimeDesc() - } - SongSortBy.Title -> when (sortOrder) { - SortOrder.Ascending -> songsByTitleAsc() - SortOrder.Descending -> songsByTitleDesc() - } - SongSortBy.DateAdded -> when (sortOrder) { - SortOrder.Ascending -> songsByRowIdAsc() - SortOrder.Descending -> songsByRowIdDesc() - } - } - } - - @Transaction - @Query("SELECT * FROM Song WHERE likedAt IS NOT NULL ORDER BY likedAt DESC") - @RewriteQueriesToDropUnusedColumns - fun favorites(): Flow> - - @Query("SELECT * FROM QueuedMediaItem") - fun queue(): List - - @Query("DELETE FROM QueuedMediaItem") - fun clearQueue() - - @Query("SELECT * FROM SearchQuery WHERE query LIKE :query ORDER BY id DESC") - fun queries(query: String): Flow> - - @Query("SELECT COUNT (*) FROM SearchQuery") - fun queriesCount(): Flow - - @Query("DELETE FROM SearchQuery") - fun clearQueries() - - @Query("SELECT * FROM Song WHERE id = :id") - fun song(id: String): Flow - - @Query("SELECT likedAt FROM Song WHERE id = :songId") - fun likedAt(songId: String): Flow - - @Query("UPDATE Song SET likedAt = :likedAt WHERE id = :songId") - fun like(songId: String, likedAt: Long?): Int - - @Query("UPDATE Song SET durationText = :durationText WHERE id = :songId") - fun updateDurationText(songId: String, durationText: String): Int - - @Query("SELECT * FROM Lyrics WHERE songId = :songId") - fun lyrics(songId: String): Flow - - @Query("SELECT * FROM Artist WHERE id = :id") - fun artist(id: String): Flow - - @Query("SELECT * FROM Artist WHERE bookmarkedAt IS NOT NULL ORDER BY name DESC") - fun artistsByNameDesc(): Flow> - - @Query("SELECT * FROM Artist WHERE bookmarkedAt IS NOT NULL ORDER BY name ASC") - fun artistsByNameAsc(): Flow> - - @Query("SELECT * FROM Artist WHERE bookmarkedAt IS NOT NULL ORDER BY bookmarkedAt DESC") - fun artistsByRowIdDesc(): Flow> - - @Query("SELECT * FROM Artist WHERE bookmarkedAt IS NOT NULL ORDER BY bookmarkedAt ASC") - fun artistsByRowIdAsc(): Flow> - - fun artists(sortBy: ArtistSortBy, sortOrder: SortOrder): Flow> { - return when (sortBy) { - ArtistSortBy.Name -> when (sortOrder) { - SortOrder.Ascending -> artistsByNameAsc() - SortOrder.Descending -> artistsByNameDesc() - } - ArtistSortBy.DateAdded -> when (sortOrder) { - SortOrder.Ascending -> artistsByRowIdAsc() - SortOrder.Descending -> artistsByRowIdDesc() - } - } - } - - @Query("SELECT * FROM Album WHERE id = :id") - fun album(id: String): Flow - - @Query("SELECT timestamp FROM Album WHERE id = :id") - fun albumTimestamp(id: String): Long? - - @Transaction - @Query("SELECT * FROM Song JOIN SongAlbumMap ON Song.id = SongAlbumMap.songId WHERE SongAlbumMap.albumId = :albumId AND position IS NOT NULL ORDER BY position") - @RewriteQueriesToDropUnusedColumns - fun albumSongs(albumId: String): Flow> - - @Query("SELECT * FROM Album WHERE bookmarkedAt IS NOT NULL ORDER BY title ASC") - fun albumsByTitleAsc(): Flow> - - @Query("SELECT * FROM Album WHERE bookmarkedAt IS NOT NULL ORDER BY year ASC") - fun albumsByYearAsc(): Flow> - - @Query("SELECT * FROM Album WHERE bookmarkedAt IS NOT NULL ORDER BY bookmarkedAt ASC") - fun albumsByRowIdAsc(): Flow> - - @Query("SELECT * FROM Album WHERE bookmarkedAt IS NOT NULL ORDER BY title DESC") - fun albumsByTitleDesc(): Flow> - - @Query("SELECT * FROM Album WHERE bookmarkedAt IS NOT NULL ORDER BY year DESC") - fun albumsByYearDesc(): Flow> - - @Query("SELECT * FROM Album WHERE bookmarkedAt IS NOT NULL ORDER BY bookmarkedAt DESC") - fun albumsByRowIdDesc(): Flow> - - fun albums(sortBy: AlbumSortBy, sortOrder: SortOrder): Flow> { - return when (sortBy) { - AlbumSortBy.Title -> when (sortOrder) { - SortOrder.Ascending -> albumsByTitleAsc() - SortOrder.Descending -> albumsByTitleDesc() - } - AlbumSortBy.Year -> when (sortOrder) { - SortOrder.Ascending -> albumsByYearAsc() - SortOrder.Descending -> albumsByYearDesc() - } - AlbumSortBy.DateAdded -> when (sortOrder) { - SortOrder.Ascending -> albumsByRowIdAsc() - SortOrder.Descending -> albumsByRowIdDesc() - } - } - } - - @Query("UPDATE Song SET totalPlayTimeMs = totalPlayTimeMs + :addition WHERE id = :id") - fun incrementTotalPlayTimeMs(id: String, addition: Long) - - @Transaction - @Query("SELECT * FROM Playlist WHERE id = :id") - fun playlistWithSongs(id: Long): Flow - - @Transaction - @Query("SELECT id, name, (SELECT COUNT(*) FROM SongPlaylistMap WHERE playlistId = id) as songCount FROM Playlist ORDER BY name ASC") - fun playlistPreviewsByNameAsc(): Flow> - - @Transaction - @Query("SELECT id, name, (SELECT COUNT(*) FROM SongPlaylistMap WHERE playlistId = id) as songCount FROM Playlist ORDER BY ROWID ASC") - fun playlistPreviewsByDateAddedAsc(): Flow> - - @Transaction - @Query("SELECT id, name, (SELECT COUNT(*) FROM SongPlaylistMap WHERE playlistId = id) as songCount FROM Playlist ORDER BY songCount ASC") - fun playlistPreviewsByDateSongCountAsc(): Flow> - - @Transaction - @Query("SELECT id, name, (SELECT COUNT(*) FROM SongPlaylistMap WHERE playlistId = id) as songCount FROM Playlist ORDER BY name DESC") - fun playlistPreviewsByNameDesc(): Flow> - - @Transaction - @Query("SELECT id, name, (SELECT COUNT(*) FROM SongPlaylistMap WHERE playlistId = id) as songCount FROM Playlist ORDER BY ROWID DESC") - fun playlistPreviewsByDateAddedDesc(): Flow> - - @Transaction - @Query("SELECT id, name, (SELECT COUNT(*) FROM SongPlaylistMap WHERE playlistId = id) as songCount FROM Playlist ORDER BY songCount DESC") - fun playlistPreviewsByDateSongCountDesc(): Flow> - - fun playlistPreviews( - sortBy: PlaylistSortBy, - sortOrder: SortOrder - ): Flow> { - return when (sortBy) { - PlaylistSortBy.Name -> when (sortOrder) { - SortOrder.Ascending -> playlistPreviewsByNameAsc() - SortOrder.Descending -> playlistPreviewsByNameDesc() - } - PlaylistSortBy.SongCount -> when (sortOrder) { - SortOrder.Ascending -> playlistPreviewsByDateSongCountAsc() - SortOrder.Descending -> playlistPreviewsByDateSongCountDesc() - } - PlaylistSortBy.DateAdded -> when (sortOrder) { - SortOrder.Ascending -> playlistPreviewsByDateAddedAsc() - SortOrder.Descending -> playlistPreviewsByDateAddedDesc() - } - } - } - - @Query("SELECT thumbnailUrl FROM Song JOIN SongPlaylistMap ON id = songId WHERE playlistId = :id ORDER BY position LIMIT 4") - fun playlistThumbnailUrls(id: Long): Flow> - - @Transaction - @Query("SELECT * FROM Song JOIN SongArtistMap ON Song.id = SongArtistMap.songId WHERE SongArtistMap.artistId = :artistId AND totalPlayTimeMs > 0 ORDER BY Song.ROWID DESC") - @RewriteQueriesToDropUnusedColumns - fun artistSongs(artistId: String): Flow> - - @Query("SELECT * FROM Format WHERE songId = :songId") - fun format(songId: String): Flow - - @Transaction - @Query("SELECT Song.*, contentLength FROM Song JOIN Format ON id = songId WHERE contentLength IS NOT NULL AND totalPlayTimeMs > 0 ORDER BY Song.ROWID DESC") - fun songsWithContentLength(): Flow> - - @Query(""" - UPDATE SongPlaylistMap SET position = - CASE - WHEN position < :fromPosition THEN position + 1 - WHEN position > :fromPosition THEN position - 1 - ELSE :toPosition - END - WHERE playlistId = :playlistId AND position BETWEEN MIN(:fromPosition,:toPosition) and MAX(:fromPosition,:toPosition) - """) - fun move(playlistId: Long, fromPosition: Int, toPosition: Int) - - @Query("DELETE FROM SongPlaylistMap WHERE playlistId = :id") - fun clearPlaylist(id: Long) - - @Query("DELETE FROM SongAlbumMap WHERE albumId = :id") - fun clearAlbum(id: String) - - @Query("SELECT loudnessDb FROM Format WHERE songId = :songId") - fun loudnessDb(songId: String): Flow - - @Query("SELECT * FROM Song WHERE title LIKE :query OR artistsText LIKE :query") - fun search(query: String): Flow> - - @Query("SELECT albumId AS id, NULL AS name FROM SongAlbumMap WHERE songId = :songId") - fun songAlbumInfo(songId: String): Info - - @Query("SELECT id, name FROM Artist LEFT JOIN SongArtistMap ON id = artistId WHERE songId = :songId") - fun songArtistInfo(songId: String): List - - @Transaction - @Query("SELECT Song.* FROM Event JOIN Song ON Song.id = songId GROUP BY songId ORDER BY SUM(CAST(playTime AS REAL) / (((:now - timestamp) / 86400000) + 1)) DESC LIMIT 1") - @RewriteQueriesToDropUnusedColumns - fun trending(now: Long = System.currentTimeMillis()): Flow - - @Query("SELECT COUNT (*) FROM Event") - fun eventsCount(): Flow - - @Query("DELETE FROM Event") - fun clearEvents() - - @Query("DELETE FROM Event WHERE songId = :songId") - fun clearEventsFor(songId: String) - - @Insert(onConflict = OnConflictStrategy.IGNORE) - @Throws(SQLException::class) - fun insert(event: Event) - - @Insert(onConflict = OnConflictStrategy.REPLACE) - fun insert(format: Format) - - @Insert(onConflict = OnConflictStrategy.REPLACE) - fun insert(searchQuery: SearchQuery) - - @Insert(onConflict = OnConflictStrategy.IGNORE) - fun insert(playlist: Playlist): Long - - @Insert(onConflict = OnConflictStrategy.IGNORE) - fun insert(songPlaylistMap: SongPlaylistMap): Long - - @Insert(onConflict = OnConflictStrategy.ABORT) - fun insert(songArtistMap: SongArtistMap): Long - - @Insert(onConflict = OnConflictStrategy.IGNORE) - fun insert(song: Song): Long - - @Insert(onConflict = OnConflictStrategy.ABORT) - fun insert(queuedMediaItems: List) - - @Insert(onConflict = OnConflictStrategy.IGNORE) - fun insertSongPlaylistMaps(songPlaylistMaps: List) - - @Insert(onConflict = OnConflictStrategy.IGNORE) - fun insert(album: Album, songAlbumMap: SongAlbumMap) - - @Insert(onConflict = OnConflictStrategy.IGNORE) - fun insert(artists: List, songArtistMaps: List) - - @Transaction - fun insert(mediaItem: MediaItem, block: (Song) -> Song = { it }) { - val song = Song( - id = mediaItem.mediaId, - title = mediaItem.mediaMetadata.title!!.toString(), - artistsText = mediaItem.mediaMetadata.artist?.toString(), - durationText = mediaItem.mediaMetadata.extras?.getString("durationText"), - thumbnailUrl = mediaItem.mediaMetadata.artworkUri?.toString() - ).let(block).also { song -> - if (insert(song) == -1L) return - } - - mediaItem.mediaMetadata.extras?.getString("albumId")?.let { albumId -> - insert( - Album(id = albumId, title = mediaItem.mediaMetadata.albumTitle?.toString()), - SongAlbumMap(songId = song.id, albumId = albumId, position = null) - ) - } - - mediaItem.mediaMetadata.extras?.getStringArrayList("artistNames")?.let { artistNames -> - mediaItem.mediaMetadata.extras?.getStringArrayList("artistIds")?.let { artistIds -> - if (artistNames.size == artistIds.size) { - insert( - artistNames.mapIndexed { index, artistName -> - Artist(id = artistIds[index], name = artistName) - }, - artistIds.map { artistId -> - SongArtistMap(songId = song.id, artistId = artistId) - } - ) - } - } - } - } - - @Update - fun update(artist: Artist) - - @Update - fun update(album: Album) - - @Update - fun update(playlist: Playlist) - - @Upsert - fun upsert(lyrics: Lyrics) - - @Upsert - fun upsert(album: Album, songAlbumMaps: List) - - @Upsert - fun upsert(songAlbumMap: SongAlbumMap) - - @Upsert - fun upsert(artist: Artist) - - @Delete - fun delete(searchQuery: SearchQuery) - - @Delete - fun delete(playlist: Playlist) - - @Delete - fun delete(songPlaylistMap: SongPlaylistMap) - - @RawQuery - fun raw(supportSQLiteQuery: SupportSQLiteQuery): Int - - fun checkpoint() { - raw(SimpleSQLiteQuery("PRAGMA wal_checkpoint(FULL)")) - } -} - -@androidx.room.Database( - entities = [ - Song::class, - SongPlaylistMap::class, - Playlist::class, - Artist::class, - SongArtistMap::class, - Album::class, - SongAlbumMap::class, - SearchQuery::class, - QueuedMediaItem::class, - Format::class, - Event::class, - Lyrics::class, - ], - views = [ - SortedSongPlaylistMap::class - ], - version = 23, - exportSchema = true, - autoMigrations = [ - AutoMigration(from = 1, to = 2), - AutoMigration(from = 2, to = 3), - AutoMigration(from = 3, to = 4, spec = DatabaseInitializer.From3To4Migration::class), - AutoMigration(from = 4, to = 5), - AutoMigration(from = 5, to = 6), - AutoMigration(from = 6, to = 7), - AutoMigration(from = 7, to = 8, spec = DatabaseInitializer.From7To8Migration::class), - AutoMigration(from = 9, to = 10), - AutoMigration(from = 11, to = 12, spec = DatabaseInitializer.From11To12Migration::class), - AutoMigration(from = 12, to = 13), - AutoMigration(from = 13, to = 14), - AutoMigration(from = 15, to = 16), - AutoMigration(from = 16, to = 17), - AutoMigration(from = 17, to = 18), - AutoMigration(from = 18, to = 19), - AutoMigration(from = 19, to = 20), - AutoMigration(from = 20, to = 21, spec = DatabaseInitializer.From20To21Migration::class), - AutoMigration(from = 21, to = 22, spec = DatabaseInitializer.From21To22Migration::class), - ], -) -@TypeConverters(Converters::class) -abstract class DatabaseInitializer protected constructor() : RoomDatabase() { - abstract val database: Database - - companion object { - lateinit var Instance: DatabaseInitializer - - context(Context) - operator fun invoke() { - if (!::Instance.isInitialized) { - Instance = Room - .databaseBuilder(this@Context, DatabaseInitializer::class.java, "data.db") - .addMigrations( - From8To9Migration(), - From10To11Migration(), - From14To15Migration(), - From22To23Migration() - ) - .build() - } - } - } - - @DeleteTable.Entries(DeleteTable(tableName = "QueuedMediaItem")) - class From3To4Migration : AutoMigrationSpec - - @RenameColumn.Entries(RenameColumn("Song", "albumInfoId", "albumId")) - class From7To8Migration : AutoMigrationSpec - - class From8To9Migration : Migration(8, 9) { - override fun migrate(it: SupportSQLiteDatabase) { - it.query(SimpleSQLiteQuery("SELECT DISTINCT browseId, text, Info.id FROM Info JOIN Song ON Info.id = Song.albumId;")) - .use { cursor -> - val albumValues = ContentValues(2) - while (cursor.moveToNext()) { - albumValues.put("id", cursor.getString(0)) - albumValues.put("title", cursor.getString(1)) - it.insert("Album", CONFLICT_IGNORE, albumValues) - - it.execSQL( - "UPDATE Song SET albumId = '${cursor.getString(0)}' WHERE albumId = ${ - cursor.getLong( - 2 - ) - }" - ) - } - } - - it.query(SimpleSQLiteQuery("SELECT GROUP_CONCAT(text, ''), SongWithAuthors.songId FROM Info JOIN SongWithAuthors ON Info.id = SongWithAuthors.authorInfoId GROUP BY songId;")) - .use { cursor -> - val songValues = ContentValues(1) - while (cursor.moveToNext()) { - songValues.put("artistsText", cursor.getString(0)) - it.update( - "Song", - CONFLICT_IGNORE, - songValues, - "id = ?", - arrayOf(cursor.getString(1)) - ) - } - } - - it.query(SimpleSQLiteQuery("SELECT browseId, text, Info.id FROM Info JOIN SongWithAuthors ON Info.id = SongWithAuthors.authorInfoId WHERE browseId NOT NULL;")) - .use { cursor -> - val artistValues = ContentValues(2) - while (cursor.moveToNext()) { - artistValues.put("id", cursor.getString(0)) - artistValues.put("name", cursor.getString(1)) - it.insert("Artist", CONFLICT_IGNORE, artistValues) - - it.execSQL( - "UPDATE SongWithAuthors SET authorInfoId = '${cursor.getString(0)}' WHERE authorInfoId = ${ - cursor.getLong( - 2 - ) - }" - ) - } - } - - it.execSQL("INSERT INTO SongArtistMap(songId, artistId) SELECT songId, authorInfoId FROM SongWithAuthors") - - it.execSQL("DROP TABLE Info;") - it.execSQL("DROP TABLE SongWithAuthors;") - } - } - - class From10To11Migration : Migration(10, 11) { - override fun migrate(it: SupportSQLiteDatabase) { - it.query(SimpleSQLiteQuery("SELECT id, albumId FROM Song;")).use { cursor -> - val songAlbumMapValues = ContentValues(2) - while (cursor.moveToNext()) { - songAlbumMapValues.put("songId", cursor.getString(0)) - songAlbumMapValues.put("albumId", cursor.getString(1)) - it.insert("SongAlbumMap", CONFLICT_IGNORE, songAlbumMapValues) - } - } - - it.execSQL("CREATE TABLE IF NOT EXISTS `Song_new` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessDb` REAL, `contentLength` INTEGER, PRIMARY KEY(`id`))") - - it.execSQL("INSERT INTO Song_new(id, title, artistsText, durationText, thumbnailUrl, lyrics, likedAt, totalPlayTimeMs, loudnessDb, contentLength) SELECT id, title, artistsText, durationText, thumbnailUrl, lyrics, likedAt, totalPlayTimeMs, loudnessDb, contentLength FROM Song;") - it.execSQL("DROP TABLE Song;") - it.execSQL("ALTER TABLE Song_new RENAME TO Song;") - } - } - - @RenameTable("SongInPlaylist", "SongPlaylistMap") - @RenameTable("SortedSongInPlaylist", "SortedSongPlaylistMap") - class From11To12Migration : AutoMigrationSpec - - class From14To15Migration : Migration(14, 15) { - override fun migrate(it: SupportSQLiteDatabase) { - it.query(SimpleSQLiteQuery("SELECT id, loudnessDb, contentLength FROM Song;")) - .use { cursor -> - val formatValues = ContentValues(3) - while (cursor.moveToNext()) { - formatValues.put("songId", cursor.getString(0)) - formatValues.put("loudnessDb", cursor.getFloatOrNull(1)) - formatValues.put("contentLength", cursor.getFloatOrNull(2)) - it.insert("Format", CONFLICT_IGNORE, formatValues) - } - } - - it.execSQL("CREATE TABLE IF NOT EXISTS `Song_new` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))") - - it.execSQL("INSERT INTO Song_new(id, title, artistsText, durationText, thumbnailUrl, lyrics, likedAt, totalPlayTimeMs) SELECT id, title, artistsText, durationText, thumbnailUrl, lyrics, likedAt, totalPlayTimeMs FROM Song;") - it.execSQL("DROP TABLE Song;") - it.execSQL("ALTER TABLE Song_new RENAME TO Song;") - } - } - - @DeleteColumn.Entries( - DeleteColumn("Artist", "shuffleVideoId"), - DeleteColumn("Artist", "shufflePlaylistId"), - DeleteColumn("Artist", "radioVideoId"), - DeleteColumn("Artist", "radioPlaylistId"), - ) - class From20To21Migration : AutoMigrationSpec - - @DeleteColumn.Entries(DeleteColumn("Artist", "info")) - class From21To22Migration : AutoMigrationSpec - - class From22To23Migration : Migration(22, 23) { - override fun migrate(it: SupportSQLiteDatabase) { - it.execSQL("CREATE TABLE IF NOT EXISTS Lyrics (`songId` TEXT NOT NULL, `fixed` TEXT, `synced` TEXT, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE)") - - it.query(SimpleSQLiteQuery("SELECT id, lyrics, synchronizedLyrics FROM Song;")).use { cursor -> - val lyricsValues = ContentValues(3) - while (cursor.moveToNext()) { - lyricsValues.put("songId", cursor.getString(0)) - lyricsValues.put("fixed", cursor.getString(1)) - lyricsValues.put("synced", cursor.getString(2)) - it.insert("Lyrics", CONFLICT_IGNORE, lyricsValues) - } - } - - it.execSQL("CREATE TABLE IF NOT EXISTS Song_new (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT, `thumbnailUrl` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))") - it.execSQL("INSERT INTO Song_new(id, title, artistsText, durationText, thumbnailUrl, likedAt, totalPlayTimeMs) SELECT id, title, artistsText, durationText, thumbnailUrl, likedAt, totalPlayTimeMs FROM Song;") - it.execSQL("DROP TABLE Song;") - it.execSQL("ALTER TABLE Song_new RENAME TO Song;") - } - } -} - -@TypeConverters -object Converters { - @TypeConverter - fun mediaItemFromByteArray(value: ByteArray?): MediaItem? { - return value?.let { byteArray -> - runCatching { - val parcel = Parcel.obtain() - parcel.unmarshall(byteArray, 0, byteArray.size) - parcel.setDataPosition(0) - val bundle = parcel.readBundle(MediaItem::class.java.classLoader) - parcel.recycle() - - bundle?.let(MediaItem.CREATOR::fromBundle) - }.getOrNull() - } - } - - @TypeConverter - fun mediaItemToByteArray(mediaItem: MediaItem?): ByteArray? { - return mediaItem?.toBundle()?.let { persistableBundle -> - val parcel = Parcel.obtain() - parcel.writeBundle(persistableBundle) - val bytes = parcel.marshall() - parcel.recycle() - - bytes - } - } -} - -val Database.internal: RoomDatabase - get() = DatabaseInitializer.Instance - -fun query(block: () -> Unit) = DatabaseInitializer.Instance.queryExecutor.execute(block) - -fun transaction(block: () -> Unit) = with(DatabaseInitializer.Instance) { - transactionExecutor.execute { - runInTransaction(block) - } -} - -val RoomDatabase.path: String? - get() = openHelper.writableDatabase.path diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/MainActivity.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/MainActivity.kt deleted file mode 100644 index f663119..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/MainActivity.kt +++ /dev/null @@ -1,491 +0,0 @@ -package it.vfsfitvnm.vimusic - -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.ServiceConnection -import android.content.SharedPreferences -import android.graphics.Bitmap -import android.os.Bundle -import android.os.IBinder -import android.widget.Toast -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.tween -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.LocalIndication -import androidx.compose.foundation.background -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.add -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.systemBars -import androidx.compose.material.ripple.LocalRippleTheme -import androidx.compose.material.ripple.RippleAlpha -import androidx.compose.material.ripple.RippleTheme -import androidx.compose.material.ripple.rememberRipple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.coerceIn -import androidx.compose.ui.unit.dp -import androidx.core.view.WindowCompat -import androidx.lifecycle.lifecycleScope -import androidx.media3.common.MediaItem -import androidx.media3.common.Player -import com.valentinilk.shimmer.LocalShimmerTheme -import com.valentinilk.shimmer.defaultShimmerTheme -import it.vfsfitvnm.compose.persist.PersistMap -import it.vfsfitvnm.compose.persist.PersistMapOwner -import it.vfsfitvnm.innertube.Innertube -import it.vfsfitvnm.innertube.models.bodies.BrowseBody -import it.vfsfitvnm.innertube.requests.playlistPage -import it.vfsfitvnm.innertube.requests.song -import it.vfsfitvnm.vimusic.enums.ColorPaletteMode -import it.vfsfitvnm.vimusic.enums.ColorPaletteName -import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness -import it.vfsfitvnm.vimusic.service.PlayerService -import it.vfsfitvnm.vimusic.ui.components.BottomSheetMenu -import it.vfsfitvnm.vimusic.ui.components.LocalMenuState -import it.vfsfitvnm.vimusic.ui.components.rememberBottomSheetState -import it.vfsfitvnm.vimusic.ui.screens.albumRoute -import it.vfsfitvnm.vimusic.ui.screens.artistRoute -import it.vfsfitvnm.vimusic.ui.screens.home.HomeScreen -import it.vfsfitvnm.vimusic.ui.screens.player.Player -import it.vfsfitvnm.vimusic.ui.screens.playlistRoute -import it.vfsfitvnm.vimusic.ui.styling.Appearance -import it.vfsfitvnm.vimusic.ui.styling.Dimensions -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.colorPaletteOf -import it.vfsfitvnm.vimusic.ui.styling.dynamicColorPaletteOf -import it.vfsfitvnm.vimusic.ui.styling.typographyOf -import it.vfsfitvnm.vimusic.utils.applyFontPaddingKey -import it.vfsfitvnm.vimusic.utils.asMediaItem -import it.vfsfitvnm.vimusic.utils.colorPaletteModeKey -import it.vfsfitvnm.vimusic.utils.colorPaletteNameKey -import it.vfsfitvnm.vimusic.utils.forcePlay -import it.vfsfitvnm.vimusic.utils.getEnum -import it.vfsfitvnm.vimusic.utils.intent -import it.vfsfitvnm.vimusic.utils.isAtLeastAndroid6 -import it.vfsfitvnm.vimusic.utils.isAtLeastAndroid8 -import it.vfsfitvnm.vimusic.utils.preferences -import it.vfsfitvnm.vimusic.utils.thumbnailRoundnessKey -import it.vfsfitvnm.vimusic.utils.useSystemFontKey -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -class MainActivity : ComponentActivity(), PersistMapOwner { - private val serviceConnection = object : ServiceConnection { - override fun onServiceConnected(name: ComponentName?, service: IBinder?) { - if (service is PlayerService.Binder) { - this@MainActivity.binder = service - } - } - - override fun onServiceDisconnected(name: ComponentName?) { - binder = null - } - } - - private var binder by mutableStateOf(null) - - override lateinit var persistMap: PersistMap - - override fun onStart() { - super.onStart() - bindService(intent(), serviceConnection, Context.BIND_AUTO_CREATE) - } - - @OptIn(ExperimentalFoundationApi::class, ExperimentalAnimationApi::class) - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - @Suppress("DEPRECATION", "UNCHECKED_CAST") - persistMap = lastCustomNonConfigurationInstance as? PersistMap ?: PersistMap() - - WindowCompat.setDecorFitsSystemWindows(window, false) - - val launchedFromNotification = intent?.extras?.getBoolean("expandPlayerBottomSheet") == true - - setContent { - val coroutineScope = rememberCoroutineScope() - val isSystemInDarkTheme = isSystemInDarkTheme() - - var appearance by rememberSaveable( - isSystemInDarkTheme, - stateSaver = Appearance.Companion - ) { - with(preferences) { - val colorPaletteName = getEnum(colorPaletteNameKey, ColorPaletteName.Dynamic) - val colorPaletteMode = getEnum(colorPaletteModeKey, ColorPaletteMode.System) - val thumbnailRoundness = - getEnum(thumbnailRoundnessKey, ThumbnailRoundness.Light) - - val useSystemFont = getBoolean(useSystemFontKey, false) - val applyFontPadding = getBoolean(applyFontPaddingKey, false) - - val colorPalette = - colorPaletteOf(colorPaletteName, colorPaletteMode, isSystemInDarkTheme) - - setSystemBarAppearance(colorPalette.isDark) - - mutableStateOf( - Appearance( - colorPalette = colorPalette, - typography = typographyOf(colorPalette.text, useSystemFont, applyFontPadding), - thumbnailShape = thumbnailRoundness.shape() - ) - ) - } - } - - DisposableEffect(binder, isSystemInDarkTheme) { - var bitmapListenerJob: Job? = null - - fun setDynamicPalette(colorPaletteMode: ColorPaletteMode) { - val isDark = - colorPaletteMode == ColorPaletteMode.Dark || (colorPaletteMode == ColorPaletteMode.System && isSystemInDarkTheme) - - binder?.setBitmapListener { bitmap: Bitmap? -> - if (bitmap == null) { - val colorPalette = - colorPaletteOf( - ColorPaletteName.Dynamic, - colorPaletteMode, - isSystemInDarkTheme - ) - - setSystemBarAppearance(colorPalette.isDark) - - appearance = appearance.copy( - colorPalette = colorPalette, - typography = appearance.typography.copy(colorPalette.text) - ) - - return@setBitmapListener - } - - bitmapListenerJob = coroutineScope.launch(Dispatchers.IO) { - dynamicColorPaletteOf(bitmap, isDark)?.let { - withContext(Dispatchers.Main) { - setSystemBarAppearance(it.isDark) - } - appearance = appearance.copy( - colorPalette = it, - typography = appearance.typography.copy(it.text) - ) - } - } - } - } - - val listener = - SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key -> - when (key) { - colorPaletteNameKey, colorPaletteModeKey -> { - val colorPaletteName = - sharedPreferences.getEnum( - colorPaletteNameKey, - ColorPaletteName.Dynamic - ) - - val colorPaletteMode = - sharedPreferences.getEnum( - colorPaletteModeKey, - ColorPaletteMode.System - ) - - if (colorPaletteName == ColorPaletteName.Dynamic) { - setDynamicPalette(colorPaletteMode) - } else { - bitmapListenerJob?.cancel() - binder?.setBitmapListener(null) - - val colorPalette = colorPaletteOf( - colorPaletteName, - colorPaletteMode, - isSystemInDarkTheme - ) - - setSystemBarAppearance(colorPalette.isDark) - - appearance = appearance.copy( - colorPalette = colorPalette, - typography = appearance.typography.copy(colorPalette.text), - ) - } - } - - thumbnailRoundnessKey -> { - val thumbnailRoundness = - sharedPreferences.getEnum(key, ThumbnailRoundness.Light) - - appearance = appearance.copy( - thumbnailShape = thumbnailRoundness.shape() - ) - } - - useSystemFontKey, applyFontPaddingKey -> { - val useSystemFont = sharedPreferences.getBoolean(useSystemFontKey, false) - val applyFontPadding = sharedPreferences.getBoolean(applyFontPaddingKey, false) - - appearance = appearance.copy( - typography = typographyOf(appearance.colorPalette.text, useSystemFont, applyFontPadding), - ) - } - } - } - - with(preferences) { - registerOnSharedPreferenceChangeListener(listener) - - val colorPaletteName = getEnum(colorPaletteNameKey, ColorPaletteName.Dynamic) - if (colorPaletteName == ColorPaletteName.Dynamic) { - setDynamicPalette(getEnum(colorPaletteModeKey, ColorPaletteMode.System)) - } - - onDispose { - bitmapListenerJob?.cancel() - binder?.setBitmapListener(null) - unregisterOnSharedPreferenceChangeListener(listener) - } - } - } - - val rippleTheme = - remember(appearance.colorPalette.text, appearance.colorPalette.isDark) { - object : RippleTheme { - @Composable - override fun defaultColor(): Color = RippleTheme.defaultRippleColor( - contentColor = appearance.colorPalette.text, - lightTheme = !appearance.colorPalette.isDark - ) - - @Composable - override fun rippleAlpha(): RippleAlpha = RippleTheme.defaultRippleAlpha( - contentColor = appearance.colorPalette.text, - lightTheme = !appearance.colorPalette.isDark - ) - } - } - - val shimmerTheme = remember { - defaultShimmerTheme.copy( - animationSpec = infiniteRepeatable( - animation = tween( - durationMillis = 800, - easing = LinearEasing, - delayMillis = 250, - ), - repeatMode = RepeatMode.Restart - ), - shaderColors = listOf( - Color.Unspecified.copy(alpha = 0.25f), - Color.White.copy(alpha = 0.50f), - Color.Unspecified.copy(alpha = 0.25f), - ), - ) - } - - BoxWithConstraints( - modifier = Modifier - .fillMaxSize() - .background(appearance.colorPalette.background0) - ) { - val density = LocalDensity.current - val windowsInsets = WindowInsets.systemBars - val bottomDp = with(density) { windowsInsets.getBottom(density).toDp() } - - val playerBottomSheetState = rememberBottomSheetState( - dismissedBound = 0.dp, - collapsedBound = Dimensions.collapsedPlayer + bottomDp, - expandedBound = maxHeight, - ) - - val playerAwareWindowInsets by remember(bottomDp, playerBottomSheetState.value) { - derivedStateOf { - val bottom = playerBottomSheetState.value.coerceIn(bottomDp, playerBottomSheetState.collapsedBound) - - windowsInsets - .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top) - .add(WindowInsets(bottom = bottom)) - } - } - - CompositionLocalProvider( - LocalAppearance provides appearance, - LocalIndication provides rememberRipple(bounded = true), - LocalRippleTheme provides rippleTheme, - LocalShimmerTheme provides shimmerTheme, - LocalPlayerServiceBinder provides binder, - LocalPlayerAwareWindowInsets provides playerAwareWindowInsets, - LocalLayoutDirection provides LayoutDirection.Ltr - ) { - HomeScreen( - onPlaylistUrl = { url -> - onNewIntent(Intent.parseUri(url, 0)) - } - ) - - Player( - layoutState = playerBottomSheetState, - modifier = Modifier - .align(Alignment.BottomCenter) - ) - - BottomSheetMenu( - state = LocalMenuState.current, - modifier = Modifier - .align(Alignment.BottomCenter) - ) - } - - DisposableEffect(binder?.player) { - val player = binder?.player ?: return@DisposableEffect onDispose { } - - if (player.currentMediaItem == null) { - if (!playerBottomSheetState.isDismissed) { - playerBottomSheetState.dismiss() - } - } else { - if (playerBottomSheetState.isDismissed) { - if (launchedFromNotification) { - intent.replaceExtras(Bundle()) - playerBottomSheetState.expand(tween(700)) - } else { - playerBottomSheetState.collapse(tween(700)) - } - } - } - - val listener = object : Player.Listener { - override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { - if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED && mediaItem != null) { - if (mediaItem.mediaMetadata.extras?.getBoolean("isFromPersistentQueue") != true) { - playerBottomSheetState.expand(tween(500)) - } else { - playerBottomSheetState.collapse(tween(700)) - } - } - } - } - - player.addListener(listener) - - onDispose { player.removeListener(listener) } - } - } - } - - onNewIntent(intent) - } - - override fun onNewIntent(intent: Intent?) { - super.onNewIntent(intent) - - val uri = intent?.data ?: return - - intent.data = null - this.intent = null - - Toast.makeText(this, "Opening url...", Toast.LENGTH_SHORT).show() - - lifecycleScope.launch(Dispatchers.IO) { - when (val path = uri.pathSegments.firstOrNull()) { - "playlist" -> uri.getQueryParameter("list")?.let { playlistId -> - val browseId = "VL$playlistId" - - if (playlistId.startsWith("OLAK5uy_")) { - Innertube.playlistPage(BrowseBody(browseId = browseId))?.getOrNull()?.let { - it.songsPage?.items?.firstOrNull()?.album?.endpoint?.browseId?.let { browseId -> - albumRoute.ensureGlobal(browseId) - } - } - } else { - playlistRoute.ensureGlobal(browseId) - } - } - - "channel", "c" -> uri.lastPathSegment?.let { channelId -> - artistRoute.ensureGlobal(channelId) - } - - else -> when { - path == "watch" -> uri.getQueryParameter("v") - uri.host == "youtu.be" -> path - else -> null - }?.let { videoId -> - Innertube.song(videoId)?.getOrNull()?.let { song -> - val binder = snapshotFlow { binder }.filterNotNull().first() - withContext(Dispatchers.Main) { - binder.player.forcePlay(song.asMediaItem) - } - } - } - } - } - } - - override fun onRetainCustomNonConfigurationInstance() = persistMap - - override fun onStop() { - unbindService(serviceConnection) - super.onStop() - } - - override fun onDestroy() { - if (!isChangingConfigurations) { - persistMap.clear() - } - - super.onDestroy() - } - - private fun setSystemBarAppearance(isDark: Boolean) { - with(WindowCompat.getInsetsController(window, window.decorView.rootView)) { - isAppearanceLightStatusBars = !isDark - isAppearanceLightNavigationBars = !isDark - } - - if (!isAtLeastAndroid6) { - window.statusBarColor = - (if (isDark) Color.Transparent else Color.Black.copy(alpha = 0.2f)).toArgb() - } - - if (!isAtLeastAndroid8) { - window.navigationBarColor = - (if (isDark) Color.Transparent else Color.Black.copy(alpha = 0.2f)).toArgb() - } - } -} - -val LocalPlayerServiceBinder = staticCompositionLocalOf { null } - -val LocalPlayerAwareWindowInsets = staticCompositionLocalOf { TODO() } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/MainApplication.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/MainApplication.kt deleted file mode 100644 index d0d458f..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/MainApplication.kt +++ /dev/null @@ -1,35 +0,0 @@ -package it.vfsfitvnm.vimusic - -import android.app.Application -import coil.ImageLoader -import coil.ImageLoaderFactory -import coil.disk.DiskCache -import it.vfsfitvnm.vimusic.enums.CoilDiskCacheMaxSize -import it.vfsfitvnm.vimusic.utils.coilDiskCacheMaxSizeKey -import it.vfsfitvnm.vimusic.utils.getEnum -import it.vfsfitvnm.vimusic.utils.preferences - -class MainApplication : Application(), ImageLoaderFactory { - override fun onCreate() { - super.onCreate() - DatabaseInitializer() - } - - override fun newImageLoader(): ImageLoader { - return ImageLoader.Builder(this) - .crossfade(true) - .respectCacheHeaders(false) - .diskCache( - DiskCache.Builder() - .directory(cacheDir.resolve("coil")) - .maxSizeBytes( - preferences.getEnum( - coilDiskCacheMaxSizeKey, - CoilDiskCacheMaxSize.`128MB` - ).bytes - ) - .build() - ) - .build() - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/BuiltInPlaylist.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/BuiltInPlaylist.kt deleted file mode 100644 index e93c7f2..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/BuiltInPlaylist.kt +++ /dev/null @@ -1,6 +0,0 @@ -package it.vfsfitvnm.vimusic.enums - -enum class BuiltInPlaylist { - Favorites, - Offline -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/CoilDiskCacheSize.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/CoilDiskCacheSize.kt deleted file mode 100644 index fa13e0c..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/CoilDiskCacheSize.kt +++ /dev/null @@ -1,18 +0,0 @@ -package it.vfsfitvnm.vimusic.enums - -enum class CoilDiskCacheMaxSize { - `128MB`, - `256MB`, - `512MB`, - `1GB`, - `2GB`; - - val bytes: Long - get() = when (this) { - `128MB` -> 128 - `256MB` -> 256 - `512MB` -> 512 - `1GB` -> 1024 - `2GB` -> 2048 - } * 1000 * 1000L -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/ColorPaletteMode.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/ColorPaletteMode.kt deleted file mode 100644 index 63bcc3b..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/ColorPaletteMode.kt +++ /dev/null @@ -1,7 +0,0 @@ -package it.vfsfitvnm.vimusic.enums - -enum class ColorPaletteMode { - Light, - Dark, - System -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/ColorPaletteName.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/ColorPaletteName.kt deleted file mode 100644 index 49ec459..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/ColorPaletteName.kt +++ /dev/null @@ -1,7 +0,0 @@ -package it.vfsfitvnm.vimusic.enums - -enum class ColorPaletteName { - Default, - Dynamic, - PureBlack -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/ExoPlayerDiskCacheMaxSize.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/ExoPlayerDiskCacheMaxSize.kt deleted file mode 100644 index 14c7419..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/ExoPlayerDiskCacheMaxSize.kt +++ /dev/null @@ -1,22 +0,0 @@ -package it.vfsfitvnm.vimusic.enums - -enum class ExoPlayerDiskCacheMaxSize { - `32MB`, - `512MB`, - `1GB`, - `2GB`, - `4GB`, - `8GB`, - Unlimited; - - val bytes: Long - get() = when (this) { - `32MB` -> 32 - `512MB` -> 512 - `1GB` -> 1024 - `2GB` -> 2048 - `4GB` -> 4096 - `8GB` -> 8192 - Unlimited -> 0 - } * 1000 * 1000L -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/ThumbnailRoundness.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/ThumbnailRoundness.kt deleted file mode 100644 index 9fd8ba3..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/ThumbnailRoundness.kt +++ /dev/null @@ -1,25 +0,0 @@ -package it.vfsfitvnm.vimusic.enums - -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.ReadOnlyComposable -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.unit.dp -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance - -enum class ThumbnailRoundness { - None, - Light, - Medium, - Heavy; - - fun shape(): Shape { - return when (this) { - None -> RectangleShape - Light -> RoundedCornerShape(2.dp) - Medium -> RoundedCornerShape(4.dp) - Heavy -> RoundedCornerShape(8.dp) - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/PlaylistPreview.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/PlaylistPreview.kt deleted file mode 100644 index 8caab6b..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/PlaylistPreview.kt +++ /dev/null @@ -1,10 +0,0 @@ -package it.vfsfitvnm.vimusic.models - -import androidx.compose.runtime.Immutable -import androidx.room.Embedded - -@Immutable -data class PlaylistPreview( - @Embedded val playlist: Playlist, - val songCount: Int -) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Song.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Song.kt deleted file mode 100644 index 3421956..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Song.kt +++ /dev/null @@ -1,36 +0,0 @@ -package it.vfsfitvnm.vimusic.models - -import androidx.compose.runtime.Immutable -import androidx.room.Entity -import androidx.room.PrimaryKey - -@Immutable -@Entity -data class Song( - @PrimaryKey val id: String, - val title: String, - val artistsText: String? = null, - val durationText: String?, - val thumbnailUrl: String?, - val likedAt: Long? = null, - val totalPlayTimeMs: Long = 0 -) { - val formattedTotalPlayTime: String - get() { - val seconds = totalPlayTimeMs / 1000 - - val hours = seconds / 3600 - - return when { - hours == 0L -> "${seconds / 60}m" - hours < 24L -> "${hours}h" - else -> "${hours / 24}d" - } - } - - fun toggleLike(): Song { - return copy( - likedAt = if (likedAt == null) System.currentTimeMillis() else null - ) - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/service/BitmapProvider.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/service/BitmapProvider.kt deleted file mode 100644 index 771d8c0..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/service/BitmapProvider.kt +++ /dev/null @@ -1,83 +0,0 @@ -package it.vfsfitvnm.vimusic.service - -import android.content.Context -import android.content.res.Configuration -import android.graphics.Bitmap -import android.graphics.drawable.BitmapDrawable -import android.net.Uri -import androidx.core.graphics.applyCanvas -import coil.imageLoader -import coil.request.Disposable -import coil.request.ImageRequest -import it.vfsfitvnm.vimusic.utils.thumbnail - -context(Context) -class BitmapProvider( - private val bitmapSize: Int, - private val colorProvider: (isSystemInDarkMode: Boolean) -> Int -) { - var lastUri: Uri? = null - private set - - var lastBitmap: Bitmap? = null - private var lastIsSystemInDarkMode = false - - private var lastEnqueued: Disposable? = null - - private lateinit var defaultBitmap: Bitmap - - val bitmap: Bitmap - get() = lastBitmap ?: defaultBitmap - - var listener: ((Bitmap?) -> Unit)? = null - set(value) { - field = value - value?.invoke(lastBitmap) - } - - init { - setDefaultBitmap() - } - - fun setDefaultBitmap(): Boolean { - val isSystemInDarkMode = resources.configuration.uiMode and - Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES - - if (::defaultBitmap.isInitialized && isSystemInDarkMode == lastIsSystemInDarkMode) return false - - lastIsSystemInDarkMode = isSystemInDarkMode - - defaultBitmap = - Bitmap.createBitmap(bitmapSize, bitmapSize, Bitmap.Config.ARGB_8888).applyCanvas { - drawColor(colorProvider(isSystemInDarkMode)) - } - - return lastBitmap == null - } - - fun load(uri: Uri?, onDone: (Bitmap) -> Unit) { - if (lastUri == uri) return - - lastEnqueued?.dispose() - lastUri = uri - - lastEnqueued = applicationContext.imageLoader.enqueue( - ImageRequest.Builder(applicationContext) - .data(uri.thumbnail(bitmapSize)) - .allowHardware(false) - .listener( - onError = { _, _ -> - lastBitmap = null - onDone(bitmap) - listener?.invoke(lastBitmap) - }, - onSuccess = { _, result -> - lastBitmap = (result.drawable as BitmapDrawable).bitmap - onDone(bitmap) - listener?.invoke(lastBitmap) - } - ) - .build() - ) - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/service/PlaybackExceptions.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/service/PlaybackExceptions.kt deleted file mode 100644 index d38c279..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/service/PlaybackExceptions.kt +++ /dev/null @@ -1,11 +0,0 @@ -package it.vfsfitvnm.vimusic.service - -import androidx.media3.common.PlaybackException - -class PlayableFormatNotFoundException : PlaybackException(null, null, ERROR_CODE_REMOTE_ERROR) - -class UnplayableException : PlaybackException(null, null, ERROR_CODE_REMOTE_ERROR) - -class LoginRequiredException : PlaybackException(null, null, ERROR_CODE_REMOTE_ERROR) - -class VideoIdMismatchException : PlaybackException(null, null, ERROR_CODE_REMOTE_ERROR) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/service/PlayerMediaBrowserService.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/service/PlayerMediaBrowserService.kt deleted file mode 100644 index c97cdcf..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/service/PlayerMediaBrowserService.kt +++ /dev/null @@ -1,301 +0,0 @@ -package it.vfsfitvnm.vimusic.service - -import android.media.MediaDescription as BrowserMediaDescription -import android.media.browse.MediaBrowser.MediaItem as BrowserMediaItem -import android.content.ComponentName -import android.content.ContentResolver -import android.content.Context -import android.content.ServiceConnection -import android.media.session.MediaSession -import android.net.Uri -import android.os.Bundle -import android.os.IBinder -import android.os.Process -import android.service.media.MediaBrowserService -import androidx.annotation.DrawableRes -import androidx.core.net.toUri -import androidx.core.os.bundleOf -import androidx.media3.common.Player -import androidx.media3.datasource.cache.Cache -import it.vfsfitvnm.vimusic.Database -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.models.Album -import it.vfsfitvnm.vimusic.models.PlaylistPreview -import it.vfsfitvnm.vimusic.models.Song -import it.vfsfitvnm.vimusic.models.SongWithContentLength -import it.vfsfitvnm.vimusic.utils.asMediaItem -import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex -import it.vfsfitvnm.vimusic.utils.forceSeekToNext -import it.vfsfitvnm.vimusic.utils.forceSeekToPrevious -import it.vfsfitvnm.vimusic.utils.intent -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext - -class PlayerMediaBrowserService : MediaBrowserService(), ServiceConnection { - private val coroutineScope = CoroutineScope(Dispatchers.IO) - private var lastSongs = emptyList() - - private var bound = false - - override fun onDestroy() { - if (bound) { - unbindService(this) - } - super.onDestroy() - } - - override fun onServiceConnected(className: ComponentName, service: IBinder) { - if (service is PlayerService.Binder) { - bound = true - sessionToken = service.mediaSession.sessionToken - service.mediaSession.setCallback(SessionCallback(service.player, service.cache)) - } - } - - override fun onServiceDisconnected(name: ComponentName) = Unit - - override fun onGetRoot( - clientPackageName: String, - clientUid: Int, - rootHints: Bundle? - ): BrowserRoot? { - return if (clientUid == Process.myUid() - || clientUid == Process.SYSTEM_UID - || clientPackageName == "com.google.android.projection.gearhead" - ) { - bindService(intent(), this, Context.BIND_AUTO_CREATE) - BrowserRoot( - MediaId.root, - bundleOf("android.media.browse.CONTENT_STYLE_BROWSABLE_HINT" to 1) - ) - } else { - null - } - } - - override fun onLoadChildren(parentId: String, result: Result>) { - runBlocking(Dispatchers.IO) { - result.sendResult( - when (parentId) { - MediaId.root -> mutableListOf( - songsBrowserMediaItem, - playlistsBrowserMediaItem, - albumsBrowserMediaItem - ) - - MediaId.songs -> Database - .songsByPlayTimeDesc() - .first() - .take(30) - .also { lastSongs = it } - .map { it.asBrowserMediaItem } - .toMutableList() - .apply { - if (isNotEmpty()) add(0, shuffleBrowserMediaItem) - } - - MediaId.playlists -> Database - .playlistPreviewsByDateAddedDesc() - .first() - .map { it.asBrowserMediaItem } - .toMutableList() - .apply { - add(0, favoritesBrowserMediaItem) - add(1, offlineBrowserMediaItem) - } - - MediaId.albums -> Database - .albumsByRowIdDesc() - .first() - .map { it.asBrowserMediaItem } - .toMutableList() - - else -> mutableListOf() - } - ) - } - } - - private fun uriFor(@DrawableRes id: Int) = Uri.Builder() - .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) - .authority(resources.getResourcePackageName(id)) - .appendPath(resources.getResourceTypeName(id)) - .appendPath(resources.getResourceEntryName(id)) - .build() - - private val shuffleBrowserMediaItem - inline get() = BrowserMediaItem( - BrowserMediaDescription.Builder() - .setMediaId(MediaId.shuffle) - .setTitle("Shuffle") - .setIconUri(uriFor(R.drawable.shuffle)) - .build(), - BrowserMediaItem.FLAG_PLAYABLE - ) - - private val songsBrowserMediaItem - inline get() = BrowserMediaItem( - BrowserMediaDescription.Builder() - .setMediaId(MediaId.songs) - .setTitle("Songs") - .setIconUri(uriFor(R.drawable.musical_notes)) - .build(), - BrowserMediaItem.FLAG_BROWSABLE - ) - - - private val playlistsBrowserMediaItem - inline get() = BrowserMediaItem( - BrowserMediaDescription.Builder() - .setMediaId(MediaId.playlists) - .setTitle("Playlists") - .setIconUri(uriFor(R.drawable.playlist)) - .build(), - BrowserMediaItem.FLAG_BROWSABLE - ) - - private val albumsBrowserMediaItem - inline get() = BrowserMediaItem( - BrowserMediaDescription.Builder() - .setMediaId(MediaId.albums) - .setTitle("Albums") - .setIconUri(uriFor(R.drawable.disc)) - .build(), - BrowserMediaItem.FLAG_BROWSABLE - ) - - private val favoritesBrowserMediaItem - inline get() = BrowserMediaItem( - BrowserMediaDescription.Builder() - .setMediaId(MediaId.favorites) - .setTitle("Favorites") - .setIconUri(uriFor(R.drawable.heart)) - .build(), - BrowserMediaItem.FLAG_PLAYABLE - ) - - private val offlineBrowserMediaItem - inline get() = BrowserMediaItem( - BrowserMediaDescription.Builder() - .setMediaId(MediaId.offline) - .setTitle("Offline") - .setIconUri(uriFor(R.drawable.airplane)) - .build(), - BrowserMediaItem.FLAG_PLAYABLE - ) - - private val Song.asBrowserMediaItem - inline get() = BrowserMediaItem( - BrowserMediaDescription.Builder() - .setMediaId(MediaId.forSong(id)) - .setTitle(title) - .setSubtitle(artistsText) - .setIconUri(thumbnailUrl?.toUri()) - .build(), - BrowserMediaItem.FLAG_PLAYABLE - ) - - private val PlaylistPreview.asBrowserMediaItem - inline get() = BrowserMediaItem( - BrowserMediaDescription.Builder() - .setMediaId(MediaId.forPlaylist(playlist.id)) - .setTitle(playlist.name) - .setSubtitle("$songCount songs") - .setIconUri(uriFor(R.drawable.playlist)) - .build(), - BrowserMediaItem.FLAG_PLAYABLE - ) - - private val Album.asBrowserMediaItem - inline get() = BrowserMediaItem( - BrowserMediaDescription.Builder() - .setMediaId(MediaId.forAlbum(id)) - .setTitle(title) - .setSubtitle(authorsText) - .setIconUri(thumbnailUrl?.toUri()) - .build(), - BrowserMediaItem.FLAG_PLAYABLE - ) - - private inner class SessionCallback(private val player: Player, private val cache: Cache) : - MediaSession.Callback() { - override fun onPlay() = player.play() - override fun onPause() = player.pause() - override fun onSkipToPrevious() = player.forceSeekToPrevious() - override fun onSkipToNext() = player.forceSeekToNext() - override fun onSeekTo(pos: Long) = player.seekTo(pos) - override fun onSkipToQueueItem(id: Long) = player.seekToDefaultPosition(id.toInt()) - - override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { - val data = mediaId?.split('/') ?: return - var index = 0 - - coroutineScope.launch { - val mediaItems = when (data.getOrNull(0)) { - MediaId.shuffle -> lastSongs - - MediaId.songs -> data - .getOrNull(1) - ?.let { songId -> - index = lastSongs.indexOfFirst { it.id == songId } - lastSongs - } - - MediaId.favorites -> Database - .favorites() - .first() - .shuffled() - - MediaId.offline -> Database - .songsWithContentLength() - .first() - .filter { song -> - song.contentLength?.let { - cache.isCached(song.song.id, 0, it) - } ?: false - } - .map(SongWithContentLength::song) - .shuffled() - - MediaId.playlists -> data - .getOrNull(1) - ?.toLongOrNull() - ?.let(Database::playlistWithSongs) - ?.first() - ?.songs - ?.shuffled() - - MediaId.albums -> data - .getOrNull(1) - ?.let(Database::albumSongs) - ?.first() - - else -> emptyList() - }?.map(Song::asMediaItem) ?: return@launch - - withContext(Dispatchers.Main) { - player.forcePlayAtIndex(mediaItems, index.coerceIn(0, mediaItems.size)) - } - } - } - } - - private object MediaId { - const val root = "root" - const val songs = "songs" - const val playlists = "playlists" - const val albums = "albums" - - const val favorites = "favorites" - const val offline = "offline" - const val shuffle = "shuffle" - - fun forSong(id: String) = "songs/$id" - fun forPlaylist(id: Long) = "playlists/$id" - fun forAlbum(id: String) = "albums/$id" - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/service/PlayerService.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/service/PlayerService.kt deleted file mode 100644 index b2be4ce..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/service/PlayerService.kt +++ /dev/null @@ -1,1008 +0,0 @@ -package it.vfsfitvnm.vimusic.service - -import android.os.Binder as AndroidBinder -import android.annotation.SuppressLint -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.content.SharedPreferences -import android.content.res.Configuration -import android.database.SQLException -import android.graphics.Bitmap -import android.graphics.Color -import android.media.AudioDeviceCallback -import android.media.AudioDeviceInfo -import android.media.AudioManager -import android.media.MediaDescription -import android.media.MediaMetadata -import android.media.audiofx.AudioEffect -import android.media.audiofx.LoudnessEnhancer -import android.media.session.MediaSession -import android.media.session.PlaybackState -import android.net.Uri -import android.os.Handler -import android.text.format.DateUtils -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.core.app.NotificationCompat -import androidx.core.content.ContextCompat.startForegroundService -import androidx.core.content.getSystemService -import androidx.core.net.toUri -import androidx.core.text.isDigitsOnly -import androidx.media3.common.AudioAttributes -import androidx.media3.common.C -import androidx.media3.common.MediaItem -import androidx.media3.common.PlaybackException -import androidx.media3.common.Player -import androidx.media3.common.Timeline -import androidx.media3.database.StandaloneDatabaseProvider -import androidx.media3.datasource.DataSource -import androidx.media3.datasource.DefaultHttpDataSource -import androidx.media3.datasource.ResolvingDataSource -import androidx.media3.datasource.cache.Cache -import androidx.media3.datasource.cache.CacheDataSource -import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor -import androidx.media3.datasource.cache.NoOpCacheEvictor -import androidx.media3.datasource.cache.SimpleCache -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.exoplayer.RenderersFactory -import androidx.media3.exoplayer.analytics.AnalyticsListener -import androidx.media3.exoplayer.analytics.PlaybackStats -import androidx.media3.exoplayer.analytics.PlaybackStatsListener -import androidx.media3.exoplayer.audio.AudioRendererEventListener -import androidx.media3.exoplayer.audio.DefaultAudioSink -import androidx.media3.exoplayer.audio.DefaultAudioSink.DefaultAudioProcessorChain -import androidx.media3.exoplayer.audio.MediaCodecAudioRenderer -import androidx.media3.exoplayer.audio.SilenceSkippingAudioProcessor -import androidx.media3.exoplayer.audio.SonicAudioProcessor -import androidx.media3.exoplayer.mediacodec.MediaCodecSelector -import androidx.media3.exoplayer.source.DefaultMediaSourceFactory -import androidx.media3.exoplayer.source.MediaSource -import androidx.media3.extractor.ExtractorsFactory -import androidx.media3.extractor.mkv.MatroskaExtractor -import androidx.media3.extractor.mp4.FragmentedMp4Extractor -import it.vfsfitvnm.innertube.Innertube -import it.vfsfitvnm.innertube.models.NavigationEndpoint -import it.vfsfitvnm.innertube.models.bodies.PlayerBody -import it.vfsfitvnm.innertube.requests.player -import it.vfsfitvnm.vimusic.Database -import it.vfsfitvnm.vimusic.MainActivity -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.enums.ExoPlayerDiskCacheMaxSize -import it.vfsfitvnm.vimusic.models.Event -import it.vfsfitvnm.vimusic.models.QueuedMediaItem -import it.vfsfitvnm.vimusic.query -import it.vfsfitvnm.vimusic.utils.InvincibleService -import it.vfsfitvnm.vimusic.utils.RingBuffer -import it.vfsfitvnm.vimusic.utils.TimerJob -import it.vfsfitvnm.vimusic.utils.YouTubeRadio -import it.vfsfitvnm.vimusic.utils.activityPendingIntent -import it.vfsfitvnm.vimusic.utils.broadCastPendingIntent -import it.vfsfitvnm.vimusic.utils.exoPlayerDiskCacheMaxSizeKey -import it.vfsfitvnm.vimusic.utils.findNextMediaItemById -import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning -import it.vfsfitvnm.vimusic.utils.forceSeekToNext -import it.vfsfitvnm.vimusic.utils.forceSeekToPrevious -import it.vfsfitvnm.vimusic.utils.getEnum -import it.vfsfitvnm.vimusic.utils.intent -import it.vfsfitvnm.vimusic.utils.isAtLeastAndroid13 -import it.vfsfitvnm.vimusic.utils.isAtLeastAndroid6 -import it.vfsfitvnm.vimusic.utils.isAtLeastAndroid8 -import it.vfsfitvnm.vimusic.utils.isInvincibilityEnabledKey -import it.vfsfitvnm.vimusic.utils.isShowingThumbnailInLockscreenKey -import it.vfsfitvnm.vimusic.utils.mediaItems -import it.vfsfitvnm.vimusic.utils.persistentQueueKey -import it.vfsfitvnm.vimusic.utils.preferences -import it.vfsfitvnm.vimusic.utils.queueLoopEnabledKey -import it.vfsfitvnm.vimusic.utils.resumePlaybackWhenDeviceConnectedKey -import it.vfsfitvnm.vimusic.utils.shouldBePlaying -import it.vfsfitvnm.vimusic.utils.skipSilenceKey -import it.vfsfitvnm.vimusic.utils.timer -import it.vfsfitvnm.vimusic.utils.trackLoopEnabledKey -import it.vfsfitvnm.vimusic.utils.volumeNormalizationKey -import kotlin.math.roundToInt -import kotlin.system.exitProcess -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.cancellable -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import kotlinx.coroutines.plus -import kotlinx.coroutines.runBlocking - -@Suppress("DEPRECATION") -class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListener.Callback, - SharedPreferences.OnSharedPreferenceChangeListener { - private lateinit var mediaSession: MediaSession - private lateinit var cache: SimpleCache - private lateinit var player: ExoPlayer - - private val stateBuilder = PlaybackState.Builder() - .setActions( - PlaybackState.ACTION_PLAY - or PlaybackState.ACTION_PAUSE - or PlaybackState.ACTION_PLAY_PAUSE - or PlaybackState.ACTION_STOP - or PlaybackState.ACTION_SKIP_TO_PREVIOUS - or PlaybackState.ACTION_SKIP_TO_NEXT - or PlaybackState.ACTION_SKIP_TO_QUEUE_ITEM - or PlaybackState.ACTION_SEEK_TO - or PlaybackState.ACTION_REWIND - ) - - private val metadataBuilder = MediaMetadata.Builder() - - private var notificationManager: NotificationManager? = null - - private var timerJob: TimerJob? = null - - private var radio: YouTubeRadio? = null - - private lateinit var bitmapProvider: BitmapProvider - - private val coroutineScope = CoroutineScope(Dispatchers.IO) + Job() - - private var volumeNormalizationJob: Job? = null - - private var isPersistentQueueEnabled = false - private var isShowingThumbnailInLockscreen = true - override var isInvincibilityEnabled = false - - private var audioManager: AudioManager? = null - private var audioDeviceCallback: AudioDeviceCallback? = null - - private var loudnessEnhancer: LoudnessEnhancer? = null - - private val binder = Binder() - - private var isNotificationStarted = false - - override val notificationId: Int - get() = NotificationId - - private lateinit var notificationActionReceiver: NotificationActionReceiver - - override fun onBind(intent: Intent?): AndroidBinder { - super.onBind(intent) - return binder - } - - override fun onCreate() { - super.onCreate() - - bitmapProvider = BitmapProvider( - bitmapSize = (256 * resources.displayMetrics.density).roundToInt(), - colorProvider = { isSystemInDarkMode -> - if (isSystemInDarkMode) Color.BLACK else Color.WHITE - } - ) - - createNotificationChannel() - - preferences.registerOnSharedPreferenceChangeListener(this) - - val preferences = preferences - isPersistentQueueEnabled = preferences.getBoolean(persistentQueueKey, false) - isInvincibilityEnabled = preferences.getBoolean(isInvincibilityEnabledKey, false) - isShowingThumbnailInLockscreen = - preferences.getBoolean(isShowingThumbnailInLockscreenKey, false) - - val cacheEvictor = when (val size = - preferences.getEnum(exoPlayerDiskCacheMaxSizeKey, ExoPlayerDiskCacheMaxSize.`2GB`)) { - ExoPlayerDiskCacheMaxSize.Unlimited -> NoOpCacheEvictor() - else -> LeastRecentlyUsedCacheEvictor(size.bytes) - } - - // TODO: Remove in a future release - val directory = cacheDir.resolve("exoplayer").also { directory -> - if (directory.exists()) return@also - - directory.mkdir() - - cacheDir.listFiles()?.forEach { file -> - if (file.isDirectory && file.name.length == 1 && file.name.isDigitsOnly() || file.extension == "uid") { - if (!file.renameTo(directory.resolve(file.name))) { - file.deleteRecursively() - } - } - } - - filesDir.resolve("coil").deleteRecursively() - } - cache = SimpleCache(directory, cacheEvictor, StandaloneDatabaseProvider(this)) - - player = ExoPlayer.Builder(this, createRendersFactory(), createMediaSourceFactory()) - .setHandleAudioBecomingNoisy(true) - .setWakeMode(C.WAKE_MODE_LOCAL) - .setAudioAttributes( - AudioAttributes.Builder() - .setUsage(C.USAGE_MEDIA) - .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) - .build(), - true - ) - .setUsePlatformDiagnostics(false) - .build() - - player.repeatMode = when { - preferences.getBoolean(trackLoopEnabledKey, false) -> Player.REPEAT_MODE_ONE - preferences.getBoolean(queueLoopEnabledKey, true) -> Player.REPEAT_MODE_ALL - else -> Player.REPEAT_MODE_OFF - } - - player.skipSilenceEnabled = preferences.getBoolean(skipSilenceKey, false) - player.addListener(this) - player.addAnalyticsListener(PlaybackStatsListener(false, this)) - - maybeRestorePlayerQueue() - - mediaSession = MediaSession(baseContext, "PlayerService") - mediaSession.setFlags(MediaSession.FLAG_HANDLES_MEDIA_BUTTONS or MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS) - mediaSession.setCallback(SessionCallback(player)) - mediaSession.setPlaybackState(stateBuilder.build()) - mediaSession.isActive = true - - notificationActionReceiver = NotificationActionReceiver(player) - - val filter = IntentFilter().apply { - addAction(Action.play.value) - addAction(Action.pause.value) - addAction(Action.next.value) - addAction(Action.previous.value) - } - - registerReceiver(notificationActionReceiver, filter) - - maybeResumePlaybackWhenDeviceConnected() - } - - override fun onTaskRemoved(rootIntent: Intent?) { - if (!player.shouldBePlaying) { - broadCastPendingIntent().send() - } - super.onTaskRemoved(rootIntent) - } - - override fun onDestroy() { - maybeSavePlayerQueue() - - preferences.unregisterOnSharedPreferenceChangeListener(this) - - player.removeListener(this) - player.stop() - player.release() - - unregisterReceiver(notificationActionReceiver) - - mediaSession.isActive = false - mediaSession.release() - cache.release() - - loudnessEnhancer?.release() - - super.onDestroy() - } - - override fun shouldBeInvincible(): Boolean { - return !player.shouldBePlaying - } - - override fun onConfigurationChanged(newConfig: Configuration) { - if (bitmapProvider.setDefaultBitmap() && player.currentMediaItem != null) { - notificationManager?.notify(NotificationId, notification()) - } - super.onConfigurationChanged(newConfig) - } - - override fun onPlaybackStatsReady( - eventTime: AnalyticsListener.EventTime, - playbackStats: PlaybackStats - ) { - val mediaItem = - eventTime.timeline.getWindow(eventTime.windowIndex, Timeline.Window()).mediaItem - - val totalPlayTimeMs = playbackStats.totalPlayTimeMs - - if (totalPlayTimeMs > 5000) { - query { - Database.incrementTotalPlayTimeMs(mediaItem.mediaId, totalPlayTimeMs) - } - } - - if (totalPlayTimeMs > 30000) { - query { - try { - Database.insert( - Event( - songId = mediaItem.mediaId, - timestamp = System.currentTimeMillis(), - playTime = totalPlayTimeMs - ) - ) - } catch (_: SQLException) { - } - } - } - } - - override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { - maybeRecoverPlaybackError() - maybeNormalizeVolume() - maybeProcessRadio() - - if (mediaItem == null) { - bitmapProvider.listener?.invoke(null) - } else if (mediaItem.mediaMetadata.artworkUri == bitmapProvider.lastUri) { - bitmapProvider.listener?.invoke(bitmapProvider.lastBitmap) - } - - if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO || reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK) { - updateMediaSessionQueue(player.currentTimeline) - } - } - - override fun onTimelineChanged(timeline: Timeline, reason: Int) { - if (reason == Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) { - updateMediaSessionQueue(timeline) - } - } - - private fun updateMediaSessionQueue(timeline: Timeline) { - val builder = MediaDescription.Builder() - - val currentMediaItemIndex = player.currentMediaItemIndex - val lastIndex = timeline.windowCount - 1 - var startIndex = currentMediaItemIndex - 7 - var endIndex = currentMediaItemIndex + 7 - - if (startIndex < 0) { - endIndex -= startIndex - } - - if (endIndex > lastIndex) { - startIndex -= (endIndex - lastIndex) - endIndex = lastIndex - } - - startIndex = startIndex.coerceAtLeast(0) - - mediaSession.setQueue( - List(endIndex - startIndex + 1) { index -> - val mediaItem = timeline.getWindow(index + startIndex, Timeline.Window()).mediaItem - MediaSession.QueueItem( - builder - .setMediaId(mediaItem.mediaId) - .setTitle(mediaItem.mediaMetadata.title) - .setSubtitle(mediaItem.mediaMetadata.artist) - .setIconUri(mediaItem.mediaMetadata.artworkUri) - .build(), - (index + startIndex).toLong() - ) - } - ) - } - - private fun maybeRecoverPlaybackError() { - if (player.playerError != null) { - player.prepare() - } - } - - private fun maybeProcessRadio() { - radio?.let { radio -> - if (player.mediaItemCount - player.currentMediaItemIndex <= 3) { - coroutineScope.launch(Dispatchers.Main) { - player.addMediaItems(radio.process()) - } - } - } - } - - private fun maybeSavePlayerQueue() { - if (!isPersistentQueueEnabled) return - - val mediaItems = player.currentTimeline.mediaItems - val mediaItemIndex = player.currentMediaItemIndex - val mediaItemPosition = player.currentPosition - - mediaItems.mapIndexed { index, mediaItem -> - QueuedMediaItem( - mediaItem = mediaItem, - position = if (index == mediaItemIndex) mediaItemPosition else null - ) - }.let { queuedMediaItems -> - query { - Database.clearQueue() - Database.insert(queuedMediaItems) - } - } - } - - private fun maybeRestorePlayerQueue() { - if (!isPersistentQueueEnabled) return - - query { - val queuedSong = Database.queue() - Database.clearQueue() - - if (queuedSong.isEmpty()) return@query - - val index = queuedSong.indexOfFirst { it.position != null }.coerceAtLeast(0) - - runBlocking(Dispatchers.Main) { - player.setMediaItems( - queuedSong.map { mediaItem -> - mediaItem.mediaItem.buildUpon() - .setUri(mediaItem.mediaItem.mediaId) - .setCustomCacheKey(mediaItem.mediaItem.mediaId) - .build().apply { - mediaMetadata.extras?.putBoolean("isFromPersistentQueue", true) - } - }, - index, - queuedSong[index].position ?: C.TIME_UNSET - ) - player.prepare() - - isNotificationStarted = true - startForegroundService(this@PlayerService, intent()) - startForeground(NotificationId, notification()) - } - } - } - - private fun maybeNormalizeVolume() { - if (!preferences.getBoolean(volumeNormalizationKey, false)) { - loudnessEnhancer?.enabled = false - loudnessEnhancer?.release() - loudnessEnhancer = null - volumeNormalizationJob?.cancel() - player.volume = 1f - return - } - - if (loudnessEnhancer == null) { - loudnessEnhancer = LoudnessEnhancer(player.audioSessionId) - } - - player.currentMediaItem?.mediaId?.let { songId -> - volumeNormalizationJob?.cancel() - volumeNormalizationJob = coroutineScope.launch(Dispatchers.Main) { - Database.loudnessDb(songId).cancellable().collectLatest { loudnessDb -> - try { - loudnessEnhancer?.setTargetGain(-((loudnessDb ?: 0f) * 100).toInt() + 500) - loudnessEnhancer?.enabled = true - } catch (_: Exception) { } - } - } - } - } - - private fun maybeShowSongCoverInLockScreen() { - val bitmap = - if (isAtLeastAndroid13 || isShowingThumbnailInLockscreen) bitmapProvider.bitmap else null - - metadataBuilder.putBitmap(MediaMetadata.METADATA_KEY_ART, bitmap) - - if (isAtLeastAndroid13 && player.currentMediaItemIndex == 0) { - metadataBuilder.putText( - MediaMetadata.METADATA_KEY_TITLE, - "${player.mediaMetadata.title} " - ) - } - - mediaSession.setMetadata(metadataBuilder.build()) - } - - @SuppressLint("NewApi") - private fun maybeResumePlaybackWhenDeviceConnected() { - if (!isAtLeastAndroid6) return - - if (preferences.getBoolean(resumePlaybackWhenDeviceConnectedKey, false)) { - if (audioManager == null) { - audioManager = getSystemService(AUDIO_SERVICE) as AudioManager? - } - - audioDeviceCallback = object : AudioDeviceCallback() { - private fun canPlayMusic(audioDeviceInfo: AudioDeviceInfo): Boolean { - if (!audioDeviceInfo.isSink) return false - - return audioDeviceInfo.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP || - audioDeviceInfo.type == AudioDeviceInfo.TYPE_WIRED_HEADSET || - audioDeviceInfo.type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES || - audioDeviceInfo.type == AudioDeviceInfo.TYPE_USB_HEADSET - } - - override fun onAudioDevicesAdded(addedDevices: Array) { - if (!player.isPlaying && addedDevices.any(::canPlayMusic)) { - player.play() - } - } - - override fun onAudioDevicesRemoved(removedDevices: Array) = Unit - } - - audioManager?.registerAudioDeviceCallback(audioDeviceCallback, handler) - - } else { - audioManager?.unregisterAudioDeviceCallback(audioDeviceCallback) - audioDeviceCallback = null - } - } - - private fun sendOpenEqualizerIntent() { - sendBroadcast( - Intent(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION).apply { - putExtra(AudioEffect.EXTRA_AUDIO_SESSION, player.audioSessionId) - putExtra(AudioEffect.EXTRA_PACKAGE_NAME, packageName) - putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC) - } - ) - } - - private fun sendCloseEqualizerIntent() { - sendBroadcast( - Intent(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION).apply { - putExtra(AudioEffect.EXTRA_AUDIO_SESSION, player.audioSessionId) - } - ) - } - - private val Player.androidPlaybackState: Int - get() = when (playbackState) { - Player.STATE_BUFFERING -> if (playWhenReady) PlaybackState.STATE_BUFFERING else PlaybackState.STATE_PAUSED - Player.STATE_READY -> if (playWhenReady) PlaybackState.STATE_PLAYING else PlaybackState.STATE_PAUSED - Player.STATE_ENDED -> PlaybackState.STATE_STOPPED - Player.STATE_IDLE -> PlaybackState.STATE_NONE - else -> PlaybackState.STATE_NONE - } - - override fun onEvents(player: Player, events: Player.Events) { - if (player.duration != C.TIME_UNSET) { - mediaSession.setMetadata( - metadataBuilder - .putText(MediaMetadata.METADATA_KEY_TITLE, player.mediaMetadata.title) - .putText(MediaMetadata.METADATA_KEY_ARTIST, player.mediaMetadata.artist) - .putText(MediaMetadata.METADATA_KEY_ALBUM, player.mediaMetadata.albumTitle) - .putLong(MediaMetadata.METADATA_KEY_DURATION, player.duration) - .build() - ) - } - - stateBuilder - .setState(player.androidPlaybackState, player.currentPosition, 1f) - .setBufferedPosition(player.bufferedPosition) - - mediaSession.setPlaybackState(stateBuilder.build()) - - if (events.containsAny( - Player.EVENT_PLAYBACK_STATE_CHANGED, - Player.EVENT_PLAY_WHEN_READY_CHANGED, - Player.EVENT_IS_PLAYING_CHANGED, - Player.EVENT_POSITION_DISCONTINUITY - ) - ) { - val notification = notification() - - if (notification == null) { - isNotificationStarted = false - makeInvincible(false) - stopForeground(false) - sendCloseEqualizerIntent() - notificationManager?.cancel(NotificationId) - return - } - - if (player.shouldBePlaying && !isNotificationStarted) { - isNotificationStarted = true - startForegroundService(this@PlayerService, intent()) - startForeground(NotificationId, notification) - makeInvincible(false) - sendOpenEqualizerIntent() - } else { - if (!player.shouldBePlaying) { - isNotificationStarted = false - stopForeground(false) - makeInvincible(true) - sendCloseEqualizerIntent() - } - notificationManager?.notify(NotificationId, notification) - } - } - } - - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) { - when (key) { - persistentQueueKey -> isPersistentQueueEnabled = - sharedPreferences.getBoolean(key, isPersistentQueueEnabled) - - volumeNormalizationKey -> maybeNormalizeVolume() - - resumePlaybackWhenDeviceConnectedKey -> maybeResumePlaybackWhenDeviceConnected() - - isInvincibilityEnabledKey -> isInvincibilityEnabled = - sharedPreferences.getBoolean(key, isInvincibilityEnabled) - - skipSilenceKey -> player.skipSilenceEnabled = sharedPreferences.getBoolean(key, false) - isShowingThumbnailInLockscreenKey -> { - isShowingThumbnailInLockscreen = sharedPreferences.getBoolean(key, true) - maybeShowSongCoverInLockScreen() - } - - trackLoopEnabledKey, queueLoopEnabledKey -> { - player.repeatMode = when { - preferences.getBoolean(trackLoopEnabledKey, false) -> Player.REPEAT_MODE_ONE - preferences.getBoolean(queueLoopEnabledKey, true) -> Player.REPEAT_MODE_ALL - else -> Player.REPEAT_MODE_OFF - } - } - } - } - - override fun notification(): Notification? { - if (player.currentMediaItem == null) return null - - val playIntent = Action.play.pendingIntent - val pauseIntent = Action.pause.pendingIntent - val nextIntent = Action.next.pendingIntent - val prevIntent = Action.previous.pendingIntent - - val mediaMetadata = player.mediaMetadata - - val builder = if (isAtLeastAndroid8) { - Notification.Builder(applicationContext, NotificationChannelId) - } else { - Notification.Builder(applicationContext) - } - .setContentTitle(mediaMetadata.title) - .setContentText(mediaMetadata.artist) - .setSubText(player.playerError?.message) - .setLargeIcon(bitmapProvider.bitmap) - .setAutoCancel(false) - .setOnlyAlertOnce(true) - .setShowWhen(false) - .setSmallIcon(player.playerError?.let { R.drawable.alert_circle } - ?: R.drawable.app_icon) - .setOngoing(false) - .setContentIntent(activityPendingIntent( - flags = PendingIntent.FLAG_UPDATE_CURRENT - ) { - putExtra("expandPlayerBottomSheet", true) - }) - .setDeleteIntent(broadCastPendingIntent()) - .setVisibility(Notification.VISIBILITY_PUBLIC) - .setCategory(NotificationCompat.CATEGORY_TRANSPORT) - .setStyle( - Notification.MediaStyle() - .setShowActionsInCompactView(0, 1, 2) - .setMediaSession(mediaSession.sessionToken) - ) - .addAction(R.drawable.play_skip_back, "Skip back", prevIntent) - .addAction( - if (player.shouldBePlaying) R.drawable.pause else R.drawable.play, - if (player.shouldBePlaying) "Pause" else "Play", - if (player.shouldBePlaying) pauseIntent else playIntent - ) - .addAction(R.drawable.play_skip_forward, "Skip forward", nextIntent) - - bitmapProvider.load(mediaMetadata.artworkUri) { bitmap -> - maybeShowSongCoverInLockScreen() - notificationManager?.notify(NotificationId, builder.setLargeIcon(bitmap).build()) - } - - return builder.build() - } - - private fun createNotificationChannel() { - notificationManager = getSystemService() - - if (!isAtLeastAndroid8) return - - notificationManager?.run { - if (getNotificationChannel(NotificationChannelId) == null) { - createNotificationChannel( - NotificationChannel( - NotificationChannelId, - "Now playing", - NotificationManager.IMPORTANCE_LOW - ).apply { - setSound(null, null) - enableLights(false) - enableVibration(false) - } - ) - } - - if (getNotificationChannel(SleepTimerNotificationChannelId) == null) { - createNotificationChannel( - NotificationChannel( - SleepTimerNotificationChannelId, - "Sleep timer", - NotificationManager.IMPORTANCE_LOW - ).apply { - setSound(null, null) - enableLights(false) - enableVibration(false) - } - ) - } - } - } - - private fun createCacheDataSource(): DataSource.Factory { - return CacheDataSource.Factory().setCache(cache).apply { - setUpstreamDataSourceFactory( - DefaultHttpDataSource.Factory() - .setConnectTimeoutMs(16000) - .setReadTimeoutMs(8000) - .setUserAgent("Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0") - ) - } - } - - private fun createDataSourceFactory(): DataSource.Factory { - val chunkLength = 512 * 1024L - val ringBuffer = RingBuffer?>(2) { null } - - return ResolvingDataSource.Factory(createCacheDataSource()) { dataSpec -> - val videoId = dataSpec.key ?: error("A key must be set") - - if (cache.isCached(videoId, dataSpec.position, chunkLength)) { - dataSpec - } else { - when (videoId) { - ringBuffer.getOrNull(0)?.first -> dataSpec.withUri(ringBuffer.getOrNull(0)!!.second) - ringBuffer.getOrNull(1)?.first -> dataSpec.withUri(ringBuffer.getOrNull(1)!!.second) - else -> { - val urlResult = runBlocking(Dispatchers.IO) { - Innertube.player(PlayerBody(videoId = videoId)) - }?.mapCatching { body -> - if (body.videoDetails?.videoId != videoId) { - throw VideoIdMismatchException() - } - - when (val status = body.playabilityStatus?.status) { - "OK" -> body.streamingData?.highestQualityFormat?.let { format -> - val mediaItem = runBlocking(Dispatchers.Main) { - player.findNextMediaItemById(videoId) - } - - if (mediaItem?.mediaMetadata?.extras?.getString("durationText") == null) { - format.approxDurationMs?.div(1000) - ?.let(DateUtils::formatElapsedTime)?.removePrefix("0") - ?.let { durationText -> - mediaItem?.mediaMetadata?.extras?.putString( - "durationText", - durationText - ) - Database.updateDurationText(videoId, durationText) - } - } - - query { - mediaItem?.let(Database::insert) - - Database.insert( - it.vfsfitvnm.vimusic.models.Format( - songId = videoId, - itag = format.itag, - mimeType = format.mimeType, - bitrate = format.bitrate, - loudnessDb = body.playerConfig?.audioConfig?.normalizedLoudnessDb, - contentLength = format.contentLength, - lastModified = format.lastModified - ) - ) - } - - format.url - } ?: throw PlayableFormatNotFoundException() - - "UNPLAYABLE" -> throw UnplayableException() - "LOGIN_REQUIRED" -> throw LoginRequiredException() - else -> throw PlaybackException( - status, - null, - PlaybackException.ERROR_CODE_REMOTE_ERROR - ) - } - } - - urlResult?.getOrThrow()?.let { url -> - ringBuffer.append(videoId to url.toUri()) - dataSpec.withUri(url.toUri()) - .subrange(dataSpec.uriPositionOffset, chunkLength) - } ?: throw PlaybackException( - null, - urlResult?.exceptionOrNull(), - PlaybackException.ERROR_CODE_REMOTE_ERROR - ) - } - } - } - } - } - - private fun createMediaSourceFactory(): MediaSource.Factory { - return DefaultMediaSourceFactory(createDataSourceFactory(), createExtractorsFactory()) - } - - private fun createExtractorsFactory(): ExtractorsFactory { - return ExtractorsFactory { - arrayOf(MatroskaExtractor(), FragmentedMp4Extractor()) - } - } - - private fun createRendersFactory(): RenderersFactory { - val audioSink = DefaultAudioSink.Builder() - .setEnableFloatOutput(false) - .setEnableAudioTrackPlaybackParams(false) - .setOffloadMode(DefaultAudioSink.OFFLOAD_MODE_DISABLED) - .setAudioProcessorChain( - DefaultAudioProcessorChain( - emptyArray(), - SilenceSkippingAudioProcessor(2_000_000, 20_000, 256), - SonicAudioProcessor() - ) - ) - .build() - - return RenderersFactory { handler: Handler?, _, audioListener: AudioRendererEventListener?, _, _ -> - arrayOf( - MediaCodecAudioRenderer( - this, - MediaCodecSelector.DEFAULT, - handler, - audioListener, - audioSink - ) - ) - } - } - - inner class Binder : AndroidBinder() { - val player: ExoPlayer - get() = this@PlayerService.player - - val cache: Cache - get() = this@PlayerService.cache - - val mediaSession - get() = this@PlayerService.mediaSession - - val sleepTimerMillisLeft: StateFlow? - get() = timerJob?.millisLeft - - private var radioJob: Job? = null - - var isLoadingRadio by mutableStateOf(false) - private set - - fun setBitmapListener(listener: ((Bitmap?) -> Unit)?) { - bitmapProvider.listener = listener - } - - fun startSleepTimer(delayMillis: Long) { - timerJob?.cancel() - - timerJob = coroutineScope.timer(delayMillis) { - val notification = NotificationCompat - .Builder(this@PlayerService, SleepTimerNotificationChannelId) - .setContentTitle("Sleep timer ended") - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setAutoCancel(true) - .setOnlyAlertOnce(true) - .setShowWhen(true) - .setSmallIcon(R.drawable.app_icon) - .build() - - notificationManager?.notify(SleepTimerNotificationId, notification) - - stopSelf() - exitProcess(0) - } - } - - fun cancelSleepTimer() { - timerJob?.cancel() - timerJob = null - } - - fun setupRadio(endpoint: NavigationEndpoint.Endpoint.Watch?) = - startRadio(endpoint = endpoint, justAdd = true) - - fun playRadio(endpoint: NavigationEndpoint.Endpoint.Watch?) = - startRadio(endpoint = endpoint, justAdd = false) - - private fun startRadio(endpoint: NavigationEndpoint.Endpoint.Watch?, justAdd: Boolean) { - radioJob?.cancel() - radio = null - YouTubeRadio( - endpoint?.videoId, - endpoint?.playlistId, - endpoint?.playlistSetVideoId, - endpoint?.params - ).let { - isLoadingRadio = true - radioJob = coroutineScope.launch(Dispatchers.Main) { - if (justAdd) { - player.addMediaItems(it.process().drop(1)) - } else { - player.forcePlayFromBeginning(it.process()) - } - radio = it - isLoadingRadio = false - } - } - } - - fun stopRadio() { - isLoadingRadio = false - radioJob?.cancel() - radio = null - } - } - - private class SessionCallback(private val player: Player) : MediaSession.Callback() { - override fun onPlay() = player.play() - override fun onPause() = player.pause() - override fun onSkipToPrevious() = runCatching(player::forceSeekToPrevious).let { } - override fun onSkipToNext() = runCatching(player::forceSeekToNext).let { } - override fun onSeekTo(pos: Long) = player.seekTo(pos) - override fun onStop() = player.pause() - override fun onRewind() = player.seekToDefaultPosition() - override fun onSkipToQueueItem(id: Long) = runCatching { player.seekToDefaultPosition(id.toInt()) }.let { } - } - - private class NotificationActionReceiver(private val player: Player) : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - when (intent.action) { - Action.pause.value -> player.pause() - Action.play.value -> player.play() - Action.next.value -> player.forceSeekToNext() - Action.previous.value -> player.forceSeekToPrevious() - } - } - } - - class NotificationDismissReceiver : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - context.stopService(context.intent()) - } - } - - @JvmInline - private value class Action(val value: String) { - context(Context) - val pendingIntent: PendingIntent - get() = PendingIntent.getBroadcast( - this@Context, - 100, - Intent(value).setPackage(packageName), - PendingIntent.FLAG_UPDATE_CURRENT.or(if (isAtLeastAndroid6) PendingIntent.FLAG_IMMUTABLE else 0) - ) - - companion object { - val pause = Action("it.vfsfitvnm.vimusic.pause") - val play = Action("it.vfsfitvnm.vimusic.play") - val next = Action("it.vfsfitvnm.vimusic.next") - val previous = Action("it.vfsfitvnm.vimusic.previous") - } - } - - private companion object { - const val NotificationId = 1001 - const val NotificationChannelId = "default_channel_id" - - const val SleepTimerNotificationId = 1002 - const val SleepTimerNotificationChannelId = "sleep_timer_channel_id" - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/BottomSheet.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/BottomSheet.kt deleted file mode 100644 index 0699f15..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/BottomSheet.kt +++ /dev/null @@ -1,301 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.components - -import androidx.activity.compose.BackHandler -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.AnimationSpec -import androidx.compose.animation.core.AnimationVector1D -import androidx.compose.animation.core.SpringSpec -import androidx.compose.animation.core.VectorConverter -import androidx.compose.animation.core.tween -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.DraggableState -import androidx.compose.foundation.gestures.detectVerticalDragGestures -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.input.pointer.util.VelocityTracker -import androidx.compose.ui.input.pointer.util.addPointerInputChange -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.Velocity -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch - -@Composable -fun BottomSheet( - state: BottomSheetState, - modifier: Modifier = Modifier, - onDismiss: (() -> Unit)? = null, - collapsedContent: @Composable BoxScope.() -> Unit, - content: @Composable BoxScope.() -> Unit -) { - Box( - modifier = modifier - .offset { - val y = (state.expandedBound - state.value) - .roundToPx() - .coerceAtLeast(0) - IntOffset(x = 0, y = y) - } - .pointerInput(state) { - val velocityTracker = VelocityTracker() - - detectVerticalDragGestures( - onVerticalDrag = { change, dragAmount -> - velocityTracker.addPointerInputChange(change) - state.dispatchRawDelta(dragAmount) - }, - onDragCancel = { - velocityTracker.resetTracking() - state.snapTo(state.collapsedBound) - }, - onDragEnd = { - val velocity = -velocityTracker.calculateVelocity().y - velocityTracker.resetTracking() - state.performFling(velocity, onDismiss) - } - ) - } - .fillMaxSize() - ) { - if (!state.isCollapsed) { - BackHandler(onBack = state::collapseSoft) - content() - } - - if (!state.isExpanded && (onDismiss == null || !state.isDismissed)) { - Box( - modifier = Modifier - .graphicsLayer { - alpha = 1f - (state.progress * 16).coerceAtMost(1f) - } - .clickable(onClick = state::expandSoft) - .fillMaxWidth() - .height(state.collapsedBound), - content = collapsedContent - ) - } - } -} - -@Stable -class BottomSheetState( - draggableState: DraggableState, - private val coroutineScope: CoroutineScope, - private val animatable: Animatable, - private val onAnchorChanged: (Int) -> Unit, - val collapsedBound: Dp, -) : DraggableState by draggableState { - val dismissedBound: Dp - get() = animatable.lowerBound!! - - val expandedBound: Dp - get() = animatable.upperBound!! - - val value by animatable.asState() - - val isDismissed by derivedStateOf { - value == animatable.lowerBound!! - } - - val isCollapsed by derivedStateOf { - value == collapsedBound - } - - val isExpanded by derivedStateOf { - value == animatable.upperBound - } - - val progress by derivedStateOf { - 1f - (animatable.upperBound!! - animatable.value) / (animatable.upperBound!! - collapsedBound) - } - - fun collapse(animationSpec: AnimationSpec) { - onAnchorChanged(collapsedAnchor) - coroutineScope.launch { - animatable.animateTo(collapsedBound, animationSpec) - } - } - - fun expand(animationSpec: AnimationSpec) { - onAnchorChanged(expandedAnchor) - coroutineScope.launch { - animatable.animateTo(animatable.upperBound!!, animationSpec) - } - } - - private fun collapse() { - collapse(SpringSpec()) - } - - private fun expand() { - expand(SpringSpec()) - } - - fun collapseSoft() { - collapse(tween(300)) - } - - fun expandSoft() { - expand(tween(300)) - } - - fun dismiss() { - onAnchorChanged(dismissedAnchor) - coroutineScope.launch { - animatable.animateTo(animatable.lowerBound!!) - } - } - - fun snapTo(value: Dp) { - coroutineScope.launch { - animatable.snapTo(value) - } - } - - fun performFling(velocity: Float, onDismiss: (() -> Unit)?) { - if (velocity > 250) { - expand() - } else if (velocity < -250) { - if (value < collapsedBound && onDismiss != null) { - dismiss() - onDismiss.invoke() - } else { - collapse() - } - } else { - val l0 = dismissedBound - val l1 = (collapsedBound - dismissedBound) / 2 - val l2 = (expandedBound - collapsedBound) / 2 - val l3 = expandedBound - - when (value) { - in l0..l1 -> { - if (onDismiss != null) { - dismiss() - onDismiss.invoke() - } else { - collapse() - } - } - in l1..l2 -> collapse() - in l2..l3 -> expand() - else -> Unit - } - } - } - - val preUpPostDownNestedScrollConnection - get() = object : NestedScrollConnection { - var isTopReached = false - - override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { - if (isExpanded && available.y < 0) { - isTopReached = false - } - - return if (isTopReached && available.y < 0 && source == NestedScrollSource.Drag) { - dispatchRawDelta(available.y) - available - } else { - Offset.Zero - } - } - - override fun onPostScroll( - consumed: Offset, - available: Offset, - source: NestedScrollSource - ): Offset { - if (!isTopReached) { - isTopReached = consumed.y == 0f && available.y > 0 - } - - return if (isTopReached && source == NestedScrollSource.Drag) { - dispatchRawDelta(available.y) - available - } else { - Offset.Zero - } - } - - override suspend fun onPreFling(available: Velocity): Velocity { - return if (isTopReached) { - val velocity = -available.y - performFling(velocity, null) - - available - } else { - Velocity.Zero - } - } - - override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { - isTopReached = false - return Velocity.Zero - } - } -} - -const val expandedAnchor = 2 -const val collapsedAnchor = 1 -const val dismissedAnchor = 0 - -@Composable -fun rememberBottomSheetState( - dismissedBound: Dp, - expandedBound: Dp, - collapsedBound: Dp = dismissedBound, - initialAnchor: Int = dismissedAnchor -): BottomSheetState { - val density = LocalDensity.current - val coroutineScope = rememberCoroutineScope() - - var previousAnchor by rememberSaveable { - mutableStateOf(initialAnchor) - } - - return remember(dismissedBound, expandedBound, collapsedBound, coroutineScope) { - val initialValue = when (previousAnchor) { - expandedAnchor -> expandedBound - collapsedAnchor -> collapsedBound - dismissedAnchor -> dismissedBound - else -> error("Unknown BottomSheet anchor") - } - - val animatable = Animatable(initialValue, Dp.VectorConverter).also { - it.updateBounds(dismissedBound.coerceAtMost(expandedBound), expandedBound) - } - - BottomSheetState( - draggableState = DraggableState { delta -> - coroutineScope.launch { - animatable.snapTo(animatable.value - with(density) { delta.toDp() }) - } - }, - onAnchorChanged = { previousAnchor = it }, - coroutineScope = coroutineScope, - animatable = animatable, - collapsedBound = collapsedBound - ) - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/Menu.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/Menu.kt deleted file mode 100644 index 87e6ecb..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/Menu.kt +++ /dev/null @@ -1,75 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.components - -import androidx.activity.compose.BackHandler -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.pointerInput - -val LocalMenuState = staticCompositionLocalOf { MenuState() } - -@Stable -class MenuState { - var isDisplayed by mutableStateOf(false) - private set - - var content by mutableStateOf<@Composable () -> Unit>({}) - private set - - fun display(content: @Composable () -> Unit) { - this.content = content - isDisplayed = true - } - - fun hide() { - isDisplayed = false - } -} - -@Composable -fun BottomSheetMenu( - state: MenuState, - modifier: Modifier = Modifier -) { - AnimatedVisibility( - visible = state.isDisplayed, - enter = fadeIn(), - exit = fadeOut() - ) { - BackHandler(onBack = state::hide) - - Spacer( - modifier = Modifier - .pointerInput(Unit) { - detectTapGestures { - state.hide() - } - } - .background(Color.Black.copy(alpha = 0.5f)) - .fillMaxSize() - ) - } - - AnimatedVisibility( - visible = state.isDisplayed, - enter = slideInVertically { it }, - exit = slideOutVertically { it }, - modifier = modifier - ) { - state.content() - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/MusicBars.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/MusicBars.kt deleted file mode 100644 index 47e40d7..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/MusicBars.kt +++ /dev/null @@ -1,149 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.components - -import androidx.compose.animation.core.Animatable -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.width -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.launch - -@Composable -fun MusicBars( - color: Color, - modifier: Modifier = Modifier, - barWidth: Dp = 4.dp, - cornerRadius: Dp = 16.dp -) { - val animatablesWithSteps = remember { - listOf( - Animatable(0f) to listOf( - 0.2f, - 0.8f, - 0.1f, - 0.1f, - 0.3f, - 0.1f, - 0.2f, - 0.8f, - 0.7f, - 0.2f, - 0.4f, - 0.9f, - 0.7f, - 0.6f, - 0.1f, - 0.3f, - 0.1f, - 0.4f, - 0.1f, - 0.8f, - 0.7f, - 0.9f, - 0.5f, - 0.6f, - 0.3f, - 0.1f - ), - Animatable(0f) to listOf( - 0.2f, - 0.5f, - 1.0f, - 0.5f, - 0.3f, - 0.1f, - 0.2f, - 0.3f, - 0.5f, - 0.1f, - 0.6f, - 0.5f, - 0.3f, - 0.7f, - 0.8f, - 0.9f, - 0.3f, - 0.1f, - 0.5f, - 0.3f, - 0.6f, - 1.0f, - 0.6f, - 0.7f, - 0.4f, - 0.1f - ), - Animatable(0f) to listOf( - 0.6f, - 0.5f, - 1.0f, - 0.6f, - 0.5f, - 1.0f, - 0.6f, - 0.5f, - 1.0f, - 0.5f, - 0.6f, - 0.7f, - 0.2f, - 0.3f, - 0.1f, - 0.5f, - 0.4f, - 0.6f, - 0.7f, - 0.1f, - 0.4f, - 0.3f, - 0.1f, - 0.4f, - 0.3f, - 0.7f - ) - ) - } - - LaunchedEffect(Unit) { - animatablesWithSteps.forEach { (animatable, steps) -> - launch { - while (true) { - steps.forEach { step -> - animatable.animateTo(step) - } - } - } - } - } - - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.Bottom, - modifier = modifier - ) { - animatablesWithSteps.forEach { (animatable) -> - Canvas( - modifier = Modifier - .fillMaxHeight() - .width(barWidth) - ) { - drawRoundRect( - color = color, - topLeft = Offset(x = 0f, y = size.height * (1 - animatable.value)), - size = size.copy(height = animatable.value * size.height), - cornerRadius = CornerRadius(cornerRadius.toPx()) - ) - } - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/SeekBar.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/SeekBar.kt deleted file mode 100644 index 9025a85..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/SeekBar.kt +++ /dev/null @@ -1,143 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.components - -import androidx.compose.animation.core.MutableTransitionState -import androidx.compose.animation.core.animateDp -import androidx.compose.animation.core.updateTransition -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectHorizontalDragGestures -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import kotlin.math.roundToLong - -@Composable -fun SeekBar( - value: Long, - minimumValue: Long, - maximumValue: Long, - onDragStart: (Long) -> Unit, - onDrag: (Long) -> Unit, - onDragEnd: () -> Unit, - color: Color, - backgroundColor: Color, - modifier: Modifier = Modifier, - barHeight: Dp = 3.dp, - scrubberColor: Color = color, - scrubberRadius: Dp = 6.dp, - shape: Shape = RectangleShape, - drawSteps: Boolean = false, -) { - val isDragging = remember { - MutableTransitionState(false) - } - - val transition = updateTransition(transitionState = isDragging, label = null) - - val currentBarHeight by transition.animateDp(label = "") { if (it) scrubberRadius else barHeight } - val currentScrubberRadius by transition.animateDp(label = "") { if (it) 0.dp else scrubberRadius } - - Box( - modifier = modifier - .pointerInput(minimumValue, maximumValue) { - if (maximumValue < minimumValue) return@pointerInput - - var acc = 0f - - detectHorizontalDragGestures( - onDragStart = { - isDragging.targetState = true - }, - onHorizontalDrag = { _, delta -> - acc += delta / size.width * (maximumValue - minimumValue) - - if (acc !in -1f..1f) { - onDrag(acc.toLong()) - acc -= acc.toLong() - } - }, - onDragEnd = { - isDragging.targetState = false - acc = 0f - onDragEnd() - }, - onDragCancel = { - isDragging.targetState = false - acc = 0f - onDragEnd() - } - ) - } - .pointerInput(minimumValue, maximumValue) { - if (maximumValue < minimumValue) return@pointerInput - - detectTapGestures( - onPress = { offset -> - onDragStart((offset.x / size.width * (maximumValue - minimumValue) + minimumValue).roundToLong()) - }, - onTap = { - onDragEnd() - } - ) - } - .padding(horizontal = scrubberRadius) - .drawWithContent { - drawContent() - - val scrubberPosition = if (maximumValue < minimumValue) { - 0f - } else { - (value.toFloat() - minimumValue) / (maximumValue - minimumValue) * size.width - } - - drawCircle( - color = scrubberColor, - radius = currentScrubberRadius.toPx(), - center = center.copy(x = scrubberPosition) - ) - - if (drawSteps) { - for (i in value + 1..maximumValue) { - val stepPosition = - (i.toFloat() - minimumValue) / (maximumValue - minimumValue) * size.width - drawCircle( - color = scrubberColor, - radius = scrubberRadius.toPx() / 2, - center = center.copy(x = stepPosition), - ) - } - } - } - .height(scrubberRadius) - ) { - Spacer( - modifier = Modifier - .height(currentBarHeight) - .fillMaxWidth() - .background(color = backgroundColor, shape = shape) - .align(Alignment.Center) - ) - - Spacer( - modifier = Modifier - .height(currentBarHeight) - .fillMaxWidth((value.toFloat() - minimumValue) / (maximumValue - minimumValue)) - .background(color = color, shape = shape) - .align(Alignment.CenterStart) - ) - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Dialog.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Dialog.kt deleted file mode 100644 index 811fd51..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Dialog.kt +++ /dev/null @@ -1,334 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.components.themed - -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicText -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.center -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shadow -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.text.TextRange -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.utils.center -import it.vfsfitvnm.vimusic.utils.drawCircle -import it.vfsfitvnm.vimusic.utils.medium -import it.vfsfitvnm.vimusic.utils.secondary -import it.vfsfitvnm.vimusic.utils.semiBold -import kotlinx.coroutines.delay - -@Composable -fun TextFieldDialog( - hintText: String, - onDismiss: () -> Unit, - onDone: (String) -> Unit, - modifier: Modifier = Modifier, - cancelText: String = "Cancel", - doneText: String = "Done", - initialTextInput: String = "", - singleLine: Boolean = true, - maxLines: Int = 1, - onCancel: () -> Unit = onDismiss, - isTextInputValid: (String) -> Boolean = { it.isNotEmpty() } -) { - val focusRequester = remember { - FocusRequester() - } - val (colorPalette, typography) = LocalAppearance.current - - var textFieldValue by rememberSaveable(initialTextInput, stateSaver = TextFieldValue.Saver) { - mutableStateOf( - TextFieldValue( - text = initialTextInput, - selection = TextRange(initialTextInput.length) - ) - ) - } - - DefaultDialog( - onDismiss = onDismiss, - modifier = modifier - ) { - BasicTextField( - value = textFieldValue, - onValueChange = { textFieldValue = it }, - textStyle = typography.xs.semiBold.center, - singleLine = singleLine, - maxLines = maxLines, - keyboardOptions = KeyboardOptions(imeAction = if (singleLine) ImeAction.Done else ImeAction.None), - keyboardActions = KeyboardActions( - onDone = { - if (isTextInputValid(textFieldValue.text)) { - onDismiss() - onDone(textFieldValue.text) - } - } - ), - cursorBrush = SolidColor(colorPalette.text), - decorationBox = { innerTextField -> - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .weight(1f) - ) { - androidx.compose.animation.AnimatedVisibility( - visible = textFieldValue.text.isEmpty(), - enter = fadeIn(tween(100)), - exit = fadeOut(tween(100)), - ) { - BasicText( - text = hintText, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = typography.xs.semiBold.secondary, - ) - } - - innerTextField() - } - }, - modifier = Modifier - .padding(all = 16.dp) - .weight(weight = 1f, fill = false) - .focusRequester(focusRequester) - ) - - Row( - horizontalArrangement = Arrangement.SpaceEvenly, - modifier = Modifier - .fillMaxWidth() - ) { - DialogTextButton( - text = cancelText, - onClick = onCancel - ) - - DialogTextButton( - primary = true, - text = doneText, - onClick = { - if (isTextInputValid(textFieldValue.text)) { - onDismiss() - onDone(textFieldValue.text) - } - } - ) - } - } - - LaunchedEffect(Unit) { - delay(300) - focusRequester.requestFocus() - } -} - -@Composable -fun ConfirmationDialog( - text: String, - onDismiss: () -> Unit, - onConfirm: () -> Unit, - modifier: Modifier = Modifier, - cancelText: String = "Cancel", - confirmText: String = "Confirm", - onCancel: () -> Unit = onDismiss -) { - val (_, typography) = LocalAppearance.current - - DefaultDialog( - onDismiss = onDismiss, - modifier = modifier - ) { - BasicText( - text = text, - style = typography.xs.medium.center, - modifier = Modifier - .padding(all = 16.dp) - ) - - Row( - horizontalArrangement = Arrangement.SpaceEvenly, - modifier = Modifier - .fillMaxWidth() - ) { - DialogTextButton( - text = cancelText, - onClick = onCancel - ) - - DialogTextButton( - text = confirmText, - primary = true, - onClick = { - onConfirm() - onDismiss() - } - ) - } - } -} - -@OptIn(ExperimentalComposeUiApi::class) -@Composable -inline fun DefaultDialog( - noinline onDismiss: () -> Unit, - modifier: Modifier = Modifier, - horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, - crossinline content: @Composable ColumnScope.() -> Unit -) { - val (colorPalette) = LocalAppearance.current - - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - Column( - horizontalAlignment = horizontalAlignment, - modifier = modifier - .padding(all = 48.dp) - .background( - color = colorPalette.background1, - shape = RoundedCornerShape(8.dp) - ) - .padding(horizontal = 24.dp, vertical = 16.dp), - content = content - ) - } -} - -@Composable -inline fun ValueSelectorDialog( - noinline onDismiss: () -> Unit, - title: String, - selectedValue: T, - values: List, - crossinline onValueSelected: (T) -> Unit, - modifier: Modifier = Modifier, - crossinline valueText: (T) -> String = { it.toString() } -) { - val (colorPalette, typography) = LocalAppearance.current - - Dialog(onDismissRequest = onDismiss) { - Column( - modifier = modifier - .padding(all = 48.dp) - .background(color = colorPalette.background1, shape = RoundedCornerShape(8.dp)) - .padding(vertical = 16.dp), - ) { - BasicText( - text = title, - style = typography.s.semiBold, - modifier = Modifier - .padding(vertical = 8.dp, horizontal = 24.dp) - ) - - Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - ) { - values.forEach { value -> - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier - .clickable( - onClick = { - onDismiss() - onValueSelected(value) - } - ) - .padding(vertical = 12.dp, horizontal = 24.dp) - .fillMaxWidth() - ) { - if (selectedValue == value) { - Canvas( - modifier = Modifier - .size(18.dp) - .background( - color = colorPalette.accent, - shape = CircleShape - ) - ) { - drawCircle( - color = colorPalette.onAccent, - radius = 4.dp.toPx(), - center = size.center, - shadow = Shadow( - color = Color.Black.copy(alpha = 0.4f), - blurRadius = 4.dp.toPx(), - offset = Offset(x = 0f, y = 1.dp.toPx()) - ) - ) - } - } else { - Spacer( - modifier = Modifier - .size(18.dp) - .border( - width = 1.dp, - color = colorPalette.textDisabled, - shape = CircleShape - ) - ) - } - - BasicText( - text = valueText(value), - style = typography.xs.medium - ) - } - } - } - - Box( - modifier = Modifier - .align(Alignment.End) - .padding(end = 24.dp) - ) { - DialogTextButton( - text = "Cancel", - onClick = onDismiss, - modifier = Modifier - ) - } - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/FloatingActionsContainer.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/FloatingActionsContainer.kt deleted file mode 100644 index 8b0b57c..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/FloatingActionsContainer.kt +++ /dev/null @@ -1,169 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.components.themed - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.core.MutableTransitionState -import androidx.compose.animation.core.tween -import androidx.compose.animation.core.updateTransition -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.ScrollState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.grid.LazyGridState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.utils.ScrollingInfo -import it.vfsfitvnm.vimusic.utils.scrollingInfo -import it.vfsfitvnm.vimusic.utils.smoothScrollToTop -import kotlinx.coroutines.launch - -@ExperimentalAnimationApi -@Composable -fun BoxScope.FloatingActionsContainerWithScrollToTop( - lazyGridState: LazyGridState, - modifier: Modifier = Modifier, - visible: Boolean = true, - iconId: Int? = null, - onClick: (() -> Unit)? = null, - windowInsets: WindowInsets = LocalPlayerAwareWindowInsets.current -) { - val transitionState = remember { - MutableTransitionState(ScrollingInfo()) - }.apply { targetState = if (visible) lazyGridState.scrollingInfo() else null } - - FloatingActions( - transitionState = transitionState, - onScrollToTop = lazyGridState::smoothScrollToTop, - iconId = iconId, - onClick = onClick, - windowInsets = windowInsets, - modifier = modifier - ) -} - -@ExperimentalAnimationApi -@Composable -fun BoxScope.FloatingActionsContainerWithScrollToTop( - lazyListState: LazyListState, - modifier: Modifier = Modifier, - visible: Boolean = true, - iconId: Int? = null, - onClick: (() -> Unit)? = null, - windowInsets: WindowInsets = LocalPlayerAwareWindowInsets.current -) { - val transitionState = remember { - MutableTransitionState(ScrollingInfo()) - }.apply { targetState = if (visible) lazyListState.scrollingInfo() else null } - - FloatingActions( - transitionState = transitionState, - onScrollToTop = lazyListState::smoothScrollToTop, - iconId = iconId, - onClick = onClick, - windowInsets = windowInsets, - modifier = modifier - ) -} - -@ExperimentalAnimationApi -@Composable -fun BoxScope.FloatingActionsContainerWithScrollToTop( - scrollState: ScrollState, - modifier: Modifier = Modifier, - visible: Boolean = true, - iconId: Int? = null, - onClick: (() -> Unit)? = null, - windowInsets: WindowInsets = LocalPlayerAwareWindowInsets.current -) { - val transitionState = remember { - MutableTransitionState(ScrollingInfo()) - }.apply { targetState = if (visible) scrollState.scrollingInfo() else null } - - FloatingActions( - transitionState = transitionState, - iconId = iconId, - onClick = onClick, - windowInsets = windowInsets, - modifier = modifier - ) -} - -@ExperimentalAnimationApi -@Composable -fun BoxScope.FloatingActions( - transitionState: MutableTransitionState, - windowInsets: WindowInsets, - modifier: Modifier = Modifier, - onScrollToTop: (suspend () -> Unit)? = null, - iconId: Int? = null, - onClick: (() -> Unit)? = null -) { - val transition = updateTransition(transitionState, "") - - val bottomPaddingValues = windowInsets.only(WindowInsetsSides.Bottom).asPaddingValues() - - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.Bottom, - modifier = modifier - .align(Alignment.BottomEnd) - .padding(end = 16.dp) - .padding(windowInsets.only(WindowInsetsSides.End).asPaddingValues()) - ) { - onScrollToTop?.let { - transition.AnimatedVisibility( - visible = { it?.isScrollingDown == false && it.isFar }, - enter = slideInVertically(tween(500, if (iconId == null) 0 else 100)) { it }, - exit = slideOutVertically(tween(500, 0)) { it }, - ) { - val coroutineScope = rememberCoroutineScope() - - SecondaryButton( - onClick = { - coroutineScope.launch { - onScrollToTop() - } - }, - enabled = transition.targetState?.isScrollingDown == false && transition.targetState?.isFar == true, - iconId = R.drawable.chevron_up, - modifier = Modifier - .padding(bottom = 16.dp) - .padding(bottomPaddingValues) - ) - } - } - - iconId?.let { - onClick?.let { - transition.AnimatedVisibility( - visible = { it?.isScrollingDown == false }, - enter = slideInVertically(tween(500, 0)) { it }, - exit = slideOutVertically(tween(500, 100)) { it }, - ) { - PrimaryButton( - iconId = iconId, - onClick = onClick, - enabled = transition.targetState?.isScrollingDown == false, - modifier = Modifier - .padding(bottom = 16.dp) - .padding(bottomPaddingValues) - ) - } - } - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Header.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Header.kt deleted file mode 100644 index b848979..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Header.kt +++ /dev/null @@ -1,99 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.components.themed - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.BasicText -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import it.vfsfitvnm.vimusic.ui.styling.Dimensions -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.shimmer -import it.vfsfitvnm.vimusic.utils.medium -import kotlin.random.Random - -@Composable -fun Header( - title: String, - modifier: Modifier = Modifier, - actionsContent: @Composable RowScope.() -> Unit = {}, -) { - val typography = LocalAppearance.current.typography - - Header( - modifier = modifier, - titleContent = { - BasicText( - text = title, - style = typography.xxl.medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - }, - actionsContent = actionsContent - ) -} - -@Composable -fun Header( - modifier: Modifier = Modifier, - titleContent: @Composable () -> Unit, - actionsContent: @Composable RowScope.() -> Unit, -) { - Box( - contentAlignment = Alignment.CenterEnd, - modifier = modifier - .padding(horizontal = 16.dp) - .height(Dimensions.headerHeight) - .fillMaxWidth() - ) { - titleContent() - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier - .align(Alignment.BottomEnd) - .heightIn(min = 48.dp), - content = actionsContent, - ) - } -} - -@Composable -fun HeaderPlaceholder( - modifier: Modifier = Modifier, -) { - val (colorPalette, typography) = LocalAppearance.current - - Box( - contentAlignment = Alignment.CenterEnd, - modifier = modifier - .padding(horizontal = 16.dp) - .height(Dimensions.headerHeight) - .fillMaxWidth() - ) { - Box( - modifier = Modifier - .background(colorPalette.shimmer) - .fillMaxWidth(remember { 0.25f + Random.nextFloat() * 0.5f }) - ) { - BasicText( - text = "", - style = typography.xxl.medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/IconButton.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/IconButton.kt deleted file mode 100644 index a5558eb..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/IconButton.kt +++ /dev/null @@ -1,62 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.components.themed - -import androidx.annotation.DrawableRes -import androidx.compose.foundation.Image -import androidx.compose.foundation.Indication -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material.ripple.rememberRipple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp - -@Composable -fun HeaderIconButton( - onClick: () -> Unit, - @DrawableRes icon: Int, - color: Color, - modifier: Modifier = Modifier, - enabled: Boolean = true, - indication: Indication? = null -) { - IconButton( - icon = icon, - color = color, - onClick = onClick, - enabled = enabled, - indication = indication, - modifier = modifier - .padding(all = 4.dp) - .size(18.dp) - ) -} - -@Composable -fun IconButton( - onClick: () -> Unit, - @DrawableRes icon: Int, - color: Color, - modifier: Modifier = Modifier, - enabled: Boolean = true, - indication: Indication? = null -) { - Image( - painter = painterResource(icon), - contentDescription = null, - colorFilter = ColorFilter.tint(color), - modifier = Modifier - .clickable( - indication = indication ?: rememberRipple(bounded = false), - interactionSource = remember { MutableInteractionSource() }, - enabled = enabled, - onClick = onClick - ) - .then(modifier) - ) -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/LayoutWithAdaptiveThumbnail.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/LayoutWithAdaptiveThumbnail.kt deleted file mode 100644 index 8c362de..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/LayoutWithAdaptiveThumbnail.kt +++ /dev/null @@ -1,70 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.components.themed - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.unit.dp -import coil.compose.AsyncImage -import com.valentinilk.shimmer.shimmer -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.px -import it.vfsfitvnm.vimusic.ui.styling.shimmer -import it.vfsfitvnm.vimusic.utils.isLandscape -import it.vfsfitvnm.vimusic.utils.thumbnail - -@Composable -inline fun LayoutWithAdaptiveThumbnail( - thumbnailContent: @Composable () -> Unit, - content: @Composable () -> Unit -) { - val isLandscape = isLandscape - - if (isLandscape) { - Row(verticalAlignment = Alignment.CenterVertically) { - thumbnailContent() - content() - } - } else { - content() - } -} - -fun adaptiveThumbnailContent( - isLoading: Boolean, - url: String?, - shape: Shape? = null -): @Composable () -> Unit = { - val (colorPalette, _, thumbnailShape) = LocalAppearance.current - - BoxWithConstraints(contentAlignment = Alignment.Center) { - val thumbnailSizeDp = if (isLandscape) (maxHeight - 128.dp) else (maxWidth - 64.dp) - val thumbnailSizePx = thumbnailSizeDp.px - - val modifier = Modifier - .padding(all = 16.dp) - .clip(shape ?: thumbnailShape) - .size(thumbnailSizeDp) - - if (isLoading) { - Spacer( - modifier = modifier - .shimmer() - .background(colorPalette.shimmer) - ) - } else { - AsyncImage( - model = url?.thumbnail(thumbnailSizePx), - contentDescription = null, - modifier = modifier - ) - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/MediaItemMenu.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/MediaItemMenu.kt deleted file mode 100644 index 055d690..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/MediaItemMenu.kt +++ /dev/null @@ -1,735 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.components.themed - -import android.content.Intent -import androidx.activity.compose.BackHandler -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedContentScope -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.tween -import androidx.compose.animation.with -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredHeight -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicText -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.onPlaced -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import androidx.media3.common.MediaItem -import it.vfsfitvnm.innertube.models.NavigationEndpoint -import it.vfsfitvnm.vimusic.Database -import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.enums.PlaylistSortBy -import it.vfsfitvnm.vimusic.enums.SortOrder -import it.vfsfitvnm.vimusic.models.Info -import it.vfsfitvnm.vimusic.models.Playlist -import it.vfsfitvnm.vimusic.models.Song -import it.vfsfitvnm.vimusic.models.SongPlaylistMap -import it.vfsfitvnm.vimusic.query -import it.vfsfitvnm.vimusic.transaction -import it.vfsfitvnm.vimusic.ui.items.SongItem -import it.vfsfitvnm.vimusic.ui.screens.albumRoute -import it.vfsfitvnm.vimusic.ui.screens.artistRoute -import it.vfsfitvnm.vimusic.ui.styling.Dimensions -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.favoritesIcon -import it.vfsfitvnm.vimusic.ui.styling.px -import it.vfsfitvnm.vimusic.utils.addNext -import it.vfsfitvnm.vimusic.utils.asMediaItem -import it.vfsfitvnm.vimusic.utils.enqueue -import it.vfsfitvnm.vimusic.utils.forcePlay -import it.vfsfitvnm.vimusic.utils.formatAsDuration -import it.vfsfitvnm.vimusic.utils.medium -import it.vfsfitvnm.vimusic.utils.semiBold -import it.vfsfitvnm.vimusic.utils.thumbnail -import kotlin.system.measureTimeMillis -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.withContext - -@ExperimentalAnimationApi -@Composable -fun InHistoryMediaItemMenu( - onDismiss: () -> Unit, - song: Song, - modifier: Modifier = Modifier -) { - val binder = LocalPlayerServiceBinder.current - - var isHiding by remember { - mutableStateOf(false) - } - - if (isHiding) { - ConfirmationDialog( - text = "本当にこの曲を非表示にしますか?再生時間とキャッシュが消去されます。\nこの操作は取り消すことができません。", - onDismiss = { isHiding = false }, - onConfirm = { - onDismiss() - query { - // Not sure we can to this here - binder?.cache?.removeResource(song.id) - Database.incrementTotalPlayTimeMs(song.id, -song.totalPlayTimeMs) - } - } - ) - } - - NonQueuedMediaItemMenu( - mediaItem = song.asMediaItem, - onDismiss = onDismiss, - onHideFromDatabase = { isHiding = true }, - modifier = modifier - ) -} - -@ExperimentalAnimationApi -@Composable -fun InPlaylistMediaItemMenu( - onDismiss: () -> Unit, - playlistId: Long, - positionInPlaylist: Int, - song: Song, - modifier: Modifier = Modifier -) { - NonQueuedMediaItemMenu( - mediaItem = song.asMediaItem, - onDismiss = onDismiss, - onRemoveFromPlaylist = { - transaction { - Database.move(playlistId, positionInPlaylist, Int.MAX_VALUE) - Database.delete(SongPlaylistMap(song.id, playlistId, Int.MAX_VALUE)) - } - }, - modifier = modifier - ) -} - -@ExperimentalAnimationApi -@Composable -fun NonQueuedMediaItemMenu( - onDismiss: () -> Unit, - mediaItem: MediaItem, - modifier: Modifier = Modifier, - onRemoveFromPlaylist: (() -> Unit)? = null, - onHideFromDatabase: (() -> Unit)? = null, - onRemoveFromQuickPicks: (() -> Unit)? = null, -) { - val binder = LocalPlayerServiceBinder.current - - BaseMediaItemMenu( - mediaItem = mediaItem, - onDismiss = onDismiss, - onStartRadio = { - binder?.stopRadio() - binder?.player?.forcePlay(mediaItem) - binder?.setupRadio( - NavigationEndpoint.Endpoint.Watch( - videoId = mediaItem.mediaId, - playlistId = mediaItem.mediaMetadata.extras?.getString("playlistId") - ) - ) - }, - onPlayNext = { binder?.player?.addNext(mediaItem) }, - onEnqueue = { binder?.player?.enqueue(mediaItem) }, - onRemoveFromPlaylist = onRemoveFromPlaylist, - onHideFromDatabase = onHideFromDatabase, - onRemoveFromQuickPicks = onRemoveFromQuickPicks, - modifier = modifier - ) -} - -@ExperimentalAnimationApi -@Composable -fun QueuedMediaItemMenu( - onDismiss: () -> Unit, - mediaItem: MediaItem, - indexInQueue: Int?, - modifier: Modifier = Modifier -) { - val binder = LocalPlayerServiceBinder.current - - BaseMediaItemMenu( - mediaItem = mediaItem, - onDismiss = onDismiss, - onRemoveFromQueue = if (indexInQueue != null) ({ - binder?.player?.removeMediaItem(indexInQueue) - }) else null, - modifier = modifier - ) -} - -@ExperimentalAnimationApi -@Composable -fun BaseMediaItemMenu( - onDismiss: () -> Unit, - mediaItem: MediaItem, - modifier: Modifier = Modifier, - onGoToEqualizer: (() -> Unit)? = null, - onShowSleepTimer: (() -> Unit)? = null, - onStartRadio: (() -> Unit)? = null, - onPlayNext: (() -> Unit)? = null, - onEnqueue: (() -> Unit)? = null, - onRemoveFromQueue: (() -> Unit)? = null, - onRemoveFromPlaylist: (() -> Unit)? = null, - onHideFromDatabase: (() -> Unit)? = null, - onRemoveFromQuickPicks: (() -> Unit)? = null, -) { - val context = LocalContext.current - - MediaItemMenu( - mediaItem = mediaItem, - onDismiss = onDismiss, - onGoToEqualizer = onGoToEqualizer, - onShowSleepTimer = onShowSleepTimer, - onStartRadio = onStartRadio, - onPlayNext = onPlayNext, - onEnqueue = onEnqueue, - onAddToPlaylist = { playlist, position -> - transaction { - Database.insert(mediaItem) - Database.insert( - SongPlaylistMap( - songId = mediaItem.mediaId, - playlistId = Database.insert(playlist).takeIf { it != -1L } ?: playlist.id, - position = position - ) - ) - } - }, - onHideFromDatabase = onHideFromDatabase, - onRemoveFromPlaylist = onRemoveFromPlaylist, - onRemoveFromQueue = onRemoveFromQueue, - onGoToAlbum = albumRoute::global, - onGoToArtist = artistRoute::global, - onShare = { - val sendIntent = Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra( - Intent.EXTRA_TEXT, - "https://music.youtube.com/watch?v=${mediaItem.mediaId}" - ) - } - - context.startActivity(Intent.createChooser(sendIntent, null)) - }, - onRemoveFromQuickPicks = onRemoveFromQuickPicks, - modifier = modifier - ) -} - -@ExperimentalAnimationApi -@Composable -fun MediaItemMenu( - onDismiss: () -> Unit, - mediaItem: MediaItem, - modifier: Modifier = Modifier, - onGoToEqualizer: (() -> Unit)? = null, - onShowSleepTimer: (() -> Unit)? = null, - onStartRadio: (() -> Unit)? = null, - onPlayNext: (() -> Unit)? = null, - onEnqueue: (() -> Unit)? = null, - onHideFromDatabase: (() -> Unit)? = null, - onRemoveFromQueue: (() -> Unit)? = null, - onRemoveFromPlaylist: (() -> Unit)? = null, - onAddToPlaylist: ((Playlist, Int) -> Unit)? = null, - onGoToAlbum: ((String) -> Unit)? = null, - onGoToArtist: ((String) -> Unit)? = null, - onRemoveFromQuickPicks: (() -> Unit)? = null, - onShare: () -> Unit -) { - val (colorPalette) = LocalAppearance.current - val density = LocalDensity.current - - var isViewingPlaylists by remember { - mutableStateOf(false) - } - - var height by remember { - mutableStateOf(0.dp) - } - - var albumInfo by remember { - mutableStateOf(mediaItem.mediaMetadata.extras?.getString("albumId")?.let { albumId -> - Info(albumId, null) - }) - } - - var artistsInfo by remember { - mutableStateOf( - mediaItem.mediaMetadata.extras?.getStringArrayList("artistNames")?.let { artistNames -> - mediaItem.mediaMetadata.extras?.getStringArrayList("artistIds")?.let { artistIds -> - artistNames.zip(artistIds).map { (authorName, authorId) -> - Info(authorId, authorName) - } - } - } - ) - } - - var likedAt by remember { - mutableStateOf(null) - } - - LaunchedEffect(Unit) { - withContext(Dispatchers.IO) { - if (albumInfo == null) albumInfo = Database.songAlbumInfo(mediaItem.mediaId) - if (artistsInfo == null) artistsInfo = Database.songArtistInfo(mediaItem.mediaId) - - Database.likedAt(mediaItem.mediaId).collect { likedAt = it } - } - } - - AnimatedContent( - targetState = isViewingPlaylists, - transitionSpec = { - val animationSpec = tween(400) - val slideDirection = - if (targetState) AnimatedContentScope.SlideDirection.Left else AnimatedContentScope.SlideDirection.Right - - slideIntoContainer(slideDirection, animationSpec) with - slideOutOfContainer(slideDirection, animationSpec) - } - ) { currentIsViewingPlaylists -> - if (currentIsViewingPlaylists) { - val playlistPreviews by remember { - Database.playlistPreviews(PlaylistSortBy.DateAdded, SortOrder.Descending) - }.collectAsState(initial = emptyList(), context = Dispatchers.IO) - - var isCreatingNewPlaylist by rememberSaveable { - mutableStateOf(false) - } - - if (isCreatingNewPlaylist && onAddToPlaylist != null) { - TextFieldDialog( - hintText = "プレイリスト名を入力してください", - onDismiss = { isCreatingNewPlaylist = false }, - onDone = { text -> - onDismiss() - onAddToPlaylist(Playlist(name = text), 0) - } - ) - } - - BackHandler { - isViewingPlaylists = false - } - - Menu( - modifier = modifier - .requiredHeight(height) - ) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .padding(horizontal = 16.dp, vertical = 8.dp) - .fillMaxWidth() - ) { - IconButton( - onClick = { isViewingPlaylists = false }, - icon = R.drawable.chevron_back, - color = colorPalette.textSecondary, - modifier = Modifier - .padding(all = 4.dp) - .size(20.dp) - ) - - if (onAddToPlaylist != null) { - SecondaryTextButton( - text = "新しいプレイリスト", - onClick = { isCreatingNewPlaylist = true }, - alternative = true - ) - } - } - - onAddToPlaylist?.let { onAddToPlaylist -> - playlistPreviews.forEach { playlistPreview -> - MenuEntry( - icon = R.drawable.playlist, - text = playlistPreview.playlist.name, - secondaryText = "${playlistPreview.songCount} songs", - onClick = { - onDismiss() - onAddToPlaylist(playlistPreview.playlist, playlistPreview.songCount) - } - ) - } - } - } - } else { - Menu( - modifier = modifier - .onPlaced { height = with(density) { it.size.height.toDp() } } - ) { - val thumbnailSizeDp = Dimensions.thumbnails.song - val thumbnailSizePx = thumbnailSizeDp.px - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .padding(end = 12.dp) - ) { - SongItem( - thumbnailUrl = mediaItem.mediaMetadata.artworkUri.thumbnail(thumbnailSizePx) - ?.toString(), - title = mediaItem.mediaMetadata.title.toString(), - authors = mediaItem.mediaMetadata.artist.toString(), - duration = null, - thumbnailSizeDp = thumbnailSizeDp, - modifier = Modifier - .weight(1f) - ) - - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - IconButton( - icon = if (likedAt == null) R.drawable.heart_outline else R.drawable.heart, - color = colorPalette.favoritesIcon, - onClick = { - query { - if (Database.like( - mediaItem.mediaId, - if (likedAt == null) System.currentTimeMillis() else null - ) == 0 - ) { - Database.insert(mediaItem, Song::toggleLike) - } - } - }, - modifier = Modifier - .padding(all = 4.dp) - .size(18.dp) - ) - - IconButton( - icon = R.drawable.share_social, - color = colorPalette.text, - onClick = onShare, - modifier = Modifier - .padding(all = 4.dp) - .size(17.dp) - ) - } - } - - Spacer( - modifier = Modifier - .height(8.dp) - ) - - Spacer( - modifier = Modifier - .alpha(0.5f) - .align(Alignment.CenterHorizontally) - .background(colorPalette.textDisabled) - .height(1.dp) - .fillMaxWidth(1f) - ) - - Spacer( - modifier = Modifier - .height(8.dp) - ) - - onStartRadio?.let { onStartRadio -> - MenuEntry( - icon = R.drawable.radio, - text = "ラジオを始める", - onClick = { - onDismiss() - onStartRadio() - } - ) - } - - onPlayNext?.let { onPlayNext -> - MenuEntry( - icon = R.drawable.play_skip_forward, - text = "次を再生", - onClick = { - onDismiss() - onPlayNext() - } - ) - } - - onEnqueue?.let { onEnqueue -> - MenuEntry( - icon = R.drawable.enqueue, - text = "キューに入れる", - onClick = { - onDismiss() - onEnqueue() - } - ) - } - - onGoToEqualizer?.let { onGoToEqualizer -> - MenuEntry( - icon = R.drawable.equalizer, - text = "イコライザー", - onClick = { - onDismiss() - onGoToEqualizer() - } - ) - } - - // TODO: find solution to this shit - onShowSleepTimer?.let { - val binder = LocalPlayerServiceBinder.current - val (_, typography) = LocalAppearance.current - - var isShowingSleepTimerDialog by remember { - mutableStateOf(false) - } - - val sleepTimerMillisLeft by (binder?.sleepTimerMillisLeft - ?: flowOf(null)) - .collectAsState(initial = null) - - if (isShowingSleepTimerDialog) { - if (sleepTimerMillisLeft != null) { - ConfirmationDialog( - text = "スリープタイマーを停止しますか?", - cancelText = "いいえ", - confirmText = "停止する", - onDismiss = { isShowingSleepTimerDialog = false }, - onConfirm = { - binder?.cancelSleepTimer() - onDismiss() - } - ) - } else { - DefaultDialog( - onDismiss = { isShowingSleepTimerDialog = false } - ) { - var amount by remember { - mutableStateOf(1) - } - - BasicText( - text = "スリープタイマーを設定", - style = typography.s.semiBold, - modifier = Modifier - .padding(vertical = 8.dp, horizontal = 24.dp) - ) - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy( - space = 16.dp, - alignment = Alignment.CenterHorizontally - ), - modifier = Modifier - .padding(vertical = 16.dp) - ) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .alpha(if (amount <= 1) 0.5f else 1f) - .clip(CircleShape) - .clickable(enabled = amount > 1) { amount-- } - .size(48.dp) - .background(colorPalette.background0) - ) { - BasicText( - text = "-", - style = typography.xs.semiBold - ) - } - - Box(contentAlignment = Alignment.Center) { - BasicText( - text = "88h 88m", - style = typography.s.semiBold, - modifier = Modifier - .alpha(0f) - ) - BasicText( - text = "${amount / 6}h ${(amount % 6) * 10}m", - style = typography.s.semiBold - ) - } - - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .alpha(if (amount >= 60) 0.5f else 1f) - .clip(CircleShape) - .clickable(enabled = amount < 60) { amount++ } - .size(48.dp) - .background(colorPalette.background0) - ) { - BasicText( - text = "+", - style = typography.xs.semiBold - ) - } - } - - Row( - horizontalArrangement = Arrangement.SpaceEvenly, - modifier = Modifier - .fillMaxWidth() - ) { - DialogTextButton( - text = "キャンセル", - onClick = { isShowingSleepTimerDialog = false } - ) - - DialogTextButton( - text = "セット", - enabled = amount > 0, - primary = true, - onClick = { - binder?.startSleepTimer(amount * 10 * 60 * 1000L) - isShowingSleepTimerDialog = false - } - ) - } - } - } - } - - MenuEntry( - icon = R.drawable.alarm, - text = "スリープタイマー", - onClick = { isShowingSleepTimerDialog = true }, - trailingContent = sleepTimerMillisLeft?.let { - { - BasicText( - text = "${formatAsDuration(it)} left", - style = typography.xxs.medium, - modifier = modifier - .background( - color = colorPalette.background0, - shape = RoundedCornerShape(16.dp) - ) - .padding(horizontal = 16.dp, vertical = 8.dp) - .animateContentSize() - ) - } - } - ) - } - - if (onAddToPlaylist != null) { - MenuEntry( - icon = R.drawable.playlist, - text = "プレイリストへ追加", - onClick = { isViewingPlaylists = true }, - trailingContent = { - Image( - painter = painterResource(R.drawable.chevron_forward), - contentDescription = null, - colorFilter = androidx.compose.ui.graphics.ColorFilter.tint( - colorPalette.textSecondary - ), - modifier = Modifier - .size(16.dp) - ) - } - ) - } - - onGoToAlbum?.let { onGoToAlbum -> - albumInfo?.let { (albumId) -> - MenuEntry( - icon = R.drawable.disc, - text = "アルバムへ", - onClick = { - onDismiss() - onGoToAlbum(albumId) - } - ) - } - } - - onGoToArtist?.let { onGoToArtist -> - artistsInfo?.forEach { (authorId, authorName) -> - MenuEntry( - icon = R.drawable.person, - text = "$authorName を見る", - onClick = { - onDismiss() - onGoToArtist(authorId) - } - ) - } - } - - onRemoveFromQueue?.let { onRemoveFromQueue -> - MenuEntry( - icon = R.drawable.trash, - text = "キューを削除", - onClick = { - onDismiss() - onRemoveFromQueue() - } - ) - } - - onRemoveFromPlaylist?.let { onRemoveFromPlaylist -> - MenuEntry( - icon = R.drawable.trash, - text = "プレイリストから削除", - onClick = { - onDismiss() - onRemoveFromPlaylist() - } - ) - } - - onHideFromDatabase?.let { onHideFromDatabase -> - MenuEntry( - icon = R.drawable.trash, - text = "隠す", - onClick = onHideFromDatabase - ) - } - - onRemoveFromQuickPicks?.let { - MenuEntry( - icon = R.drawable.trash, - text = "\"Quick picks\" から隠す", - onClick = { - onDismiss() - onRemoveFromQuickPicks() - } - ) - } - } - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/NavigationRail.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/NavigationRail.kt deleted file mode 100644 index d7fff64..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/NavigationRail.kt +++ /dev/null @@ -1,170 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.components.themed - -import androidx.compose.animation.animateColor -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.updateTransition -import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicText -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.layout -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp -import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets -import it.vfsfitvnm.vimusic.ui.styling.Dimensions -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.utils.center -import it.vfsfitvnm.vimusic.utils.color -import it.vfsfitvnm.vimusic.utils.isLandscape -import it.vfsfitvnm.vimusic.utils.semiBold - -@Composable -inline fun NavigationRail( - topIconButtonId: Int, - noinline onTopIconButtonClick: () -> Unit, - tabIndex: Int, - crossinline onTabIndexChanged: (Int) -> Unit, - content: @Composable ColumnScope.(@Composable (Int, String, Int) -> Unit) -> Unit, - modifier: Modifier = Modifier -) { - val (colorPalette, typography) = LocalAppearance.current - - val isLandscape = isLandscape - - val paddingValues = LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Vertical + WindowInsetsSides.Start).asPaddingValues() - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = modifier - .verticalScroll(rememberScrollState()) - .padding(paddingValues) - ) { - Box( - contentAlignment = Alignment.TopCenter, - modifier = Modifier - .size( - width = if (isLandscape) Dimensions.navigationRailWidthLandscape else Dimensions.navigationRailWidth, - height = Dimensions.headerHeight - ) - ) { - Image( - painter = painterResource(topIconButtonId), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.textSecondary), - modifier = Modifier - .offset( - x = if (isLandscape) 0.dp else Dimensions.navigationRailIconOffset, - y = 48.dp - ) - .clip(CircleShape) - .clickable(onClick = onTopIconButtonClick) - .padding(all = 12.dp) - .size(22.dp) - ) - } - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .width(if (isLandscape) Dimensions.navigationRailWidthLandscape else Dimensions.navigationRailWidth) - ) { - val transition = updateTransition(targetState = tabIndex, label = null) - - content { index, text, icon -> - val dothAlpha by transition.animateFloat(label = "") { - if (it == index) 1f else 0f - } - - val textColor by transition.animateColor(label = "") { - if (it == index) colorPalette.text else colorPalette.textDisabled - } - - val iconContent: @Composable () -> Unit = { - Image( - painter = painterResource(icon), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .vertical(enabled = !isLandscape) - .graphicsLayer { - alpha = dothAlpha - translationX = (1f - dothAlpha) * -48.dp.toPx() - rotationZ = if (isLandscape) 0f else -90f - } - .size(Dimensions.navigationRailIconOffset * 2) - ) - } - - val textContent: @Composable () -> Unit = { - BasicText( - text = text, - style = typography.xs.semiBold.center.color(textColor), - modifier = Modifier - .vertical(enabled = !isLandscape) - .rotate(if (isLandscape) 0f else -90f) - .padding(horizontal = 16.dp) - ) - } - - val contentModifier = Modifier - .clip(RoundedCornerShape(24.dp)) - .clickable(onClick = { onTabIndexChanged(index) }) - - if (isLandscape) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = contentModifier - .padding(vertical = 8.dp) - ) { - iconContent() - textContent() - } - } else { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = contentModifier - .padding(horizontal = 8.dp) - ) { - iconContent() - textContent() - } - } - } - } - } -} - -fun Modifier.vertical(enabled: Boolean = true) = - if (enabled) - layout { measurable, constraints -> - val placeable = measurable.measure(constraints.copy(maxWidth = Int.MAX_VALUE)) - layout(placeable.height, placeable.width) { - placeable.place( - x = -(placeable.width / 2 - placeable.height / 2), - y = -(placeable.height / 2 - placeable.width / 2) - ) - } - } else this diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/AlbumItem.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/AlbumItem.kt deleted file mode 100644 index d6d13fd..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/AlbumItem.kt +++ /dev/null @@ -1,155 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.items - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.text.BasicText -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import coil.compose.AsyncImage -import it.vfsfitvnm.vimusic.models.Album -import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.shimmer -import it.vfsfitvnm.vimusic.utils.secondary -import it.vfsfitvnm.vimusic.utils.semiBold -import it.vfsfitvnm.vimusic.utils.thumbnail -import it.vfsfitvnm.innertube.Innertube - -@Composable -fun AlbumItem( - album: Album, - thumbnailSizePx: Int, - thumbnailSizeDp: Dp, - modifier: Modifier = Modifier, - alternative: Boolean = false -) { - AlbumItem( - thumbnailUrl = album.thumbnailUrl, - title = album.title, - authors = album.authorsText, - year = album.year, - thumbnailSizePx = thumbnailSizePx, - thumbnailSizeDp = thumbnailSizeDp, - alternative = alternative, - modifier = modifier - ) -} - -@Composable -fun AlbumItem( - album: Innertube.AlbumItem, - thumbnailSizePx: Int, - thumbnailSizeDp: Dp, - modifier: Modifier = Modifier, - alternative: Boolean = false -) { - AlbumItem( - thumbnailUrl = album.thumbnail?.url, - title = album.info?.name, - authors = album.authors?.joinToString("") { it.name ?: "" }, - year = album.year, - thumbnailSizePx = thumbnailSizePx, - thumbnailSizeDp = thumbnailSizeDp, - alternative = alternative, - modifier = modifier - ) -} - -@Composable -fun AlbumItem( - thumbnailUrl: String?, - title: String?, - authors: String?, - year: String?, - thumbnailSizePx: Int, - thumbnailSizeDp: Dp, - modifier: Modifier = Modifier, - alternative: Boolean = false -) { - val (_, typography, thumbnailShape) = LocalAppearance.current - - ItemContainer( - alternative = alternative, - thumbnailSizeDp = thumbnailSizeDp, - modifier = modifier - ) { - AsyncImage( - model = thumbnailUrl?.thumbnail(thumbnailSizePx), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .clip(thumbnailShape) - .size(thumbnailSizeDp) - ) - - ItemInfoContainer { - BasicText( - text = title ?: "", - style = typography.xs.semiBold, - maxLines = if (alternative) 1 else 2, - overflow = TextOverflow.Ellipsis, - ) - - if (!alternative) { - authors?.let { - BasicText( - text = authors, - style = typography.xs.semiBold.secondary, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - } - - BasicText( - text = year ?: "", - style = typography.xxs.semiBold.secondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .padding(top = 4.dp) - ) - } - } -} - -@Composable -fun AlbumItemPlaceholder( - thumbnailSizeDp: Dp, - modifier: Modifier = Modifier, - alternative: Boolean = false -) { - val (colorPalette, _, thumbnailShape) = LocalAppearance.current - - ItemContainer( - alternative = alternative, - thumbnailSizeDp = thumbnailSizeDp, - modifier = modifier - ) { - Spacer( - modifier = Modifier - .background(color = colorPalette.shimmer, shape = thumbnailShape) - .size(thumbnailSizeDp) - ) - - ItemInfoContainer { - TextPlaceholder() - - if (!alternative) { - TextPlaceholder() - } - - TextPlaceholder( - modifier = Modifier - .padding(top = 4.dp) - ) - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/ArtistItem.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/ArtistItem.kt deleted file mode 100644 index 732e32e..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/ArtistItem.kt +++ /dev/null @@ -1,145 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.items - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.text.BasicText -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import coil.compose.AsyncImage -import it.vfsfitvnm.vimusic.models.Artist -import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.shimmer -import it.vfsfitvnm.vimusic.utils.secondary -import it.vfsfitvnm.vimusic.utils.semiBold -import it.vfsfitvnm.vimusic.utils.thumbnail -import it.vfsfitvnm.innertube.Innertube - -@Composable -fun ArtistItem( - artist: Artist, - thumbnailSizePx: Int, - thumbnailSizeDp: Dp, - modifier: Modifier = Modifier, - alternative: Boolean = false, -) { - ArtistItem( - thumbnailUrl = artist.thumbnailUrl, - name = artist.name, - subscribersCount = null, - thumbnailSizePx = thumbnailSizePx, - thumbnailSizeDp = thumbnailSizeDp, - modifier = modifier, - alternative = alternative - ) -} - -@Composable -fun ArtistItem( - artist: Innertube.ArtistItem, - thumbnailSizePx: Int, - thumbnailSizeDp: Dp, - modifier: Modifier = Modifier, - alternative: Boolean = false, -) { - ArtistItem( - thumbnailUrl = artist.thumbnail?.url, - name = artist.info?.name, - subscribersCount = artist.subscribersCountText, - thumbnailSizePx = thumbnailSizePx, - thumbnailSizeDp = thumbnailSizeDp, - modifier = modifier, - alternative = alternative - ) -} - -@Composable -fun ArtistItem( - thumbnailUrl: String?, - name: String?, - subscribersCount: String?, - thumbnailSizePx: Int, - thumbnailSizeDp: Dp, - modifier: Modifier = Modifier, - alternative: Boolean = false, -) { - val (_, typography) = LocalAppearance.current - - ItemContainer( - alternative = alternative, - thumbnailSizeDp = thumbnailSizeDp, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = modifier - ) { - AsyncImage( - model = thumbnailUrl?.thumbnail(thumbnailSizePx), - contentDescription = null, - modifier = Modifier - .clip(CircleShape) - .requiredSize(thumbnailSizeDp) - ) - - ItemInfoContainer( - horizontalAlignment = if (alternative) Alignment.CenterHorizontally else Alignment.Start, - ) { - BasicText( - text = name ?: "", - style = typography.xs.semiBold, - maxLines = if (alternative) 1 else 2, - overflow = TextOverflow.Ellipsis - ) - - subscribersCount?.let { - BasicText( - text = subscribersCount, - style = typography.xxs.semiBold.secondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .padding(top = 4.dp) - ) - } - } - } -} - -@Composable -fun ArtistItemPlaceholder( - thumbnailSizeDp: Dp, - modifier: Modifier = Modifier, - alternative: Boolean = false, -) { - val (colorPalette) = LocalAppearance.current - - ItemContainer( - alternative = alternative, - thumbnailSizeDp = thumbnailSizeDp, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = modifier - ) { - Spacer( - modifier = Modifier - .background(color = colorPalette.shimmer, shape = CircleShape) - .size(thumbnailSizeDp) - ) - - ItemInfoContainer( - horizontalAlignment = if (alternative) Alignment.CenterHorizontally else Alignment.Start, - ) { - TextPlaceholder() - TextPlaceholder( - modifier = Modifier - .padding(top = 4.dp) - ) - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/ItemContainer.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/ItemContainer.kt deleted file mode 100644 index fe16910..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/ItemContainer.kt +++ /dev/null @@ -1,68 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.items - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import it.vfsfitvnm.vimusic.ui.styling.Dimensions - -@Composable -inline fun ItemContainer( - alternative: Boolean, - thumbnailSizeDp: Dp, - modifier: Modifier = Modifier, - horizontalAlignment: Alignment.Horizontal = Alignment.Start, - verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, - content: @Composable (centeredModifier: Modifier) -> Unit -) { - if (alternative) { - Column( - horizontalAlignment = horizontalAlignment, - verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = modifier - .padding(vertical = Dimensions.itemsVerticalPadding, horizontal = 16.dp) - .width(thumbnailSizeDp) - ) { - content( - centeredModifier = Modifier - .align(Alignment.CenterHorizontally) - ) - } - } else { - Row( - verticalAlignment = verticalAlignment, - horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = modifier - .padding(vertical = Dimensions.itemsVerticalPadding, horizontal = 16.dp) - .fillMaxWidth() - ) { - content( - centeredModifier = Modifier - .align(Alignment.CenterVertically) - ) - } - } -} - -@Composable -inline fun ItemInfoContainer( - modifier: Modifier = Modifier, - horizontalAlignment: Alignment.Horizontal = Alignment.Start, - content: @Composable ColumnScope.() -> Unit -) { - Column( - horizontalAlignment = horizontalAlignment, - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = modifier, - content = content - ) -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/PlaylistItem.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/PlaylistItem.kt deleted file mode 100644 index a614243..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/PlaylistItem.kt +++ /dev/null @@ -1,274 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.items - -import androidx.annotation.DrawableRes -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicText -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import coil.compose.AsyncImage -import it.vfsfitvnm.vimusic.Database -import it.vfsfitvnm.vimusic.models.PlaylistPreview -import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.onOverlay -import it.vfsfitvnm.vimusic.ui.styling.overlay -import it.vfsfitvnm.vimusic.ui.styling.shimmer -import it.vfsfitvnm.vimusic.utils.color -import it.vfsfitvnm.vimusic.utils.medium -import it.vfsfitvnm.vimusic.utils.secondary -import it.vfsfitvnm.vimusic.utils.semiBold -import it.vfsfitvnm.vimusic.utils.thumbnail -import it.vfsfitvnm.innertube.Innertube -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map - -@Composable -fun PlaylistItem( - @DrawableRes icon: Int, - colorTint: Color, - name: String?, - songCount: Int?, - thumbnailSizeDp: Dp, - modifier: Modifier = Modifier, - alternative: Boolean = false, -) { - PlaylistItem( - thumbnailContent = { - Image( - painter = painterResource(icon), - contentDescription = null, - colorFilter = ColorFilter.tint(colorTint), - modifier = Modifier - .align(Alignment.Center) - .size(24.dp) - ) - }, - songCount = songCount, - name = name, - channelName = null, - thumbnailSizeDp = thumbnailSizeDp, - modifier = modifier, - alternative = alternative - ) -} - -@Composable -fun PlaylistItem( - playlist: PlaylistPreview, - thumbnailSizePx: Int, - thumbnailSizeDp: Dp, - modifier: Modifier = Modifier, - alternative: Boolean = false, -) { - val thumbnails by remember { - Database.playlistThumbnailUrls(playlist.playlist.id).distinctUntilChanged().map { - it.map { url -> - url.thumbnail(thumbnailSizePx / 2) - } - } - }.collectAsState(initial = emptyList(), context = Dispatchers.IO) - - PlaylistItem( - thumbnailContent = { - if (thumbnails.toSet().size == 1) { - AsyncImage( - model = thumbnails.first().thumbnail(thumbnailSizePx), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = it - ) - } else { - Box( - modifier = it - .fillMaxSize() - ) { - listOf( - Alignment.TopStart, - Alignment.TopEnd, - Alignment.BottomStart, - Alignment.BottomEnd - ).forEachIndexed { index, alignment -> - AsyncImage( - model = thumbnails.getOrNull(index), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .align(alignment) - .size(thumbnailSizeDp / 2) - ) - } - } - } - }, - songCount = playlist.songCount, - name = playlist.playlist.name, - channelName = null, - thumbnailSizeDp = thumbnailSizeDp, - modifier = modifier, - alternative = alternative - ) -} - -@Composable -fun PlaylistItem( - playlist: Innertube.PlaylistItem, - thumbnailSizePx: Int, - thumbnailSizeDp: Dp, - modifier: Modifier = Modifier, - alternative: Boolean = false, -) { - PlaylistItem( - thumbnailUrl = playlist.thumbnail?.url, - songCount = playlist.songCount, - name = playlist.info?.name, - channelName = playlist.channel?.name, - thumbnailSizePx = thumbnailSizePx, - thumbnailSizeDp = thumbnailSizeDp, - modifier = modifier, - alternative = alternative - ) -} - -@Composable -fun PlaylistItem( - thumbnailUrl: String?, - songCount: Int?, - name: String?, - channelName: String?, - thumbnailSizePx: Int, - thumbnailSizeDp: Dp, - modifier: Modifier = Modifier, - alternative: Boolean = false, -) { - PlaylistItem( - thumbnailContent = { - AsyncImage( - model = thumbnailUrl?.thumbnail(thumbnailSizePx), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = it - ) - }, - songCount = songCount, - name = name, - channelName = channelName, - thumbnailSizeDp = thumbnailSizeDp, - modifier = modifier, - alternative = alternative, - ) -} - -@Composable -fun PlaylistItem( - thumbnailContent: @Composable BoxScope.(modifier: Modifier) -> Unit, - songCount: Int?, - name: String?, - channelName: String?, - thumbnailSizeDp: Dp, - modifier: Modifier = Modifier, - alternative: Boolean = false, -) { - val (colorPalette, typography, thumbnailShape) = LocalAppearance.current - - ItemContainer( - alternative = alternative, - thumbnailSizeDp = thumbnailSizeDp, - modifier = modifier - ) { centeredModifier -> - Box( - modifier = centeredModifier - .clip(thumbnailShape) - .background(color = colorPalette.background1) - .requiredSize(thumbnailSizeDp) - ) { - thumbnailContent( - modifier = Modifier - .fillMaxSize() - ) - - songCount?.let { - BasicText( - text = "$songCount", - style = typography.xxs.medium.color(colorPalette.onOverlay), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .padding(all = 4.dp) - .background(color = colorPalette.overlay, shape = RoundedCornerShape(2.dp)) - .padding(horizontal = 4.dp, vertical = 2.dp) - .align(Alignment.BottomEnd) - ) - } - } - - ItemInfoContainer( - horizontalAlignment = if (alternative && channelName == null) Alignment.CenterHorizontally else Alignment.Start, - ) { - BasicText( - text = name ?: "", - style = typography.xs.semiBold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - - channelName?.let { - BasicText( - text = channelName, - style = typography.xs.semiBold.secondary, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - } - } -} - -@Composable -fun PlaylistItemPlaceholder( - thumbnailSizeDp: Dp, - modifier: Modifier = Modifier, - alternative: Boolean = false, -) { - val (colorPalette, _, thumbnailShape) = LocalAppearance.current - - ItemContainer( - alternative = alternative, - thumbnailSizeDp = thumbnailSizeDp, - modifier = modifier - ) { - Spacer( - modifier = Modifier - .background(color = colorPalette.shimmer, shape = thumbnailShape) - .size(thumbnailSizeDp) - ) - - ItemInfoContainer( - horizontalAlignment = if (alternative) Alignment.CenterHorizontally else Alignment.Start, - ) { - TextPlaceholder() - TextPlaceholder() - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/SongItem.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/SongItem.kt deleted file mode 100644 index f9a6cea..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/SongItem.kt +++ /dev/null @@ -1,218 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.items - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.text.BasicText -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.media3.common.MediaItem -import coil.compose.AsyncImage -import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.shimmer -import it.vfsfitvnm.vimusic.utils.medium -import it.vfsfitvnm.vimusic.utils.secondary -import it.vfsfitvnm.vimusic.utils.semiBold -import it.vfsfitvnm.vimusic.utils.thumbnail -import it.vfsfitvnm.innertube.Innertube -import it.vfsfitvnm.vimusic.models.Song - -@Composable -fun SongItem( - song: Innertube.SongItem, - thumbnailSizePx: Int, - thumbnailSizeDp: Dp, - modifier: Modifier = Modifier -) { - SongItem( - thumbnailUrl = song.thumbnail?.size(thumbnailSizePx), - title = song.info?.name, - authors = song.authors?.joinToString("") { it.name ?: "" }, - duration = song.durationText, - thumbnailSizeDp = thumbnailSizeDp, - modifier = modifier, - ) -} - -@Composable -fun SongItem( - song: MediaItem, - thumbnailSizeDp: Dp, - thumbnailSizePx: Int, - modifier: Modifier = Modifier, - onThumbnailContent: (@Composable BoxScope.() -> Unit)? = null, - trailingContent: (@Composable () -> Unit)? = null -) { - SongItem( - thumbnailUrl = song.mediaMetadata.artworkUri.thumbnail(thumbnailSizePx)?.toString(), - title = song.mediaMetadata.title.toString(), - authors = song.mediaMetadata.artist.toString(), - duration = song.mediaMetadata.extras?.getString("durationText"), - thumbnailSizeDp = thumbnailSizeDp, - onThumbnailContent = onThumbnailContent, - trailingContent = trailingContent, - modifier = modifier, - ) -} - -@Composable -fun SongItem( - song: Song, - thumbnailSizePx: Int, - thumbnailSizeDp: Dp, - modifier: Modifier = Modifier, - onThumbnailContent: (@Composable BoxScope.() -> Unit)? = null, - trailingContent: (@Composable () -> Unit)? = null -) { - SongItem( - thumbnailUrl = song.thumbnailUrl?.thumbnail(thumbnailSizePx), - title = song.title, - authors = song.artistsText, - duration = song.durationText, - thumbnailSizeDp = thumbnailSizeDp, - onThumbnailContent = onThumbnailContent, - trailingContent = trailingContent, - modifier = modifier, - ) -} - -@Composable -fun SongItem( - thumbnailUrl: String?, - title: String?, - authors: String?, - duration: String?, - thumbnailSizeDp: Dp, - modifier: Modifier = Modifier, - onThumbnailContent: (@Composable BoxScope.() -> Unit)? = null, - trailingContent: (@Composable () -> Unit)? = null -) { - SongItem( - title = title, - authors = authors, - duration = duration, - thumbnailSizeDp = thumbnailSizeDp, - thumbnailContent = { - AsyncImage( - model = thumbnailUrl, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .clip(LocalAppearance.current.thumbnailShape) - .fillMaxSize() - ) - - onThumbnailContent?.invoke(this) - }, - modifier = modifier, - trailingContent = trailingContent - ) -} - -@Composable -fun SongItem( - thumbnailContent: @Composable BoxScope.() -> Unit, - title: String?, - authors: String?, - duration: String?, - thumbnailSizeDp: Dp, - modifier: Modifier = Modifier, - trailingContent: @Composable (() -> Unit)? = null, -) { - val (_, typography) = LocalAppearance.current - - ItemContainer( - alternative = false, - thumbnailSizeDp = thumbnailSizeDp, - modifier = modifier - ) { - Box( - modifier = Modifier - .size(thumbnailSizeDp) - ) { - thumbnailContent() - } - - ItemInfoContainer { - trailingContent?.let { - Row(verticalAlignment = Alignment.CenterVertically) { - BasicText( - text = title ?: "", - style = typography.xs.semiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .weight(1f) - ) - - it() - } - } ?: BasicText( - text = title ?: "", - style = typography.xs.semiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - - - Row(verticalAlignment = Alignment.CenterVertically) { - BasicText( - text = authors ?: "", - style = typography.xs.semiBold.secondary, - maxLines = 1, - overflow = TextOverflow.Clip, - modifier = Modifier - .weight(1f) - ) - - duration?.let { - BasicText( - text = duration, - style = typography.xxs.secondary.medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .padding(top = 4.dp) - ) - } - } - } - } -} - -@Composable -fun SongItemPlaceholder( - thumbnailSizeDp: Dp, - modifier: Modifier = Modifier -) { - val (colorPalette, _, thumbnailShape) = LocalAppearance.current - - ItemContainer( - alternative = false, - thumbnailSizeDp =thumbnailSizeDp, - modifier = modifier - ) { - Spacer( - modifier = Modifier - .background(color = colorPalette.shimmer, shape = thumbnailShape) - .size(thumbnailSizeDp) - ) - - ItemInfoContainer { - TextPlaceholder() - TextPlaceholder() - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/VideoItem.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/VideoItem.kt deleted file mode 100644 index f3ee2f2..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/items/VideoItem.kt +++ /dev/null @@ -1,149 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.items - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicText -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import coil.compose.AsyncImage -import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.onOverlay -import it.vfsfitvnm.vimusic.ui.styling.overlay -import it.vfsfitvnm.vimusic.ui.styling.shimmer -import it.vfsfitvnm.vimusic.utils.color -import it.vfsfitvnm.vimusic.utils.medium -import it.vfsfitvnm.vimusic.utils.secondary -import it.vfsfitvnm.vimusic.utils.semiBold -import it.vfsfitvnm.innertube.Innertube - -@Composable -fun VideoItem( - video: Innertube.VideoItem, - thumbnailHeightDp: Dp, - thumbnailWidthDp: Dp, - modifier: Modifier = Modifier -) { - VideoItem( - thumbnailUrl = video.thumbnail?.url, - duration = video.durationText, - title = video.info?.name, - uploader = video.authors?.joinToString("") { it.name ?: "" }, - views = video.viewsText, - thumbnailHeightDp = thumbnailHeightDp, - thumbnailWidthDp = thumbnailWidthDp, - modifier = modifier - ) -} - -@Composable -fun VideoItem( - thumbnailUrl: String?, - duration: String?, - title: String?, - uploader: String?, - views: String?, - thumbnailHeightDp: Dp, - thumbnailWidthDp: Dp, - modifier: Modifier = Modifier -) { - val (colorPalette, typography, thumbnailShape) = LocalAppearance.current - - ItemContainer( - alternative = false, - thumbnailSizeDp = 0.dp, - modifier = modifier - ) { - Box { - AsyncImage( - model = thumbnailUrl, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .clip(thumbnailShape) - .size(width = thumbnailWidthDp, height = thumbnailHeightDp) - ) - - duration?.let { - BasicText( - text = duration, - style = typography.xxs.medium.color(colorPalette.onOverlay), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .padding(all = 4.dp) - .background(color = colorPalette.overlay, shape = RoundedCornerShape(2.dp)) - .padding(horizontal = 4.dp, vertical = 2.dp) - .align(Alignment.BottomEnd) - ) - } - } - - ItemInfoContainer { - BasicText( - text = title ?: "", - style = typography.xs.semiBold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - - BasicText( - text = uploader ?: "", - style = typography.xs.semiBold.secondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - - views?.let { - BasicText( - text = views, - style = typography.xxs.medium.secondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .padding(top = 4.dp) - ) - } - } - } -} - -@Composable -fun VideoItemPlaceholder( - thumbnailHeightDp: Dp, - thumbnailWidthDp: Dp, - modifier: Modifier = Modifier -) { - val (colorPalette, _, thumbnailShape) = LocalAppearance.current - - ItemContainer( - alternative = false, - thumbnailSizeDp = 0.dp, - modifier = modifier - ) { - Spacer( - modifier = Modifier - .background(color = colorPalette.shimmer, shape = thumbnailShape) - .size(width = thumbnailWidthDp, height = thumbnailHeightDp) - ) - - ItemInfoContainer { - TextPlaceholder() - TextPlaceholder() - TextPlaceholder( - modifier = Modifier - .padding(top = 8.dp) - ) - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/Routes.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/Routes.kt deleted file mode 100644 index 5535ccb..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/Routes.kt +++ /dev/null @@ -1,47 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens - -import android.annotation.SuppressLint -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.runtime.Composable -import it.vfsfitvnm.compose.routing.Route0 -import it.vfsfitvnm.compose.routing.Route1 -import it.vfsfitvnm.compose.routing.RouteHandlerScope -import it.vfsfitvnm.vimusic.enums.BuiltInPlaylist -import it.vfsfitvnm.vimusic.ui.screens.album.AlbumScreen -import it.vfsfitvnm.vimusic.ui.screens.artist.ArtistScreen -import it.vfsfitvnm.vimusic.ui.screens.playlist.PlaylistScreen - -val albumRoute = Route1("albumRoute") -val artistRoute = Route1("artistRoute") -val builtInPlaylistRoute = Route1("builtInPlaylistRoute") -val localPlaylistRoute = Route1("localPlaylistRoute") -val playlistRoute = Route1("playlistRoute") -val searchResultRoute = Route1("searchResultRoute") -val searchRoute = Route1("searchRoute") -val settingsRoute = Route0("settingsRoute") - -@SuppressLint("ComposableNaming") -@Suppress("NOTHING_TO_INLINE") -@ExperimentalAnimationApi -@ExperimentalFoundationApi -@Composable -inline fun RouteHandlerScope.globalRoutes() { - albumRoute { browseId -> - AlbumScreen( - browseId = browseId ?: error("browseId cannot be null") - ) - } - - artistRoute { browseId -> - ArtistScreen( - browseId = browseId ?: error("browseId cannot be null") - ) - } - - playlistRoute { browseId -> - PlaylistScreen( - browseId = browseId ?: error("browseId cannot be null") - ) - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/album/AlbumScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/album/AlbumScreen.kt deleted file mode 100644 index b594202..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/album/AlbumScreen.kt +++ /dev/null @@ -1,236 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens.album - -import android.content.Intent -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Spacer -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.saveable.rememberSaveableStateHolder -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import com.valentinilk.shimmer.shimmer -import it.vfsfitvnm.compose.persist.PersistMapCleanup -import it.vfsfitvnm.compose.persist.persist -import it.vfsfitvnm.innertube.Innertube -import it.vfsfitvnm.innertube.models.bodies.BrowseBody -import it.vfsfitvnm.innertube.requests.albumPage -import it.vfsfitvnm.compose.routing.RouteHandler -import it.vfsfitvnm.vimusic.Database -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.models.Album -import it.vfsfitvnm.vimusic.models.SongAlbumMap -import it.vfsfitvnm.vimusic.query -import it.vfsfitvnm.vimusic.ui.components.themed.Header -import it.vfsfitvnm.vimusic.ui.components.themed.HeaderIconButton -import it.vfsfitvnm.vimusic.ui.components.themed.HeaderPlaceholder -import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold -import it.vfsfitvnm.vimusic.ui.components.themed.adaptiveThumbnailContent -import it.vfsfitvnm.vimusic.ui.items.AlbumItem -import it.vfsfitvnm.vimusic.ui.items.AlbumItemPlaceholder -import it.vfsfitvnm.vimusic.ui.screens.albumRoute -import it.vfsfitvnm.vimusic.ui.screens.globalRoutes -import it.vfsfitvnm.vimusic.ui.screens.searchresult.ItemsPage -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.px -import it.vfsfitvnm.vimusic.utils.asMediaItem -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.withContext - -@ExperimentalFoundationApi -@ExperimentalAnimationApi -@Composable -fun AlbumScreen(browseId: String) { - val saveableStateHolder = rememberSaveableStateHolder() - - var tabIndex by rememberSaveable { - mutableStateOf(0) - } - - var album by persist("album/$browseId/album") - var albumPage by persist("album/$browseId/albumPage") - - PersistMapCleanup(tagPrefix = "album/$browseId/") - - LaunchedEffect(Unit) { - Database - .album(browseId) - .combine(snapshotFlow { tabIndex }) { album, tabIndex -> album to tabIndex } - .collect { (currentAlbum, tabIndex) -> - album = currentAlbum - - if (albumPage == null && (currentAlbum?.timestamp == null || currentAlbum.title == null || tabIndex == 1)) { - withContext(Dispatchers.IO) { - Innertube.albumPage(BrowseBody(browseId = browseId)) - ?.onSuccess { currentAlbumPage -> - albumPage = currentAlbumPage - - Database.clearAlbum(browseId) - - Database.upsert( - Album( - id = browseId, - title = currentAlbumPage.title, - thumbnailUrl = currentAlbumPage.thumbnail?.url, - year = currentAlbumPage.year, - authorsText = currentAlbumPage.authors - ?.joinToString("") { it.name ?: "" }, - shareUrl = currentAlbumPage.url, - timestamp = System.currentTimeMillis(), - bookmarkedAt = album?.bookmarkedAt - ), - currentAlbumPage - .songsPage - ?.items - ?.map(Innertube.SongItem::asMediaItem) - ?.onEach(Database::insert) - ?.mapIndexed { position, mediaItem -> - SongAlbumMap( - songId = mediaItem.mediaId, - albumId = browseId, - position = position - ) - } ?: emptyList() - ) - } - } - - } - } - } - - RouteHandler(listenToGlobalEmitter = true) { - globalRoutes() - - host { - val headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit = - { textButton -> - if (album?.timestamp == null) { - HeaderPlaceholder( - modifier = Modifier - .shimmer() - ) - } else { - val (colorPalette) = LocalAppearance.current - val context = LocalContext.current - - Header(title = album?.title ?: "Unknown") { - textButton?.invoke() - - Spacer( - modifier = Modifier - .weight(1f) - ) - - HeaderIconButton( - icon = if (album?.bookmarkedAt == null) { - R.drawable.bookmark_outline - } else { - R.drawable.bookmark - }, - color = colorPalette.accent, - onClick = { - val bookmarkedAt = - if (album?.bookmarkedAt == null) System.currentTimeMillis() else null - - query { - album - ?.copy(bookmarkedAt = bookmarkedAt) - ?.let(Database::update) - } - } - ) - - HeaderIconButton( - icon = R.drawable.share_social, - color = colorPalette.text, - onClick = { - album?.shareUrl?.let { url -> - val sendIntent = Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, url) - } - - context.startActivity( - Intent.createChooser( - sendIntent, - null - ) - ) - } - } - ) - } - } - } - - val thumbnailContent = - adaptiveThumbnailContent(album?.timestamp == null, album?.thumbnailUrl) - - Scaffold( - topIconButtonId = R.drawable.chevron_back, - onTopIconButtonClick = pop, - tabIndex = tabIndex, - onTabChanged = { tabIndex = it }, - tabColumnContent = { Item -> - Item(0, "Songs", R.drawable.musical_notes) - Item(1, "Other versions", R.drawable.disc) - } - ) { currentTabIndex -> - saveableStateHolder.SaveableStateProvider(key = currentTabIndex) { - when (currentTabIndex) { - 0 -> AlbumSongs( - browseId = browseId, - headerContent = headerContent, - thumbnailContent = thumbnailContent, - ) - - 1 -> { - val thumbnailSizeDp = 108.dp - val thumbnailSizePx = thumbnailSizeDp.px - - ItemsPage( - tag = "album/$browseId/alternatives", - headerContent = headerContent, - initialPlaceholderCount = 1, - continuationPlaceholderCount = 1, - emptyItemsText = "This album doesn't have any alternative version", - itemsPageProvider = albumPage?.let { - ({ - Result.success( - Innertube.ItemsPage( - items = albumPage?.otherVersions, - continuation = null - ) - ) - }) - }, - itemContent = { album -> - AlbumItem( - album = album, - thumbnailSizePx = thumbnailSizePx, - thumbnailSizeDp = thumbnailSizeDp, - modifier = Modifier - .clickable { albumRoute(album.key) } - ) - }, - itemPlaceholderContent = { - AlbumItemPlaceholder(thumbnailSizeDp = thumbnailSizeDp) - } - ) - } - } - } - } - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/album/AlbumSongs.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/album/AlbumSongs.kt deleted file mode 100644 index ae0352e..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/album/AlbumSongs.kt +++ /dev/null @@ -1,172 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens.album - -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.text.BasicText -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextOverflow -import it.vfsfitvnm.compose.persist.persistList -import it.vfsfitvnm.vimusic.Database -import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets -import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.models.Song -import it.vfsfitvnm.vimusic.ui.components.LocalMenuState -import it.vfsfitvnm.vimusic.ui.components.ShimmerHost -import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop -import it.vfsfitvnm.vimusic.ui.components.themed.LayoutWithAdaptiveThumbnail -import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu -import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton -import it.vfsfitvnm.vimusic.ui.items.SongItem -import it.vfsfitvnm.vimusic.ui.items.SongItemPlaceholder -import it.vfsfitvnm.vimusic.ui.styling.Dimensions -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.utils.asMediaItem -import it.vfsfitvnm.vimusic.utils.center -import it.vfsfitvnm.vimusic.utils.color -import it.vfsfitvnm.vimusic.utils.enqueue -import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex -import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning -import it.vfsfitvnm.vimusic.utils.isLandscape -import it.vfsfitvnm.vimusic.utils.semiBold - -@ExperimentalAnimationApi -@ExperimentalFoundationApi -@Composable -fun AlbumSongs( - browseId: String, - headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit, - thumbnailContent: @Composable () -> Unit, -) { - val (colorPalette, typography) = LocalAppearance.current - val binder = LocalPlayerServiceBinder.current - val menuState = LocalMenuState.current - - var songs by persistList("album/$browseId/songs") - - LaunchedEffect(Unit) { - Database.albumSongs(browseId).collect { songs = it } - } - - val thumbnailSizeDp = Dimensions.thumbnails.song - - val lazyListState = rememberLazyListState() - - LayoutWithAdaptiveThumbnail(thumbnailContent = thumbnailContent) { - Box { - LazyColumn( - state = lazyListState, - contentPadding = LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), - modifier = Modifier - .background(colorPalette.background0) - .fillMaxSize() - ) { - item( - key = "header", - contentType = 0 - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - headerContent { - SecondaryTextButton( - text = "Enqueue", - enabled = songs.isNotEmpty(), - onClick = { - binder?.player?.enqueue(songs.map(Song::asMediaItem)) - } - ) - } - - if (!isLandscape) { - thumbnailContent() - } - } - } - - itemsIndexed( - items = songs, - key = { _, song -> song.id } - ) { index, song -> - SongItem( - title = song.title, - authors = song.artistsText, - duration = song.durationText, - thumbnailSizeDp = thumbnailSizeDp, - thumbnailContent = { - BasicText( - text = "${index + 1}", - style = typography.s.semiBold.center.color(colorPalette.textDisabled), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .width(thumbnailSizeDp) - .align(Alignment.Center) - ) - }, - modifier = Modifier - .combinedClickable( - onLongClick = { - menuState.display { - NonQueuedMediaItemMenu( - onDismiss = menuState::hide, - mediaItem = song.asMediaItem, - ) - } - }, - onClick = { - binder?.stopRadio() - binder?.player?.forcePlayAtIndex( - songs.map(Song::asMediaItem), - index - ) - } - ) - ) - } - - if (songs.isEmpty()) { - item(key = "loading") { - ShimmerHost( - modifier = Modifier - .fillParentMaxSize() - ) { - repeat(4) { - SongItemPlaceholder(thumbnailSizeDp = Dimensions.thumbnails.song) - } - } - } - } - } - - FloatingActionsContainerWithScrollToTop( - lazyListState = lazyListState, - iconId = R.drawable.shuffle, - onClick = { - if (songs.isNotEmpty()) { - binder?.stopRadio() - binder?.player?.forcePlayFromBeginning( - songs.shuffled().map(Song::asMediaItem) - ) - } - } - ) - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistOverview.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistOverview.kt deleted file mode 100644 index 74592af..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistOverview.kt +++ /dev/null @@ -1,349 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens.artist - -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.BasicText -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import it.vfsfitvnm.innertube.Innertube -import it.vfsfitvnm.innertube.models.NavigationEndpoint -import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets -import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.ui.components.LocalMenuState -import it.vfsfitvnm.vimusic.ui.components.ShimmerHost -import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop -import it.vfsfitvnm.vimusic.ui.components.themed.LayoutWithAdaptiveThumbnail -import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu -import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton -import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder -import it.vfsfitvnm.vimusic.ui.items.AlbumItem -import it.vfsfitvnm.vimusic.ui.items.AlbumItemPlaceholder -import it.vfsfitvnm.vimusic.ui.items.SongItem -import it.vfsfitvnm.vimusic.ui.items.SongItemPlaceholder -import it.vfsfitvnm.vimusic.ui.styling.Dimensions -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.px -import it.vfsfitvnm.vimusic.utils.align -import it.vfsfitvnm.vimusic.utils.asMediaItem -import it.vfsfitvnm.vimusic.utils.color -import it.vfsfitvnm.vimusic.utils.forcePlay -import it.vfsfitvnm.vimusic.utils.secondary -import it.vfsfitvnm.vimusic.utils.semiBold - -@ExperimentalFoundationApi -@ExperimentalAnimationApi -@Composable -fun ArtistOverview( - youtubeArtistPage: Innertube.ArtistPage?, - onViewAllSongsClick: () -> Unit, - onViewAllAlbumsClick: () -> Unit, - onViewAllSinglesClick: () -> Unit, - onAlbumClick: (String) -> Unit, - thumbnailContent: @Composable () -> Unit, - headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit, -) { - val (colorPalette, typography) = LocalAppearance.current - val binder = LocalPlayerServiceBinder.current - val menuState = LocalMenuState.current - val windowInsets = LocalPlayerAwareWindowInsets.current - - val songThumbnailSizeDp = Dimensions.thumbnails.song - val songThumbnailSizePx = songThumbnailSizeDp.px - val albumThumbnailSizeDp = 108.dp - val albumThumbnailSizePx = albumThumbnailSizeDp.px - - val endPaddingValues = windowInsets.only(WindowInsetsSides.End).asPaddingValues() - - val sectionTextModifier = Modifier - .padding(horizontal = 16.dp) - .padding(top = 24.dp, bottom = 8.dp) - - val scrollState = rememberScrollState() - - LayoutWithAdaptiveThumbnail(thumbnailContent = thumbnailContent) { - Box { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .background(colorPalette.background0) - .fillMaxSize() - .verticalScroll(scrollState) - .padding( - windowInsets - .only(WindowInsetsSides.Vertical) - .asPaddingValues() - ) - ) { - Box( - modifier = Modifier - .padding(endPaddingValues) - ) { - headerContent { - youtubeArtistPage?.shuffleEndpoint?.let { endpoint -> - SecondaryTextButton( - text = "Shuffle", - onClick = { - binder?.stopRadio() - binder?.playRadio(endpoint) - } - ) - } - } - } - - thumbnailContent() - - if (youtubeArtistPage != null) { - youtubeArtistPage.songs?.let { songs -> - Row( - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier - .fillMaxSize() - .padding(endPaddingValues) - ) { - BasicText( - text = "Songs", - style = typography.m.semiBold, - modifier = sectionTextModifier - ) - - youtubeArtistPage.songsEndpoint?.let { - BasicText( - text = "View all", - style = typography.xs.secondary, - modifier = sectionTextModifier - .clickable(onClick = onViewAllSongsClick), - ) - } - } - - songs.forEach { song -> - SongItem( - song = song, - thumbnailSizeDp = songThumbnailSizeDp, - thumbnailSizePx = songThumbnailSizePx, - modifier = Modifier - .combinedClickable( - onLongClick = { - menuState.display { - NonQueuedMediaItemMenu( - onDismiss = menuState::hide, - mediaItem = song.asMediaItem, - ) - } - }, - onClick = { - val mediaItem = song.asMediaItem - binder?.stopRadio() - binder?.player?.forcePlay(mediaItem) - binder?.setupRadio( - NavigationEndpoint.Endpoint.Watch(videoId = mediaItem.mediaId) - ) - } - ) - .padding(endPaddingValues) - ) - } - } - - youtubeArtistPage.albums?.let { albums -> - Row( - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier - .fillMaxSize() - .padding(endPaddingValues) - ) { - BasicText( - text = "Albums", - style = typography.m.semiBold, - modifier = sectionTextModifier - ) - - youtubeArtistPage.albumsEndpoint?.let { - BasicText( - text = "View all", - style = typography.xs.secondary, - modifier = sectionTextModifier - .clickable(onClick = onViewAllAlbumsClick), - ) - } - } - - LazyRow( - contentPadding = endPaddingValues, - modifier = Modifier - .fillMaxWidth() - ) { - items( - items = albums, - key = Innertube.AlbumItem::key - ) { album -> - AlbumItem( - album = album, - thumbnailSizePx = albumThumbnailSizePx, - thumbnailSizeDp = albumThumbnailSizeDp, - alternative = true, - modifier = Modifier - .clickable(onClick = { onAlbumClick(album.key) }) - ) - } - } - } - - youtubeArtistPage.singles?.let { singles -> - Row( - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier - .fillMaxSize() - .padding(endPaddingValues) - ) { - BasicText( - text = "Singles", - style = typography.m.semiBold, - modifier = sectionTextModifier - ) - - youtubeArtistPage.singlesEndpoint?.let { - BasicText( - text = "View all", - style = typography.xs.secondary, - modifier = sectionTextModifier - .clickable(onClick = onViewAllSinglesClick), - ) - } - } - - LazyRow( - contentPadding = endPaddingValues, - modifier = Modifier - .fillMaxWidth() - ) { - items( - items = singles, - key = Innertube.AlbumItem::key - ) { album -> - AlbumItem( - album = album, - thumbnailSizePx = albumThumbnailSizePx, - thumbnailSizeDp = albumThumbnailSizeDp, - alternative = true, - modifier = Modifier - .clickable(onClick = { onAlbumClick(album.key) }) - ) - } - } - } - - youtubeArtistPage.description?.let { description -> - val attributionsIndex = description.lastIndexOf("\n\nFrom Wikipedia") - - Row( - modifier = Modifier - .padding(top = 16.dp) - .padding(vertical = 16.dp, horizontal = 8.dp) - .padding(endPaddingValues) - ) { - BasicText( - text = "“", - style = typography.xxl.semiBold, - modifier = Modifier - .offset(y = (-8).dp) - .align(Alignment.Top) - ) - - BasicText( - text = if (attributionsIndex == -1) { - description - } else { - description.substring(0, attributionsIndex) - }, - style = typography.xxs.secondary, - modifier = Modifier - .padding(horizontal = 8.dp) - .weight(1f) - ) - - BasicText( - text = "„", - style = typography.xxl.semiBold, - modifier = Modifier - .offset(y = 4.dp) - .align(Alignment.Bottom) - ) - } - - if (attributionsIndex != -1) { - BasicText( - text = "From Wikipedia under Creative Commons Attribution CC-BY-SA 3.0", - style = typography.xxs.color(colorPalette.textDisabled).align(TextAlign.End), - modifier = Modifier - .padding(horizontal = 16.dp) - .padding(bottom = 16.dp) - .padding(endPaddingValues) - ) - } - } - } else { - ShimmerHost { - TextPlaceholder(modifier = sectionTextModifier) - - repeat(5) { - SongItemPlaceholder( - thumbnailSizeDp = songThumbnailSizeDp, - ) - } - - repeat(2) { - TextPlaceholder(modifier = sectionTextModifier) - - Row { - repeat(2) { - AlbumItemPlaceholder( - thumbnailSizeDp = albumThumbnailSizeDp, - alternative = true - ) - } - } - } - } - } - } - - youtubeArtistPage?.radioEndpoint?.let { endpoint -> - FloatingActionsContainerWithScrollToTop( - scrollState = scrollState, - iconId = R.drawable.radio, - onClick = { - binder?.stopRadio() - binder?.playRadio(endpoint) - } - ) - } - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistScreen.kt deleted file mode 100644 index d252e97..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistScreen.kt +++ /dev/null @@ -1,373 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens.artist - -import android.content.Intent -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.clickable -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.saveable.rememberSaveableStateHolder -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import com.valentinilk.shimmer.shimmer -import it.vfsfitvnm.compose.persist.PersistMapCleanup -import it.vfsfitvnm.compose.persist.persist -import it.vfsfitvnm.innertube.Innertube -import it.vfsfitvnm.innertube.models.bodies.BrowseBody -import it.vfsfitvnm.innertube.models.bodies.ContinuationBody -import it.vfsfitvnm.innertube.requests.artistPage -import it.vfsfitvnm.innertube.requests.itemsPage -import it.vfsfitvnm.innertube.utils.from -import it.vfsfitvnm.compose.routing.RouteHandler -import it.vfsfitvnm.vimusic.Database -import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.models.Artist -import it.vfsfitvnm.vimusic.query -import it.vfsfitvnm.vimusic.ui.components.LocalMenuState -import it.vfsfitvnm.vimusic.ui.components.themed.Header -import it.vfsfitvnm.vimusic.ui.components.themed.HeaderIconButton -import it.vfsfitvnm.vimusic.ui.components.themed.HeaderPlaceholder -import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu -import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold -import it.vfsfitvnm.vimusic.ui.components.themed.adaptiveThumbnailContent -import it.vfsfitvnm.vimusic.ui.items.AlbumItem -import it.vfsfitvnm.vimusic.ui.items.AlbumItemPlaceholder -import it.vfsfitvnm.vimusic.ui.items.SongItem -import it.vfsfitvnm.vimusic.ui.items.SongItemPlaceholder -import it.vfsfitvnm.vimusic.ui.screens.albumRoute -import it.vfsfitvnm.vimusic.ui.screens.globalRoutes -import it.vfsfitvnm.vimusic.ui.screens.searchresult.ItemsPage -import it.vfsfitvnm.vimusic.ui.styling.Dimensions -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.px -import it.vfsfitvnm.vimusic.utils.artistScreenTabIndexKey -import it.vfsfitvnm.vimusic.utils.asMediaItem -import it.vfsfitvnm.vimusic.utils.forcePlay -import it.vfsfitvnm.vimusic.utils.rememberPreference -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.withContext - -@ExperimentalFoundationApi -@ExperimentalAnimationApi -@Composable -fun ArtistScreen(browseId: String) { - val saveableStateHolder = rememberSaveableStateHolder() - - var tabIndex by rememberPreference(artistScreenTabIndexKey, defaultValue = 0) - - PersistMapCleanup(tagPrefix = "artist/$browseId/") - - var artist by persist("artist/$browseId/artist") - - var artistPage by persist("artist/$browseId/artistPage") - - LaunchedEffect(Unit) { - Database - .artist(browseId) - .combine(snapshotFlow { tabIndex }.map { it != 4 }) { artist, mustFetch -> artist to mustFetch } - .distinctUntilChanged() - .collect { (currentArtist, mustFetch) -> - artist = currentArtist - - if (artistPage == null && (currentArtist?.timestamp == null || mustFetch)) { - withContext(Dispatchers.IO) { - Innertube.artistPage(BrowseBody(browseId = browseId)) - ?.onSuccess { currentArtistPage -> - artistPage = currentArtistPage - - Database.upsert( - Artist( - id = browseId, - name = currentArtistPage.name, - thumbnailUrl = currentArtistPage.thumbnail?.url, - timestamp = System.currentTimeMillis(), - bookmarkedAt = currentArtist?.bookmarkedAt - ) - ) - } - } - } - } - } - - RouteHandler(listenToGlobalEmitter = true) { - globalRoutes() - - host { - val thumbnailContent = - adaptiveThumbnailContent( - artist?.timestamp == null, - artist?.thumbnailUrl, - CircleShape - ) - - val headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit = - { textButton -> - if (artist?.timestamp == null) { - HeaderPlaceholder( - modifier = Modifier - .shimmer() - ) - } else { - val (colorPalette) = LocalAppearance.current - val context = LocalContext.current - - Header(title = artist?.name ?: "Unknown") { - textButton?.invoke() - - Spacer( - modifier = Modifier - .weight(1f) - ) - - HeaderIconButton( - icon = if (artist?.bookmarkedAt == null) { - R.drawable.bookmark_outline - } else { - R.drawable.bookmark - }, - color = colorPalette.accent, - onClick = { - val bookmarkedAt = - if (artist?.bookmarkedAt == null) System.currentTimeMillis() else null - - query { - artist - ?.copy(bookmarkedAt = bookmarkedAt) - ?.let(Database::update) - } - } - ) - - HeaderIconButton( - icon = R.drawable.share_social, - color = colorPalette.text, - onClick = { - val sendIntent = Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra( - Intent.EXTRA_TEXT, - "https://music.youtube.com/channel/$browseId" - ) - } - - context.startActivity(Intent.createChooser(sendIntent, null)) - } - ) - } - } - } - - Scaffold( - topIconButtonId = R.drawable.chevron_back, - onTopIconButtonClick = pop, - tabIndex = tabIndex, - onTabChanged = { tabIndex = it }, - tabColumnContent = { Item -> - Item(0, "Overview", R.drawable.sparkles) - Item(1, "Songs", R.drawable.musical_notes) - Item(2, "Albums", R.drawable.disc) - Item(3, "Singles", R.drawable.disc) - Item(4, "Library", R.drawable.library) - }, - ) { currentTabIndex -> - saveableStateHolder.SaveableStateProvider(key = currentTabIndex) { - when (currentTabIndex) { - 0 -> ArtistOverview( - youtubeArtistPage = artistPage, - thumbnailContent = thumbnailContent, - headerContent = headerContent, - onAlbumClick = { albumRoute(it) }, - onViewAllSongsClick = { tabIndex = 1 }, - onViewAllAlbumsClick = { tabIndex = 2 }, - onViewAllSinglesClick = { tabIndex = 3 }, - ) - - 1 -> { - val binder = LocalPlayerServiceBinder.current - val menuState = LocalMenuState.current - val thumbnailSizeDp = Dimensions.thumbnails.song - val thumbnailSizePx = thumbnailSizeDp.px - - ItemsPage( - tag = "artist/$browseId/songs", - headerContent = headerContent, - itemsPageProvider = artistPage?.let { - ({ continuation -> - continuation?.let { - Innertube.itemsPage( - body = ContinuationBody(continuation = continuation), - fromMusicResponsiveListItemRenderer = Innertube.SongItem::from, - ) - } ?: artistPage - ?.songsEndpoint - ?.takeIf { it.browseId != null } - ?.let { endpoint -> - Innertube.itemsPage( - body = BrowseBody( - browseId = endpoint.browseId!!, - params = endpoint.params, - ), - fromMusicResponsiveListItemRenderer = Innertube.SongItem::from, - ) - } - ?: Result.success( - Innertube.ItemsPage( - items = artistPage?.songs, - continuation = null - ) - ) - }) - }, - itemContent = { song -> - SongItem( - song = song, - thumbnailSizeDp = thumbnailSizeDp, - thumbnailSizePx = thumbnailSizePx, - modifier = Modifier - .combinedClickable( - onLongClick = { - menuState.display { - NonQueuedMediaItemMenu( - onDismiss = menuState::hide, - mediaItem = song.asMediaItem, - ) - } - }, - onClick = { - binder?.stopRadio() - binder?.player?.forcePlay(song.asMediaItem) - binder?.setupRadio(song.info?.endpoint) - } - ) - ) - }, - itemPlaceholderContent = { - SongItemPlaceholder(thumbnailSizeDp = thumbnailSizeDp) - } - ) - } - - 2 -> { - val thumbnailSizeDp = 108.dp - val thumbnailSizePx = thumbnailSizeDp.px - - ItemsPage( - tag = "artist/$browseId/albums", - headerContent = headerContent, - emptyItemsText = "This artist didn't release any album", - itemsPageProvider = artistPage?.let { - ({ continuation -> - continuation?.let { - Innertube.itemsPage( - body = ContinuationBody(continuation = continuation), - fromMusicTwoRowItemRenderer = Innertube.AlbumItem::from, - ) - } ?: artistPage - ?.albumsEndpoint - ?.takeIf { it.browseId != null } - ?.let { endpoint -> - Innertube.itemsPage( - body = BrowseBody( - browseId = endpoint.browseId!!, - params = endpoint.params, - ), - fromMusicTwoRowItemRenderer = Innertube.AlbumItem::from, - ) - } - ?: Result.success( - Innertube.ItemsPage( - items = artistPage?.albums, - continuation = null - ) - ) - }) - }, - itemContent = { album -> - AlbumItem( - album = album, - thumbnailSizePx = thumbnailSizePx, - thumbnailSizeDp = thumbnailSizeDp, - modifier = Modifier - .clickable(onClick = { albumRoute(album.key) }) - ) - }, - itemPlaceholderContent = { - AlbumItemPlaceholder(thumbnailSizeDp = thumbnailSizeDp) - } - ) - } - - 3 -> { - val thumbnailSizeDp = 108.dp - val thumbnailSizePx = thumbnailSizeDp.px - - ItemsPage( - tag = "artist/$browseId/singles", - headerContent = headerContent, - emptyItemsText = "This artist didn't release any single", - itemsPageProvider = artistPage?.let { - ({ continuation -> - continuation?.let { - Innertube.itemsPage( - body = ContinuationBody(continuation = continuation), - fromMusicTwoRowItemRenderer = Innertube.AlbumItem::from, - ) - } ?: artistPage - ?.singlesEndpoint - ?.takeIf { it.browseId != null } - ?.let { endpoint -> - Innertube.itemsPage( - body = BrowseBody( - browseId = endpoint.browseId!!, - params = endpoint.params, - ), - fromMusicTwoRowItemRenderer = Innertube.AlbumItem::from, - ) - } - ?: Result.success( - Innertube.ItemsPage( - items = artistPage?.singles, - continuation = null - ) - ) - }) - }, - itemContent = { album -> - AlbumItem( - album = album, - thumbnailSizePx = thumbnailSizePx, - thumbnailSizeDp = thumbnailSizeDp, - modifier = Modifier - .clickable(onClick = { albumRoute(album.key) }) - ) - }, - itemPlaceholderContent = { - AlbumItemPlaceholder(thumbnailSizeDp = thumbnailSizeDp) - } - ) - } - - 4 -> ArtistLocalSongs( - browseId = browseId, - headerContent = headerContent, - thumbnailContent = thumbnailContent, - ) - } - } - } - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/builtinplaylist/BuiltInPlaylistScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/builtinplaylist/BuiltInPlaylistScreen.kt deleted file mode 100644 index 2d678d9..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/builtinplaylist/BuiltInPlaylistScreen.kt +++ /dev/null @@ -1,54 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens.builtinplaylist - -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.saveable.rememberSaveableStateHolder -import it.vfsfitvnm.compose.persist.PersistMapCleanup -import it.vfsfitvnm.compose.routing.RouteHandler -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.enums.BuiltInPlaylist -import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold -import it.vfsfitvnm.vimusic.ui.screens.globalRoutes - -@ExperimentalFoundationApi -@ExperimentalAnimationApi -@Composable -fun BuiltInPlaylistScreen(builtInPlaylist: BuiltInPlaylist) { - val saveableStateHolder = rememberSaveableStateHolder() - - val (tabIndex, onTabIndexChanged) = rememberSaveable { - mutableStateOf(when (builtInPlaylist) { - BuiltInPlaylist.Favorites -> 0 - BuiltInPlaylist.Offline -> 1 - }) - } - - PersistMapCleanup(tagPrefix = "${builtInPlaylist.name}/") - - RouteHandler(listenToGlobalEmitter = true) { - globalRoutes() - - host { - Scaffold( - topIconButtonId = R.drawable.chevron_back, - onTopIconButtonClick = pop, - tabIndex = tabIndex, - onTabChanged = onTabIndexChanged, - tabColumnContent = { Item -> - Item(0, "Favorites", R.drawable.heart) - Item(1, "Offline", R.drawable.airplane) - } - ) { currentTabIndex -> - saveableStateHolder.SaveableStateProvider(key = currentTabIndex) { - when (currentTabIndex) { - 0 -> BuiltInPlaylistSongs(builtInPlaylist = BuiltInPlaylist.Favorites) - 1 -> BuiltInPlaylistSongs(builtInPlaylist = BuiltInPlaylist.Offline) - } - } - } - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/builtinplaylist/BuiltInPlaylistSongs.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/builtinplaylist/BuiltInPlaylistSongs.kt deleted file mode 100644 index 546625e..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/builtinplaylist/BuiltInPlaylistSongs.kt +++ /dev/null @@ -1,170 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens.builtinplaylist - -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import it.vfsfitvnm.compose.persist.persistList -import it.vfsfitvnm.vimusic.Database -import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets -import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.enums.BuiltInPlaylist -import it.vfsfitvnm.vimusic.models.Song -import it.vfsfitvnm.vimusic.models.SongWithContentLength -import it.vfsfitvnm.vimusic.ui.components.LocalMenuState -import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop -import it.vfsfitvnm.vimusic.ui.components.themed.Header -import it.vfsfitvnm.vimusic.ui.components.themed.InHistoryMediaItemMenu -import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu -import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton -import it.vfsfitvnm.vimusic.ui.items.SongItem -import it.vfsfitvnm.vimusic.ui.styling.Dimensions -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.px -import it.vfsfitvnm.vimusic.utils.asMediaItem -import it.vfsfitvnm.vimusic.utils.enqueue -import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex -import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map - -@ExperimentalFoundationApi -@ExperimentalAnimationApi -@Composable -fun BuiltInPlaylistSongs(builtInPlaylist: BuiltInPlaylist) { - val (colorPalette) = LocalAppearance.current - val binder = LocalPlayerServiceBinder.current - val menuState = LocalMenuState.current - - var songs by persistList("${builtInPlaylist.name}/songs") - - LaunchedEffect(Unit) { - when (builtInPlaylist) { - BuiltInPlaylist.Favorites -> Database - .favorites() - - BuiltInPlaylist.Offline -> Database - .songsWithContentLength() - .flowOn(Dispatchers.IO) - .map { songs -> - songs.filter { song -> - song.contentLength?.let { - binder?.cache?.isCached(song.song.id, 0, song.contentLength) - } ?: false - }.map(SongWithContentLength::song) - } - }.collect { songs = it } - } - - val thumbnailSizeDp = Dimensions.thumbnails.song - val thumbnailSize = thumbnailSizeDp.px - - val lazyListState = rememberLazyListState() - - Box { - LazyColumn( - state = lazyListState, - contentPadding = LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), - modifier = Modifier - .background(colorPalette.background0) - .fillMaxSize() - ) { - item( - key = "header", - contentType = 0 - ) { - Header( - title = when (builtInPlaylist) { - BuiltInPlaylist.Favorites -> "Favorites" - BuiltInPlaylist.Offline -> "Offline" - }, - modifier = Modifier - .padding(bottom = 8.dp) - ) { - SecondaryTextButton( - text = "Enqueue", - enabled = songs.isNotEmpty(), - onClick = { - binder?.player?.enqueue(songs.map(Song::asMediaItem)) - } - ) - - Spacer( - modifier = Modifier - .weight(1f) - ) - } - } - - itemsIndexed( - items = songs, - key = { _, song -> song.id }, - contentType = { _, song -> song }, - ) { index, song -> - SongItem( - song = song, - thumbnailSizeDp = thumbnailSizeDp, - thumbnailSizePx = thumbnailSize, - modifier = Modifier - .combinedClickable( - onLongClick = { - menuState.display { - when (builtInPlaylist) { - BuiltInPlaylist.Favorites -> NonQueuedMediaItemMenu( - mediaItem = song.asMediaItem, - onDismiss = menuState::hide - ) - - BuiltInPlaylist.Offline -> InHistoryMediaItemMenu( - song = song, - onDismiss = menuState::hide - ) - } - } - }, - onClick = { - binder?.stopRadio() - binder?.player?.forcePlayAtIndex( - songs.map(Song::asMediaItem), - index - ) - } - ) - .animateItemPlacement() - ) - } - } - - FloatingActionsContainerWithScrollToTop( - lazyListState = lazyListState, - iconId = R.drawable.shuffle, - onClick = { - if (songs.isNotEmpty()) { - binder?.stopRadio() - binder?.player?.forcePlayFromBeginning( - songs.shuffled().map(Song::asMediaItem) - ) - } - } - ) - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomePlaylists.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomePlaylists.kt deleted file mode 100644 index 1841621..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomePlaylists.kt +++ /dev/null @@ -1,210 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens.home - -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.GridItemSpan -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.unit.dp -import it.vfsfitvnm.compose.persist.persistList -import it.vfsfitvnm.vimusic.Database -import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.enums.BuiltInPlaylist -import it.vfsfitvnm.vimusic.enums.PlaylistSortBy -import it.vfsfitvnm.vimusic.enums.SortOrder -import it.vfsfitvnm.vimusic.models.Playlist -import it.vfsfitvnm.vimusic.models.PlaylistPreview -import it.vfsfitvnm.vimusic.query -import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop -import it.vfsfitvnm.vimusic.ui.components.themed.Header -import it.vfsfitvnm.vimusic.ui.components.themed.HeaderIconButton -import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton -import it.vfsfitvnm.vimusic.ui.components.themed.TextFieldDialog -import it.vfsfitvnm.vimusic.ui.items.PlaylistItem -import it.vfsfitvnm.vimusic.ui.styling.Dimensions -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.px -import it.vfsfitvnm.vimusic.utils.playlistSortByKey -import it.vfsfitvnm.vimusic.utils.playlistSortOrderKey -import it.vfsfitvnm.vimusic.utils.rememberPreference - -@ExperimentalAnimationApi -@ExperimentalFoundationApi -@Composable -fun HomePlaylists( - onBuiltInPlaylist: (BuiltInPlaylist) -> Unit, - onPlaylistClick: (Playlist) -> Unit, - onSearchClick: () -> Unit, -) { - val (colorPalette) = LocalAppearance.current - - var isCreatingANewPlaylist by rememberSaveable { - mutableStateOf(false) - } - - if (isCreatingANewPlaylist) { - TextFieldDialog( - hintText = "Enter the playlist name", - onDismiss = { - isCreatingANewPlaylist = false - }, - onDone = { text -> - query { - Database.insert(Playlist(name = text)) - } - } - ) - } - - var sortBy by rememberPreference(playlistSortByKey, PlaylistSortBy.DateAdded) - var sortOrder by rememberPreference(playlistSortOrderKey, SortOrder.Descending) - - var items by persistList("home/playlists") - - LaunchedEffect(sortBy, sortOrder) { - Database.playlistPreviews(sortBy, sortOrder).collect { items = it } - } - - val sortOrderIconRotation by animateFloatAsState( - targetValue = if (sortOrder == SortOrder.Ascending) 0f else 180f, - animationSpec = tween(durationMillis = 400, easing = LinearEasing) - ) - - val thumbnailSizeDp = 108.dp - val thumbnailSizePx = thumbnailSizeDp.px - - val lazyGridState = rememberLazyGridState() - - Box { - LazyVerticalGrid( - state = lazyGridState, - columns = GridCells.Adaptive(Dimensions.thumbnails.song * 2 + Dimensions.itemsVerticalPadding * 2), - contentPadding = LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), - verticalArrangement = Arrangement.spacedBy(Dimensions.itemsVerticalPadding * 2), - horizontalArrangement = Arrangement.spacedBy( - space = Dimensions.itemsVerticalPadding * 2, - alignment = Alignment.CenterHorizontally - ), - modifier = Modifier - .fillMaxSize() - .background(colorPalette.background0) - ) { - item(key = "header", contentType = 0, span = { GridItemSpan(maxLineSpan) }) { - Header(title = "Playlists") { - SecondaryTextButton( - text = "新しいプレイリスト", - onClick = { isCreatingANewPlaylist = true } - ) - - Spacer( - modifier = Modifier - .weight(1f) - ) - - HeaderIconButton( - icon = R.drawable.medical, - color = if (sortBy == PlaylistSortBy.SongCount) colorPalette.text else colorPalette.textDisabled, - onClick = { sortBy = PlaylistSortBy.SongCount } - ) - - HeaderIconButton( - icon = R.drawable.text, - color = if (sortBy == PlaylistSortBy.Name) colorPalette.text else colorPalette.textDisabled, - onClick = { sortBy = PlaylistSortBy.Name } - ) - - HeaderIconButton( - icon = R.drawable.time, - color = if (sortBy == PlaylistSortBy.DateAdded) colorPalette.text else colorPalette.textDisabled, - onClick = { sortBy = PlaylistSortBy.DateAdded } - ) - - Spacer( - modifier = Modifier - .width(2.dp) - ) - - HeaderIconButton( - icon = R.drawable.arrow_up, - color = colorPalette.text, - onClick = { sortOrder = !sortOrder }, - modifier = Modifier - .graphicsLayer { rotationZ = sortOrderIconRotation } - ) - } - } - - item(key = "Favorites") { - PlaylistItem( - icon = R.drawable.heart, - colorTint = colorPalette.red, - name = "お気に入り", - songCount = null, - thumbnailSizeDp = thumbnailSizeDp, - alternative = true, - modifier = Modifier - .clickable(onClick = { onBuiltInPlaylist(BuiltInPlaylist.Favorites) }) - .animateItemPlacement() - ) - } - - item(key = "Offline") { - PlaylistItem( - icon = R.drawable.airplane, - colorTint = colorPalette.blue, - name = "オフライン", - songCount = null, - thumbnailSizeDp = thumbnailSizeDp, - alternative = true, - modifier = Modifier - .clickable(onClick = { onBuiltInPlaylist(BuiltInPlaylist.Offline) }) - .animateItemPlacement() - ) - } - - items(items = items, key = { it.playlist.id }) { playlistPreview -> - PlaylistItem( - playlist = playlistPreview, - thumbnailSizeDp = thumbnailSizeDp, - thumbnailSizePx = thumbnailSizePx, - alternative = true, - modifier = Modifier - .clickable(onClick = { onPlaylistClick(playlistPreview.playlist) }) - .animateItemPlacement() - ) - } - } - - FloatingActionsContainerWithScrollToTop( - lazyGridState = lazyGridState, - iconId = R.drawable.search, - onClick = onSearchClick - ) - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeScreen.kt deleted file mode 100644 index 6dedb89..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeScreen.kt +++ /dev/null @@ -1,162 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens.home - -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.runtime.Composable -import androidx.compose.runtime.saveable.rememberSaveableStateHolder -import androidx.compose.ui.platform.LocalContext -import it.vfsfitvnm.compose.persist.PersistMapCleanup -import it.vfsfitvnm.compose.routing.RouteHandler -import it.vfsfitvnm.compose.routing.defaultStacking -import it.vfsfitvnm.compose.routing.defaultStill -import it.vfsfitvnm.compose.routing.defaultUnstacking -import it.vfsfitvnm.compose.routing.isStacking -import it.vfsfitvnm.compose.routing.isUnknown -import it.vfsfitvnm.compose.routing.isUnstacking -import it.vfsfitvnm.vimusic.Database -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.models.SearchQuery -import it.vfsfitvnm.vimusic.query -import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold -import it.vfsfitvnm.vimusic.ui.screens.albumRoute -import it.vfsfitvnm.vimusic.ui.screens.artistRoute -import it.vfsfitvnm.vimusic.ui.screens.builtInPlaylistRoute -import it.vfsfitvnm.vimusic.ui.screens.builtinplaylist.BuiltInPlaylistScreen -import it.vfsfitvnm.vimusic.ui.screens.globalRoutes -import it.vfsfitvnm.vimusic.ui.screens.localPlaylistRoute -import it.vfsfitvnm.vimusic.ui.screens.localplaylist.LocalPlaylistScreen -import it.vfsfitvnm.vimusic.ui.screens.playlistRoute -import it.vfsfitvnm.vimusic.ui.screens.search.SearchScreen -import it.vfsfitvnm.vimusic.ui.screens.searchResultRoute -import it.vfsfitvnm.vimusic.ui.screens.searchRoute -import it.vfsfitvnm.vimusic.ui.screens.searchresult.SearchResultScreen -import it.vfsfitvnm.vimusic.ui.screens.settings.SettingsScreen -import it.vfsfitvnm.vimusic.ui.screens.settingsRoute -import it.vfsfitvnm.vimusic.utils.homeScreenTabIndexKey -import it.vfsfitvnm.vimusic.utils.pauseSearchHistoryKey -import it.vfsfitvnm.vimusic.utils.preferences -import it.vfsfitvnm.vimusic.utils.rememberPreference - -@ExperimentalFoundationApi -@ExperimentalAnimationApi -@Composable -fun HomeScreen(onPlaylistUrl: (String) -> Unit) { - val saveableStateHolder = rememberSaveableStateHolder() - - PersistMapCleanup("home/") - - RouteHandler( - listenToGlobalEmitter = true, - transitionSpec = { - when { - isStacking -> defaultStacking - isUnstacking -> defaultUnstacking - isUnknown -> when { - initialState.route == searchRoute && targetState.route == searchResultRoute -> defaultStacking - initialState.route == searchResultRoute && targetState.route == searchRoute -> defaultUnstacking - else -> defaultStill - } - - else -> defaultStill - } - } - ) { - globalRoutes() - - settingsRoute { - SettingsScreen() - } - - localPlaylistRoute { playlistId -> - LocalPlaylistScreen( - playlistId = playlistId ?: error("playlistId cannot be null") - ) - } - - builtInPlaylistRoute { builtInPlaylist -> - BuiltInPlaylistScreen( - builtInPlaylist = builtInPlaylist - ) - } - - searchResultRoute { query -> - SearchResultScreen( - query = query, - onSearchAgain = { - searchRoute(query) - } - ) - } - - searchRoute { initialTextInput -> - val context = LocalContext.current - - SearchScreen( - initialTextInput = initialTextInput, - onSearch = { query -> - pop() - searchResultRoute(query) - - if (!context.preferences.getBoolean(pauseSearchHistoryKey, false)) { - query { - Database.insert(SearchQuery(query = query)) - } - } - }, - onViewPlaylist = onPlaylistUrl - ) - } - - host { - val (tabIndex, onTabChanged) = rememberPreference( - homeScreenTabIndexKey, - defaultValue = 0 - ) - - Scaffold( - topIconButtonId = R.drawable.equalizer, - onTopIconButtonClick = { settingsRoute() }, - tabIndex = tabIndex, - onTabChanged = onTabChanged, - tabColumnContent = { Item -> - Item(0, "Quick picks", R.drawable.sparkles) - Item(1, "Songs", R.drawable.musical_notes) - Item(2, "Playlists", R.drawable.playlist) - Item(3, "Artists", R.drawable.person) - Item(4, "Albums", R.drawable.disc) - } - ) { currentTabIndex -> - saveableStateHolder.SaveableStateProvider(key = currentTabIndex) { - when (currentTabIndex) { - 0 -> QuickPicks( - onAlbumClick = { albumRoute(it) }, - onArtistClick = { artistRoute(it) }, - onPlaylistClick = { playlistRoute(it) }, - onSearchClick = { searchRoute("") } - ) - - 1 -> HomeSongs( - onSearchClick = { searchRoute("") } - ) - - 2 -> HomePlaylists( - onBuiltInPlaylist = { builtInPlaylistRoute(it) }, - onPlaylistClick = { localPlaylistRoute(it.id) }, - onSearchClick = { searchRoute("") } - ) - - 3 -> HomeArtistList( - onArtistClick = { artistRoute(it.id) }, - onSearchClick = { searchRoute("") } - ) - - 4 -> HomeAlbums( - onAlbumClick = { albumRoute(it.id) }, - onSearchClick = { searchRoute("") } - ) - } - } - } - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeSongs.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeSongs.kt deleted file mode 100644 index c0f6ebb..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeSongs.kt +++ /dev/null @@ -1,194 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens.home - -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.text.BasicText -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import it.vfsfitvnm.compose.persist.persistList -import it.vfsfitvnm.vimusic.Database -import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets -import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.enums.SongSortBy -import it.vfsfitvnm.vimusic.enums.SortOrder -import it.vfsfitvnm.vimusic.models.Song -import it.vfsfitvnm.vimusic.ui.components.LocalMenuState -import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop -import it.vfsfitvnm.vimusic.ui.components.themed.Header -import it.vfsfitvnm.vimusic.ui.components.themed.HeaderIconButton -import it.vfsfitvnm.vimusic.ui.components.themed.InHistoryMediaItemMenu -import it.vfsfitvnm.vimusic.ui.items.SongItem -import it.vfsfitvnm.vimusic.ui.styling.Dimensions -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.onOverlay -import it.vfsfitvnm.vimusic.ui.styling.overlay -import it.vfsfitvnm.vimusic.ui.styling.px -import it.vfsfitvnm.vimusic.utils.asMediaItem -import it.vfsfitvnm.vimusic.utils.center -import it.vfsfitvnm.vimusic.utils.color -import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex -import it.vfsfitvnm.vimusic.utils.rememberPreference -import it.vfsfitvnm.vimusic.utils.semiBold -import it.vfsfitvnm.vimusic.utils.songSortByKey -import it.vfsfitvnm.vimusic.utils.songSortOrderKey - -@ExperimentalFoundationApi -@ExperimentalAnimationApi -@Composable -fun HomeSongs( - onSearchClick: () -> Unit -) { - val (colorPalette, typography, thumbnailShape) = LocalAppearance.current - val binder = LocalPlayerServiceBinder.current - val menuState = LocalMenuState.current - - val thumbnailSizeDp = Dimensions.thumbnails.song - val thumbnailSizePx = thumbnailSizeDp.px - - var sortBy by rememberPreference(songSortByKey, SongSortBy.DateAdded) - var sortOrder by rememberPreference(songSortOrderKey, SortOrder.Descending) - - var items by persistList("home/songs") - - LaunchedEffect(sortBy, sortOrder) { - Database.songs(sortBy, sortOrder).collect { items = it } - } - - val sortOrderIconRotation by animateFloatAsState( - targetValue = if (sortOrder == SortOrder.Ascending) 0f else 180f, - animationSpec = tween(durationMillis = 400, easing = LinearEasing) - ) - - val lazyListState = rememberLazyListState() - - Box( - modifier = Modifier - .background(colorPalette.background0) - .fillMaxSize() - ) { - LazyColumn( - state = lazyListState, - contentPadding = LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), - ) { - item( - key = "header", - contentType = 0 - ) { - Header(title = "Songs") { - HeaderIconButton( - icon = R.drawable.trending, - color = if (sortBy == SongSortBy.PlayTime) colorPalette.text else colorPalette.textDisabled, - onClick = { sortBy = SongSortBy.PlayTime } - ) - - HeaderIconButton( - icon = R.drawable.text, - color = if (sortBy == SongSortBy.Title) colorPalette.text else colorPalette.textDisabled, - onClick = { sortBy = SongSortBy.Title } - ) - - HeaderIconButton( - icon = R.drawable.time, - color = if (sortBy == SongSortBy.DateAdded) colorPalette.text else colorPalette.textDisabled, - onClick = { sortBy = SongSortBy.DateAdded } - ) - - Spacer( - modifier = Modifier - .width(2.dp) - ) - - HeaderIconButton( - icon = R.drawable.arrow_up, - color = colorPalette.text, - onClick = { sortOrder = !sortOrder }, - modifier = Modifier - .graphicsLayer { rotationZ = sortOrderIconRotation } - ) - } - } - - itemsIndexed( - items = items, - key = { _, song -> song.id } - ) { index, song -> - SongItem( - song = song, - thumbnailSizePx = thumbnailSizePx, - thumbnailSizeDp = thumbnailSizeDp, - onThumbnailContent = if (sortBy == SongSortBy.PlayTime) ({ - BasicText( - text = song.formattedTotalPlayTime, - style = typography.xxs.semiBold.center.color(colorPalette.onOverlay), - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .fillMaxWidth() - .background( - brush = Brush.verticalGradient( - colors = listOf(Color.Transparent, colorPalette.overlay) - ), - shape = thumbnailShape - ) - .padding(horizontal = 8.dp, vertical = 4.dp) - .align(Alignment.BottomCenter) - ) - }) else null, - modifier = Modifier - .combinedClickable( - onLongClick = { - menuState.display { - InHistoryMediaItemMenu( - song = song, - onDismiss = menuState::hide - ) - } - }, - onClick = { - binder?.stopRadio() - binder?.player?.forcePlayAtIndex( - items.map(Song::asMediaItem), - index - ) - } - ) - .animateItemPlacement() - ) - } - } - - FloatingActionsContainerWithScrollToTop( - lazyListState = lazyListState, - iconId = R.drawable.search, - onClick = onSearchClick - ) - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/localplaylist/LocalPlaylistScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/localplaylist/LocalPlaylistScreen.kt deleted file mode 100644 index 8df4f82..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/localplaylist/LocalPlaylistScreen.kt +++ /dev/null @@ -1,45 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens.localplaylist - -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.runtime.Composable -import androidx.compose.runtime.saveable.rememberSaveableStateHolder -import it.vfsfitvnm.compose.persist.PersistMapCleanup -import it.vfsfitvnm.compose.routing.RouteHandler -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold -import it.vfsfitvnm.vimusic.ui.screens.globalRoutes - -@ExperimentalFoundationApi -@ExperimentalAnimationApi -@Composable -fun LocalPlaylistScreen(playlistId: Long) { - val saveableStateHolder = rememberSaveableStateHolder() - - PersistMapCleanup(tagPrefix = "localPlaylist/$playlistId/") - - RouteHandler(listenToGlobalEmitter = true) { - globalRoutes() - - host { - Scaffold( - topIconButtonId = R.drawable.chevron_back, - onTopIconButtonClick = pop, - tabIndex = 0, - onTabChanged = { }, - tabColumnContent = { Item -> - Item(0, "Songs", R.drawable.musical_notes) - } - ) { currentTabIndex -> - saveableStateHolder.SaveableStateProvider(currentTabIndex) { - when (currentTabIndex) { - 0 -> LocalPlaylistSongs( - playlistId = playlistId, - onDelete = pop - ) - } - } - } - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/localplaylist/LocalPlaylistSongs.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/localplaylist/LocalPlaylistSongs.kt deleted file mode 100644 index ad18145..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/localplaylist/LocalPlaylistSongs.kt +++ /dev/null @@ -1,298 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens.localplaylist - -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.ripple.rememberRipple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import it.vfsfitvnm.compose.persist.persist -import it.vfsfitvnm.innertube.Innertube -import it.vfsfitvnm.innertube.models.bodies.BrowseBody -import it.vfsfitvnm.innertube.requests.playlistPage -import it.vfsfitvnm.compose.reordering.ReorderingLazyColumn -import it.vfsfitvnm.compose.reordering.animateItemPlacement -import it.vfsfitvnm.compose.reordering.draggedItem -import it.vfsfitvnm.compose.reordering.rememberReorderingState -import it.vfsfitvnm.compose.reordering.reorder -import it.vfsfitvnm.vimusic.Database -import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets -import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.models.PlaylistWithSongs -import it.vfsfitvnm.vimusic.models.Song -import it.vfsfitvnm.vimusic.models.SongPlaylistMap -import it.vfsfitvnm.vimusic.query -import it.vfsfitvnm.vimusic.transaction -import it.vfsfitvnm.vimusic.ui.components.LocalMenuState -import it.vfsfitvnm.vimusic.ui.components.themed.ConfirmationDialog -import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop -import it.vfsfitvnm.vimusic.ui.components.themed.Header -import it.vfsfitvnm.vimusic.ui.components.themed.HeaderIconButton -import it.vfsfitvnm.vimusic.ui.components.themed.IconButton -import it.vfsfitvnm.vimusic.ui.components.themed.InPlaylistMediaItemMenu -import it.vfsfitvnm.vimusic.ui.components.themed.Menu -import it.vfsfitvnm.vimusic.ui.components.themed.MenuEntry -import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton -import it.vfsfitvnm.vimusic.ui.components.themed.TextFieldDialog -import it.vfsfitvnm.vimusic.ui.items.SongItem -import it.vfsfitvnm.vimusic.ui.styling.Dimensions -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.px -import it.vfsfitvnm.vimusic.utils.asMediaItem -import it.vfsfitvnm.vimusic.utils.completed -import it.vfsfitvnm.vimusic.utils.enqueue -import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex -import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext - -@ExperimentalAnimationApi -@ExperimentalFoundationApi -@Composable -fun LocalPlaylistSongs( - playlistId: Long, - onDelete: () -> Unit, -) { - val (colorPalette) = LocalAppearance.current - val binder = LocalPlayerServiceBinder.current - val menuState = LocalMenuState.current - - var playlistWithSongs by persist("localPlaylist/$playlistId/playlistWithSongs") - - LaunchedEffect(Unit) { - Database.playlistWithSongs(playlistId).filterNotNull().collect { playlistWithSongs = it } - } - - val lazyListState = rememberLazyListState() - - val reorderingState = rememberReorderingState( - lazyListState = lazyListState, - key = playlistWithSongs?.songs ?: emptyList(), - onDragEnd = { fromIndex, toIndex -> - query { - Database.move(playlistId, fromIndex, toIndex) - } - }, - extraItemCount = 1 - ) - - var isRenaming by rememberSaveable { - mutableStateOf(false) - } - - if (isRenaming) { - TextFieldDialog( - hintText = "Enter the playlist name", - initialTextInput = playlistWithSongs?.playlist?.name ?: "", - onDismiss = { isRenaming = false }, - onDone = { text -> - query { - playlistWithSongs?.playlist?.copy(name = text)?.let(Database::update) - } - } - ) - } - - var isDeleting by rememberSaveable { - mutableStateOf(false) - } - - if (isDeleting) { - ConfirmationDialog( - text = "Do you really want to delete this playlist?", - onDismiss = { isDeleting = false }, - onConfirm = { - query { - playlistWithSongs?.playlist?.let(Database::delete) - } - onDelete() - } - ) - } - - val thumbnailSizeDp = Dimensions.thumbnails.song - val thumbnailSizePx = thumbnailSizeDp.px - - val rippleIndication = rememberRipple(bounded = false) - - Box { - ReorderingLazyColumn( - reorderingState = reorderingState, - contentPadding = LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), - modifier = Modifier - .background(colorPalette.background0) - .fillMaxSize() - ) { - item( - key = "header", - contentType = 0 - ) { - Header( - title = playlistWithSongs?.playlist?.name ?: "Unknown", - modifier = Modifier - .padding(bottom = 8.dp) - ) { - SecondaryTextButton( - text = "Enqueue", - enabled = playlistWithSongs?.songs?.isNotEmpty() == true, - onClick = { - playlistWithSongs?.songs - ?.map(Song::asMediaItem) - ?.let { mediaItems -> - binder?.player?.enqueue(mediaItems) - } - } - ) - - Spacer( - modifier = Modifier - .weight(1f) - ) - - HeaderIconButton( - icon = R.drawable.ellipsis_horizontal, - color = colorPalette.text, - onClick = { - menuState.display { - Menu { - playlistWithSongs?.playlist?.browseId?.let { browseId -> - MenuEntry( - icon = R.drawable.sync, - text = "Sync", - onClick = { - menuState.hide() - transaction { - runBlocking(Dispatchers.IO) { - withContext(Dispatchers.IO) { - Innertube.playlistPage(BrowseBody(browseId = browseId)) - ?.completed() - } - }?.getOrNull()?.let { remotePlaylist -> - Database.clearPlaylist(playlistId) - - remotePlaylist.songsPage - ?.items - ?.map(Innertube.SongItem::asMediaItem) - ?.onEach(Database::insert) - ?.mapIndexed { position, mediaItem -> - SongPlaylistMap( - songId = mediaItem.mediaId, - playlistId = playlistId, - position = position - ) - }?.let(Database::insertSongPlaylistMaps) - } - } - } - ) - } - - MenuEntry( - icon = R.drawable.pencil, - text = "Rename", - onClick = { - menuState.hide() - isRenaming = true - } - ) - - MenuEntry( - icon = R.drawable.trash, - text = "Delete", - onClick = { - menuState.hide() - isDeleting = true - } - ) - } - } - } - ) - } - } - - itemsIndexed( - items = playlistWithSongs?.songs ?: emptyList(), - key = { _, song -> song.id }, - contentType = { _, song -> song }, - ) { index, song -> - SongItem( - song = song, - thumbnailSizePx = thumbnailSizePx, - thumbnailSizeDp = thumbnailSizeDp, - trailingContent = { - IconButton( - icon = R.drawable.reorder, - color = colorPalette.textDisabled, - indication = rippleIndication, - onClick = {}, - modifier = Modifier - .reorder(reorderingState = reorderingState, index = index) - .size(18.dp) - ) - }, - modifier = Modifier - .combinedClickable( - onLongClick = { - menuState.display { - InPlaylistMediaItemMenu( - playlistId = playlistId, - positionInPlaylist = index, - song = song, - onDismiss = menuState::hide - ) - } - }, - onClick = { - playlistWithSongs?.songs - ?.map(Song::asMediaItem) - ?.let { mediaItems -> - binder?.stopRadio() - binder?.player?.forcePlayAtIndex(mediaItems, index) - } - } - ) - .animateItemPlacement(reorderingState = reorderingState) - .draggedItem(reorderingState = reorderingState, index = index) - ) - } - } - - FloatingActionsContainerWithScrollToTop( - lazyListState = lazyListState, - iconId = R.drawable.shuffle, - visible = !reorderingState.isDragging, - onClick = { - playlistWithSongs?.songs?.let { songs -> - if (songs.isNotEmpty()) { - binder?.stopRadio() - binder?.player?.forcePlayFromBeginning( - songs.shuffled().map(Song::asMediaItem) - ) - } - } - } - ) - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Controls.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Controls.kt deleted file mode 100644 index f5ccd35..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Controls.kt +++ /dev/null @@ -1,278 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens.player - -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.animateDp -import androidx.compose.animation.core.tween -import androidx.compose.animation.core.updateTransition -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicText -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.media3.common.C -import androidx.media3.common.Player -import it.vfsfitvnm.vimusic.Database -import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.models.Song -import it.vfsfitvnm.vimusic.query -import it.vfsfitvnm.vimusic.ui.components.SeekBar -import it.vfsfitvnm.vimusic.ui.components.themed.IconButton -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.favoritesIcon -import it.vfsfitvnm.vimusic.utils.bold -import it.vfsfitvnm.vimusic.utils.forceSeekToNext -import it.vfsfitvnm.vimusic.utils.forceSeekToPrevious -import it.vfsfitvnm.vimusic.utils.formatAsDuration -import it.vfsfitvnm.vimusic.utils.rememberPreference -import it.vfsfitvnm.vimusic.utils.secondary -import it.vfsfitvnm.vimusic.utils.semiBold -import it.vfsfitvnm.vimusic.utils.trackLoopEnabledKey -import kotlinx.coroutines.flow.distinctUntilChanged - -@Composable -fun Controls( - mediaId: String, - title: String?, - artist: String?, - shouldBePlaying: Boolean, - position: Long, - duration: Long, - modifier: Modifier = Modifier -) { - val (colorPalette, typography) = LocalAppearance.current - - val binder = LocalPlayerServiceBinder.current - binder?.player ?: return - - var trackLoopEnabled by rememberPreference(trackLoopEnabledKey, defaultValue = false) - - var scrubbingPosition by remember(mediaId) { - mutableStateOf(null) - } - - var likedAt by rememberSaveable { - mutableStateOf(null) - } - - LaunchedEffect(mediaId) { - Database.likedAt(mediaId).distinctUntilChanged().collect { likedAt = it } - } - - val shouldBePlayingTransition = updateTransition(shouldBePlaying, label = "shouldBePlaying") - - val playPauseRoundness by shouldBePlayingTransition.animateDp( - transitionSpec = { tween(durationMillis = 100, easing = LinearEasing) }, - label = "playPauseRoundness", - targetValueByState = { if (it) 32.dp else 16.dp } - ) - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 32.dp) - ) { - Spacer( - modifier = Modifier - .weight(1f) - ) - - BasicText( - text = title ?: "", - style = typography.l.bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - BasicText( - text = artist ?: "", - style = typography.s.semiBold.secondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - Spacer( - modifier = Modifier - .weight(0.5f) - ) - - SeekBar( - value = scrubbingPosition ?: position, - minimumValue = 0, - maximumValue = duration, - onDragStart = { - scrubbingPosition = it - }, - onDrag = { delta -> - scrubbingPosition = if (duration != C.TIME_UNSET) { - scrubbingPosition?.plus(delta)?.coerceIn(0, duration) - } else { - null - } - }, - onDragEnd = { - scrubbingPosition?.let(binder.player::seekTo) - scrubbingPosition = null - }, - color = colorPalette.text, - backgroundColor = colorPalette.background2, - shape = RoundedCornerShape(8.dp) - ) - - Spacer( - modifier = Modifier - .height(8.dp) - ) - - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - ) { - BasicText( - text = formatAsDuration(scrubbingPosition ?: position), - style = typography.xxs.semiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - - if (duration != C.TIME_UNSET) { - BasicText( - text = formatAsDuration(duration), - style = typography.xxs.semiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - - Spacer( - modifier = Modifier - .weight(1f) - ) - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - ) { - IconButton( - icon = if (likedAt == null) R.drawable.heart_outline else R.drawable.heart, - color = colorPalette.favoritesIcon, - onClick = { - val currentMediaItem = binder.player.currentMediaItem - query { - if (Database.like( - mediaId, - if (likedAt == null) System.currentTimeMillis() else null - ) == 0 - ) { - currentMediaItem - ?.takeIf { it.mediaId == mediaId } - ?.let { - Database.insert(currentMediaItem, Song::toggleLike) - } - } - } - }, - modifier = Modifier - .weight(1f) - .size(24.dp) - ) - - IconButton( - icon = R.drawable.play_skip_back, - color = colorPalette.text, - onClick = binder.player::forceSeekToPrevious, - modifier = Modifier - .weight(1f) - .size(24.dp) - ) - - Spacer( - modifier = Modifier - .width(8.dp) - ) - - Box( - modifier = Modifier - .clip(RoundedCornerShape(playPauseRoundness)) - .clickable { - if (shouldBePlaying) { - binder.player.pause() - } else { - if (binder.player.playbackState == Player.STATE_IDLE) { - binder.player.prepare() - } - binder.player.play() - } - } - .background(colorPalette.background2) - .size(64.dp) - ) { - Image( - painter = painterResource(if (shouldBePlaying) R.drawable.pause else R.drawable.play), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .align(Alignment.Center) - .size(28.dp) - ) - } - - Spacer( - modifier = Modifier - .width(8.dp) - ) - - IconButton( - icon = R.drawable.play_skip_forward, - color = colorPalette.text, - onClick = binder.player::forceSeekToNext, - modifier = Modifier - .weight(1f) - .size(24.dp) - ) - - IconButton( - icon = R.drawable.infinite, - color = if (trackLoopEnabled) colorPalette.text else colorPalette.textDisabled, - onClick = { trackLoopEnabled = !trackLoopEnabled }, - modifier = Modifier - .weight(1f) - .size(24.dp) - ) - } - - Spacer( - modifier = Modifier - .weight(1f) - ) - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Lyrics.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Lyrics.kt deleted file mode 100644 index b0d7665..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Lyrics.kt +++ /dev/null @@ -1,404 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens.player - -import android.app.SearchManager -import android.content.ActivityNotFoundException -import android.content.Intent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.BasicText -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.ripple.rememberRipple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.media3.common.C -import androidx.media3.common.MediaMetadata -import com.valentinilk.shimmer.shimmer -import it.vfsfitvnm.innertube.Innertube -import it.vfsfitvnm.innertube.models.bodies.NextBody -import it.vfsfitvnm.innertube.requests.lyrics -import it.vfsfitvnm.kugou.KuGou -import it.vfsfitvnm.vimusic.Database -import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.models.Lyrics -import it.vfsfitvnm.vimusic.query -import it.vfsfitvnm.vimusic.ui.components.LocalMenuState -import it.vfsfitvnm.vimusic.ui.components.themed.Menu -import it.vfsfitvnm.vimusic.ui.components.themed.MenuEntry -import it.vfsfitvnm.vimusic.ui.components.themed.TextFieldDialog -import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder -import it.vfsfitvnm.vimusic.ui.styling.DefaultDarkColorPalette -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.PureBlackColorPalette -import it.vfsfitvnm.vimusic.ui.styling.onOverlayShimmer -import it.vfsfitvnm.vimusic.utils.SynchronizedLyrics -import it.vfsfitvnm.vimusic.utils.center -import it.vfsfitvnm.vimusic.utils.color -import it.vfsfitvnm.vimusic.utils.isShowingSynchronizedLyricsKey -import it.vfsfitvnm.vimusic.utils.medium -import it.vfsfitvnm.vimusic.utils.rememberPreference -import it.vfsfitvnm.vimusic.utils.toast -import it.vfsfitvnm.vimusic.utils.verticalFadingEdge -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.withContext - -@Composable -fun Lyrics( - mediaId: String, - isDisplayed: Boolean, - onDismiss: () -> Unit, - size: Dp, - mediaMetadataProvider: () -> MediaMetadata, - durationProvider: () -> Long, - ensureSongInserted: () -> Unit, - modifier: Modifier = Modifier -) { - AnimatedVisibility( - visible = isDisplayed, - enter = fadeIn(), - exit = fadeOut(), - ) { - val (colorPalette, typography) = LocalAppearance.current - val context = LocalContext.current - val menuState = LocalMenuState.current - val currentView = LocalView.current - - var isShowingSynchronizedLyrics by rememberPreference(isShowingSynchronizedLyricsKey, false) - - var isEditing by remember(mediaId, isShowingSynchronizedLyrics) { - mutableStateOf(false) - } - - var lyrics by remember { - mutableStateOf(null) - } - - val text = if (isShowingSynchronizedLyrics) lyrics?.synced else lyrics?.fixed - - var isError by remember(mediaId, isShowingSynchronizedLyrics) { - mutableStateOf(false) - } - - LaunchedEffect(mediaId, isShowingSynchronizedLyrics) { - withContext(Dispatchers.IO) { - Database.lyrics(mediaId).collect { - if (isShowingSynchronizedLyrics && it?.synced == null) { - val mediaMetadata = mediaMetadataProvider() - var duration = withContext(Dispatchers.Main) { - durationProvider() - } - - while (duration == C.TIME_UNSET) { - delay(100) - duration = withContext(Dispatchers.Main) { - durationProvider() - } - } - - KuGou.lyrics( - artist = mediaMetadata.artist?.toString() ?: "", - title = mediaMetadata.title?.toString() ?: "", - duration = duration / 1000 - )?.onSuccess { syncedLyrics -> - Database.upsert( - Lyrics( - songId = mediaId, - fixed = it?.fixed, - synced = syncedLyrics?.value ?: "" - ) - ) - }?.onFailure { - isError = true - } - } else if (!isShowingSynchronizedLyrics && it?.fixed == null) { - Innertube.lyrics(NextBody(videoId = mediaId))?.onSuccess { fixedLyrics -> - Database.upsert( - Lyrics( - songId = mediaId, - fixed = fixedLyrics ?: "", - synced = it?.synced - ) - ) - }?.onFailure { - isError = true - } - } else { - lyrics = it - } - } - } - } - - if (isEditing) { - TextFieldDialog( - hintText = "Enter the lyrics", - initialTextInput = text ?: "", - singleLine = false, - maxLines = 10, - isTextInputValid = { true }, - onDismiss = { isEditing = false }, - onDone = { - query { - ensureSongInserted() - Database.upsert( - Lyrics( - songId = mediaId, - fixed = if (isShowingSynchronizedLyrics) lyrics?.fixed else it, - synced = if (isShowingSynchronizedLyrics) it else lyrics?.synced, - ) - ) - } - } - ) - } - - if (isShowingSynchronizedLyrics) { - DisposableEffect(Unit) { - currentView.keepScreenOn = true - onDispose { - currentView.keepScreenOn = false - } - } - } - - Box( - contentAlignment = Alignment.Center, - modifier = modifier - .pointerInput(Unit) { - detectTapGestures( - onTap = { onDismiss() } - ) - } - .fillMaxSize() - .background(Color.Black.copy(0.8f)) - ) { - AnimatedVisibility( - visible = isError && text == null, - enter = slideInVertically { -it }, - exit = slideOutVertically { -it }, - modifier = Modifier - .align(Alignment.TopCenter) - ) { - BasicText( - text = "An error has occurred while fetching the ${if (isShowingSynchronizedLyrics) "synchronized " else ""}lyrics", - style = typography.xs.center.medium.color(PureBlackColorPalette.text), - modifier = Modifier - .background(Color.Black.copy(0.4f)) - .padding(all = 8.dp) - .fillMaxWidth() - ) - } - - AnimatedVisibility( - visible = text?.let(String::isEmpty) ?: false, - enter = slideInVertically { -it }, - exit = slideOutVertically { -it }, - modifier = Modifier - .align(Alignment.TopCenter) - ) { - BasicText( - text = "${if (isShowingSynchronizedLyrics) "Synchronized l" else "L"}yrics are not available for this song", - style = typography.xs.center.medium.color(PureBlackColorPalette.text), - modifier = Modifier - .background(Color.Black.copy(0.4f)) - .padding(all = 8.dp) - .fillMaxWidth() - ) - } - - if (text?.isNotEmpty() == true) { - if (isShowingSynchronizedLyrics) { - val density = LocalDensity.current - val player = LocalPlayerServiceBinder.current?.player - ?: return@AnimatedVisibility - - val synchronizedLyrics = remember(text) { - SynchronizedLyrics(KuGou.Lyrics(text).sentences) { - player.currentPosition + 50 - } - } - - val lazyListState = rememberLazyListState( - synchronizedLyrics.index, - with(density) { size.roundToPx() } / 6) - - LaunchedEffect(synchronizedLyrics) { - val center = with(density) { size.roundToPx() } / 6 - - while (isActive) { - delay(50) - if (synchronizedLyrics.update()) { - lazyListState.animateScrollToItem( - synchronizedLyrics.index, - center - ) - } - } - } - - LazyColumn( - state = lazyListState, - userScrollEnabled = false, - contentPadding = PaddingValues(vertical = size / 2), - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .verticalFadingEdge() - ) { - itemsIndexed(items = synchronizedLyrics.sentences) { index, sentence -> - BasicText( - text = sentence.second, - style = typography.xs.center.medium.color(if (index == synchronizedLyrics.index) PureBlackColorPalette.text else PureBlackColorPalette.textDisabled), - modifier = Modifier - .padding(vertical = 4.dp, horizontal = 32.dp) - ) - } - } - } else { - BasicText( - text = text, - style = typography.xs.center.medium.color(PureBlackColorPalette.text), - modifier = Modifier - .verticalFadingEdge() - .verticalScroll(rememberScrollState()) - .fillMaxWidth() - .padding(vertical = size / 4, horizontal = 32.dp) - ) - } - } - - if (text == null && !isError) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .shimmer() - ) { - repeat(4) { - TextPlaceholder( - color = colorPalette.onOverlayShimmer, - modifier = Modifier - .alpha(1f - it * 0.2f) - ) - } - } - } - - Image( - painter = painterResource(R.drawable.ellipsis_horizontal), - contentDescription = null, - colorFilter = ColorFilter.tint(DefaultDarkColorPalette.text), - modifier = Modifier - .padding(all = 4.dp) - .clickable( - indication = rememberRipple(bounded = false), - interactionSource = remember { MutableInteractionSource() }, - onClick = { - menuState.display { - Menu { - MenuEntry( - icon = R.drawable.time, - text = "Show ${if (isShowingSynchronizedLyrics) "un" else ""}synchronized lyrics", - secondaryText = if (isShowingSynchronizedLyrics) null else "Provided by kugou.com", - onClick = { - menuState.hide() - isShowingSynchronizedLyrics = - !isShowingSynchronizedLyrics - } - ) - - MenuEntry( - icon = R.drawable.pencil, - text = "Edit lyrics", - onClick = { - menuState.hide() - isEditing = true - } - ) - - MenuEntry( - icon = R.drawable.search, - text = "Search lyrics online", - onClick = { - menuState.hide() - val mediaMetadata = mediaMetadataProvider() - - try { - context.startActivity( - Intent(Intent.ACTION_WEB_SEARCH).apply { - putExtra( - SearchManager.QUERY, - "${mediaMetadata.title} ${mediaMetadata.artist} lyrics" - ) - } - ) - } catch (e: ActivityNotFoundException) { - context.toast("Couldn't find an application to browse the Internet") - } - } - ) - - MenuEntry( - icon = R.drawable.download, - text = "Fetch lyrics again", - enabled = lyrics != null, - onClick = { - menuState.hide() - query { - Database.upsert( - Lyrics( - songId = mediaId, - fixed = if (isShowingSynchronizedLyrics) lyrics?.fixed else null, - synced = if (isShowingSynchronizedLyrics) null else lyrics?.synced, - ) - ) - } - } - ) - } - } - } - ) - .padding(all = 8.dp) - .size(20.dp) - .align(Alignment.BottomEnd) - ) - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/PlaybackError.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/PlaybackError.kt deleted file mode 100644 index a654e67..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/PlaybackError.kt +++ /dev/null @@ -1,75 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens.player - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.BasicText -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.unit.dp -import it.vfsfitvnm.vimusic.ui.styling.PureBlackColorPalette -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.utils.center -import it.vfsfitvnm.vimusic.utils.color -import it.vfsfitvnm.vimusic.utils.medium - -@Composable -fun PlaybackError( - isDisplayed: Boolean, - messageProvider: () -> String, - onDismiss: () -> Unit, - modifier: Modifier = Modifier -) { - val (_, typography) = LocalAppearance.current - - Box { - AnimatedVisibility( - visible = isDisplayed, - enter = fadeIn(), - exit = fadeOut(), - ) { - Spacer( - modifier = modifier - .pointerInput(Unit) { - detectTapGestures( - onTap = { - onDismiss() - } - ) - } - .fillMaxSize() - .background(Color.Black.copy(0.8f)) - ) - } - - AnimatedVisibility( - visible = isDisplayed, - enter = slideInVertically { -it }, - exit = slideOutVertically { -it }, - modifier = Modifier - .align(Alignment.TopCenter) - ) { - BasicText( - text = remember { messageProvider() }, - style = typography.xs.center.medium.color(PureBlackColorPalette.text), - modifier = Modifier - .background(Color.Black.copy(0.4f)) - .padding(all = 8.dp) - .fillMaxWidth() - ) - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Player.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Player.kt deleted file mode 100644 index 12b6d10..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Player.kt +++ /dev/null @@ -1,413 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens.player - -import android.content.ActivityNotFoundException -import android.content.Intent -import android.media.audiofx.AudioEffect -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.systemBars -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.text.BasicText -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.neverEqualPolicy -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.media3.common.MediaItem -import androidx.media3.common.Player -import coil.compose.AsyncImage -import it.vfsfitvnm.innertube.models.NavigationEndpoint -import it.vfsfitvnm.compose.routing.OnGlobalRoute -import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.service.PlayerService -import it.vfsfitvnm.vimusic.ui.components.BottomSheet -import it.vfsfitvnm.vimusic.ui.components.BottomSheetState -import it.vfsfitvnm.vimusic.ui.components.LocalMenuState -import it.vfsfitvnm.vimusic.ui.components.rememberBottomSheetState -import it.vfsfitvnm.vimusic.ui.components.themed.BaseMediaItemMenu -import it.vfsfitvnm.vimusic.ui.components.themed.IconButton -import it.vfsfitvnm.vimusic.ui.styling.Dimensions -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.collapsedPlayerProgressBar -import it.vfsfitvnm.vimusic.ui.styling.px -import it.vfsfitvnm.vimusic.utils.DisposableListener -import it.vfsfitvnm.vimusic.utils.forceSeekToNext -import it.vfsfitvnm.vimusic.utils.isLandscape -import it.vfsfitvnm.vimusic.utils.positionAndDurationState -import it.vfsfitvnm.vimusic.utils.seamlessPlay -import it.vfsfitvnm.vimusic.utils.secondary -import it.vfsfitvnm.vimusic.utils.semiBold -import it.vfsfitvnm.vimusic.utils.shouldBePlaying -import it.vfsfitvnm.vimusic.utils.thumbnail -import it.vfsfitvnm.vimusic.utils.toast -import kotlin.math.absoluteValue - -@ExperimentalFoundationApi -@ExperimentalAnimationApi -@Composable -fun Player( - layoutState: BottomSheetState, - modifier: Modifier = Modifier, -) { - val menuState = LocalMenuState.current - - val (colorPalette, typography, thumbnailShape) = LocalAppearance.current - val binder = LocalPlayerServiceBinder.current - - binder?.player ?: return - - var nullableMediaItem by remember { - mutableStateOf(binder.player.currentMediaItem, neverEqualPolicy()) - } - - var shouldBePlaying by remember { - mutableStateOf(binder.player.shouldBePlaying) - } - - binder.player.DisposableListener { - object : Player.Listener { - override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { - nullableMediaItem = mediaItem - } - - override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { - shouldBePlaying = binder.player.shouldBePlaying - } - - override fun onPlaybackStateChanged(playbackState: Int) { - shouldBePlaying = binder.player.shouldBePlaying - } - } - } - - val mediaItem = nullableMediaItem ?: return - - val positionAndDuration by binder.player.positionAndDurationState() - - val windowInsets = WindowInsets.systemBars - - val horizontalBottomPaddingValues = windowInsets - .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom).asPaddingValues() - - OnGlobalRoute { - layoutState.collapseSoft() - } - - BottomSheet( - state = layoutState, - modifier = modifier, - onDismiss = { - binder.stopRadio() - binder.player.clearMediaItems() - }, - collapsedContent = { - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.Top, - modifier = Modifier - .background(colorPalette.background1) - .fillMaxSize() - .padding(horizontalBottomPaddingValues) - .drawBehind { - val progress = - positionAndDuration.first.toFloat() / positionAndDuration.second.absoluteValue - - drawLine( - color = colorPalette.collapsedPlayerProgressBar, - start = Offset(x = 0f, y = 1.dp.toPx()), - end = Offset(x = size.width * progress, y = 1.dp.toPx()), - strokeWidth = 2.dp.toPx() - ) - } - ) { - Spacer( - modifier = Modifier - .width(2.dp) - ) - - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .height(Dimensions.collapsedPlayer) - ) { - AsyncImage( - model = mediaItem.mediaMetadata.artworkUri.thumbnail(Dimensions.thumbnails.song.px), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .clip(thumbnailShape) - .size(48.dp) - ) - } - - Column( - verticalArrangement = Arrangement.Center, - modifier = Modifier - .height(Dimensions.collapsedPlayer) - .weight(1f) - ) { - BasicText( - text = mediaItem.mediaMetadata.title?.toString() ?: "", - style = typography.xs.semiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - BasicText( - text = mediaItem.mediaMetadata.artist?.toString() ?: "", - style = typography.xs.semiBold.secondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - - Spacer( - modifier = Modifier - .width(2.dp) - ) - - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .height(Dimensions.collapsedPlayer) - ) { - IconButton( - icon = if (shouldBePlaying) R.drawable.pause else R.drawable.play, - color = colorPalette.text, - onClick = { - if (shouldBePlaying) { - binder.player.pause() - } else { - if (binder.player.playbackState == Player.STATE_IDLE) { - binder.player.prepare() - } - binder.player.play() - } - }, - modifier = Modifier - .padding(horizontal = 4.dp, vertical = 8.dp) - .size(20.dp) - ) - - IconButton( - icon = R.drawable.play_skip_forward, - color = colorPalette.text, - onClick = binder.player::forceSeekToNext, - modifier = Modifier - .padding(horizontal = 4.dp, vertical = 8.dp) - .size(20.dp) - ) - } - - Spacer( - modifier = Modifier - .width(2.dp) - ) - } - } - ) { - var isShowingLyrics by rememberSaveable { - mutableStateOf(false) - } - - var isShowingStatsForNerds by rememberSaveable { - mutableStateOf(false) - } - - val playerBottomSheetState = rememberBottomSheetState( - 64.dp + horizontalBottomPaddingValues.calculateBottomPadding(), - layoutState.expandedBound - ) - - val containerModifier = Modifier - .background(colorPalette.background1) - .padding( - windowInsets - .only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) - .asPaddingValues() - ) - .padding(bottom = playerBottomSheetState.collapsedBound) - - val thumbnailContent: @Composable (modifier: Modifier) -> Unit = { modifier -> - Thumbnail( - isShowingLyrics = isShowingLyrics, - onShowLyrics = { isShowingLyrics = it }, - isShowingStatsForNerds = isShowingStatsForNerds, - onShowStatsForNerds = { isShowingStatsForNerds = it }, - modifier = modifier - .nestedScroll(layoutState.preUpPostDownNestedScrollConnection) - ) - } - - val controlsContent: @Composable (modifier: Modifier) -> Unit = { modifier -> - Controls( - mediaId = mediaItem.mediaId, - title = mediaItem.mediaMetadata.title?.toString(), - artist = mediaItem.mediaMetadata.artist?.toString(), - shouldBePlaying = shouldBePlaying, - position = positionAndDuration.first, - duration = positionAndDuration.second, - modifier = modifier - ) - } - - if (isLandscape) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = containerModifier - .padding(top = 32.dp) - ) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .weight(0.66f) - .padding(bottom = 16.dp) - ) { - thumbnailContent( - modifier = Modifier - .padding(horizontal = 16.dp) - ) - } - - controlsContent( - modifier = Modifier - .padding(vertical = 8.dp) - .fillMaxHeight() - .weight(1f) - ) - } - } else { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = containerModifier - .padding(top = 54.dp) - ) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .weight(1.25f) - ) { - thumbnailContent( - modifier = Modifier - .padding(horizontal = 32.dp, vertical = 8.dp) - ) - } - - controlsContent( - modifier = Modifier - .padding(vertical = 8.dp) - .fillMaxWidth() - .weight(1f) - ) - } - } - - - Queue( - layoutState = playerBottomSheetState, - content = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.End, - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(horizontal = 8.dp) - .fillMaxHeight() - ) { - IconButton( - icon = R.drawable.ellipsis_horizontal, - color = colorPalette.text, - onClick = { - menuState.display { - PlayerMenu( - onDismiss = menuState::hide, - mediaItem = mediaItem, - binder = binder - ) - } - }, - modifier = Modifier - .padding(horizontal = 4.dp, vertical = 8.dp) - .size(20.dp) - ) - - Spacer( - modifier = Modifier - .width(4.dp) - ) - } - }, - backgroundColorProvider = { colorPalette.background2 }, - modifier = Modifier - .align(Alignment.BottomCenter) - ) - } -} - -@ExperimentalAnimationApi -@Composable -private fun PlayerMenu( - binder: PlayerService.Binder, - mediaItem: MediaItem, - onDismiss: () -> Unit -) { - val context = LocalContext.current - - val activityResultLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { } - - BaseMediaItemMenu( - mediaItem = mediaItem, - onStartRadio = { - binder.stopRadio() - binder.player.seamlessPlay(mediaItem) - binder.setupRadio(NavigationEndpoint.Endpoint.Watch(videoId = mediaItem.mediaId)) - }, - onGoToEqualizer = { - try { - activityResultLauncher.launch( - Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL).apply { - putExtra(AudioEffect.EXTRA_AUDIO_SESSION, binder.player.audioSessionId) - putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.packageName) - putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC) - } - ) - } catch (e: ActivityNotFoundException) { - context.toast("Couldn't find an application to equalize audio") - } - }, - onShowSleepTimer = {}, - onDismiss = onDismiss - ) -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Queue.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Queue.kt deleted file mode 100644 index 138d985..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Queue.kt +++ /dev/null @@ -1,388 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens.player - -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedContentScope -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.ContentTransform -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.tween -import androidx.compose.animation.core.updateTransition -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.systemBars -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicText -import androidx.compose.material.ripple.rememberRipple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp -import androidx.media3.common.MediaItem -import androidx.media3.common.Player -import androidx.media3.common.Timeline -import com.valentinilk.shimmer.shimmer -import it.vfsfitvnm.compose.reordering.ReorderingLazyColumn -import it.vfsfitvnm.compose.reordering.animateItemPlacement -import it.vfsfitvnm.compose.reordering.draggedItem -import it.vfsfitvnm.compose.reordering.rememberReorderingState -import it.vfsfitvnm.compose.reordering.reorder -import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.ui.components.BottomSheet -import it.vfsfitvnm.vimusic.ui.components.BottomSheetState -import it.vfsfitvnm.vimusic.ui.components.LocalMenuState -import it.vfsfitvnm.vimusic.ui.components.MusicBars -import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop -import it.vfsfitvnm.vimusic.ui.components.themed.IconButton -import it.vfsfitvnm.vimusic.ui.components.themed.QueuedMediaItemMenu -import it.vfsfitvnm.vimusic.ui.items.SongItem -import it.vfsfitvnm.vimusic.ui.items.SongItemPlaceholder -import it.vfsfitvnm.vimusic.ui.styling.Dimensions -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.onOverlay -import it.vfsfitvnm.vimusic.ui.styling.px -import it.vfsfitvnm.vimusic.utils.DisposableListener -import it.vfsfitvnm.vimusic.utils.medium -import it.vfsfitvnm.vimusic.utils.queueLoopEnabledKey -import it.vfsfitvnm.vimusic.utils.rememberPreference -import it.vfsfitvnm.vimusic.utils.shouldBePlaying -import it.vfsfitvnm.vimusic.utils.shuffleQueue -import it.vfsfitvnm.vimusic.utils.smoothScrollToTop -import it.vfsfitvnm.vimusic.utils.windows -import kotlinx.coroutines.launch - -@ExperimentalFoundationApi -@ExperimentalAnimationApi -@Composable -fun Queue( - backgroundColorProvider: () -> Color, - layoutState: BottomSheetState, - modifier: Modifier = Modifier, - content: @Composable BoxScope.() -> Unit, -) { - val (colorPalette, typography, thumbnailShape) = LocalAppearance.current - - val windowInsets = WindowInsets.systemBars - - val horizontalBottomPaddingValues = windowInsets - .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom).asPaddingValues() - - BottomSheet( - state = layoutState, - modifier = modifier, - collapsedContent = { - Box( - modifier = Modifier - .drawBehind { drawRect(backgroundColorProvider()) } - .fillMaxSize() - .padding(horizontalBottomPaddingValues) - ) { - Image( - painter = painterResource(R.drawable.playlist), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .align(Alignment.Center) - .size(18.dp) - ) - - content() - } - } - ) { - val binder = LocalPlayerServiceBinder.current - - binder?.player ?: return@BottomSheet - - val player = binder.player - - var queueLoopEnabled by rememberPreference(queueLoopEnabledKey, defaultValue = true) - - val menuState = LocalMenuState.current - - val thumbnailSizeDp = Dimensions.thumbnails.song - val thumbnailSizePx = thumbnailSizeDp.px - - var mediaItemIndex by remember { - mutableStateOf(if (player.mediaItemCount == 0) -1 else player.currentMediaItemIndex) - } - - var windows by remember { - mutableStateOf(player.currentTimeline.windows) - } - - var shouldBePlaying by remember { - mutableStateOf(binder.player.shouldBePlaying) - } - - player.DisposableListener { - object : Player.Listener { - override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { - mediaItemIndex = - if (player.mediaItemCount == 0) -1 else player.currentMediaItemIndex - } - - override fun onTimelineChanged(timeline: Timeline, reason: Int) { - windows = timeline.windows - mediaItemIndex = - if (player.mediaItemCount == 0) -1 else player.currentMediaItemIndex - } - - override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { - shouldBePlaying = binder.player.shouldBePlaying - } - - override fun onPlaybackStateChanged(playbackState: Int) { - shouldBePlaying = binder.player.shouldBePlaying - } - } - } - - val reorderingState = rememberReorderingState( - lazyListState = rememberLazyListState(initialFirstVisibleItemIndex = mediaItemIndex), - key = windows, - onDragEnd = player::moveMediaItem, - extraItemCount = 0 - ) - - val rippleIndication = rememberRipple(bounded = false) - - val musicBarsTransition = updateTransition(targetState = mediaItemIndex, label = "") - - Column { - Box( - modifier = Modifier - .background(colorPalette.background1) - .weight(1f) - ) { - ReorderingLazyColumn( - reorderingState = reorderingState, - contentPadding = windowInsets - .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top) - .asPaddingValues(), - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .nestedScroll(layoutState.preUpPostDownNestedScrollConnection) - - ) { - items( - items = windows, - key = { it.uid.hashCode() } - ) { window -> - val isPlayingThisMediaItem = mediaItemIndex == window.firstPeriodIndex - - SongItem( - song = window.mediaItem, - thumbnailSizePx = thumbnailSizePx, - thumbnailSizeDp = thumbnailSizeDp, - onThumbnailContent = { - musicBarsTransition.AnimatedVisibility( - visible = { it == window.firstPeriodIndex }, - enter = fadeIn(tween(800)), - exit = fadeOut(tween(800)), - ) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .background( - color = Color.Black.copy(alpha = 0.25f), - shape = thumbnailShape - ) - .size(Dimensions.thumbnails.song) - ) { - if (shouldBePlaying) { - MusicBars( - color = colorPalette.onOverlay, - modifier = Modifier - .height(24.dp) - ) - } else { - Image( - painter = painterResource(R.drawable.play), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.onOverlay), - modifier = Modifier - .size(24.dp) - ) - } - } - } - }, - trailingContent = { - IconButton( - icon = R.drawable.reorder, - color = colorPalette.textDisabled, - indication = rippleIndication, - onClick = {}, - modifier = Modifier - .reorder( - reorderingState = reorderingState, - index = window.firstPeriodIndex - ) - .size(18.dp) - ) - }, - modifier = Modifier - .combinedClickable( - onLongClick = { - menuState.display { - QueuedMediaItemMenu( - mediaItem = window.mediaItem, - indexInQueue = if (isPlayingThisMediaItem) null else window.firstPeriodIndex, - onDismiss = menuState::hide - ) - } - }, - onClick = { - if (isPlayingThisMediaItem) { - if (shouldBePlaying) { - player.pause() - } else { - player.play() - } - } else { - player.playWhenReady = true - player.seekToDefaultPosition(window.firstPeriodIndex) - } - } - ) - .animateItemPlacement(reorderingState = reorderingState) - .draggedItem( - reorderingState = reorderingState, - index = window.firstPeriodIndex - ) - ) - } - - item { - if (binder.isLoadingRadio) { - Column( - modifier = Modifier - .shimmer() - ) { - repeat(3) { index -> - SongItemPlaceholder( - thumbnailSizeDp = thumbnailSizeDp, - modifier = Modifier - .alpha(1f - index * 0.125f) - .fillMaxWidth() - ) - } - } - } - } - } - - FloatingActionsContainerWithScrollToTop( - lazyListState = reorderingState.lazyListState, - iconId = R.drawable.shuffle, - visible = !reorderingState.isDragging, - windowInsets = windowInsets.only(WindowInsetsSides.Horizontal), - onClick = { - reorderingState.coroutineScope.launch { - reorderingState.lazyListState.smoothScrollToTop() - }.invokeOnCompletion { - player.shuffleQueue() - } - } - ) - } - - - Box( - modifier = Modifier - .clickable(onClick = layoutState::collapseSoft) - .background(colorPalette.background2) - .fillMaxWidth() - .padding(horizontal = 12.dp) - .padding(horizontalBottomPaddingValues) - .height(64.dp) - ) { - BasicText( - text = "${windows.size} songs", - style = typography.xxs.medium, - modifier = Modifier - .background( - color = colorPalette.background1, - shape = RoundedCornerShape(16.dp) - ) - .align(Alignment.CenterStart) - .padding(all = 8.dp) - ) - - Image( - painter = painterResource(R.drawable.chevron_down), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .align(Alignment.Center) - .size(18.dp) - ) - - Row( - modifier = Modifier - .clip(RoundedCornerShape(16.dp)) - .clickable { queueLoopEnabled = !queueLoopEnabled } - .background(colorPalette.background1) - .padding(horizontal = 16.dp, vertical = 8.dp) - .align(Alignment.CenterEnd) - .animateContentSize() - ) { - BasicText( - text = "Queue loop ", - style = typography.xxs.medium, - ) - - AnimatedContent( - targetState = queueLoopEnabled, - transitionSpec = { - val slideDirection = if (targetState) AnimatedContentScope.SlideDirection.Up else AnimatedContentScope.SlideDirection.Down - - ContentTransform( - targetContentEnter = slideIntoContainer(slideDirection) + fadeIn(), - initialContentExit = slideOutOfContainer(slideDirection) + fadeOut(), - ) - } - ) { - BasicText( - text = if (it) "on" else "off", - style = typography.xxs.medium, - ) - } - } - } - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/StatsForNerds.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/StatsForNerds.kt deleted file mode 100644 index cf54c5c..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/StatsForNerds.kt +++ /dev/null @@ -1,210 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens.player - -import android.text.format.Formatter -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.BasicText -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import androidx.media3.datasource.cache.Cache -import androidx.media3.datasource.cache.CacheSpan -import it.vfsfitvnm.innertube.Innertube -import it.vfsfitvnm.innertube.models.bodies.PlayerBody -import it.vfsfitvnm.innertube.requests.player -import it.vfsfitvnm.vimusic.Database -import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder -import it.vfsfitvnm.vimusic.models.Format -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.onOverlay -import it.vfsfitvnm.vimusic.ui.styling.overlay -import it.vfsfitvnm.vimusic.utils.color -import it.vfsfitvnm.vimusic.utils.medium -import kotlin.math.roundToInt -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.withContext - -@Composable -fun StatsForNerds( - mediaId: String, - isDisplayed: Boolean, - onDismiss: () -> Unit, - modifier: Modifier = Modifier -) { - val (colorPalette, typography) = LocalAppearance.current - val context = LocalContext.current - val binder = LocalPlayerServiceBinder.current ?: return - - AnimatedVisibility( - visible = isDisplayed, - enter = fadeIn(), - exit = fadeOut(), - ) { - var cachedBytes by remember(mediaId) { - mutableStateOf(binder.cache.getCachedBytes(mediaId, 0, -1)) - } - - var format by remember { - mutableStateOf(null) - } - - LaunchedEffect(mediaId) { - Database.format(mediaId).distinctUntilChanged().collectLatest { currentFormat -> - if (currentFormat?.itag == null) { - binder.player.currentMediaItem?.takeIf { it.mediaId == mediaId }?.let { mediaItem -> - withContext(Dispatchers.IO) { - delay(2000) - Innertube.player(PlayerBody(videoId = mediaId))?.onSuccess { response -> - response.streamingData?.highestQualityFormat?.let { format -> - Database.insert(mediaItem) - Database.insert( - Format( - songId = mediaId, - itag = format.itag, - mimeType = format.mimeType, - bitrate = format.bitrate, - loudnessDb = response.playerConfig?.audioConfig?.normalizedLoudnessDb, - contentLength = format.contentLength, - lastModified = format.lastModified - ) - ) - } - } - } - } - } else { - format = currentFormat - } - } - } - - DisposableEffect(mediaId) { - val listener = object : Cache.Listener { - override fun onSpanAdded(cache: Cache, span: CacheSpan) { - cachedBytes += span.length - } - - override fun onSpanRemoved(cache: Cache, span: CacheSpan) { - cachedBytes -= span.length - } - - override fun onSpanTouched(cache: Cache, oldSpan: CacheSpan, newSpan: CacheSpan) = - Unit - } - - binder.cache.addListener(mediaId, listener) - - onDispose { - binder.cache.removeListener(mediaId, listener) - } - } - - Box( - modifier = modifier - .pointerInput(Unit) { - detectTapGestures( - onTap = { - onDismiss() - } - ) - } - .background(colorPalette.overlay) - .fillMaxSize() - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier - .align(Alignment.Center) - .padding(all = 16.dp) - ) { - Column(horizontalAlignment = Alignment.End) { - BasicText( - text = "Id", - style = typography.xs.medium.color(colorPalette.onOverlay) - ) - BasicText( - text = "Itag", - style = typography.xs.medium.color(colorPalette.onOverlay) - ) - BasicText( - text = "Bitrate", - style = typography.xs.medium.color(colorPalette.onOverlay) - ) - BasicText( - text = "Size", - style = typography.xs.medium.color(colorPalette.onOverlay) - ) - BasicText( - text = "Cached", - style = typography.xs.medium.color(colorPalette.onOverlay) - ) - BasicText( - text = "Loudness", - style = typography.xs.medium.color(colorPalette.onOverlay) - ) - } - - Column { - BasicText( - text = mediaId, - maxLines = 1, - style = typography.xs.medium.color(colorPalette.onOverlay) - ) - BasicText( - text = format?.itag?.toString() ?: "Unknown", - maxLines = 1, - style = typography.xs.medium.color(colorPalette.onOverlay) - ) - BasicText( - text = format?.bitrate?.let { "${it / 1000} kbps" } ?: "Unknown", - maxLines = 1, - style = typography.xs.medium.color(colorPalette.onOverlay) - ) - BasicText( - text = format?.contentLength - ?.let { Formatter.formatShortFileSize(context, it) } ?: "Unknown", - maxLines = 1, - style = typography.xs.medium.color(colorPalette.onOverlay) - ) - BasicText( - text = buildString { - append(Formatter.formatShortFileSize(context, cachedBytes)) - - format?.contentLength?.let { - append(" (${(cachedBytes.toFloat() / it * 100).roundToInt()}%)") - } - }, - maxLines = 1, - style = typography.xs.medium.color(colorPalette.onOverlay) - ) - BasicText( - text = format?.loudnessDb?.let { "%.2f dB".format(it) } ?: "Unknown", - maxLines = 1, - style = typography.xs.medium.color(colorPalette.onOverlay) - ) - } - } - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Thumbnail.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Thumbnail.kt deleted file mode 100644 index 2cc4e4d..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Thumbnail.kt +++ /dev/null @@ -1,173 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens.player - -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedContentScope -import androidx.compose.animation.ContentTransform -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.SizeTransform -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.size -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.unit.dp -import androidx.media3.common.MediaItem -import androidx.media3.common.PlaybackException -import androidx.media3.common.Player -import coil.compose.AsyncImage -import it.vfsfitvnm.vimusic.Database -import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder -import it.vfsfitvnm.vimusic.service.LoginRequiredException -import it.vfsfitvnm.vimusic.service.PlayableFormatNotFoundException -import it.vfsfitvnm.vimusic.service.UnplayableException -import it.vfsfitvnm.vimusic.service.VideoIdMismatchException -import it.vfsfitvnm.vimusic.ui.styling.Dimensions -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.px -import it.vfsfitvnm.vimusic.utils.currentWindow -import it.vfsfitvnm.vimusic.utils.DisposableListener -import it.vfsfitvnm.vimusic.utils.thumbnail -import java.net.UnknownHostException -import java.nio.channels.UnresolvedAddressException - -@ExperimentalAnimationApi -@Composable -fun Thumbnail( - isShowingLyrics: Boolean, - onShowLyrics: (Boolean) -> Unit, - isShowingStatsForNerds: Boolean, - onShowStatsForNerds: (Boolean) -> Unit, - modifier: Modifier = Modifier -) { - val binder = LocalPlayerServiceBinder.current - val player = binder?.player ?: return - - val (thumbnailSizeDp, thumbnailSizePx) = Dimensions.thumbnails.player.song.let { - it to (it - 64.dp).px - } - - var nullableWindow by remember { - mutableStateOf(player.currentWindow) - } - - var error by remember { - mutableStateOf(player.playerError) - } - - player.DisposableListener { - object : Player.Listener { - override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { - nullableWindow = player.currentWindow - } - - override fun onPlaybackStateChanged(playbackState: Int) { - error = player.playerError - } - - override fun onPlayerError(playbackException: PlaybackException) { - error = playbackException - } - } - } - - val window = nullableWindow ?: return - - AnimatedContent( - targetState = window, - transitionSpec = { - val duration = 500 - val slideDirection = - if (targetState.firstPeriodIndex > initialState.firstPeriodIndex) AnimatedContentScope.SlideDirection.Left else AnimatedContentScope.SlideDirection.Right - - ContentTransform( - targetContentEnter = slideIntoContainer( - towards = slideDirection, - animationSpec = tween(duration) - ) + fadeIn( - animationSpec = tween(duration) - ) + scaleIn( - initialScale = 0.85f, - animationSpec = tween(duration) - ), - initialContentExit = slideOutOfContainer( - towards = slideDirection, - animationSpec = tween(duration) - ) + fadeOut( - animationSpec = tween(duration) - ) + scaleOut( - targetScale = 0.85f, - animationSpec = tween(duration) - ), - sizeTransform = SizeTransform(clip = false) - ) - }, - contentAlignment = Alignment.Center - ) {currentWindow -> - Box( - modifier = modifier - .aspectRatio(1f) - .clip(LocalAppearance.current.thumbnailShape) - .size(thumbnailSizeDp) - ) { - AsyncImage( - model = currentWindow.mediaItem.mediaMetadata.artworkUri.thumbnail(thumbnailSizePx), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .pointerInput(Unit) { - detectTapGestures( - onTap = { onShowLyrics(true) }, - onLongPress = { onShowStatsForNerds(true) } - ) - } - .fillMaxSize() - ) - - Lyrics( - mediaId = currentWindow.mediaItem.mediaId, - isDisplayed = isShowingLyrics && error == null, - onDismiss = { onShowLyrics(false) }, - ensureSongInserted = { Database.insert(currentWindow.mediaItem) }, - size = thumbnailSizeDp, - mediaMetadataProvider = currentWindow.mediaItem::mediaMetadata, - durationProvider = player::getDuration, - ) - - StatsForNerds( - mediaId = currentWindow.mediaItem.mediaId, - isDisplayed = isShowingStatsForNerds && error == null, - onDismiss = { onShowStatsForNerds(false) } - ) - - PlaybackError( - isDisplayed = error != null, - messageProvider = { - when (error?.cause?.cause) { - is UnresolvedAddressException, is UnknownHostException -> "A network error has occurred" - is PlayableFormatNotFoundException -> "Couldn't find a playable audio format" - is UnplayableException -> "The original video source of this song has been deleted" - is LoginRequiredException -> "This song cannot be played due to server restrictions" - is VideoIdMismatchException -> "The returned video id doesn't match the requested one" - else -> "An unknown playback error has occurred" - } - }, - onDismiss = player::prepare - ) - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/playlist/PlaylistScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/playlist/PlaylistScreen.kt deleted file mode 100644 index c73ac9b..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/playlist/PlaylistScreen.kt +++ /dev/null @@ -1,41 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens.playlist - -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.runtime.Composable -import androidx.compose.runtime.saveable.rememberSaveableStateHolder -import it.vfsfitvnm.compose.persist.PersistMapCleanup -import it.vfsfitvnm.compose.routing.RouteHandler -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold -import it.vfsfitvnm.vimusic.ui.screens.globalRoutes - -@ExperimentalFoundationApi -@ExperimentalAnimationApi -@Composable -fun PlaylistScreen(browseId: String) { - val saveableStateHolder = rememberSaveableStateHolder() - PersistMapCleanup(tagPrefix = "playlist/$browseId") - - RouteHandler(listenToGlobalEmitter = true) { - globalRoutes() - - host { - Scaffold( - topIconButtonId = R.drawable.chevron_back, - onTopIconButtonClick = pop, - tabIndex = 0, - onTabChanged = { }, - tabColumnContent = { Item -> - Item(0, "Songs", R.drawable.musical_notes) - } - ) { currentTabIndex -> - saveableStateHolder.SaveableStateProvider(key = currentTabIndex) { - when (currentTabIndex) { - 0 -> PlaylistSongList(browseId = browseId) - } - } - } - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/playlist/PlaylistSongList.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/playlist/PlaylistSongList.kt deleted file mode 100644 index 08b8013..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/playlist/PlaylistSongList.kt +++ /dev/null @@ -1,246 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens.playlist - -import android.content.Intent -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import com.valentinilk.shimmer.shimmer -import it.vfsfitvnm.compose.persist.persist -import it.vfsfitvnm.innertube.Innertube -import it.vfsfitvnm.innertube.models.bodies.BrowseBody -import it.vfsfitvnm.innertube.requests.playlistPage -import it.vfsfitvnm.vimusic.Database -import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets -import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.models.Playlist -import it.vfsfitvnm.vimusic.models.SongPlaylistMap -import it.vfsfitvnm.vimusic.query -import it.vfsfitvnm.vimusic.transaction -import it.vfsfitvnm.vimusic.ui.components.LocalMenuState -import it.vfsfitvnm.vimusic.ui.components.ShimmerHost -import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop -import it.vfsfitvnm.vimusic.ui.components.themed.Header -import it.vfsfitvnm.vimusic.ui.components.themed.HeaderIconButton -import it.vfsfitvnm.vimusic.ui.components.themed.HeaderPlaceholder -import it.vfsfitvnm.vimusic.ui.components.themed.LayoutWithAdaptiveThumbnail -import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu -import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton -import it.vfsfitvnm.vimusic.ui.components.themed.TextFieldDialog -import it.vfsfitvnm.vimusic.ui.components.themed.adaptiveThumbnailContent -import it.vfsfitvnm.vimusic.ui.items.SongItem -import it.vfsfitvnm.vimusic.ui.items.SongItemPlaceholder -import it.vfsfitvnm.vimusic.ui.styling.Dimensions -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.px -import it.vfsfitvnm.vimusic.utils.asMediaItem -import it.vfsfitvnm.vimusic.utils.completed -import it.vfsfitvnm.vimusic.utils.enqueue -import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex -import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning -import it.vfsfitvnm.vimusic.utils.isLandscape -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -@ExperimentalFoundationApi -@ExperimentalAnimationApi -@Composable -fun PlaylistSongList( - browseId: String, -) { - val (colorPalette) = LocalAppearance.current - val binder = LocalPlayerServiceBinder.current - val context = LocalContext.current - val menuState = LocalMenuState.current - - var playlistPage by persist("playlist/$browseId/playlistPage") - - LaunchedEffect(Unit) { - if (playlistPage != null && playlistPage?.songsPage?.continuation == null) return@LaunchedEffect - - playlistPage = withContext(Dispatchers.IO) { - Innertube.playlistPage(BrowseBody(browseId = browseId))?.completed()?.getOrNull() - } - } - - val songThumbnailSizeDp = Dimensions.thumbnails.song - val songThumbnailSizePx = songThumbnailSizeDp.px - - var isImportingPlaylist by rememberSaveable { - mutableStateOf(false) - } - - if (isImportingPlaylist) { - TextFieldDialog( - hintText = "Enter the playlist name", - initialTextInput = playlistPage?.title ?: "", - onDismiss = { isImportingPlaylist = false }, - onDone = { text -> - query { - transaction { - val playlistId = Database.insert(Playlist(name = text, browseId = browseId)) - - playlistPage?.songsPage?.items - ?.map(Innertube.SongItem::asMediaItem) - ?.onEach(Database::insert) - ?.mapIndexed { index, mediaItem -> - SongPlaylistMap( - songId = mediaItem.mediaId, - playlistId = playlistId, - position = index - ) - }?.let(Database::insertSongPlaylistMaps) - } - } - } - ) - } - - val headerContent: @Composable () -> Unit = { - if (playlistPage == null) { - HeaderPlaceholder( - modifier = Modifier - .shimmer() - ) - } else { - Header(title = playlistPage?.title ?: "Unknown") { - SecondaryTextButton( - text = "Enqueue", - enabled = playlistPage?.songsPage?.items?.isNotEmpty() == true, - onClick = { - playlistPage?.songsPage?.items?.map(Innertube.SongItem::asMediaItem)?.let { mediaItems -> - binder?.player?.enqueue(mediaItems) - } - } - ) - - Spacer( - modifier = Modifier - .weight(1f) - ) - - HeaderIconButton( - icon = R.drawable.add, - color = colorPalette.text, - onClick = { isImportingPlaylist = true } - ) - - HeaderIconButton( - icon = R.drawable.share_social, - color = colorPalette.text, - onClick = { - (playlistPage?.url ?: "https://music.youtube.com/playlist?list=${browseId.removePrefix("VL")}").let { url -> - val sendIntent = Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, url) - } - - context.startActivity(Intent.createChooser(sendIntent, null)) - } - } - ) - } - } - } - - val thumbnailContent = adaptiveThumbnailContent(playlistPage == null, playlistPage?.thumbnail?.url) - - val lazyListState = rememberLazyListState() - - LayoutWithAdaptiveThumbnail(thumbnailContent = thumbnailContent) { - Box { - LazyColumn( - state = lazyListState, - contentPadding = LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), - modifier = Modifier - .background(colorPalette.background0) - .fillMaxSize() - ) { - item( - key = "header", - contentType = 0 - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - headerContent() - if (!isLandscape) thumbnailContent() - } - } - - itemsIndexed(items = playlistPage?.songsPage?.items ?: emptyList()) { index, song -> - SongItem( - song = song, - thumbnailSizePx = songThumbnailSizePx, - thumbnailSizeDp = songThumbnailSizeDp, - modifier = Modifier - .combinedClickable( - onLongClick = { - menuState.display { - NonQueuedMediaItemMenu( - onDismiss = menuState::hide, - mediaItem = song.asMediaItem, - ) - } - }, - onClick = { - playlistPage?.songsPage?.items?.map(Innertube.SongItem::asMediaItem)?.let { mediaItems -> - binder?.stopRadio() - binder?.player?.forcePlayAtIndex(mediaItems, index) - } - } - ) - ) - } - - if (playlistPage == null) { - item(key = "loading") { - ShimmerHost( - modifier = Modifier - .fillParentMaxSize() - ) { - repeat(4) { - SongItemPlaceholder(thumbnailSizeDp = songThumbnailSizeDp) - } - } - } - } - } - - FloatingActionsContainerWithScrollToTop( - lazyListState = lazyListState, - iconId = R.drawable.shuffle, - onClick = { - playlistPage?.songsPage?.items?.let { songs -> - if (songs.isNotEmpty()) { - binder?.stopRadio() - binder?.player?.forcePlayFromBeginning( - songs.shuffled().map(Innertube.SongItem::asMediaItem) - ) - } - } - } - ) - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/OnlineSearch.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/OnlineSearch.kt deleted file mode 100644 index fe7482f..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/OnlineSearch.kt +++ /dev/null @@ -1,323 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens.search - -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.text.BasicText -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.ripple.rememberRipple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.paint -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.TextRange -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.core.net.toUri -import it.vfsfitvnm.compose.persist.persist -import it.vfsfitvnm.compose.persist.persistList -import it.vfsfitvnm.innertube.Innertube -import it.vfsfitvnm.innertube.models.bodies.SearchSuggestionsBody -import it.vfsfitvnm.innertube.requests.searchSuggestions -import it.vfsfitvnm.vimusic.Database -import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.models.SearchQuery -import it.vfsfitvnm.vimusic.query -import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop -import it.vfsfitvnm.vimusic.ui.components.themed.Header -import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.utils.align -import it.vfsfitvnm.vimusic.utils.center -import it.vfsfitvnm.vimusic.utils.medium -import it.vfsfitvnm.vimusic.utils.pauseSearchHistoryKey -import it.vfsfitvnm.vimusic.utils.preferences -import it.vfsfitvnm.vimusic.utils.secondary -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.distinctUntilChanged - -@ExperimentalAnimationApi -@Composable -fun OnlineSearch( - textFieldValue: TextFieldValue, - onTextFieldValueChanged: (TextFieldValue) -> Unit, - onSearch: (String) -> Unit, - onViewPlaylist: (String) -> Unit, - decorationBox: @Composable (@Composable () -> Unit) -> Unit -) { - val context = LocalContext.current - - val (colorPalette, typography) = LocalAppearance.current - - var history by persistList("search/online/history") - - LaunchedEffect(textFieldValue.text) { - if (!context.preferences.getBoolean(pauseSearchHistoryKey, false)) { - Database.queries("%${textFieldValue.text}%") - .distinctUntilChanged { old, new -> old.size == new.size } - .collect { history = it } - } - } - - var suggestionsResult by persist?>?>("search/online/suggestionsResult") - - LaunchedEffect(textFieldValue.text) { - if (textFieldValue.text.isNotEmpty()) { - delay(200) - suggestionsResult = - Innertube.searchSuggestions(SearchSuggestionsBody(input = textFieldValue.text)) - } - } - - val playlistId = remember(textFieldValue.text) { - val isPlaylistUrl = listOf( - "https://www.youtube.com/playlist?", - "https://music.youtube.com/playlist?", - "https://m.youtube.com/playlist?" - ).any(textFieldValue.text::startsWith) - - if (isPlaylistUrl) textFieldValue.text.toUri().getQueryParameter("list") else null - } - - val rippleIndication = rememberRipple(bounded = false) - val timeIconPainter = painterResource(R.drawable.time) - val closeIconPainter = painterResource(R.drawable.close) - val arrowForwardIconPainter = painterResource(R.drawable.arrow_forward) - - val focusRequester = remember { - FocusRequester() - } - - val lazyListState = rememberLazyListState() - - Box { - LazyColumn( - state = lazyListState, - contentPadding = LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), - modifier = Modifier - .fillMaxSize() - ) { - item( - key = "header", - contentType = 0 - ) { - Header( - titleContent = { - BasicTextField( - value = textFieldValue, - onValueChange = onTextFieldValueChanged, - textStyle = typography.xxl.medium.align(TextAlign.End), - singleLine = true, - maxLines = 1, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), - keyboardActions = KeyboardActions( - onSearch = { - if (textFieldValue.text.isNotEmpty()) { - onSearch(textFieldValue.text) - } - } - ), - cursorBrush = SolidColor(colorPalette.text), - decorationBox = decorationBox, - modifier = Modifier - .focusRequester(focusRequester) - ) - }, - actionsContent = { - if (playlistId != null) { - val isAlbum = playlistId.startsWith("OLAK5uy_") - - SecondaryTextButton( - text = "View ${if (isAlbum) "album" else "playlist"}", - onClick = { onViewPlaylist(textFieldValue.text) } - ) - } - - Spacer( - modifier = Modifier - .weight(1f) - ) - - if (textFieldValue.text.isNotEmpty()) { - SecondaryTextButton( - text = "Clear", - onClick = { onTextFieldValueChanged(TextFieldValue()) } - ) - } - } - ) - } - - items( - items = history, - key = SearchQuery::id - ) { searchQuery -> - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .clickable(onClick = { onSearch(searchQuery.query) }) - .fillMaxWidth() - .padding(all = 16.dp) - ) { - Spacer( - modifier = Modifier - .padding(horizontal = 8.dp) - .size(20.dp) - .paint( - painter = timeIconPainter, - colorFilter = ColorFilter.tint(colorPalette.textDisabled) - ) - ) - - BasicText( - text = searchQuery.query, - style = typography.s.secondary, - modifier = Modifier - .padding(horizontal = 8.dp) - .weight(1f) - ) - - Image( - painter = closeIconPainter, - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.textDisabled), - modifier = Modifier - .clickable( - indication = rippleIndication, - interactionSource = remember { MutableInteractionSource() }, - onClick = { - query { - Database.delete(searchQuery) - } - } - ) - .padding(horizontal = 8.dp) - .size(20.dp) - ) - - Image( - painter = arrowForwardIconPainter, - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.textDisabled), - modifier = Modifier - .clickable( - indication = rippleIndication, - interactionSource = remember { MutableInteractionSource() }, - onClick = { - onTextFieldValueChanged( - TextFieldValue( - text = searchQuery.query, - selection = TextRange(searchQuery.query.length) - ) - ) - } - ) - .rotate(225f) - .padding(horizontal = 8.dp) - .size(22.dp) - ) - } - } - - suggestionsResult?.getOrNull()?.let { suggestions -> - items(items = suggestions) { suggestion -> - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .clickable(onClick = { onSearch(suggestion) }) - .fillMaxWidth() - .padding(all = 16.dp) - ) { - Spacer( - modifier = Modifier - .padding(horizontal = 8.dp) - .size(20.dp) - ) - - BasicText( - text = suggestion, - style = typography.s.secondary, - modifier = Modifier - .padding(horizontal = 8.dp) - .weight(1f) - ) - - Image( - painter = arrowForwardIconPainter, - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.textDisabled), - modifier = Modifier - .clickable( - indication = rippleIndication, - interactionSource = remember { MutableInteractionSource() }, - onClick = { - onTextFieldValueChanged( - TextFieldValue( - text = suggestion, - selection = TextRange(suggestion.length) - ) - ) - } - ) - .rotate(225f) - .padding(horizontal = 8.dp) - .size(22.dp) - ) - } - } - } ?: suggestionsResult?.exceptionOrNull()?.let { - item { - Box( - modifier = Modifier - .fillMaxSize() - ) { - BasicText( - text = "An error has occurred.", - style = typography.s.secondary.center, - modifier = Modifier - .align(Alignment.Center) - ) - } - } - } - } - - FloatingActionsContainerWithScrollToTop(lazyListState = lazyListState) - } - - LaunchedEffect(Unit) { - delay(300) - focusRequester.requestFocus() - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/ItemsPage.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/ItemsPage.kt deleted file mode 100644 index f2c26db..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/ItemsPage.kt +++ /dev/null @@ -1,130 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens.searchresult - -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyItemScope -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.text.BasicText -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import it.vfsfitvnm.compose.persist.persist -import it.vfsfitvnm.innertube.Innertube -import it.vfsfitvnm.innertube.utils.plus -import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets -import it.vfsfitvnm.vimusic.ui.components.ShimmerHost -import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.utils.center -import it.vfsfitvnm.vimusic.utils.secondary -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -@ExperimentalAnimationApi -@Composable -inline fun ItemsPage( - tag: String, - crossinline headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit, - crossinline itemContent: @Composable LazyItemScope.(T) -> Unit, - noinline itemPlaceholderContent: @Composable () -> Unit, - modifier: Modifier = Modifier, - initialPlaceholderCount: Int = 8, - continuationPlaceholderCount: Int = 3, - emptyItemsText: String = "No items found", - noinline itemsPageProvider: (suspend (String?) -> Result?>?)? = null, -) { - val (_, typography) = LocalAppearance.current - - val updatedItemsPageProvider by rememberUpdatedState(itemsPageProvider) - - val lazyListState = rememberLazyListState() - - var itemsPage by persist?>(tag) - - LaunchedEffect(lazyListState, updatedItemsPageProvider) { - val currentItemsPageProvider = updatedItemsPageProvider ?: return@LaunchedEffect - - snapshotFlow { lazyListState.layoutInfo.visibleItemsInfo.any { it.key == "loading" } } - .collect { shouldLoadMore -> - if (!shouldLoadMore) return@collect - - withContext(Dispatchers.IO) { - currentItemsPageProvider(itemsPage?.continuation) - }?.onSuccess { - if (it == null) { - if (itemsPage == null) { - itemsPage = Innertube.ItemsPage(null, null) - } - } else { - itemsPage += it - } - } - } - } - - Box { - LazyColumn( - state = lazyListState, - contentPadding = LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), - modifier = modifier - .fillMaxSize() - ) { - item( - key = "header", - contentType = "header", - ) { - headerContent(null) - } - - items( - items = itemsPage?.items ?: emptyList(), - key = Innertube.Item::key, - itemContent = itemContent - ) - - if (itemsPage != null && itemsPage?.items.isNullOrEmpty()) { - item(key = "empty") { - BasicText( - text = emptyItemsText, - style = typography.xs.secondary.center, - modifier = Modifier - .padding(horizontal = 16.dp, vertical = 32.dp) - .fillMaxWidth() - ) - } - } - - if (!(itemsPage != null && itemsPage?.continuation == null)) { - item(key = "loading") { - val isFirstLoad = itemsPage?.items.isNullOrEmpty() - ShimmerHost( - modifier = Modifier - .run { - if (isFirstLoad) fillParentMaxSize() else this - } - ) { - repeat(if (isFirstLoad) initialPlaceholderCount else continuationPlaceholderCount) { - itemPlaceholderContent() - } - } - } - } - } - - FloatingActionsContainerWithScrollToTop(lazyListState = lazyListState) - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/SearchResultScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/SearchResultScreen.kt deleted file mode 100644 index 46266bb..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/SearchResultScreen.kt +++ /dev/null @@ -1,322 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens.searchresult - -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.clickable -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.runtime.Composable -import androidx.compose.runtime.saveable.rememberSaveableStateHolder -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import it.vfsfitvnm.compose.persist.PersistMapCleanup -import it.vfsfitvnm.compose.persist.persistMap -import it.vfsfitvnm.innertube.Innertube -import it.vfsfitvnm.innertube.models.bodies.ContinuationBody -import it.vfsfitvnm.innertube.models.bodies.SearchBody -import it.vfsfitvnm.innertube.requests.searchPage -import it.vfsfitvnm.innertube.utils.from -import it.vfsfitvnm.compose.routing.RouteHandler -import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.ui.components.LocalMenuState -import it.vfsfitvnm.vimusic.ui.components.themed.Header -import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu -import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold -import it.vfsfitvnm.vimusic.ui.items.AlbumItem -import it.vfsfitvnm.vimusic.ui.items.AlbumItemPlaceholder -import it.vfsfitvnm.vimusic.ui.items.ArtistItem -import it.vfsfitvnm.vimusic.ui.items.ArtistItemPlaceholder -import it.vfsfitvnm.vimusic.ui.items.PlaylistItem -import it.vfsfitvnm.vimusic.ui.items.PlaylistItemPlaceholder -import it.vfsfitvnm.vimusic.ui.items.SongItem -import it.vfsfitvnm.vimusic.ui.items.SongItemPlaceholder -import it.vfsfitvnm.vimusic.ui.items.VideoItem -import it.vfsfitvnm.vimusic.ui.items.VideoItemPlaceholder -import it.vfsfitvnm.vimusic.ui.screens.albumRoute -import it.vfsfitvnm.vimusic.ui.screens.artistRoute -import it.vfsfitvnm.vimusic.ui.screens.globalRoutes -import it.vfsfitvnm.vimusic.ui.screens.playlistRoute -import it.vfsfitvnm.vimusic.ui.styling.Dimensions -import it.vfsfitvnm.vimusic.ui.styling.px -import it.vfsfitvnm.vimusic.utils.asMediaItem -import it.vfsfitvnm.vimusic.utils.forcePlay -import it.vfsfitvnm.vimusic.utils.rememberPreference -import it.vfsfitvnm.vimusic.utils.searchResultScreenTabIndexKey - -@ExperimentalFoundationApi -@ExperimentalAnimationApi -@Composable -fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) { - val context = LocalContext.current - val saveableStateHolder = rememberSaveableStateHolder() - val (tabIndex, onTabIndexChanges) = rememberPreference(searchResultScreenTabIndexKey, 0) - - PersistMapCleanup(tagPrefix = "searchResults/$query/") - - RouteHandler(listenToGlobalEmitter = true) { - globalRoutes() - - host { - val headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit = { - Header( - title = query, - modifier = Modifier - .pointerInput(Unit) { - detectTapGestures { - context.persistMap?.keys?.removeAll { - it.startsWith("searchResults/$query/") - } - onSearchAgain() - } - } - ) - } - - val emptyItemsText = "No results found. Please try a different query or category" - - Scaffold( - topIconButtonId = R.drawable.chevron_back, - onTopIconButtonClick = pop, - tabIndex = tabIndex, - onTabChanged = onTabIndexChanges, - tabColumnContent = { Item -> - Item(0, "Songs", R.drawable.musical_notes) - Item(1, "Albums", R.drawable.disc) - Item(2, "Artists", R.drawable.person) - Item(3, "Videos", R.drawable.film) - Item(4, "Playlists", R.drawable.playlist) - Item(5, "Featured", R.drawable.playlist) - } - ) { tabIndex -> - saveableStateHolder.SaveableStateProvider(tabIndex) { - when (tabIndex) { - 0 -> { - val binder = LocalPlayerServiceBinder.current - val menuState = LocalMenuState.current - val thumbnailSizeDp = Dimensions.thumbnails.song - val thumbnailSizePx = thumbnailSizeDp.px - - ItemsPage( - tag = "searchResults/$query/songs", - itemsPageProvider = { continuation -> - if (continuation == null) { - Innertube.searchPage( - body = SearchBody(query = query, params = Innertube.SearchFilter.Song.value), - fromMusicShelfRendererContent = Innertube.SongItem.Companion::from - ) - } else { - Innertube.searchPage( - body = ContinuationBody(continuation = continuation), - fromMusicShelfRendererContent = Innertube.SongItem.Companion::from - ) - } - }, - emptyItemsText = emptyItemsText, - headerContent = headerContent, - itemContent = { song -> - SongItem( - song = song, - thumbnailSizePx = thumbnailSizePx, - thumbnailSizeDp = thumbnailSizeDp, - modifier = Modifier - .combinedClickable( - onLongClick = { - menuState.display { - NonQueuedMediaItemMenu( - onDismiss = menuState::hide, - mediaItem = song.asMediaItem, - ) - } - }, - onClick = { - binder?.stopRadio() - binder?.player?.forcePlay(song.asMediaItem) - binder?.setupRadio(song.info?.endpoint) - } - ) - ) - }, - itemPlaceholderContent = { - SongItemPlaceholder(thumbnailSizeDp = thumbnailSizeDp) - } - ) - } - - 1 -> { - val thumbnailSizeDp = 108.dp - val thumbnailSizePx = thumbnailSizeDp.px - - ItemsPage( - tag = "searchResults/$query/albums", - itemsPageProvider = { continuation -> - if (continuation == null) { - Innertube.searchPage( - body = SearchBody(query = query, params = Innertube.SearchFilter.Album.value), - fromMusicShelfRendererContent = Innertube.AlbumItem::from - ) - } else { - Innertube.searchPage( - body = ContinuationBody(continuation = continuation), - fromMusicShelfRendererContent = Innertube.AlbumItem::from - ) - } - }, - emptyItemsText = emptyItemsText, - headerContent = headerContent, - itemContent = { album -> - AlbumItem( - album = album, - thumbnailSizePx = thumbnailSizePx, - thumbnailSizeDp = thumbnailSizeDp, - modifier = Modifier - .clickable(onClick = { albumRoute(album.key) }) - ) - - }, - itemPlaceholderContent = { - AlbumItemPlaceholder(thumbnailSizeDp = thumbnailSizeDp) - } - ) - } - - 2 -> { - val thumbnailSizeDp = 64.dp - val thumbnailSizePx = thumbnailSizeDp.px - - ItemsPage( - tag = "searchResults/$query/artists", - itemsPageProvider = { continuation -> - if (continuation == null) { - Innertube.searchPage( - body = SearchBody(query = query, params = Innertube.SearchFilter.Artist.value), - fromMusicShelfRendererContent = Innertube.ArtistItem::from - ) - } else { - Innertube.searchPage( - body = ContinuationBody(continuation = continuation), - fromMusicShelfRendererContent = Innertube.ArtistItem::from - ) - } - }, - emptyItemsText = emptyItemsText, - headerContent = headerContent, - itemContent = { artist -> - ArtistItem( - artist = artist, - thumbnailSizePx = thumbnailSizePx, - thumbnailSizeDp = thumbnailSizeDp, - modifier = Modifier - .clickable(onClick = { artistRoute(artist.key) }) - ) - }, - itemPlaceholderContent = { - ArtistItemPlaceholder(thumbnailSizeDp = thumbnailSizeDp) - } - ) - } - - 3 -> { - val binder = LocalPlayerServiceBinder.current - val menuState = LocalMenuState.current - val thumbnailHeightDp = 72.dp - val thumbnailWidthDp = 128.dp - - ItemsPage( - tag = "searchResults/$query/videos", - itemsPageProvider = { continuation -> - if (continuation == null) { - Innertube.searchPage( - body = SearchBody(query = query, params = Innertube.SearchFilter.Video.value), - fromMusicShelfRendererContent = Innertube.VideoItem::from - ) - } else { - Innertube.searchPage( - body = ContinuationBody(continuation = continuation), - fromMusicShelfRendererContent = Innertube.VideoItem::from - ) - } - }, - emptyItemsText = emptyItemsText, - headerContent = headerContent, - itemContent = { video -> - VideoItem( - video = video, - thumbnailWidthDp = thumbnailWidthDp, - thumbnailHeightDp = thumbnailHeightDp, - modifier = Modifier - .combinedClickable( - onLongClick = { - menuState.display { - NonQueuedMediaItemMenu( - mediaItem = video.asMediaItem, - onDismiss = menuState::hide - ) - } - }, - onClick = { - binder?.stopRadio() - binder?.player?.forcePlay(video.asMediaItem) - binder?.setupRadio(video.info?.endpoint) - } - ) - ) - }, - itemPlaceholderContent = { - VideoItemPlaceholder( - thumbnailHeightDp = thumbnailHeightDp, - thumbnailWidthDp = thumbnailWidthDp - ) - } - ) - } - - 4, 5 -> { - val thumbnailSizeDp = 108.dp - val thumbnailSizePx = thumbnailSizeDp.px - - ItemsPage( - tag = "searchResults/$query/${if (tabIndex == 4) "playlists" else "featured"}", - itemsPageProvider = { continuation -> - if (continuation == null) { - val filter = if (tabIndex == 4) { - Innertube.SearchFilter.CommunityPlaylist - } else { - Innertube.SearchFilter.FeaturedPlaylist - } - - Innertube.searchPage( - body = SearchBody(query = query, params = filter.value), - fromMusicShelfRendererContent = Innertube.PlaylistItem::from - ) - } else { - Innertube.searchPage( - body = ContinuationBody(continuation = continuation), - fromMusicShelfRendererContent = Innertube.PlaylistItem::from - ) - } - }, - emptyItemsText = emptyItemsText, - headerContent = headerContent, - itemContent = { playlist -> - PlaylistItem( - playlist = playlist, - thumbnailSizePx = thumbnailSizePx, - thumbnailSizeDp = thumbnailSizeDp, - modifier = Modifier - .clickable(onClick = { playlistRoute(playlist.key) }) - ) - }, - itemPlaceholderContent = { - PlaylistItemPlaceholder(thumbnailSizeDp = thumbnailSizeDp) - } - ) - } - } - } - } - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/About.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/About.kt deleted file mode 100644 index c7c5955..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/About.kt +++ /dev/null @@ -1,77 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens.settings - -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.BasicText -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalUriHandler -import it.vfsfitvnm.vimusic.BuildConfig -import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets -import it.vfsfitvnm.vimusic.ui.components.themed.Header -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.utils.secondary - -@ExperimentalAnimationApi -@Composable -fun About() { - val (colorPalette, typography) = LocalAppearance.current - val uriHandler = LocalUriHandler.current - - Column( - modifier = Modifier - .background(colorPalette.background0) - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding( - LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) - .asPaddingValues() - ) - ) { - Header(title = "このアプリについて") { - BasicText( - text = "v${BuildConfig.VERSION_NAME} by vfsfitvnm", - style = typography.s.secondary - ) - } - - SettingsEntryGroupText(title = "SOCIAL") - - SettingsEntry( - title = "GitHub", - text = "ソースコードを表示", - onClick = { - uriHandler.openUri("https://github.com/vfsfitvnm/ViMusic") - } - ) - - SettingsGroupSpacer() - - SettingsEntryGroupText(title = "トラブルシューティング") - - SettingsEntry( - title = "問題を報告", - text = "GitHub にリダイレクトされます", - onClick = { - uriHandler.openUri("https://github.com/vfsfitvnm/ViMusic/issues/new?assignees=&labels=bug&template=bug_report.yaml") - } - ) - - SettingsEntry( - title = "機能のリクエストまたはアイデアの提案", - text = "GitHub にリダイレクトされます", - onClick = { - uriHandler.openUri("https://github.com/vfsfitvnm/ViMusic/issues/new?assignees=&labels=enhancement&template=feature_request.yaml") - } - ) - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/AppearanceSettings.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/AppearanceSettings.kt deleted file mode 100644 index 07d6c0e..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/AppearanceSettings.kt +++ /dev/null @@ -1,131 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens.settings - -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets -import it.vfsfitvnm.vimusic.enums.ColorPaletteMode -import it.vfsfitvnm.vimusic.enums.ColorPaletteName -import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness -import it.vfsfitvnm.vimusic.ui.components.themed.Header -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.utils.applyFontPaddingKey -import it.vfsfitvnm.vimusic.utils.colorPaletteModeKey -import it.vfsfitvnm.vimusic.utils.colorPaletteNameKey -import it.vfsfitvnm.vimusic.utils.isAtLeastAndroid13 -import it.vfsfitvnm.vimusic.utils.isShowingThumbnailInLockscreenKey -import it.vfsfitvnm.vimusic.utils.rememberPreference -import it.vfsfitvnm.vimusic.utils.thumbnailRoundnessKey -import it.vfsfitvnm.vimusic.utils.useSystemFontKey - -@ExperimentalAnimationApi -@Composable -fun AppearanceSettings() { - val (colorPalette) = LocalAppearance.current - - var colorPaletteName by rememberPreference(colorPaletteNameKey, ColorPaletteName.Dynamic) - var colorPaletteMode by rememberPreference(colorPaletteModeKey, ColorPaletteMode.System) - var thumbnailRoundness by rememberPreference( - thumbnailRoundnessKey, - ThumbnailRoundness.Light - ) - var useSystemFont by rememberPreference(useSystemFontKey, false) - var applyFontPadding by rememberPreference(applyFontPaddingKey, false) - var isShowingThumbnailInLockscreen by rememberPreference( - isShowingThumbnailInLockscreenKey, - false - ) - - Column( - modifier = Modifier - .background(colorPalette.background0) - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding( - LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) - .asPaddingValues() - ) - ) { - Header(title = "外見") - - SettingsEntryGroupText(title = "カラー") - - EnumValueSelectorSettingsEntry( - title = "テーマ", - selectedValue = colorPaletteName, - onValueSelected = { colorPaletteName = it } - ) - - EnumValueSelectorSettingsEntry( - title = "テーマモード", - selectedValue = colorPaletteMode, - isEnabled = colorPaletteName != ColorPaletteName.PureBlack, - onValueSelected = { colorPaletteMode = it } - ) - - SettingsGroupSpacer() - - SettingsEntryGroupText(title = "シェイプ") - - EnumValueSelectorSettingsEntry( - title = "画像サムネイル(縮小表示された画像)の角の丸み", - selectedValue = thumbnailRoundness, - onValueSelected = { thumbnailRoundness = it }, - trailingContent = { - Spacer( - modifier = Modifier - .border(width = 1.dp, color = colorPalette.accent, shape = thumbnailRoundness.shape()) - .background(color = colorPalette.background1, shape = thumbnailRoundness.shape()) - .size(36.dp) - ) - } - ) - - SettingsGroupSpacer() - - SettingsEntryGroupText(title = "テキスト") - - SwitchSettingEntry( - title = "システムフォントを使う", - text = "システムが適用したフォントを使用する", - isChecked = useSystemFont, - onCheckedChange = { useSystemFont = it } - ) - - SwitchSettingEntry( - title = "フォントの余白を適用する", - text = "テキストの周りにスペースを追加する", - isChecked = applyFontPadding, - onCheckedChange = { applyFontPadding = it } - ) - - if (!isAtLeastAndroid13) { - SettingsGroupSpacer() - - SettingsEntryGroupText(title = "ロックスクリーン") - - SwitchSettingEntry( - title = "曲のアルバムジャケットを表示する", - text = "再生中の曲のアルバムジャケットをロックスクリーンの壁紙として使用する", - isChecked = isShowingThumbnailInLockscreen, - onCheckedChange = { isShowingThumbnailInLockscreen = it } - ) - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/CacheSettings.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/CacheSettings.kt deleted file mode 100644 index 47d0519..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/CacheSettings.kt +++ /dev/null @@ -1,119 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens.settings - -import android.text.format.Formatter -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import coil.Coil -import coil.annotation.ExperimentalCoilApi -import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets -import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder -import it.vfsfitvnm.vimusic.enums.CoilDiskCacheMaxSize -import it.vfsfitvnm.vimusic.enums.ExoPlayerDiskCacheMaxSize -import it.vfsfitvnm.vimusic.ui.components.themed.Header -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.utils.coilDiskCacheMaxSizeKey -import it.vfsfitvnm.vimusic.utils.exoPlayerDiskCacheMaxSizeKey -import it.vfsfitvnm.vimusic.utils.rememberPreference - -@OptIn(ExperimentalCoilApi::class) -@ExperimentalAnimationApi -@Composable -fun CacheSettings() { - val context = LocalContext.current - val (colorPalette) = LocalAppearance.current - val binder = LocalPlayerServiceBinder.current - - var coilDiskCacheMaxSize by rememberPreference( - coilDiskCacheMaxSizeKey, - CoilDiskCacheMaxSize.`128MB` - ) - var exoPlayerDiskCacheMaxSize by rememberPreference( - exoPlayerDiskCacheMaxSizeKey, - ExoPlayerDiskCacheMaxSize.`2GB` - ) - - Column( - modifier = Modifier - .background(colorPalette.background0) - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding( - LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) - .asPaddingValues() - ) - ) { - Header(title = "キャッシュ") - - SettingsDescription(text = "キャッシュが空き領域を使い果たした場合、古い順からアクセスされていないリソースがクリアされます。") - - Coil.imageLoader(context).diskCache?.let { diskCache -> - val diskCacheSize = remember(diskCache) { - diskCache.size - } - - SettingsGroupSpacer() - - SettingsEntryGroupText(title = "画像のキャッシュ") - - SettingsDescription( - text = "${ - Formatter.formatShortFileSize( - context, - diskCacheSize - ) - } used (${diskCacheSize * 100 / coilDiskCacheMaxSize.bytes.coerceAtLeast(1)}%)" - ) - - EnumValueSelectorSettingsEntry( - title = "最大サイズ", - selectedValue = coilDiskCacheMaxSize, - onValueSelected = { coilDiskCacheMaxSize = it } - ) - } - - binder?.cache?.let { cache -> - val diskCacheSize by remember { - derivedStateOf { - cache.cacheSpace - } - } - - SettingsGroupSpacer() - - SettingsEntryGroupText(title = "曲のキャッシュ") - - SettingsDescription( - text = buildString { - append(Formatter.formatShortFileSize(context, diskCacheSize)) - append(" used") - when (val size = exoPlayerDiskCacheMaxSize) { - ExoPlayerDiskCacheMaxSize.Unlimited -> {} - else -> append(" (${diskCacheSize * 100 / size.bytes}%)") - } - } - ) - - EnumValueSelectorSettingsEntry( - title = "最大サイズ", - selectedValue = exoPlayerDiskCacheMaxSize, - onValueSelected = { exoPlayerDiskCacheMaxSize = it } - ) - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/DatabaseSettings.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/DatabaseSettings.kt deleted file mode 100644 index 8ac5841..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/DatabaseSettings.kt +++ /dev/null @@ -1,153 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens.settings - -import android.annotation.SuppressLint -import android.content.ActivityNotFoundException -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import it.vfsfitvnm.vimusic.Database -import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets -import it.vfsfitvnm.vimusic.internal -import it.vfsfitvnm.vimusic.path -import it.vfsfitvnm.vimusic.query -import it.vfsfitvnm.vimusic.service.PlayerService -import it.vfsfitvnm.vimusic.ui.components.themed.Header -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.utils.intent -import it.vfsfitvnm.vimusic.utils.toast -import java.io.FileInputStream -import java.io.FileOutputStream -import java.text.SimpleDateFormat -import java.util.Date -import kotlin.system.exitProcess -import kotlinx.coroutines.flow.distinctUntilChanged - -@ExperimentalAnimationApi -@Composable -fun DatabaseSettings() { - val context = LocalContext.current - val (colorPalette) = LocalAppearance.current - - val eventsCount by remember { - Database.eventsCount().distinctUntilChanged() - }.collectAsState(initial = 0) - - val backupLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/vnd.sqlite3")) { uri -> - if (uri == null) return@rememberLauncherForActivityResult - - query { - Database.checkpoint() - - context.applicationContext.contentResolver.openOutputStream(uri) - ?.use { outputStream -> - FileInputStream(Database.internal.path).use { inputStream -> - inputStream.copyTo(outputStream) - } - } - } - } - - val restoreLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> - if (uri == null) return@rememberLauncherForActivityResult - - query { - Database.checkpoint() - Database.internal.close() - - context.applicationContext.contentResolver.openInputStream(uri) - ?.use { inputStream -> - FileOutputStream(Database.internal.path).use { outputStream -> - inputStream.copyTo(outputStream) - } - } - - context.stopService(context.intent()) - exitProcess(0) - } - } - - Column( - modifier = Modifier - .background(colorPalette.background0) - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding( - LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) - .asPaddingValues() - ) - ) { - Header(title = "データベース") - - SettingsEntryGroupText(title = "クリーンアップ") - - SettingsEntry( - title = "Quick picksをリセットする", - text = if (eventsCount > 0) { - "$eventsCount 個の再生イベントを削除する" - } else { - "Quick picksがクリアされました" - }, - isEnabled = eventsCount > 0, - onClick = { query(Database::clearEvents) } - ) - - SettingsGroupSpacer() - - SettingsEntryGroupText(title = "バックアップ") - - SettingsDescription(text = "個人の設定(テーマモードなど)とキャッシュは除外されます。") - - SettingsEntry( - title = "バックアップ", - text = "データベースを外部ストレージにエクスポートする", - onClick = { - @SuppressLint("SimpleDateFormat") - val dateFormat = SimpleDateFormat("yyyyMMddHHmmss") - - try { - backupLauncher.launch("vimusic_${dateFormat.format(Date())}.db") - } catch (e: ActivityNotFoundException) { - context.toast("作成するためのアプリケーションが見つかりませんでした") - } - } - ) - - SettingsGroupSpacer() - - SettingsEntryGroupText(title = "復元") - - ImportantSettingsDescription(text = "既存のデータは上書きされます。\n${context.applicationInfo.nonLocalizedLabel} は、データベースの復元後に自動的に閉じられます。") - - SettingsEntry( - title = "復元", - text = "外部ストレージからデータベースをインポートする", - onClick = { - try { - restoreLauncher.launch( - arrayOf("application/vnd.sqlite3", "application/octet-stream") - ) - } catch (e: ActivityNotFoundException) { - context.toast("開く為のアプリケーションが見つかりませんでした") - } - } - ) - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/OtherSettings.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/OtherSettings.kt deleted file mode 100644 index 57e0bbb..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/OtherSettings.kt +++ /dev/null @@ -1,182 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens.settings - -import android.annotation.SuppressLint -import android.content.ActivityNotFoundException -import android.content.ComponentName -import android.content.Intent -import android.content.pm.PackageManager -import android.net.Uri -import android.provider.Settings -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.Composable -import androidx.compose.runtime.SnapshotMutationPolicy -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import it.vfsfitvnm.vimusic.Database -import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets -import it.vfsfitvnm.vimusic.query -import it.vfsfitvnm.vimusic.service.PlayerMediaBrowserService -import it.vfsfitvnm.vimusic.ui.components.themed.Header -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.utils.isAtLeastAndroid12 -import it.vfsfitvnm.vimusic.utils.isAtLeastAndroid6 -import it.vfsfitvnm.vimusic.utils.isIgnoringBatteryOptimizations -import it.vfsfitvnm.vimusic.utils.isInvincibilityEnabledKey -import it.vfsfitvnm.vimusic.utils.pauseSearchHistoryKey -import it.vfsfitvnm.vimusic.utils.rememberPreference -import it.vfsfitvnm.vimusic.utils.toast -import kotlinx.coroutines.flow.distinctUntilChanged - -@SuppressLint("BatteryLife") -@ExperimentalAnimationApi -@Composable -fun OtherSettings() { - val context = LocalContext.current - val (colorPalette) = LocalAppearance.current - - var isAndroidAutoEnabled by remember { - val component = ComponentName(context, PlayerMediaBrowserService::class.java) - val disabledFlag = PackageManager.COMPONENT_ENABLED_STATE_DISABLED - val enabledFlag = PackageManager.COMPONENT_ENABLED_STATE_ENABLED - - mutableStateOf( - value = context.packageManager.getComponentEnabledSetting(component) == enabledFlag, - policy = object : SnapshotMutationPolicy { - override fun equivalent(a: Boolean, b: Boolean): Boolean { - context.packageManager.setComponentEnabledSetting( - component, - if (b) enabledFlag else disabledFlag, - PackageManager.DONT_KILL_APP - ) - return a == b - } - } - ) - } - - var isInvincibilityEnabled by rememberPreference(isInvincibilityEnabledKey, false) - - var isIgnoringBatteryOptimizations by remember { - mutableStateOf(context.isIgnoringBatteryOptimizations) - } - - val activityResultLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { - isIgnoringBatteryOptimizations = context.isIgnoringBatteryOptimizations - } - - var pauseSearchHistory by rememberPreference(pauseSearchHistoryKey, false) - - val queriesCount by remember { - Database.queriesCount().distinctUntilChanged() - }.collectAsState(initial = 0) - - Column( - modifier = Modifier - .background(colorPalette.background0) - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding( - LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) - .asPaddingValues() - ) - ) { - Header(title = "その他") - - SettingsEntryGroupText(title = "ANDROID AUTO") - - SettingsDescription(text = "Android Autoの開発者設定で「未知のソースを許可」を有効にすることを忘れないでください。") - - SwitchSettingEntry( - title = "Android Auto", - text = "Android Autoサポートを有効にする", - isChecked = isAndroidAutoEnabled, - onCheckedChange = { isAndroidAutoEnabled = it } - ) - - SettingsGroupSpacer() - - SettingsEntryGroupText(title = "検索履歴") - - SwitchSettingEntry( - title = "検索履歴の一時停止", - text = "新しい検索クエリを保存せず、履歴を表示しない", - isChecked = pauseSearchHistory, - onCheckedChange = { pauseSearchHistory = it } - ) - - SettingsEntry( - title = "検索履歴のクリア", - text = if (queriesCount > 0) { - "$queriesCount 件の検索クエリを削除" - } else { - "履歴は空です" - }, - isEnabled = queriesCount > 0, - onClick = { query(Database::clearQueries) } - ) - - SettingsGroupSpacer() - - SettingsEntryGroupText(title = "サービス寿命") - - ImportantSettingsDescription(text = "バッテリーの最適化が適用されている場合、一時停止時に再生通知が突然表示されなくなることがあります。") - - if (isAtLeastAndroid12) { - SettingsDescription(text = "Android 12以降、\"サービスの重要度\" オプションを有効にするには、バッテリーの最適化を無効にする必要があります。") - } - - SettingsEntry( - title = "バッテリーの最適化を無視", - isEnabled = !isIgnoringBatteryOptimizations, - text = if (isIgnoringBatteryOptimizations) { - "既に制限解除済み" - } else { - "バックグラウンドでの制限を無効にする" - }, - onClick = { - if (!isAtLeastAndroid6) return@SettingsEntry - - try { - activityResultLauncher.launch( - Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { - data = Uri.parse("package:${context.packageName}") - } - ) - } catch (e: ActivityNotFoundException) { - try { - activityResultLauncher.launch( - Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS) - ) - } catch (e: ActivityNotFoundException) { - context.toast("バッテリーの最適化設定が見つかりませんでした。ViMusicを手動でホワイトリストに登録してください。") - } - } - } - ) - - SwitchSettingEntry( - title = "サービスの重要度を変更", - text = "バッテリーの最適化だけでは不十分な場合", - isChecked = isInvincibilityEnabled, - onCheckedChange = { isInvincibilityEnabled = it } - ) - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/PlayerSettings.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/PlayerSettings.kt deleted file mode 100644 index 811f0f4..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/PlayerSettings.kt +++ /dev/null @@ -1,128 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens.settings - -import android.content.ActivityNotFoundException -import android.content.Intent -import android.media.audiofx.AudioEffect -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import it.vfsfitvnm.vimusic.LocalPlayerAwareWindowInsets -import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder -import it.vfsfitvnm.vimusic.ui.components.themed.Header -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.utils.isAtLeastAndroid6 -import it.vfsfitvnm.vimusic.utils.persistentQueueKey -import it.vfsfitvnm.vimusic.utils.rememberPreference -import it.vfsfitvnm.vimusic.utils.resumePlaybackWhenDeviceConnectedKey -import it.vfsfitvnm.vimusic.utils.skipSilenceKey -import it.vfsfitvnm.vimusic.utils.toast -import it.vfsfitvnm.vimusic.utils.volumeNormalizationKey - -@ExperimentalAnimationApi -@Composable -fun PlayerSettings() { - val context = LocalContext.current - val (colorPalette) = LocalAppearance.current - val binder = LocalPlayerServiceBinder.current - - var persistentQueue by rememberPreference(persistentQueueKey, false) - var resumePlaybackWhenDeviceConnected by rememberPreference( - resumePlaybackWhenDeviceConnectedKey, - false - ) - var skipSilence by rememberPreference(skipSilenceKey, false) - var volumeNormalization by rememberPreference(volumeNormalizationKey, false) - - val activityResultLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { } - - Column( - modifier = Modifier - .background(colorPalette.background0) - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding( - LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) - .asPaddingValues() - ) - ) { - Header(title = "再生と音楽") - - SettingsEntryGroupText(title = "再生") - - SwitchSettingEntry( - title = "永続的なキュー", - text = "再生中の曲を保存および復元する", - isChecked = persistentQueue, - onCheckedChange = { - persistentQueue = it - } - ) - - if (isAtLeastAndroid6) { - SwitchSettingEntry( - title = "再生を再開", - text = "有線またはBluetoothデバイスが接続された場合", - isChecked = resumePlaybackWhenDeviceConnected, - onCheckedChange = { - resumePlaybackWhenDeviceConnected = it - } - ) - } - - SettingsGroupSpacer() - - SettingsEntryGroupText(title = "音楽") - - SwitchSettingEntry( - title = "無音時間", - text = "再生中に無音部分をスキップする", - isChecked = skipSilence, - onCheckedChange = { - skipSilence = it - } - ) - - SwitchSettingEntry( - title = "音量の調整", - text = "音量を固定のレベルに調整して偏りを減らす", - isChecked = volumeNormalization, - onCheckedChange = { - volumeNormalization = it - } - ) - - SettingsEntry( - title = "イコライザー", - text = "システムのイコライザーと連携する", - onClick = { - val intent = Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL).apply { - putExtra(AudioEffect.EXTRA_AUDIO_SESSION, binder?.player?.audioSessionId) - putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.packageName) - putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC) - } - - try { - activityResultLauncher.launch(intent) - } catch (e: ActivityNotFoundException) { - context.toast("Couldn't find an application to equalize audio") - } - } - ) - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/SettingsScreen.kt deleted file mode 100644 index c521cad..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/SettingsScreen.kt +++ /dev/null @@ -1,252 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens.settings - -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.BasicText -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.saveable.rememberSaveableStateHolder -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.unit.dp -import it.vfsfitvnm.compose.routing.RouteHandler -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold -import it.vfsfitvnm.vimusic.ui.components.themed.Switch -import it.vfsfitvnm.vimusic.ui.components.themed.ValueSelectorDialog -import it.vfsfitvnm.vimusic.ui.screens.globalRoutes -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.utils.color -import it.vfsfitvnm.vimusic.utils.secondary -import it.vfsfitvnm.vimusic.utils.semiBold - -@ExperimentalFoundationApi -@ExperimentalAnimationApi -@Composable -fun SettingsScreen() { - val saveableStateHolder = rememberSaveableStateHolder() - - val (tabIndex, onTabChanged) = rememberSaveable { - mutableStateOf(0) - } - - RouteHandler(listenToGlobalEmitter = true) { - globalRoutes() - - host { - Scaffold( - topIconButtonId = R.drawable.chevron_back, - onTopIconButtonClick = pop, - tabIndex = tabIndex, - onTabChanged = onTabChanged, - tabColumnContent = { Item -> - Item(0, "外見", R.drawable.color_palette) - Item(1, "再生", R.drawable.play) - Item(2, "キャッシュ", R.drawable.server) - Item(3, "データベース", R.drawable.server) - Item(4, "その他", R.drawable.shapes) - Item(5, "詳細", R.drawable.information) - } - ) { currentTabIndex -> - saveableStateHolder.SaveableStateProvider(currentTabIndex) { - when (currentTabIndex) { - 0 -> AppearanceSettings() - 1 -> PlayerSettings() - 2 -> CacheSettings() - 3 -> DatabaseSettings() - 4 -> OtherSettings() - 5 -> About() - } - } - } - } - } -} - -@Composable -inline fun > EnumValueSelectorSettingsEntry( - title: String, - selectedValue: T, - crossinline onValueSelected: (T) -> Unit, - modifier: Modifier = Modifier, - isEnabled: Boolean = true, - crossinline valueText: (T) -> String = Enum::name, - noinline trailingContent: (@Composable () -> Unit)? = null -) { - ValueSelectorSettingsEntry( - title = title, - selectedValue = selectedValue, - values = enumValues().toList(), - onValueSelected = onValueSelected, - modifier = modifier, - isEnabled = isEnabled, - valueText = valueText, - trailingContent = trailingContent, - ) -} - -@Composable -inline fun ValueSelectorSettingsEntry( - title: String, - selectedValue: T, - values: List, - crossinline onValueSelected: (T) -> Unit, - modifier: Modifier = Modifier, - isEnabled: Boolean = true, - crossinline valueText: (T) -> String = { it.toString() }, - noinline trailingContent: (@Composable () -> Unit)? = null -) { - var isShowingDialog by remember { - mutableStateOf(false) - } - - if (isShowingDialog) { - ValueSelectorDialog( - onDismiss = { isShowingDialog = false }, - title = title, - selectedValue = selectedValue, - values = values, - onValueSelected = onValueSelected, - valueText = valueText - ) - } - - SettingsEntry( - title = title, - text = valueText(selectedValue), - modifier = modifier, - isEnabled = isEnabled, - onClick = { isShowingDialog = true }, - trailingContent = trailingContent - ) -} - -@Composable -fun SwitchSettingEntry( - title: String, - text: String, - isChecked: Boolean, - onCheckedChange: (Boolean) -> Unit, - modifier: Modifier = Modifier, - isEnabled: Boolean = true -) { - SettingsEntry( - title = title, - text = text, - isEnabled = isEnabled, - onClick = { onCheckedChange(!isChecked) }, - trailingContent = { Switch(isChecked = isChecked) }, - modifier = modifier - ) -} - -@Composable -fun SettingsEntry( - title: String, - text: String, - modifier: Modifier = Modifier, - onClick: () -> Unit, - isEnabled: Boolean = true, - trailingContent: (@Composable () -> Unit)? = null -) { - val (colorPalette, typography) = LocalAppearance.current - - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = modifier - .clickable(enabled = isEnabled, onClick = onClick) - .alpha(if (isEnabled) 1f else 0.5f) - .padding(start = 16.dp) - .padding(all = 16.dp) - .fillMaxWidth() - ) { - Column( - modifier = Modifier - .weight(1f) - ) { - BasicText( - text = title, - style = typography.xs.semiBold.copy(color = colorPalette.text), - ) - - BasicText( - text = text, - style = typography.xs.semiBold.copy(color = colorPalette.textSecondary), - ) - } - - trailingContent?.invoke() - } -} - -@Composable -fun SettingsDescription( - text: String, - modifier: Modifier = Modifier, -) { - val (_, typography) = LocalAppearance.current - - BasicText( - text = text, - style = typography.xxs.secondary, - modifier = modifier - .padding(start = 16.dp) - .padding(horizontal = 16.dp, vertical = 8.dp) - ) -} - -@Composable -fun ImportantSettingsDescription( - text: String, - modifier: Modifier = Modifier, -) { - val (colorPalette, typography) = LocalAppearance.current - - BasicText( - text = text, - style = typography.xxs.semiBold.color(colorPalette.red), - modifier = modifier - .padding(start = 16.dp) - .padding(horizontal = 16.dp, vertical = 8.dp) - ) -} - -@Composable -fun SettingsEntryGroupText( - title: String, - modifier: Modifier = Modifier, -) { - val (colorPalette, typography) = LocalAppearance.current - - BasicText( - text = title.uppercase(), - style = typography.xxs.semiBold.copy(colorPalette.accent), - modifier = modifier - .padding(start = 16.dp) - .padding(horizontal = 16.dp) - ) -} - -@Composable -fun SettingsGroupSpacer( - modifier: Modifier = Modifier, -) { - Spacer( - modifier = modifier - .height(24.dp) - ) -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/Appearance.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/Appearance.kt deleted file mode 100644 index 42fa241..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/Appearance.kt +++ /dev/null @@ -1,39 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.styling - -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.saveable.SaverScope -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.unit.dp - -data class Appearance( - val colorPalette: ColorPalette, - val typography: Typography, - val thumbnailShape: Shape, -) { - companion object : Saver> { - @Suppress("UNCHECKED_CAST") - override fun restore(value: List): Appearance { - return Appearance( - colorPalette = ColorPalette.restore(value[0] as List), - typography = Typography.restore(value[1] as List), - thumbnailShape = RoundedCornerShape((value[2] as Int).dp) - ) - } - - override fun SaverScope.save(value: Appearance) = - listOf( - with (ColorPalette.Companion) { save(value.colorPalette) }, - with (Typography.Companion) { save(value.typography) }, - when (value.thumbnailShape) { - RoundedCornerShape(2.dp) -> 2 - RoundedCornerShape(4.dp) -> 4 - RoundedCornerShape(8.dp) -> 8 - else -> 0 - } - ) - } -} - -val LocalAppearance = staticCompositionLocalOf { TODO() } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/ColorPalette.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/ColorPalette.kt deleted file mode 100644 index 84cf6b2..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/ColorPalette.kt +++ /dev/null @@ -1,177 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.styling - -import android.graphics.Bitmap -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.saveable.SaverScope -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb -import androidx.core.graphics.ColorUtils -import androidx.palette.graphics.Palette -import it.vfsfitvnm.vimusic.enums.ColorPaletteMode -import it.vfsfitvnm.vimusic.enums.ColorPaletteName - -@Immutable -data class ColorPalette( - val background0: Color, - val background1: Color, - val background2: Color, - val accent: Color, - val onAccent: Color, - val red: Color = Color(0xffbf4040), - val blue: Color = Color(0xff4472cf), - val text: Color, - val textSecondary: Color, - val textDisabled: Color, - val isDark: Boolean -) { - companion object : Saver> { - override fun restore(value: List) = when (val accent = value[0] as Int) { - 0 -> DefaultDarkColorPalette - 1 -> DefaultLightColorPalette - 2 -> PureBlackColorPalette - else -> dynamicColorPaletteOf( - FloatArray(3).apply { ColorUtils.colorToHSL(accent, this) }, - value[1] as Boolean - ) - } - - override fun SaverScope.save(value: ColorPalette) = - listOf( - when { - value === DefaultDarkColorPalette -> 0 - value === DefaultLightColorPalette -> 1 - value === PureBlackColorPalette -> 2 - else -> value.accent.toArgb() - }, - value.isDark - ) - } -} - -val DefaultDarkColorPalette = ColorPalette( - background0 = Color(0xff16171d), - background1 = Color(0xff1f2029), - background2 = Color(0xff2b2d3b), - text = Color(0xffe1e1e2), - textSecondary = Color(0xffa3a4a6), - textDisabled = Color(0xff6f6f73), - accent = Color(0xff5055c0), - onAccent = Color.White, - isDark = true -) - -val DefaultLightColorPalette = ColorPalette( - background0 = Color(0xfffdfdfe), - background1 = Color(0xfff8f8fc), - background2 = Color(0xffeaeaf5), - text = Color(0xff212121), - textSecondary = Color(0xff656566), - textDisabled = Color(0xff9d9d9d), - accent = Color(0xff5055c0), - onAccent = Color.White, - isDark = false -) - -val PureBlackColorPalette = DefaultDarkColorPalette.copy( - background0 = Color.Black, - background1 = Color.Black, - background2 = Color.Black -) - -fun colorPaletteOf( - colorPaletteName: ColorPaletteName, - colorPaletteMode: ColorPaletteMode, - isSystemInDarkMode: Boolean -): ColorPalette { - return when (colorPaletteName) { - ColorPaletteName.Default, ColorPaletteName.Dynamic -> when (colorPaletteMode) { - ColorPaletteMode.Light -> DefaultLightColorPalette - ColorPaletteMode.Dark -> DefaultDarkColorPalette - ColorPaletteMode.System -> when (isSystemInDarkMode) { - true -> DefaultDarkColorPalette - false -> DefaultLightColorPalette - } - } - ColorPaletteName.PureBlack -> PureBlackColorPalette - } -} - -fun dynamicColorPaletteOf(bitmap: Bitmap, isDark: Boolean): ColorPalette? { - val palette = Palette - .from(bitmap) - .maximumColorCount(8) - .addFilter(if (isDark) ({ _, hsl -> hsl[0] !in 36f..100f }) else null) - .generate() - - val hsl = if (isDark) { - palette.dominantSwatch ?: Palette - .from(bitmap) - .maximumColorCount(8) - .generate() - .dominantSwatch - } else { - palette.dominantSwatch - }?.hsl ?: return null - - return if (hsl[1] < 0.08) { - val newHsl = palette.swatches - .map(Palette.Swatch::getHsl) - .sortedByDescending(FloatArray::component2) - .find { it[1] != 0f } - ?: hsl - - dynamicColorPaletteOf(newHsl, isDark) - } else { - dynamicColorPaletteOf(hsl, isDark) - } -} - -fun dynamicColorPaletteOf(hsl: FloatArray, isDark: Boolean): ColorPalette { - return colorPaletteOf(ColorPaletteName.Dynamic, if (isDark) ColorPaletteMode.Dark else ColorPaletteMode.Light, false).copy( - background0 = Color.hsl(hsl[0], hsl[1].coerceAtMost(0.1f), if (isDark) 0.10f else 0.925f), - background1 = Color.hsl(hsl[0], hsl[1].coerceAtMost(0.3f), if (isDark) 0.15f else 0.90f), - background2 = Color.hsl(hsl[0], hsl[1].coerceAtMost(0.4f), if (isDark) 0.2f else 0.85f), - accent = Color.hsl(hsl[0], hsl[1].coerceAtMost(0.5f), 0.5f), - text = Color.hsl(hsl[0], hsl[1].coerceAtMost(0.02f), if (isDark) 0.88f else 0.12f), - textSecondary = Color.hsl(hsl[0], hsl[1].coerceAtMost(0.1f), if (isDark) 0.65f else 0.40f), - textDisabled = Color.hsl(hsl[0], hsl[1].coerceAtMost(0.2f), if (isDark) 0.40f else 0.65f), - ) -} - -inline val ColorPalette.collapsedPlayerProgressBar: Color - get() = if (this === DefaultDarkColorPalette || this === DefaultLightColorPalette || this === PureBlackColorPalette) { - text - } else { - accent - } - -inline val ColorPalette.favoritesIcon: Color - get() = if (this === DefaultDarkColorPalette || this === DefaultLightColorPalette || this === PureBlackColorPalette) { - red - } else { - accent - } - -inline val ColorPalette.shimmer: Color - get() = if (this === DefaultDarkColorPalette || this === DefaultLightColorPalette || this === PureBlackColorPalette) { - Color(0xff838383) - } else { - accent - } - -inline val ColorPalette.primaryButton: Color - get() = if (this === PureBlackColorPalette) { - Color(0xFF272727) - } else { - background2 - } - -inline val ColorPalette.overlay: Color - get() = PureBlackColorPalette.background0.copy(alpha = 0.75f) - -inline val ColorPalette.onOverlay: Color - get() = PureBlackColorPalette.text - -inline val ColorPalette.onOverlayShimmer: Color - get() = PureBlackColorPalette.shimmer diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/Dimensions.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/Dimensions.kt deleted file mode 100644 index e6b6004..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/Dimensions.kt +++ /dev/null @@ -1,38 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.styling - -import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp - -@Suppress("ClassName") -object Dimensions { - val itemsVerticalPadding = 8.dp - - val navigationRailWidth = 64.dp - val navigationRailWidthLandscape = 128.dp - val navigationRailIconOffset = 6.dp - val headerHeight = 140.dp - - object thumbnails { - val album = 128.dp - val artist = 192.dp - val song = 54.dp - val playlist = album - - object player { - val song: Dp - @Composable - get() = with(LocalConfiguration.current) { - minOf(screenHeightDp, screenWidthDp) - }.dp - } - } - - val collapsedPlayer = 64.dp -} - -inline val Dp.px: Int - @Composable - inline get() = with(LocalDensity.current) { roundToPx() } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/Typography.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/Typography.kt deleted file mode 100644 index 1c1faa1..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/Typography.kt +++ /dev/null @@ -1,90 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.styling - -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.saveable.SaverScope -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.PlatformTextStyle -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.sp -import it.vfsfitvnm.vimusic.R - -@Immutable -data class Typography( - val xxs: TextStyle, - val xs: TextStyle, - val s: TextStyle, - val m: TextStyle, - val l: TextStyle, - val xxl: TextStyle, -) { - fun copy(color: Color) = Typography( - xxs = xxs.copy(color = color), - xs = xs.copy(color = color), - s = s.copy(color = color), - m = m.copy(color = color), - l = l.copy(color = color), - xxl = xxl.copy(color = color) - ) - - companion object : Saver> { - override fun restore(value: List) = typographyOf( - Color((value[0] as Long).toULong()), - value[1] as Boolean, - value[2] as Boolean - ) - - override fun SaverScope.save(value: Typography) = - listOf( - value.xxs.color.value.toLong(), - value.xxs.fontFamily == FontFamily.Default, - value.xxs.platformStyle?.paragraphStyle?.includeFontPadding ?: false - ) - } -} - -fun typographyOf(color: Color, useSystemFont: Boolean, applyFontPadding: Boolean): Typography { - val textStyle = TextStyle( - fontFamily = if (useSystemFont) { - FontFamily.Default - } else { - FontFamily( - Font( - resId = R.font.poppins_w300, - weight = FontWeight.Light - ), - Font( - resId = R.font.poppins_w400, - weight = FontWeight.Normal - ), - Font( - resId = R.font.poppins_w500, - weight = FontWeight.Medium - ), - Font( - resId = R.font.poppins_w600, - weight = FontWeight.SemiBold - ), - Font( - resId = R.font.poppins_w700, - weight = FontWeight.Bold - ), - ) - }, - fontWeight = FontWeight.Normal, - color = color, - platformStyle = @Suppress("DEPRECATION") (PlatformTextStyle(includeFontPadding = applyFontPadding)) - ) - - return Typography( - xxs = textStyle.copy(fontSize = 12.sp), - xs = textStyle.copy(fontSize = 14.sp), - s = textStyle.copy(fontSize = 16.sp), - m = textStyle.copy(fontSize = 18.sp), - l = textStyle.copy(fontSize = 20.sp), - xxl = textStyle.copy(fontSize = 32.sp) - ) -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Configuration.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Configuration.kt deleted file mode 100644 index 6011a8f..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Configuration.kt +++ /dev/null @@ -1,11 +0,0 @@ -package it.vfsfitvnm.vimusic.utils - -import android.content.res.Configuration -import androidx.compose.runtime.Composable -import androidx.compose.runtime.ReadOnlyComposable -import androidx.compose.ui.platform.LocalConfiguration - -val isLandscape - @Composable - @ReadOnlyComposable - get() = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Context.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Context.kt deleted file mode 100644 index 04ee2b3..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Context.kt +++ /dev/null @@ -1,40 +0,0 @@ -package it.vfsfitvnm.vimusic.utils - -import android.app.Activity -import android.app.PendingIntent -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.os.PowerManager -import android.widget.Toast -import androidx.core.content.getSystemService - -inline fun Context.intent(): Intent = - Intent(this@Context, T::class.java) - -inline fun Context.broadCastPendingIntent( - requestCode: Int = 0, - flags: Int = if (isAtLeastAndroid6) PendingIntent.FLAG_IMMUTABLE else 0, -): PendingIntent = - PendingIntent.getBroadcast(this, requestCode, intent(), flags) - -inline fun Context.activityPendingIntent( - requestCode: Int = 0, - flags: Int = 0, - block: Intent.() -> Unit = {}, -): PendingIntent = - PendingIntent.getActivity( - this, - requestCode, - intent().apply(block), - (if (isAtLeastAndroid6) PendingIntent.FLAG_IMMUTABLE else 0) or flags - ) - -val Context.isIgnoringBatteryOptimizations: Boolean - get() = if (isAtLeastAndroid6) { - getSystemService()?.isIgnoringBatteryOptimizations(packageName) ?: true - } else { - true - } - -fun Context.toast(message: String) = Toast.makeText(this, message, Toast.LENGTH_SHORT).show() diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/FadingEdge.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/FadingEdge.kt deleted file mode 100644 index 8d79e5d..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/FadingEdge.kt +++ /dev/null @@ -1,24 +0,0 @@ -package it.vfsfitvnm.vimusic.utils - -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.graphics.BlendMode -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer - -fun Modifier.verticalFadingEdge() = - graphicsLayer(alpha = 0.99f) - .drawWithContent { - drawContent() - drawRect( - brush = Brush.verticalGradient( - listOf( - Color.Transparent, - Color.Black, Color.Black, Color.Black, - Color.Transparent - ) - ), - blendMode = BlendMode.DstIn - ) - } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/LazyGridSnapLayoutInfoProvider.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/LazyGridSnapLayoutInfoProvider.kt deleted file mode 100644 index 2fc20ba..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/LazyGridSnapLayoutInfoProvider.kt +++ /dev/null @@ -1,72 +0,0 @@ -package it.vfsfitvnm.vimusic.utils - -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider -import androidx.compose.foundation.lazy.grid.LazyGridItemInfo -import androidx.compose.foundation.lazy.grid.LazyGridLayoutInfo -import androidx.compose.foundation.lazy.grid.LazyGridState -import androidx.compose.ui.unit.Density -import androidx.compose.ui.util.fastForEach -import androidx.compose.ui.util.fastSumBy - -fun Density.calculateDistanceToDesiredSnapPosition( - layoutInfo: LazyGridLayoutInfo, - item: LazyGridItemInfo, - positionInLayout: Density.(layoutSize: Float, itemSize: Float) -> Float -): Float { - val containerSize = - with(layoutInfo) { singleAxisViewportSize - beforeContentPadding - afterContentPadding } - - val desiredDistance = positionInLayout(containerSize.toFloat(), item.size.width.toFloat()) - val itemCurrentPosition = item.offset.x.toFloat() - - return itemCurrentPosition - desiredDistance -} - -private val LazyGridLayoutInfo.singleAxisViewportSize: Int - get() = if (orientation == Orientation.Vertical) viewportSize.height else viewportSize.width - -@ExperimentalFoundationApi -fun SnapLayoutInfoProvider( - lazyGridState: LazyGridState, - positionInLayout: Density.(layoutSize: Float, itemSize: Float) -> Float = - { layoutSize, itemSize -> (layoutSize / 2f - itemSize / 2f) } -): SnapLayoutInfoProvider = object : SnapLayoutInfoProvider { - - private val layoutInfo: LazyGridLayoutInfo - get() = lazyGridState.layoutInfo - - // Single page snapping is the default - override fun Density.calculateApproachOffset(initialVelocity: Float): Float = 0f - - override fun Density.calculateSnappingOffsetBounds(): ClosedFloatingPointRange { - var lowerBoundOffset = Float.NEGATIVE_INFINITY - var upperBoundOffset = Float.POSITIVE_INFINITY - - layoutInfo.visibleItemsInfo.fastForEach { item -> - val offset = - calculateDistanceToDesiredSnapPosition(layoutInfo, item, positionInLayout) - - // Find item that is closest to the center - if (offset <= 0 && offset > lowerBoundOffset) { - lowerBoundOffset = offset - } - - // Find item that is closest to center, but after it - if (offset >= 0 && offset < upperBoundOffset) { - upperBoundOffset = offset - } - } - - return lowerBoundOffset.rangeTo(upperBoundOffset) - } - - override fun Density.snapStepSize(): Float = with(layoutInfo) { - if (visibleItemsInfo.isNotEmpty()) { - visibleItemsInfo.fastSumBy { it.size.width } / visibleItemsInfo.size.toFloat() - } else { - 0f - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Player.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Player.kt deleted file mode 100644 index db99676..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Player.kt +++ /dev/null @@ -1,99 +0,0 @@ -package it.vfsfitvnm.vimusic.utils - -import androidx.media3.common.C -import androidx.media3.common.MediaItem -import androidx.media3.common.Player -import androidx.media3.common.Timeline - -val Player.currentWindow: Timeline.Window? - get() = if (mediaItemCount == 0) null else currentTimeline.getWindow(currentMediaItemIndex, Timeline.Window()) - -val Timeline.mediaItems: List - get() = List(windowCount) { - getWindow(it, Timeline.Window()).mediaItem - } - -inline val Timeline.windows: List - get() = List(windowCount) { - getWindow(it, Timeline.Window()) - } - -val Player.shouldBePlaying: Boolean - get() = !(playbackState == Player.STATE_ENDED || !playWhenReady) - -fun Player.seamlessPlay(mediaItem: MediaItem) { - if (mediaItem.mediaId == currentMediaItem?.mediaId) { - if (currentMediaItemIndex > 0) removeMediaItems(0, currentMediaItemIndex) - if (currentMediaItemIndex < mediaItemCount - 1) removeMediaItems(currentMediaItemIndex + 1, mediaItemCount) - } else { - forcePlay(mediaItem) - } -} - -fun Player.shuffleQueue() { - val mediaItems = currentTimeline.mediaItems.toMutableList().apply { removeAt(currentMediaItemIndex) } - if (currentMediaItemIndex > 0) removeMediaItems(0, currentMediaItemIndex) - if (currentMediaItemIndex < mediaItemCount - 1) removeMediaItems(currentMediaItemIndex + 1, mediaItemCount) - addMediaItems(mediaItems.shuffled()) -} - -fun Player.forcePlay(mediaItem: MediaItem) { - setMediaItem(mediaItem, true) - playWhenReady = true - prepare() -} - -fun Player.forcePlayAtIndex(mediaItems: List, mediaItemIndex: Int) { - if (mediaItems.isEmpty()) return - - setMediaItems(mediaItems, mediaItemIndex, C.TIME_UNSET) - playWhenReady = true - prepare() -} - -fun Player.forcePlayFromBeginning(mediaItems: List) = - forcePlayAtIndex(mediaItems, 0) - -fun Player.forceSeekToPrevious() { - if (hasPreviousMediaItem() || currentPosition > maxSeekToPreviousPosition) { - seekToPrevious() - } else if (mediaItemCount > 0) { - seekTo(mediaItemCount - 1, C.TIME_UNSET) - } -} - -fun Player.forceSeekToNext() = - if (hasNextMediaItem()) seekToNext() else seekTo(0, C.TIME_UNSET) - -fun Player.addNext(mediaItem: MediaItem) { - if (playbackState == Player.STATE_IDLE || playbackState == Player.STATE_ENDED) { - forcePlay(mediaItem) - } else { - addMediaItem(currentMediaItemIndex + 1, mediaItem) - } -} - -fun Player.enqueue(mediaItem: MediaItem) { - if (playbackState == Player.STATE_IDLE || playbackState == Player.STATE_ENDED) { - forcePlay(mediaItem) - } else { - addMediaItem(mediaItemCount, mediaItem) - } -} - -fun Player.enqueue(mediaItems: List) { - if (playbackState == Player.STATE_IDLE || playbackState == Player.STATE_ENDED) { - forcePlayFromBeginning(mediaItems) - } else { - addMediaItems(mediaItemCount, mediaItems) - } -} - -fun Player.findNextMediaItemById(mediaId: String): MediaItem? { - for (i in currentMediaItemIndex until mediaItemCount) { - if (getMediaItemAt(i).mediaId == mediaId) { - return getMediaItemAt(i) - } - } - return null -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/PlayerState.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/PlayerState.kt deleted file mode 100644 index 6229dcb..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/PlayerState.kt +++ /dev/null @@ -1,77 +0,0 @@ -package it.vfsfitvnm.vimusic.utils - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.State -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.media3.common.MediaItem -import androidx.media3.common.Player -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine - -@Composable -inline fun Player.DisposableListener(crossinline listenerProvider: () -> Player.Listener) { - DisposableEffect(this) { - val listener = listenerProvider() - addListener(listener) - onDispose { removeListener(listener) } - } -} - -@Composable -fun Player.positionAndDurationState(): State> { - val state = remember { - mutableStateOf(currentPosition to duration) - } - - LaunchedEffect(this) { - var isSeeking = false - - val listener = object : Player.Listener { - override fun onPlaybackStateChanged(playbackState: Int) { - if (playbackState == Player.STATE_READY) { - isSeeking = false - } - } - - override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { - state.value = currentPosition to state.value.second - } - - override fun onPositionDiscontinuity( - oldPosition: Player.PositionInfo, - newPosition: Player.PositionInfo, - reason: Int - ) { - if (reason == Player.DISCONTINUITY_REASON_SEEK) { - isSeeking = true - state.value = currentPosition to duration - } - } - } - - addListener(listener) - - val pollJob = launch { - while (isActive) { - delay(500) - if (!isSeeking) { - state.value = currentPosition to duration - } - } - } - - try { - suspendCancellableCoroutine { } - } finally { - pollJob.cancel() - removeListener(listener) - } - } - - return state -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Preferences.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Preferences.kt deleted file mode 100644 index d97ef52..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Preferences.kt +++ /dev/null @@ -1,115 +0,0 @@ -package it.vfsfitvnm.vimusic.utils - -import android.content.Context -import android.content.SharedPreferences -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.SnapshotMutationPolicy -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalContext -import androidx.core.content.edit - -const val colorPaletteNameKey = "colorPaletteName" -const val colorPaletteModeKey = "colorPaletteMode" -const val thumbnailRoundnessKey = "thumbnailRoundness" -const val coilDiskCacheMaxSizeKey = "coilDiskCacheMaxSize" -const val exoPlayerDiskCacheMaxSizeKey = "exoPlayerDiskCacheMaxSize" -const val isInvincibilityEnabledKey = "isInvincibilityEnabled" -const val useSystemFontKey = "useSystemFont" -const val applyFontPaddingKey = "applyFontPadding" -const val songSortOrderKey = "songSortOrder" -const val songSortByKey = "songSortBy" -const val playlistSortOrderKey = "playlistSortOrder" -const val playlistSortByKey = "playlistSortBy" -const val albumSortOrderKey = "albumSortOrder" -const val albumSortByKey = "albumSortBy" -const val artistSortOrderKey = "artistSortOrder" -const val artistSortByKey = "artistSortBy" -const val trackLoopEnabledKey = "trackLoopEnabled" -const val queueLoopEnabledKey = "queueLoopEnabled" -const val skipSilenceKey = "skipSilence" -const val volumeNormalizationKey = "volumeNormalization" -const val resumePlaybackWhenDeviceConnectedKey = "resumePlaybackWhenDeviceConnected" -const val persistentQueueKey = "persistentQueue" -const val isShowingSynchronizedLyricsKey = "isShowingSynchronizedLyrics" -const val isShowingThumbnailInLockscreenKey = "isShowingThumbnailInLockscreen" -const val homeScreenTabIndexKey = "homeScreenTabIndex" -const val searchResultScreenTabIndexKey = "searchResultScreenTabIndex" -const val artistScreenTabIndexKey = "artistScreenTabIndex" -const val pauseSearchHistoryKey = "pauseSearchHistory" - -inline fun > SharedPreferences.getEnum( - key: String, - defaultValue: T -): T = - getString(key, null)?.let { - try { - enumValueOf(it) - } catch (e: IllegalArgumentException) { - null - } - } ?: defaultValue - -inline fun > SharedPreferences.Editor.putEnum( - key: String, - value: T -): SharedPreferences.Editor = - putString(key, value.name) - -val Context.preferences: SharedPreferences - get() = getSharedPreferences("preferences", Context.MODE_PRIVATE) - -@Composable -fun rememberPreference(key: String, defaultValue: Boolean): MutableState { - val context = LocalContext.current - return remember { - mutableStatePreferenceOf(context.preferences.getBoolean(key, defaultValue)) { - context.preferences.edit { putBoolean(key, it) } - } - } -} - -@Composable -fun rememberPreference(key: String, defaultValue: Int): MutableState { - val context = LocalContext.current - return remember { - mutableStatePreferenceOf(context.preferences.getInt(key, defaultValue)) { - context.preferences.edit { putInt(key, it) } - } - } -} - -@Composable -fun rememberPreference(key: String, defaultValue: String): MutableState { - val context = LocalContext.current - return remember { - mutableStatePreferenceOf(context.preferences.getString(key, null) ?: defaultValue) { - context.preferences.edit { putString(key, it) } - } - } -} - -@Composable -inline fun > rememberPreference(key: String, defaultValue: T): MutableState { - val context = LocalContext.current - return remember { - mutableStatePreferenceOf(context.preferences.getEnum(key, defaultValue)) { - context.preferences.edit { putEnum(key, it) } - } - } -} - -inline fun mutableStatePreferenceOf( - value: T, - crossinline onStructuralInequality: (newValue: T) -> Unit -) = - mutableStateOf( - value = value, - policy = object : SnapshotMutationPolicy { - override fun equivalent(a: T, b: T): Boolean { - val areEquals = a == b - if (!areEquals) onStructuralInequality(b) - return areEquals - } - }) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/RingBuffer.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/RingBuffer.kt deleted file mode 100644 index 6d403fc..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/RingBuffer.kt +++ /dev/null @@ -1,11 +0,0 @@ -package it.vfsfitvnm.vimusic.utils - -class RingBuffer(val size: Int, init: (index: Int) -> T) { - private val list = MutableList(size, init) - - private var index = 0 - - fun getOrNull(index: Int): T? = list.getOrNull(index) - - fun append(element: T) = list.set(index++ % size, element) -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/ScrollingInfo.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/ScrollingInfo.kt deleted file mode 100644 index 6d0f521..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/ScrollingInfo.kt +++ /dev/null @@ -1,93 +0,0 @@ -package it.vfsfitvnm.vimusic.utils - -import androidx.compose.foundation.ScrollState -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.grid.LazyGridState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue - -data class ScrollingInfo( - val isScrollingDown: Boolean = false, - val isFar: Boolean = false -) { - fun and(condition: Boolean) = -// copy(isScrollingDown = isScrollingDown && condition, isFar = isFar && condition) - if (condition) this else copy(isScrollingDown = !isScrollingDown, isFar = !isFar) -} - -@Composable -fun LazyListState.scrollingInfo(): ScrollingInfo { - var previousIndex by remember(this) { - mutableStateOf(firstVisibleItemIndex) - } - - var previousScrollOffset by remember(this) { - mutableStateOf(firstVisibleItemScrollOffset) - } - - return remember(this) { - derivedStateOf { - val isScrollingDown = if (previousIndex == firstVisibleItemIndex) { - firstVisibleItemScrollOffset > previousScrollOffset - } else { - firstVisibleItemIndex > previousIndex - } - - val isFar = firstVisibleItemIndex > layoutInfo.visibleItemsInfo.size - - previousIndex = firstVisibleItemIndex - previousScrollOffset = firstVisibleItemScrollOffset - - ScrollingInfo(isScrollingDown, isFar) - } - }.value -} - -@Composable -fun LazyGridState.scrollingInfo(): ScrollingInfo { - var previousIndex by remember(this) { - mutableStateOf(firstVisibleItemIndex) - } - - var previousScrollOffset by remember(this) { - mutableStateOf(firstVisibleItemScrollOffset) - } - - return remember(this) { - derivedStateOf { - val isScrollingDown = if (previousIndex == firstVisibleItemIndex) { - firstVisibleItemScrollOffset > previousScrollOffset - } else { - firstVisibleItemIndex > previousIndex - } - - val isFar = firstVisibleItemIndex > layoutInfo.visibleItemsInfo.size - - previousIndex = firstVisibleItemIndex - previousScrollOffset = firstVisibleItemScrollOffset - - ScrollingInfo(isScrollingDown, isFar) - } - }.value -} - -@Composable -fun ScrollState.scrollingInfo(): ScrollingInfo { - var previousValue by remember(this) { - mutableStateOf(value) - } - - return remember(this) { - derivedStateOf { - val isScrollingDown = value > previousValue - - previousValue = value - - ScrollingInfo(isScrollingDown, false) - } - }.value -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/SynchronizedLyrics.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/SynchronizedLyrics.kt deleted file mode 100644 index d7dc6ef..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/SynchronizedLyrics.kt +++ /dev/null @@ -1,30 +0,0 @@ -package it.vfsfitvnm.vimusic.utils - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue - -class SynchronizedLyrics(val sentences: List>, private val positionProvider: () -> Long) { - var index by mutableStateOf(currentIndex) - private set - - private val currentIndex: Int - get() { - var index = -1 - for (item in sentences) { - if (item.first >= positionProvider()) break - index++ - } - return if (index == -1) 0 else index - } - - fun update(): Boolean { - val newIndex = currentIndex - return if (newIndex != index) { - index = newIndex - true - } else { - false - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/TextStyle.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/TextStyle.kt deleted file mode 100644 index b8638de..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/TextStyle.kt +++ /dev/null @@ -1,35 +0,0 @@ -package it.vfsfitvnm.vimusic.utils - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.ReadOnlyComposable -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance - -fun TextStyle.style(style: FontStyle) = copy(fontStyle = style) - -fun TextStyle.weight(weight: FontWeight) = copy(fontWeight = weight) - -fun TextStyle.align(align: TextAlign) = copy(textAlign = align) - -fun TextStyle.color(color: Color) = copy(color = color) - -inline val TextStyle.medium: TextStyle - get() = weight(FontWeight.Medium) - -inline val TextStyle.semiBold: TextStyle - get() = weight(FontWeight.SemiBold) - -inline val TextStyle.bold: TextStyle - get() = weight(FontWeight.Bold) - -inline val TextStyle.center: TextStyle - get() = align(TextAlign.Center) - -inline val TextStyle.secondary: TextStyle - @Composable - @ReadOnlyComposable - get() = color(LocalAppearance.current.colorPalette.textSecondary) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Utils.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Utils.kt deleted file mode 100644 index d6562d1..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Utils.kt +++ /dev/null @@ -1,120 +0,0 @@ -package it.vfsfitvnm.vimusic.utils - -import android.net.Uri -import android.os.Build -import android.text.format.DateUtils -import androidx.core.net.toUri -import androidx.core.os.bundleOf -import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata -import it.vfsfitvnm.innertube.Innertube -import it.vfsfitvnm.innertube.models.bodies.ContinuationBody -import it.vfsfitvnm.innertube.requests.playlistPage -import it.vfsfitvnm.innertube.utils.plus -import it.vfsfitvnm.vimusic.models.Song - -val Innertube.SongItem.asMediaItem: MediaItem - get() = MediaItem.Builder() - .setMediaId(key) - .setUri(key) - .setCustomCacheKey(key) - .setMediaMetadata( - MediaMetadata.Builder() - .setTitle(info?.name) - .setArtist(authors?.joinToString("") { it.name ?: "" }) - .setAlbumTitle(album?.name) - .setArtworkUri(thumbnail?.url?.toUri()) - .setExtras( - bundleOf( - "albumId" to album?.endpoint?.browseId, - "durationText" to durationText, - "artistNames" to authors?.filter { it.endpoint != null }?.mapNotNull { it.name }, - "artistIds" to authors?.mapNotNull { it.endpoint?.browseId }, - ) - ) - .build() - ) - .build() - -val Innertube.VideoItem.asMediaItem: MediaItem - get() = MediaItem.Builder() - .setMediaId(key) - .setUri(key) - .setCustomCacheKey(key) - .setMediaMetadata( - MediaMetadata.Builder() - .setTitle(info?.name) - .setArtist(authors?.joinToString("") { it.name ?: "" }) - .setArtworkUri(thumbnail?.url?.toUri()) - .setExtras( - bundleOf( - "durationText" to durationText, - "artistNames" to if (isOfficialMusicVideo) authors?.filter { it.endpoint != null }?.mapNotNull { it.name } else null, - "artistIds" to if (isOfficialMusicVideo) authors?.mapNotNull { it.endpoint?.browseId } else null, - ) - ) - .build() - ) - .build() - -val Song.asMediaItem: MediaItem - get() = MediaItem.Builder() - .setMediaMetadata( - MediaMetadata.Builder() - .setTitle(title) - .setArtist(artistsText) - .setArtworkUri(thumbnailUrl?.toUri()) - .setExtras( - bundleOf( - "durationText" to durationText - ) - ) - .build() - ) - .setMediaId(id) - .setUri(id) - .setCustomCacheKey(id) - .build() - -fun String?.thumbnail(size: Int): String? { - return when { - this?.startsWith("https://lh3.googleusercontent.com") == true -> "$this-w$size-h$size" - this?.startsWith("https://yt3.ggpht.com") == true -> "$this-w$size-h$size-s$size" - else -> this - } -} - -fun Uri?.thumbnail(size: Int): Uri? { - return toString().thumbnail(size)?.toUri() -} - -fun formatAsDuration(millis: Long) = DateUtils.formatElapsedTime(millis / 1000).removePrefix("0") - -suspend fun Result.completed(): Result? { - var playlistPage = getOrNull() ?: return null - - while (playlistPage.songsPage?.continuation != null) { - val continuation = playlistPage.songsPage?.continuation!! - val otherPlaylistPageResult = Innertube.playlistPage(ContinuationBody(continuation = continuation)) ?: break - - if (otherPlaylistPageResult.isFailure) break - - otherPlaylistPageResult.getOrNull()?.let { otherSongsPage -> - playlistPage = playlistPage.copy(songsPage = playlistPage.songsPage + otherSongsPage) - } - } - - return Result.success(playlistPage) -} - -inline val isAtLeastAndroid6 - get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M - -inline val isAtLeastAndroid8 - get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O - -inline val isAtLeastAndroid12 - get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S - -inline val isAtLeastAndroid13 - get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU diff --git a/app/src/main/res/drawable/add.xml b/app/src/main/res/drawable/add.xml index a522620..300f401 100644 --- a/app/src/main/res/drawable/add.xml +++ b/app/src/main/res/drawable/add.xml @@ -3,18 +3,19 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - + + + diff --git a/app/src/main/res/drawable/airplane.xml b/app/src/main/res/drawable/airplane.xml index cee47b7..a4fe45d 100644 --- a/app/src/main/res/drawable/airplane.xml +++ b/app/src/main/res/drawable/airplane.xml @@ -3,7 +3,8 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/alarm.xml b/app/src/main/res/drawable/alarm.xml index 1c4ad30..ba82249 100644 --- a/app/src/main/res/drawable/alarm.xml +++ b/app/src/main/res/drawable/alarm.xml @@ -3,13 +3,14 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - - + + + + diff --git a/app/src/main/res/drawable/alert_circle.xml b/app/src/main/res/drawable/alert_circle.xml index 6300810..7f6ea33 100644 --- a/app/src/main/res/drawable/alert_circle.xml +++ b/app/src/main/res/drawable/alert_circle.xml @@ -3,7 +3,8 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/app_icon.xml b/app/src/main/res/drawable/app_icon.xml index cbe7472..bd83abc 100644 --- a/app/src/main/res/drawable/app_icon.xml +++ b/app/src/main/res/drawable/app_icon.xml @@ -1,14 +1,14 @@ - - + android:width="128dp" + android:height="128dp" + android:viewportWidth="512" + android:viewportHeight="512"> + + + + diff --git a/app/src/main/res/drawable/arrow_down.xml b/app/src/main/res/drawable/arrow_down.xml deleted file mode 100644 index 9d45330..0000000 --- a/app/src/main/res/drawable/arrow_down.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/arrow_forward.xml b/app/src/main/res/drawable/arrow_forward.xml index 386591b..591e29a 100644 --- a/app/src/main/res/drawable/arrow_forward.xml +++ b/app/src/main/res/drawable/arrow_forward.xml @@ -3,18 +3,19 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - + + + diff --git a/app/src/main/res/drawable/arrow_up.xml b/app/src/main/res/drawable/arrow_up.xml index 9de10de..e34a79e 100644 --- a/app/src/main/res/drawable/arrow_up.xml +++ b/app/src/main/res/drawable/arrow_up.xml @@ -3,18 +3,19 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - + + + diff --git a/app/src/main/res/drawable/bookmark.xml b/app/src/main/res/drawable/bookmark.xml index 416e06c..57bc7d9 100644 --- a/app/src/main/res/drawable/bookmark.xml +++ b/app/src/main/res/drawable/bookmark.xml @@ -3,7 +3,8 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/bookmark_outline.xml b/app/src/main/res/drawable/bookmark_outline.xml index 1544145..a5cdd6c 100644 --- a/app/src/main/res/drawable/bookmark_outline.xml +++ b/app/src/main/res/drawable/bookmark_outline.xml @@ -3,11 +3,12 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/bug_outline.xml b/app/src/main/res/drawable/bug_outline.xml new file mode 100644 index 0000000..9b7e853 --- /dev/null +++ b/app/src/main/res/drawable/bug_outline.xml @@ -0,0 +1,30 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/calendar.xml b/app/src/main/res/drawable/calendar.xml index 3eb788b..e610608 100644 --- a/app/src/main/res/drawable/calendar.xml +++ b/app/src/main/res/drawable/calendar.xml @@ -3,10 +3,11 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - + + + diff --git a/app/src/main/res/drawable/checkmark.xml b/app/src/main/res/drawable/checkmark.xml deleted file mode 100644 index 1c3a3fe..0000000 --- a/app/src/main/res/drawable/checkmark.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/chevron_back.xml b/app/src/main/res/drawable/chevron_back.xml index 1b7aef3..eb431e5 100644 --- a/app/src/main/res/drawable/chevron_back.xml +++ b/app/src/main/res/drawable/chevron_back.xml @@ -3,11 +3,12 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/chevron_down.xml b/app/src/main/res/drawable/chevron_down.xml index 41cdd90..3ae861a 100644 --- a/app/src/main/res/drawable/chevron_down.xml +++ b/app/src/main/res/drawable/chevron_down.xml @@ -3,11 +3,12 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/chevron_forward.xml b/app/src/main/res/drawable/chevron_forward.xml index 24a5848..008b66c 100644 --- a/app/src/main/res/drawable/chevron_forward.xml +++ b/app/src/main/res/drawable/chevron_forward.xml @@ -3,11 +3,12 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/chevron_up.xml b/app/src/main/res/drawable/chevron_up.xml index 257133c..1097389 100644 --- a/app/src/main/res/drawable/chevron_up.xml +++ b/app/src/main/res/drawable/chevron_up.xml @@ -1,13 +1,14 @@ - + + diff --git a/app/src/main/res/drawable/close.xml b/app/src/main/res/drawable/close.xml index 3b93ed8..fd3d5a2 100644 --- a/app/src/main/res/drawable/close.xml +++ b/app/src/main/res/drawable/close.xml @@ -3,7 +3,8 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/color_palette.xml b/app/src/main/res/drawable/color_palette.xml index 5c347bc..637c9e5 100644 --- a/app/src/main/res/drawable/color_palette.xml +++ b/app/src/main/res/drawable/color_palette.xml @@ -3,7 +3,8 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/delete.xml b/app/src/main/res/drawable/delete.xml new file mode 100644 index 0000000..883bcaa --- /dev/null +++ b/app/src/main/res/drawable/delete.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/disc.xml b/app/src/main/res/drawable/disc.xml index fd3f2a8..e30f9ab 100644 --- a/app/src/main/res/drawable/disc.xml +++ b/app/src/main/res/drawable/disc.xml @@ -3,10 +3,11 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - + + + diff --git a/app/src/main/res/drawable/download.xml b/app/src/main/res/drawable/download.xml index 9f5e415..7ee95a0 100644 --- a/app/src/main/res/drawable/download.xml +++ b/app/src/main/res/drawable/download.xml @@ -3,10 +3,11 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - + + + diff --git a/app/src/main/res/drawable/ellipsis_horizontal.xml b/app/src/main/res/drawable/ellipsis_horizontal.xml index 2695851..d7bc2bf 100644 --- a/app/src/main/res/drawable/ellipsis_horizontal.xml +++ b/app/src/main/res/drawable/ellipsis_horizontal.xml @@ -3,13 +3,14 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - - + + + + diff --git a/app/src/main/res/drawable/ellipsis_vertical.xml b/app/src/main/res/drawable/ellipsis_vertical.xml deleted file mode 100644 index f3f6171..0000000 --- a/app/src/main/res/drawable/ellipsis_vertical.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/enqueue.xml b/app/src/main/res/drawable/enqueue.xml index c717a87..bcfc505 100644 --- a/app/src/main/res/drawable/enqueue.xml +++ b/app/src/main/res/drawable/enqueue.xml @@ -3,10 +3,11 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - + + + diff --git a/app/src/main/res/drawable/equalizer.xml b/app/src/main/res/drawable/equalizer.xml index d366546..e07566b 100644 --- a/app/src/main/res/drawable/equalizer.xml +++ b/app/src/main/res/drawable/equalizer.xml @@ -3,13 +3,14 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - - + + + + diff --git a/app/src/main/res/drawable/expand.xml b/app/src/main/res/drawable/expand.xml new file mode 100644 index 0000000..9a9e3bc --- /dev/null +++ b/app/src/main/res/drawable/expand.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable/explicit.xml b/app/src/main/res/drawable/explicit.xml new file mode 100644 index 0000000..7178487 --- /dev/null +++ b/app/src/main/res/drawable/explicit.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/film.xml b/app/src/main/res/drawable/film.xml index 5e334a8..6774ad9 100644 --- a/app/src/main/res/drawable/film.xml +++ b/app/src/main/res/drawable/film.xml @@ -1,9 +1,12 @@ - + + diff --git a/app/src/main/res/drawable/globe.xml b/app/src/main/res/drawable/globe.xml index 10a3b37..98af4fe 100644 --- a/app/src/main/res/drawable/globe.xml +++ b/app/src/main/res/drawable/globe.xml @@ -1,33 +1,36 @@ - - - - - - - - - + + + + + + + + + + diff --git a/app/src/main/res/drawable/heart.xml b/app/src/main/res/drawable/heart.xml index 2a71a1e..738c5be 100644 --- a/app/src/main/res/drawable/heart.xml +++ b/app/src/main/res/drawable/heart.xml @@ -3,7 +3,8 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/heart_dislike.xml b/app/src/main/res/drawable/heart_dislike.xml deleted file mode 100644 index 510c67d..0000000 --- a/app/src/main/res/drawable/heart_dislike.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/heart_outline.xml b/app/src/main/res/drawable/heart_outline.xml index ba8e689..af0579b 100644 --- a/app/src/main/res/drawable/heart_outline.xml +++ b/app/src/main/res/drawable/heart_outline.xml @@ -3,10 +3,11 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/help_outline.xml b/app/src/main/res/drawable/help_outline.xml new file mode 100644 index 0000000..063225a --- /dev/null +++ b/app/src/main/res/drawable/help_outline.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/app/src/main/res/drawable/history.xml b/app/src/main/res/drawable/history.xml new file mode 100644 index 0000000..42c0df5 --- /dev/null +++ b/app/src/main/res/drawable/history.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_banner_foreground.xml b/app/src/main/res/drawable/ic_banner_foreground.xml index d4f6fc4..e270ade 100644 --- a/app/src/main/res/drawable/ic_banner_foreground.xml +++ b/app/src/main/res/drawable/ic_banner_foreground.xml @@ -1,46 +1,72 @@ - - - - - + xmlns:tools="http://schemas.android.com/tools" + android:width="512dp" + android:height="320dp" + android:viewportWidth="512" + android:viewportHeight="512" + tools:ignore="VectorRaster"> + + + + + + + + + + + + + - - - - - - - - - - + + + + + + + + + - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index 4065946..0532c1c 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,19 +1,20 @@ - - - - + android:viewportWidth="512" + android:viewportHeight="512"> + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000..2cb6716 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/infinite.xml b/app/src/main/res/drawable/infinite.xml index b8444f3..493c8f9 100644 --- a/app/src/main/res/drawable/infinite.xml +++ b/app/src/main/res/drawable/infinite.xml @@ -3,16 +3,17 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - + + + diff --git a/app/src/main/res/drawable/information.xml b/app/src/main/res/drawable/information.xml index 97e7373..551c2e4 100644 --- a/app/src/main/res/drawable/information.xml +++ b/app/src/main/res/drawable/information.xml @@ -3,20 +3,21 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - - + + + + diff --git a/app/src/main/res/drawable/information_circle_outline.xml b/app/src/main/res/drawable/information_circle_outline.xml new file mode 100644 index 0000000..7641b7e --- /dev/null +++ b/app/src/main/res/drawable/information_circle_outline.xml @@ -0,0 +1,31 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/library.xml b/app/src/main/res/drawable/library.xml index 1105723..4f80b0f 100644 --- a/app/src/main/res/drawable/library.xml +++ b/app/src/main/res/drawable/library.xml @@ -3,22 +3,23 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - - - - - + + + + + + + diff --git a/app/src/main/res/drawable/link.xml b/app/src/main/res/drawable/link.xml deleted file mode 100644 index c4de0e7..0000000 --- a/app/src/main/res/drawable/link.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/medical.xml b/app/src/main/res/drawable/medical.xml index aaba699..b7344aa 100644 --- a/app/src/main/res/drawable/medical.xml +++ b/app/src/main/res/drawable/medical.xml @@ -3,7 +3,8 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/musical_notes.xml b/app/src/main/res/drawable/musical_notes.xml index 8d3d474..e63dfa1 100644 --- a/app/src/main/res/drawable/musical_notes.xml +++ b/app/src/main/res/drawable/musical_notes.xml @@ -3,7 +3,8 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/notifications.xml b/app/src/main/res/drawable/notifications.xml deleted file mode 100644 index a06a9c2..0000000 --- a/app/src/main/res/drawable/notifications.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/pause.xml b/app/src/main/res/drawable/pause.xml index 3280645..4ce2015 100644 --- a/app/src/main/res/drawable/pause.xml +++ b/app/src/main/res/drawable/pause.xml @@ -3,10 +3,11 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - + + + diff --git a/app/src/main/res/drawable/pencil.xml b/app/src/main/res/drawable/pencil.xml index c1c2df0..f053326 100644 --- a/app/src/main/res/drawable/pencil.xml +++ b/app/src/main/res/drawable/pencil.xml @@ -3,18 +3,19 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - + + + diff --git a/app/src/main/res/drawable/person.xml b/app/src/main/res/drawable/person.xml index 4fcfdc8..57334fe 100644 --- a/app/src/main/res/drawable/person.xml +++ b/app/src/main/res/drawable/person.xml @@ -3,10 +3,11 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - + + + diff --git a/app/src/main/res/drawable/play.xml b/app/src/main/res/drawable/play.xml index 4951da4..84199e2 100644 --- a/app/src/main/res/drawable/play.xml +++ b/app/src/main/res/drawable/play.xml @@ -3,7 +3,8 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/play_skip_back.xml b/app/src/main/res/drawable/play_skip_back.xml index 14602d8..6a5047c 100644 --- a/app/src/main/res/drawable/play_skip_back.xml +++ b/app/src/main/res/drawable/play_skip_back.xml @@ -3,7 +3,8 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/play_skip_forward.xml b/app/src/main/res/drawable/play_skip_forward.xml index 24f14c4..b227e50 100644 --- a/app/src/main/res/drawable/play_skip_forward.xml +++ b/app/src/main/res/drawable/play_skip_forward.xml @@ -3,7 +3,8 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/playlist.xml b/app/src/main/res/drawable/playlist.xml index 05a33e6..8c499c4 100644 --- a/app/src/main/res/drawable/playlist.xml +++ b/app/src/main/res/drawable/playlist.xml @@ -3,44 +3,45 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - - - - - - + + + + + + + + diff --git a/app/src/main/res/drawable/radio.xml b/app/src/main/res/drawable/radio.xml index 4fa7b4a..0de0905 100644 --- a/app/src/main/res/drawable/radio.xml +++ b/app/src/main/res/drawable/radio.xml @@ -3,25 +3,26 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - - - - - - + + + + + + + + diff --git a/app/src/main/res/drawable/remove_circle_outline.xml b/app/src/main/res/drawable/remove_circle_outline.xml new file mode 100644 index 0000000..a927613 --- /dev/null +++ b/app/src/main/res/drawable/remove_circle_outline.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/reorder.xml b/app/src/main/res/drawable/reorder.xml index 5d31552..754826e 100644 --- a/app/src/main/res/drawable/reorder.xml +++ b/app/src/main/res/drawable/reorder.xml @@ -3,18 +3,19 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - + + + diff --git a/app/src/main/res/drawable/search.xml b/app/src/main/res/drawable/search.xml index 7a42efa..2f3db67 100644 --- a/app/src/main/res/drawable/search.xml +++ b/app/src/main/res/drawable/search.xml @@ -3,7 +3,8 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/server.xml b/app/src/main/res/drawable/server.xml index b481fd9..7390f0d 100644 --- a/app/src/main/res/drawable/server.xml +++ b/app/src/main/res/drawable/server.xml @@ -3,16 +3,17 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - - - + + + + + diff --git a/app/src/main/res/drawable/settings.xml b/app/src/main/res/drawable/settings.xml new file mode 100644 index 0000000..a709655 --- /dev/null +++ b/app/src/main/res/drawable/settings.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/shapes.xml b/app/src/main/res/drawable/shapes.xml index 3693f7e..540cbfc 100644 --- a/app/src/main/res/drawable/shapes.xml +++ b/app/src/main/res/drawable/shapes.xml @@ -3,10 +3,11 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - + + + diff --git a/app/src/main/res/drawable/share_social.xml b/app/src/main/res/drawable/share_social.xml index c992b57..58161a0 100644 --- a/app/src/main/res/drawable/share_social.xml +++ b/app/src/main/res/drawable/share_social.xml @@ -3,7 +3,8 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/shuffle.xml b/app/src/main/res/drawable/shuffle.xml index 330b175..e55bf15 100644 --- a/app/src/main/res/drawable/shuffle.xml +++ b/app/src/main/res/drawable/shuffle.xml @@ -3,39 +3,40 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - - - - + + + + + + diff --git a/app/src/main/res/drawable/sort.xml b/app/src/main/res/drawable/sort.xml deleted file mode 100644 index b0ca74f..0000000 --- a/app/src/main/res/drawable/sort.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/sparkles.xml b/app/src/main/res/drawable/sparkles.xml index e0c6622..dd569e2 100644 --- a/app/src/main/res/drawable/sparkles.xml +++ b/app/src/main/res/drawable/sparkles.xml @@ -3,13 +3,14 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - - + + + + diff --git a/app/src/main/res/drawable/speed.xml b/app/src/main/res/drawable/speed.xml new file mode 100644 index 0000000..4126ae3 --- /dev/null +++ b/app/src/main/res/drawable/speed.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/star.xml b/app/src/main/res/drawable/star.xml index 6313be6..dbaad33 100644 --- a/app/src/main/res/drawable/star.xml +++ b/app/src/main/res/drawable/star.xml @@ -3,7 +3,8 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/sync.xml b/app/src/main/res/drawable/sync.xml index c20ef5e..896db86 100644 --- a/app/src/main/res/drawable/sync.xml +++ b/app/src/main/res/drawable/sync.xml @@ -3,25 +3,26 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - - + + + + diff --git a/app/src/main/res/drawable/text.xml b/app/src/main/res/drawable/text.xml index 70c024a..28bdf41 100644 --- a/app/src/main/res/drawable/text.xml +++ b/app/src/main/res/drawable/text.xml @@ -3,10 +3,11 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - + + + diff --git a/app/src/main/res/drawable/time.xml b/app/src/main/res/drawable/time.xml index 13be868..a6d20af 100644 --- a/app/src/main/res/drawable/time.xml +++ b/app/src/main/res/drawable/time.xml @@ -3,7 +3,8 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/trash.xml b/app/src/main/res/drawable/trash.xml index 9025eaa..391946e 100644 --- a/app/src/main/res/drawable/trash.xml +++ b/app/src/main/res/drawable/trash.xml @@ -3,7 +3,8 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/trending.xml b/app/src/main/res/drawable/trending.xml index ed34e01..78df21f 100644 --- a/app/src/main/res/drawable/trending.xml +++ b/app/src/main/res/drawable/trending.xml @@ -3,18 +3,19 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - + + + diff --git a/app/src/main/res/drawable/trending_up.xml b/app/src/main/res/drawable/trending_up.xml new file mode 100644 index 0000000..4e85252 --- /dev/null +++ b/app/src/main/res/drawable/trending_up.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/volume_up.xml b/app/src/main/res/drawable/volume_up.xml new file mode 100644 index 0000000..5c36f58 --- /dev/null +++ b/app/src/main/res/drawable/volume_up.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/warning_outline.xml b/app/src/main/res/drawable/warning_outline.xml new file mode 100644 index 0000000..6734273 --- /dev/null +++ b/app/src/main/res/drawable/warning_outline.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_banner.xml b/app/src/main/res/mipmap-anydpi-v26/ic_banner.xml index f084dae..b4f7a81 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_banner.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_banner.xml @@ -2,4 +2,4 @@ - + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 7005cb8..1084c24 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -2,5 +2,5 @@ - + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index 7005cb8..1084c24 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -2,5 +2,5 @@ - + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index 7bf4509a285d143f13e8b3932db4e4fde7fbc70e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1746 zcmV;@1}*uCP)E@iY(*Z3M>O^@6;tPf#0)kQgCwA65WdwsJj?m0Q+kV6hRTs@!%$EkZs3Dm@@bST!WS<{n!O)Al4ZTt=bLsVnAQ1!aNP*pS+ zqW&>pbjns&0zWhT?5Kfgv8K_kFgGOW=t(+PbD=6g0zzCLswG&Z=D5?Ie64mw`}pKS zR4FE7l8sUVHTfyeYKX*EbL^-|rXofjgD!-%n=(Ry(Y z|Dbc5q-zjrN(5iO_#LR#5rcaAD^Kq+btpZ9Oq!BN2F1srR!0o#HIhr%+uj-b2Cj`M zgOXs#i2Vmpt3w8j2>c_))S-k88XgyqS{*bfA=cEPbPXCja{ocpDjUQg8)O67ARD;6 zLF1=tV8yDd@Zo|>g5Nm0yPu7RREwQw?_6lO+V6kNwLFgYtL<{s4zfF%M*C=q@BWlYhgyDR?vpeg3m5P zO-)l<8|>p@bps^)c@LH@&xSD*QUq=6rP~aew=e^;a&*wt)M8-_pEPY1HIZ#=X8LR` z=yVN&c8I~y3bF5|k*AWeFeKokpR#NneQ5cIyV zJu-DhI?Wiioi&Wc7O2%X!6PyhaR{@0bwsxr^mkH)U|3nBuC5tYug$Z1#$=B{e6Vh@ zeRV{)88kT}4U$#&p`pPj5WrGgqK6OW35&0!4MKv(s9^o(>rh!)FA%|k8K)d$*u~mT?SE^~Y;dSL? z72akL*_WByeV;3Mzm5>CK?p7oH%H%qhYv05 z3}zN?CGeV#400BtHVDB?z-=7xpJdSD$~Wm-QfqLdD;v}b)|AVc z)oli)X&*p!>t%2H}`- zr^5|z>s&~zpz9sh5Ek5Wbmm@~y@<-7sMWbNd$#So)%F@1wRU~J%~=hPHtIr%E}D%yoWuY$L9xrEfN@G z22T^&{+xlR48ly=6I(!Q4%Q>~_Yrfn^hA+0r}SPO%$n=``h$qdAO!bcxZlFP*PQv5 z_rj(`qzP^{<1OYNHsuTYaTcO82*EoSS=l=J4-89|W|H-h1}05UgWY=zEY7L1CT-t! z3xdb01${URQ5u9`Jv#795tNl1;9CA;xJQN&&Twc@9m$%^tZG=YEQ=0HwyiS}twCn+ z&c*VTIS{?$ChQ>DvSG`0x}apQdsc|vpzcOC$Of`OHjoXnfozZsoMTYHDd~S#W7xiU z)asByeiF_glcppX+NBKQPHva_7wX>g-5rZs9WrR}(4CR(oh1w2#C1u=mAcVic@_o^ zTD1tZDikrjSs$Jf^b4-f6J8(Y|64YuB6f&INdpxfUJBpXT%yt2q}7fu^!&;oqk=K55q<2PA={5z63zU~KJV-`&!W%h<35UQ$MCbG2GoL@P@A{6_luUQ z?Ta2>US2Pe$JYpN65b{Zba7AvYI&8!%jD6%p=CW3y*)g9Jo@$>;Mu3o8?U%Br~$R0 oCRU|00^ODZa>yZv94-<61FE~kq#9X1rvLx|07*qoM6N<$f_Xhh$N&HU diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..9876985c553415383371dc4ae70448ffafc8ef5b GIT binary patch literal 900 zcmV-~1AF{ZNk&F|0{{S5MM6+kP&il$0000G0000-002h-06|PpNX!5L00Dqw+t%{r zzW2vH!KF=w&cMnnN@{whkTw+#mAh0SV?YL=)3MimFYCWp#fpdtz~8$hD5VPuQgtcN zXl<@<#Cmzj;I0V_|4uC!K!2~i0f&y1@?=UliNt^?=oh$Zo)!tp=iCw(ni<+?_b3UUk9}CjmQZ@0>hGckjqbj{4Erfjd07oFw{bZ9TI7PqU$P z2;cW0W6)K;1xe@WQYk&~PPz-SQWe&tmDCElYh}7qA@q~8)^i^Ret)Og`l#|LUitN9 z)tx&qz;az>^i8VRjL%VGDUg1(16(G1$USS1*83pit4vfYzZyYrzqI*nZhNdgv_4>F zwi7FUupKUk|5;N+VG|(dCXQoJJu}9E;1|km;qZKVlotx7BeC%GAMWc^shFVJ#OP6& zqbLTiXpY1Rybm;HA}qVk{vC^sP#y#DdsQhuKdVQ`d{S3>Z!&ziaQ}X!8A%C>oL*5V zT5(OFA(mh0(xVzksQ`uAU<15{L+;5ro<78y#HZRH*gU#h^bJh8O7Gc~bzvN&b+dWHg4{gG9 literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png deleted file mode 100644 index 6d488371a2eb52330c6f9b544242fab77018d47d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3742 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 IM6N<$f^OkQ#sB~S diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..e4082801cf18f1416ae1e026f0803caa6f826394 GIT binary patch literal 2104 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 FV1lv6MYjL| diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..33c5c833bb806b48abc0be8cd2a74cc922de238e GIT binary patch literal 684 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 KMNUMnLSTX?g_W!T diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..8d83e191177d092fc7b68999bfea2815d0205bcf GIT binary patch literal 1316 zcmV+<1>5>kNk&E-1pok7MM6+kP&il$0000G0000l001ul06|PpNL&H{00E$D+qP-j z>Qj4N+cqN`nQhxvX7dAV-`K|Ub$-q+H-5I?Tx0g9jWxd@A|?POE8`3b8fO$T))xP* z(X?&brZw({`)WU&rdAs1iTa0x6F@PIxJ&&L|dpySV!ID|iUhjCcKz(@mE z!x@~W#3H<)4Ae(4eQJRk`Iz3<1)6^m)0b_4_TRZ+cz#eD3f8V;2r-1fE!F}W zEi0MEkTTx}8i1{`l_6vo0(Vuh0HD$I4SjZ=?^?k82R51bC)2D_{y8mi_?X^=U?2|F{Vr7s!k(AZC$O#ZMyavHhlQ7 zUR~QXuH~#o#>(b$u4?s~HLF*3IcF7023AlwAesUI0I&=IodGH^05AYPkwThEC8MGt zqm1~l5(#VosV5i2$Ew7~&H?9-(E-!}ykA}!UVWg;CrPZ%yl!d_(L}Ey9uqo%T^WG9 z<*zwKa!`?OQb2f93hk5jt<{l!g5`+S;n+2nRwJ4NA*{^}2><{9{^Itky?UE2OM;;} zD$3+&Wp|H==TBw~bR-wJOVTv#eXvw!cCkAE(#C&C4*)-ZhgZNWcAdV(Q;W*uGT^6w zUSJxH(Rz`fJ-tfr1otW2&|4#OlC{678+oz&oR^SS4jZ04>tWyfI`))HdD|#&3loJt zWhe17%PJp?9>}? zo9I26)8b0`g3aoZsWWMfJVvp`vo7_vfJ6q*#Ubm+u`b$IyoMu8X2ARC^9}zsw|B@0 zfpERc!h9ot0)WS}QysnE+(JjZStOXjBP@^y*yF=e1xZoDY?d=}E&`|hGGICbxFFUF z@S1NiNjuJxK&?X0R)I@mYoLeBRjrHG49owLXZzfr&LWhFAgYC~^Kb3Sf$+=M17*&G z`lZxkS6J)){8ybgG5WyUZp`9=(iaaH>m)QLV{j*%+91LMZe zQuF7|zkVkPdZ$C3ybEJxgkMujD;cVwNHz9NS&jWtIpLwDh#00#jddAqL?;xIYRbq0 aj-tM`r*cdFS+q>&^TD_A_pYU7uP^{2>~|;t literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xhdpi/ic_banner.png b/app/src/main/res/mipmap-xhdpi/ic_banner.png deleted file mode 100644 index aef3f59398366053c683dfd10a2122201304ccb2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3784 zcmds4X*k>2+x{iip-M}uWim_`Eoz%u+N73R+Zs!x#7;*oMQCfOEvOEE%FyX_(w0HQ zq=_Y#AxSMEttlO9i6tQ+bV)@>V+$4A%YVMV-{1Gkxz4$+=iJwQKj*&g>pXexu1-n{ z8VUdaC|$i`?*RZHo4tLryzJh}`y@UD00)b%+F!n&6eye;tT}}|-o3PStIogWZl&T> zuD0RU{g?SFcW(SFt)}+Z;Ri846Lak=PdqHRL$|@%=Rqr9{ZsV(W* zMsm}q5wr0~H)QGh=4-he*sK>4S-!Gbg&0GNl38sTiL$`)sgMi+@KMPGhED*XKOF$@ zm3%<@(NU1RwgFhh##dT9vsvB-xOM3NZo#6BC|Q)<DlM1t?yLYWZfmtCednmP z!&imvQkw_U*5;aOSUbsH*LInM(J)}1ya8P2Tdq~sgj>W+wnH58hL_T}7k+bkR;tU# zr3frCtb0Si-rO)LcS2cF+U&&=>zJz2?QOfQ4^LS_y9Kq&^0m`CQj0rsWp~2U$iwFt zG2ua-JFzdxn5;$a<^?lZW&{F6BwNb#c8Ig($|oj1TXl?GSos43_WS-_t%`S1zw`{N zW2d(N2`w^D@sx7LN1wKdx?verl3V&lg|q)lRlK!#lWRUwTL0)sH4IhFKW=)(^mCD?ciozCv1Sr|l0+WT zlDU^0<)i$ZRE}Z3iySsWma)QzCp(=2l+|fro801`%^9=fO&uH2RzN=9wu)E1 z=utF=I4OhW-o$ClCD*~@yZ^K}#W8SC$Sm&!GtmHoTRU$Eor{V~#Ave<9$Uh{9W zXgHdQ{{>nsJ4AlU-RQddpHUOJ5H&D2I=XN@O{_7Vm|eIu`EeBNeuU;4x0*_Zem1ct z-<|X`l>8FS2h?aoPj}@IS z+X779+l=EtE&@x|U2?&P`tN@3Eh$}Yj)(-@=D8`c+gqe zd{*LZhX+{ZuT(lrheUYAIPgR9H}+BtN)4GDI!!zQ_vSzo0LmAF0WFMy{)5q2E6iAB zQ$`ZCDjmtzE?5;uDq<0@QIXbRbg^Z;D|-0n*!`?7OAPw0nD5QrE@JnS8e^0|{AY@<_`T0q zzf#X?eB!?AEip(T1bUg$quVePZYv%##5FiQ!s@F*$YSjWIhYtGi(umWT9^G|Dnrz7 z8%m`nNC(?|&sRD(hVM(i;J7Dm!S{J@nXD6`lVKTw*zcDwJ7x36`dCSotJG8tLJr%^ z#k*g-;SJkiG&XOFWPdU2wa`ubvd9!Mxb0nTy9~Om zJdpG~&==wu+YL`{O2{k3Ph}VZQR+7+dI>;e=HxE@{A_vbXz@d?r8t%$xpr$-DZc(Q zbQxnG<<2`#4I~nMt$)+pcPW`fJqI_uozxlI)vwQS!~q51p5NBolz|sUe3>a z^l#WvJ&w78e;bYDtez>bSD^7t6MaJ)eQwOqPO>9~Z~fkr9td8}?c!>FHkfxm{4cKh zTOQ^ad!b>m`V2s$c&)zpE4GtS6(f|rDayZu3%Mm_9EpU{xVY;e;!Me=PCoIw&acSL z%2GDt8IAx_j^S+Bph%& zZ>V_!W1;GqyxpyD^fEspEYj`_6tkBfgCj9xvnQR@+(P-lXl6m+qmU3xHsDo#Z0

brtjJ%L4B(?rWM<`ixp$DY8n z>WxW$Ne};`^wvu#&MgcTj^|2j+-7P}BA-{f2aadbAYoP2d(l{vkRE$E&Glau z*-9_iR75^94&)tybt3#N$RD}ARE?jG&{UxC6Nb+D7n!a5+V|Ww1oGK&o^vg=Xr_gk zKOlgts5M!8Iy-fbkM64JFnm8A?hR2Rw9<{IibAZWLo^HlWuN*BdmrLpzZ^w6qI9Ra zY)4oF+ms5X?VX=ynLTcUBg-~>26h1N`uE@=re7=#ogOLaakd!XrmbMMMGpEBou7bt z(|AB{YvIE}kCph-#EMb-9j7+@LqM9c|L#3;3%lydX-n&~pq884JOu)sjyV9yWXxLS zucK!^Oz3B9vLKytSMD4+Hja4@z74G@QKbJ=h4>I;xzsyA0oAf@gD%j%X*B~v^HjD3 zT^Za7!%XoW_XPcGRcs9`Va?!#L8#G7z?=FWyjW7?-vYT(-yso|?$$GkXE>>W>Jzh? zaNrN-ZBRt#H}+K8xVhPM#=;v|dM9}`HPH`XqFw@O=u@O8%(aEx!8w|xqflrL)J{JF zMTFmPogJi9F$j(+0?sr#JzNuD#=Zrfh%6d z3D0T`;Mr;Ab(**Ha`+b9r2IuGS>rU|sP)DinFD%}y1i=Q(r564!rx9Aiyt<0h5mD~ zKQzBP0jdou2Gjx|UuduWt~GTGqId4WIMNLPRp5r9paIE7_S3<@j-3Gs76}btT4pP! z&nd$=Km%G=YDh0*>RM2lHbJ8A2YfO7e=;BaFH;^f1F&0jzj;9slD;>g0#_Ye?dxoV Gv;GGo5hOYQ diff --git a/app/src/main/res/mipmap-xhdpi/ic_banner.webp b/app/src/main/res/mipmap-xhdpi/ic_banner.webp new file mode 100644 index 0000000000000000000000000000000000000000..dd6e9ebca359beadfb49c28476f0d6947a2b7925 GIT binary patch literal 2332 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 diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..a2deff82e3bde7bed197b80609b38d88f3317e1a GIT binary patch literal 1142 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 O0000;C3fBY6Q8_YSjb2%a=fc}4E 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 Aj{pDw literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index 93f9f8e0b51ce69ab5ce57cd512f080e013e11c0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3752 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 TSm$X6N~e3nNV`(gG4g)^@$(2N diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..d1c003a9cfbe743423d7138d598b439d818bab28 GIT binary patch literal 1600 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 Gu>S%AMAYg4 diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..4049dd327b1c97d009e860a288d43f6e63aa530f GIT binary patch literal 1968 zcmV;h2T%A?Nk&Gf2LJ$9MM6+kP&il$0000G0002L006%L06|PpNQVLd01cqCZJQ!l zdEc+9kGs3OD-bz^9uc|AA8?1rA#x4f-93WH-QAt;uJ6U6Yp<>o!9>IaV6aUZ*?W>} zs4%E?srLW`CJh0GCIK@hTkrW7A15Iu%N&?Q^$0+!{Tv&|t^Y@u%!L zglTg&?Q5q#ijZ;&HBQ?FNPp;k3J5!&{^+SGq?AX~SiOM9jJMRpyP?RCr@z38AQyy&WRMaC;n4una$~nJKSp?q|s8F00c9?Q! zY_ovvjTFm+DeQM^LXJ#v0}6HRt3R1%5PT*}W!k8BEM;Jrj8dIceFo2fhzTqaB3KKk zGlCLI)gU25(#u6ch6GeB1k@eHq7l{EHXv0n6xE#ws#ri}08kkCf8hUt{|Ejb`2YW* zvg}0nSSX1m=76s?sZhRY$K=3dpJ+y*eDULGnL2}4>4nvW^7_<~wIM_5fjvwt4h1|g z)g0Z6ZFq9j<~9~b8((~TN{Z?ZQfw|is&Xp~AC61sj;xItKyCHdI|tCMC_LbXF>~vR z=w6V3^H=W4CbAgR4#xw}ETTwu2guW~=Crl@SMXv85jQ=%y!s^?m4PI0My7MWICO;- z175jm%&PcPWh8QdOU(#8bp4!N7ET-+)N}N2zk2)8ch|4Q&lPFNQgT-thu053`r*h3 z_8dI@G;`zn;lH$zX3RzIk`E8~`J=BBdR}qD%n@vVG1834)!pS1Y?zVkJGtsa(sB~y zNfMYKsOJb%5J(0ivK8d+l2D2y&5X!cg3BG!AJ}910|_${nF}sC1QF^nLIhzXk-Y#x z0)&1iK!O;Og0Ky!;`IV0MH=-odGJq0Kfn~kwTqGrKBSvE7XY~uo4MP z+%6Cq z?+tOnKJAL|8Z)F%85R=|4GF#_ogjwJRX=PDOfK-m4ClSeiB2!A$DRmvOG42?1FYY- z^;^E=*72`x9Jc<(T9jz})E#`ShQE7idnWOGg;$|O6bazAMlOS8`DN&y~T&79@0RI03Kmg$U zw=L-;MtBxU$7IO0kwaNs`ejy=3}(^-O6gK9W0cHQdJFCOX z>}1@7h>93YfL5zMd)v%D&wG1 zV$!*1xT7LGYu=RuG%|U>@Xe!M!O!Kwd3wNDl4A(d5}q*5FE;Hu5S#$1nDk7onT
1ZBfkjT#!E zRMZ$geE(_td7ph1BOo3OhH)ArSv?tTugY7KpH7+=&Xk7qj=31mu?K28?9cj?4Z3g= z_dipDhRinpXf?ZyHYZOQe~IlWg+NA*2oLv!^oPk-63kyTF+1FkU!}=bR!v8JO>US~ zF@jSJyTs4oONPn@_^+XAqqTNISsqf{9at+^7$t;Y5VO?IjOU_q7VZ~cnjlWO=7_s3 z(E@k5wt96U2_b4xYhFvR z;7Y}TOZKjl%A}f_{8@vn559bB36irbERt}qiyMuiJ-MJPOYD~pmd;@hD8-=( z+9hTt&#|atHMh>EOv?lfp##G!UBDDW7&uNJND^91)IckINehGtK9O`8gE3pGO#PK> zB5jJy9=Zqyq5%_*VS literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png deleted file mode 100644 index f1494aa7c70657e614b0170b18635f4f186d1623..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12378 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 IBuzv94-)jQ`2YX_ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..6b8d814908671004b1ecaaa5e11ad6ec24eca8ea GIT binary patch literal 5468 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 + + %1$dm + %1$ds + %1$ddан + %1$s преостало + %1$s dB + %1$s kbps + %1$s ms + %1$spx + + Више од %1$s + Топ %1$s + Мој топ %1$s + Прикажи топ %1$s од … + %1$sx + + Последњих 24 сата + Прошла недеља + Прошли месец + Прошла година + Све време + + Песме + Плејлисте + Албуми + Извођачи + Синглови + + Омиљено + Ван мреже + Историја + Преглед + Књижница + Откриј + Локално + Брзи избор + Расположење + На мрежи + Видео снимци + О апликацији + Изглед + Боје + Облици + + Прескочи уназад + Прескочи напред + Пусти + Пауза + Свиђа ми се + Измешај + Синхронизуј + Покрени радио + Пусти следећу + Додај у ред + + Тајмер за спавање + Тајмер за спавање је истекао + Док песма не заврши + Желите ли да зауставите тајмер за спавање? + Еквилајзер + Подеси тајмер за спавање + + Откажи + Завршено + Потврди + Не + Заустави + Подеси + Сакриј + Преименуј + Обриши + Ресетуј + Почисти + Погледај све + + укључено + искључено + Искључено + Непознато + Тренутно се репродукује + + Унесите име плејлисте + Нова плејлиста + Додај у плејлисту + + Иди на албум + + Гледај на YouTube-у + Отвори у YouTube Music-у + Гледај плејлисту на YouTube-у + YouTube Music није инсталиран на вашем уређају! + + Уклони из реда + Уклони из плејлисте + + Сакриј из "Брзи избор" + Заиста желите да сакријете ову песму? Време репродукције и кеш ће бити обрисани.\nОва радња је неповратна. + Да ли стварно желите да обришете ову плејлисту? + Да ли заиста желите да обришете ову Piped сесију? + + Друге верзије + Овај албум нема алтернативну верзију + + Из Википедије + Из Википедије под Creative Commons Attribution CC-BY-SA 3.0 лиценцом + + + + Расположења и жанрови + Нови објављени албуми + Више + + Овај уметник још није објавио албум + Овај уметник још није објавио сингл + + Дошло је до грешке + Ова локална музичка датотека више не постоји + Дошло је до мрежне грешке + Није пронађен доступан аудио формат + Оригинални видео извор ове песме је обрисан + Ова песма се не може репродуковати због ограничења сервера + Враћени видео ID се не подудара са траженим + Дошло је до непознате грешке приликом репродукције + Дошло је до непознате грешке приликом повезивања са вашим Piped налогом. Покушајте поново. + Листа Piped инстанци тренутно није доступна + Дошло је до грешке приликом иницијализације Bass Boost-а. Вероватно га ваш уређај не подржава. Покушајте да промените ниво појачања баса или покушајте поново. + Дошло је до непознате грешке приликом пред-кеширања. Покушајте поново. + Није могуће отворити URL %1$s + Дозвола одбијена, молимо да омогућите медијске дозволе у подешавањима уређаја. + Отворите подешавања + Нема пронађених ставки + Нема резултата претраге. Молимо покушајте са другачијим упитом или категоријом + + Није пронађена апликација за прегледање интернета + Није пронађена апликација за подешавање еквилајзера + Није пронађена апликација за креирање докумената + Није пронађено подешавање за оптимизацију батерије, молимо да ручно додате ViMusic на белу листу + + Повезани албуми + Слични уметници + Плејлисте које би вам се могле свидети + %s претплатника + + Унесите текст песме + Није пронађен текст песме + Изаберите песму са текстом + Синхронизовани текст није доступан за ову песму + Текст песме није доступан + Неважећи синхронизовани текст, освежите или уредите текст и покушајте поново + Прикажи несинхронизовани текст + Прикажи синхронизовани текст + Текст песме достављен од lrclib.net & kugou.com + Уреди текст песме + Претражи текст песме на мрежи + Поново преузми текст песме + Изаберите текст са lrclib.net + Подеси старт офсет + Офсетује синхронизовани текст песме у односу на тренутно време репродукције + + Брзина репродукције + Тон + Брзина & тон + Зашто бисте ово радили?! + Појачање звука + Петља + Додај ред у плејлисту + + id + Itag + Битрејт + Величина + Кеширано + Јачина звука + + Погледај албум + Погледај плејлисту + + Унесите име + v%1$s од vfsfitvnm + Друштвено + Контакт + GitHub + Погледај изворни код + Пријави грешку + Ако вам је потребна помоћ око грешке, можете пријавити проблем на GitHub-у (кликните да се преусмерите) + Затражи функцију или предложи идеју + Бићете преусмерени на GitHub + + Извор акцентне боје + Подразумевано + Динамично + Material You + + Тама + Нормално + Чиста црна + AMOLED + + Мод + Светло + Тамно + Систем + + Заобљеност сличица + Ништа + Светло + Средње + Јако + Још јаче + Најјаче + + Текст + Фонт + Користи системски фонт + Користите фонт који је примењен на систему + Примени размак око фонта + Додајте размак око текста + Језик + + Закључани екран + Прикажи омот песме + Користи омот песме као позадину на закључаном екрану + + Плејер + Претходно дугме док је минимизирано + Приказује дугме за претходну песму док је плејер минимизиран + Превуци хоризонтално за затварање + Затвара плејер када превучете лево/десно по минимизираном плејеру. Корисно за кориснике са Android режимом за коришћење једном руком. + Прикажи дугме за свиђање + Прикажи дугме за свиђање директно у плејеру + Превуци да уклониш ставку + Превуците лево да бисте уклонили ставку из реда чекања + Задржи екран укључен (текст песме) + Задржава екран укључен док је приказан текст песме + Прикажи системске траке (текст песме) + Искључивање ове опције омогућава приказ на целом екрану + PIP + Аутоматски прелази у режим Слика-у-Слици када апликација пређе у позадину + + Кеш + Када се кеш попуни, ресурси који најдуже нису коришћени биће обрисани + Кеш слика + Максимална величина + %1$s коришћено + %1$s коришћено (%2$s%%) + Кеш песама + База података + + Чишћење + Паузирај историју репродукције + Зауставља праћење репродукција за брзи избор + Напомена: ово неће утицати на кеширање ван мреже! + Ресетуј брзе изборе + Брзи избор је обрисан + Паузирај време репродукције + Зауставља бележење времена репродукције. Ово паузира статистике у плејлисти "Мој топ %1$s"! + + Бекап + Лични преференције (нпр. мод теме) и кеш су искључени. + Извези базу података на екстерну меморију + Врати + Постојећи подаци ће бити преписани.\nViMusic ће се аутоматски затворити након враћања базе података. + Увези базу података са екстерне меморије + + Остало + Android Auto + Омогући подршку за Android Auto + Запамтите да омогућите "Непознати извори" у развојним подешавањима Android Auto. + Историја претраге + Паузирај историју претраге + Не бележи нове упите за претрагу нити приказуј историју + Обриши историју претраге + Обриши %1$s упита за претрагу + Историја је празна + Уграђене плејлисте + Аутоматска синхронизација плејлисте + Аутоматски синхронизује сачуване плејлисте са YouTube-ом када их отворите + Дужина топ листе + Ограничава дужину плејлисте "Мој топ x" + + Време трајања сервиса + Ако су примењене оптимизације батерије, обавештење о репродукцији може изненада нестати када је паузирано. + Од Android-а 12, онемогућавање оптимизација батерије је потребно да би опција "неуништиви сервис" била доступна. + Игнориши оптимизације батерије + Ограничење је већ уклоњено + Онемогући ограничења у позадини + Неуништиви сервис + Треба да настави репродукцију 99.99% времена, у случају да искључивање оптимизација батерије није довољно + + Треба вам помоћ? + Већину времена, није кривица програмера (чак ни када укључите неуништиви сервис) што апликација престане да ради исправно у позадини. Проверите да ли произвођач вашег уређаја убија ваше апликације (кликните за преусмерење) + Ако стварно мислите да нешто није у реду са самом апликацијом, идите на картицу О апликацији + Решавање проблема + Упозорење: користите ове дугмиће као последње решење када репродукција звука не успе + Поново учитај интерне функције апликације + Затвори апликацију + Прикажи секцију за решавање проблема + + Упорни ред чекања + Сачувај и врати песме које се репродукују + Настави репродукцију + Када је жични или Bluetooth уређај повезан + Заустави када је затворено + Када затворите апликацију, музика престаје да свира + + Аудио + Прескочи тишину + Прескочи тихе делове током репродукције + Минимална дужина тишине + Минимално време колико звук мора да буде тих да би био прескочен + Плејер мора бити поново покренут како би промене биле ефикасне! + Поново покрени сервис + Нормализација јачине звука + Подешава јачину звука на фиксни ниво + Основно појачање звука + Циљно појачање за нормализацију јачине звука + Детектована екстремна вредност јачине (%s), искључује нормализацију за ову песму + Појачање баса + Појачава ниске фреквенције за побољшано искуство слушања + Ниво појачања баса + Ниво (0–1000) појачања ниских фреквенција; користите на сопствени ризик! + Реверб + Фокус на звук + Да ли плејер треба да захтева системски фокус на звук (медије) + Интеракција са системским еквилајзером + + Филтер… + + Последње репродуковано + Популарно + Извор брзих избора + Кеш за брзе изборе + Чува податке брзих избора на диску за приступ ван мреже + + Изглед плејера + Класичан + Модеран (нов) + + Piped + Инстанца + Кликни за избор + Корисничко име + Лозинка + Пријава + Можете хостовати плејлисте на другом месту и синхронизовати их са ViMusic. Тренутно подржава само Piped. + Додај налог + Повежите Piped налог са инстанцом, корисничким именом и лозинком. + Сазнај више + Не знате шта је Piped или немате налог? Кликните овде за преусмерење на њихову документацију + Piped сесије + Користи прилагођену инстанцу + API URL инстанце + Piped сесија успешно креирана + + Стил траке за претрагу + Статичан + Таласаста + Квалитет таласасте траке за претрагу + Лош + Низак + Средњи + Висок + Одличан + Подпиксел + + Уклони са црне листе + Додај на црну листу + Ресетуј црну листу + Црна листа је празна + + Претходно кеширање + + Превуци да сакријеш песму + Када превучете песму улево, биће уклоњена из базе и кеша + Потврди превлачење за скривање песме + Када превучете за скривање, прво морате потврдити + Сакриј експлицитне песме + Сакрива све експлицитне песме у целој апликацији. Плејер ће такође прескакати експлицитне песме када наиђе на њих, уколико је ова опција омогућена. + + Верзија + Провери ажурирања + Тренутно користите верзију v%1$s + Дошло је до непознате грешке приликом преузимања података са GitHub-а + Доступна је нова верзија + Више информација + Апликација је ажурирана: нема доступних ажурирања + + Динамичке сличице + Максимална величина динамичке сличице + Максимална величина сличице када се користи динамичка сличица + + По сату + Дневно + Недељно + Аутоматски провери ажурирања + + Аутоматски прескочи + Прескаче тренутну песму када дође до грешке. + Песма %s је прескочена због грешке + Тренутна песма је прескочена због грешке + Мала соба + Средња соба + Велика соба + Средња сала + Велика сала + Плоча + + + Уклони %1$s песму са црне листе + Уклони све %1$s песме са црне листе + + + + Обриши %1$s репродукцију + Обриши %1$s репродукција + + + + %1$d песма + %1$d песама + + diff --git a/app/src/main/res/values-b+sr+Latn/strings.xml b/app/src/main/res/values-b+sr+Latn/strings.xml new file mode 100644 index 0000000..66137af --- /dev/null +++ b/app/src/main/res/values-b+sr+Latn/strings.xml @@ -0,0 +1,423 @@ + + + %1$dm + %1$ds + %1$ddan + %1$s preostalo + %1$s dB + %1$s kbps + %1$s ms + %1$spx + + Više od %1$s + Top %1$s + Moj top %1$s + Prikaži top %1$s od … + %1$sx + + Poslednjih 24 sata + Prošla nedelja + Prošli mesec + Prošla godina + Sve vreme + + Pesme + Plejliste + Albumi + Izvođači + Singlovi + + Omiljeno + Van mreže + Istorija + Pregled + Knjižnica + Otkrij + Lokalno + Brzi izbor + Raspoloženje + Na mreži + Video snimci + O aplikaciji + Izgled + Boje + Oblici + + Preskoči unazad + Preskoči napred + Pusti + Pauza + Sviđa mi se + Izmešaj + Sinhronizuj + Pokreni radio + Pusti sledeću + Dodaj u red + + Tajmer za spavanje + Tajmer za spavanje je istekao + Dok pesma ne završi + Želite li da zaustavite tajmer za spavanje? + Ekvilajzer + Podesi tajmer za spavanje + + Otkaži + Završeno + Potvrdi + Ne + Zaustavi + Podesi + Sakrij + Preimenuj + Obriši + Resetuj + Počisti + Pogledaj sve + + uključeno + isključeno + Isključeno + Nepoznato + Trenutno se reprodukuje + + Unesite ime plejliste + Nova plejlista + Dodaj u plejlistu + + Idi na album + + Gledaj na YouTube-u + Otvori u YouTube Music-u + Gledaj plejlistu na YouTube-u + YouTube Music nije instaliran na vašem uređaju! + + Ukloni iz reda + Ukloni iz plejliste + + Sakrij iz "Brzi izbor" + Zaista želite da sakrijete ovu pesmu? Vreme reprodukcije i keš će biti obrisani.\nOva radnja je nepovratna. + Da li stvarno želite da obrišete ovu plejlistu? + Da li zaista želite da obrišete ovu Piped sesiju? + + Druge verzije + Ovaj album nema alternativnu verziju + + Iz Wikipedije + Iz Wikipedije pod Creative Commons Attribution CC-BY-SA 3.0 licencom + + + + Raspoloženja i žanrovi + Novi objavljeni albumi + Više + + Ovaj umetnik još nije objavio album + Ovaj umetnik još nije objavio singl + + Došlo je do greške + Ova lokalna muzička datoteka više ne postoji + Došlo je do mrežne greške + Nije pronađen dostupan audio format + Originalni video izvor ove pesme je obrisan + Ova pesma se ne može reprodukovati zbog ograničenja servera + Vraćeni video ID se ne podudara sa traženim + Došlo je do nepoznate greške prilikom reprodukcije + Došlo je do nepoznate greške prilikom povezivanja sa vašim Piped nalogom. Pokušajte ponovo. + Lista Piped instanci trenutno nije dostupna + Došlo je do greške prilikom inicijalizacije Bass Boost-a. Verovatno ga vaš uređaj to ne podržava. Pokušajte da promenite nivo pojačanja basa ili pokušajte ponovo. + Došlo je do nepoznate greške prilikom pred-keširanja. Pokušajte ponovo. + Nije moguće otvoriti url URL %1$s + Dozvola odbijena, molimo da omogućite medijske dozvole u podešavanjima uređaja. + Otvorite podešavanja + Nema pronađenih stavki + Nema rezultata pretrage. Molimo pokušajte sa drugačijim upitom ili kategorijom + + Nije pronađena aplikacija za pregledanje interneta + Nije pronađena aplikacija za podešavanje ekvilajzera + Nije pronađena aplikacija za kreiranje dokumenata + Nije pronađeno podešavanje za optimizaciju baterije, molimo da ručno dodate ViMusic na belu listu + + Povezani albumi + Slični umetnici + Plejliste koje bi vam se mogle svideti + %s pretplatnika + + Unesite tekst pesme + Nije pronađen tekst pesme + Izaberite pesmu sa tekstom + Sinhronizovani tekst nije dostupan za ovu pesmu + Tekst pesme nije dostupan + Nevažeći sinhronizovani tekst, osvežite ili uredite tekst i pokušajte ponovo + Prikaži nesinhronizovani tekst + Prikaži sinhronizovani tekst + Tekst pesme dostavljen od lrclib.net & kugou.com + Uredi tekst pesme + Pretraži tekst pesme na mreži + Ponovo preuzmi tekst pesme + Izaberite tekst sa lrclib.net + Podesi start ofset + Ofsetuje sinhronizovani tekst pesme u odnosu na trenutno vreme reprodukcije + + Brzina reprodukcije + Ton + Brzina & ton + Zašto biste ovo radili?! + Pojačanje zvuka + Petlja + Dodaj red u plejlistu + + id + Itag + Bitrejt + Veličina + Keširano + Jačina zvuka + + Pogledaj album + Pogledaj plejlistu + + Unesite ime + v%1$s od vfsfitvnm + Društveno + Kontakt + GitHub + Pogledaj izvorni kod + Prijavi grešku + Ako vam je potrebna pomoć oko greške, možete prijaviti problem na GitHub-u (kliknite da se preusmerite) + Zatraži funkciju ili predloži ideju + Bićete preusmereni na GitHub + + Izvor akcentne boje + Podrazumevano + Dinamično + Material You + + Tama + Normalno + Čista crna + AMOLED + + Mod + Svetlo + Tamno + Sistem + + Zaobljenost sličica + Ništa + Svetlo + Srednje + Jako + Još jače + Najjače + + Tekst + Font + Koristi sistemski font + Koristite font koji je primenjen na sistemu + Primeni razmak oko fonta + Dodajte razmak oko teksta + Jezik + + Zaključani ekran + Prikaži omot pesme + Koristi omot pesme kao pozadinu na zaključanom ekranu + + Plejer + Predhodno dugme dok je minimalizirano + Prikazuje dugme za prethodnu pesmu dok je plejer minimiziran + Prevuci horizontalno za zatvaranje + Zatvara plejer kada prevučete levo/desno po minimiziranom plejeru. Korisno za korisnike sa Android režimom za korišćenje jednom rukom. + Prikaži dugme za sviđanje + Prikaži dugme za sviđanje direktno u plejeru + Prevuci da ukloniš stavku + Prevucite levo da biste uklonili stavku iz reda čekanja + Zadrži ekran uključen (tekst pesme) + Zadržava ekran uključen dok je prikazan tekst pesme + Prikaži sistemske trake (tekst pesme) + Isključivanje ove opcije omogućava prikaz na celom ekranu + PIP + Automatski prelazi u režim Slika-u-Slici kada aplikacija pređe u pozadinu + + Keš + Kada se keš popuni, resursi koji najduže nisu korišćeni biće obrisani + Keš slika + Maksimalna veličina + %1$s korišćeno + %1$s korišćeno (%2$s%%) + Keš pesama + Baza podataka + + Čišćenje + Pauziraj istoriju reprodukcije + Zaustavlja praćenje reprodukcija za brzi izbor + Napomena: ovo neće uticati na keširanje van mreže! + Resetuj brze izbore + Brzi izbor je obrisan + Pauziraj vreme reprodukcije + Zaustavlja beleženje vremena reprodukcije. Ovo pauzira statistike u plejlisti "Moj top %1$s"! + + Bekap + Lični preferencije (npr. mod teme) i keš su isključeni. + Izvezi bazu podataka na eksternu memoriju + Vrati + Postojeći podaci će biti prepisani.\nViMusic će se automatski zatvoriti nakon vraćanja baze podataka. + Uvezi bazu podataka sa eksterne memorije + + Ostalo + Android Auto + Omogući podršku za Android Auto + Zapamtite da omogućite "Nepoznati izvori" u razvojnim podešavanjima Android Auto. + Istorija pretrage + Pauziraj istoriju pretrage + Ne beleži nove upite za pretragu niti prikazuj istoriju + Obriši istoriju pretrage + Obriši %1$s upita za pretragu + Istorija je prazna + Ugrađene plejliste + Automatska sinhronizacija plejliste + Automatski sinhronizuje sačuvane plejliste sa YouTube-om kada ih otvorite + Dužina top liste + Ograničava dužinu plejliste "Moj top x" + + Vreme trajanja servisa + Ako su primenjene optimizacije baterije, obaveštenje o reprodukciji može iznenada nestati kada je pauzirano. + Od Android-a 12, onemogućavanje optimizacija baterije je potrebno da bi opcija "neuništivi servis" bila dostupna. + Ignoriši optimizacije baterije + Ograničenje je već uklonjeno + Onemogući ograničenja u pozadini + Neuništivi servis + Treba da nastavi reprodukciju 99.99% vremena, u slučaju da isključivanje optimizacija baterije nije dovoljno + + Treba vam pomoć? + Većinu vremena, nije krivica programera (čak ni kada uključite neuništivi servis) što aplikacija prestane da radi ispravno u pozadini. Proverite da li proizvođač vašeg uređaja ubija vaše aplikacije (kliknite za preusmerenje) + Ako stvarno mislite da nešto nije u redu sa samom aplikacijom, idite na karticu O aplikaciji + Rešavanje problema + Upozorenje: koristite ove dugmiće kao poslednje rešenje kada reprodukcija zvuka ne uspe + Ponovo učitaj interne funkcije aplikacije + Zatvori aplikaciju + Prikaži sekciju za rešavanje problema + + Uporni red čekanja + Sačuvaj i vrati pesme koje se reprodukuju + Nastavi reprodukciju + Kada je žični ili Bluetooth uređaj povezan + Zaustavi kada je zatvoreno + Kada zatvorite aplikaciju, muzika prestaje da svira + + Audio + Preskoči tišinu + Preskoči tihe delove tokom reprodukcije + Minimalna dužina tišine + Minimalno vreme koliko zvuk mora da bude tih da bi bio preskočen + Plejer mora biti ponovo pokrenut kako bi promene bile efikasne! + Ponovo pokreni servis + Normalizacija jačine zvuka + Podešava jačinu zvuka na fiksni nivo + Osnovno pojačanje zvuka + Ciljno pojačanje za normalizaciju jačine zvuka + Detektovana ekstremna vrednost jačine (%s), isključuje normalizaciju za ovu pesmu + Pojačanje basa + Pojačava niske frekvencije za poboljšano iskustvo slušanja + Nivo pojačanja basa + Nivo (0–1000) pojačanja niskih frekvencija; koristite na sopstveni rizik! + Reverb + Fokus na zvuk + Da li plejer treba da zahteva sistemski fokus na zvuk (medije) + Interakcija sa sistemskim ekvilajzerom + + Filter… + + Poslednje reprodukovano + Popularno + Izvor brzih izbora + Keš za brze izbore + Čuva podatke brzih izbora na disku za pristup van mreže + + Izgled plejera + Klasičan + Moderan (nov) + + Piped + Instanca + Klikni za izbor + Korisničko ime + Lozinka + Prijava + Možete hostovati plejliste na drugom mestu i sinhronizovati ih sa ViMusic. Trenutno podržava samo Piped. + Dodaj nalog + Poveži Piped nalog sa instancom, korisničkim imenom i lozinkom. + Saznaj više + Ne znate šta je Piped ili nemate nalog? Kliknite ovde za preusmerenje na njihovu dokumentaciju + Piped sesije + Koristi prilagođenu instancu + API URL instance + Piped sesija uspešno kreirana + + Stil trake za pretraživanje + Statik + Talasasta + Kvalitet talasaste trake za pretraživanje + Loš + Nizak + Srednji + Visok + Odličan + Podpiksel + + Ukloni sa crne liste + Dodaj na crnu listu + Resetuj crnu listu + Crna lista je prazna + + Prethodno keširanje + + Prevuci da sakriješ pesmu + Kada prevučete pesmu ulevo, biće uklonjena iz baze i keša + Potvrdi prevlačenje za skrivanje pesme + Kada prevučete za skrivanje, prvo morate potvrditi + Sakrij eksplicitne pesme + Sakriva sve eksplicitne pesme u celoj aplikaciji. Plejer će takođe preskakati eksplicitne pesme kada naiđe na njih, ukoliko je ova opcija omogućena. + + Verzija + Proveri ažuriranja + Trenutno koristite verziju v%1$s + Došlo je do nepoznate greške prilikom preuzimanja podataka sa GitHub-a + Dostupna je nova verzija + Više informacija + Aplikacija je ažurirana: nema dostupnih ažuriranja + + Dinamičke sličice + Maksimalna veličina dinamičke sličice + Maksimalna veličina sličice kada se koristi dinamička sličica + + Po satu + Dnevno + Nedeljno + Automatski proveri ažuriranja + + Automatski preskoči + Preskače trenutnu pesmu kada dođe do greške. + Pesma %s je preskočena zbog greške + Trenutna pesma je preskočena zbog greške + Mala soba + Srednja soba + Velika soba + Srednja sala + Velika sala + Ploča + + + Ukloni %1$s pesmu sa crne liste + Ukloni sve %1$s pesme sa crne liste + + + + Obriši %1$s reprodukciju + Obriši %1$s reprodukcija + + + + %1$d pesma + %1$d pesama + + \ No newline at end of file diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml new file mode 100644 index 0000000..43efbf4 --- /dev/null +++ b/app/src/main/res/values-de/strings.xml @@ -0,0 +1,411 @@ + + + %1$dmin + %1$dStd. + %1$dd + %1$s verbleiben + %1$s dB + %1$s kbps + %1$s ms + %1$spx + + Mehr von %1$s + Top %1$s + Meine Top %1$s + Top %1$s… + %1$sx + + 24 Stunden + 1 Woche + 1 Monat + 1 Jahr + Alles + + Songs + Wiedergabelisten + Alben + Künstler + Singles + + Favoriten + Offline + Übersicht + Bibliothek + Entdecken + Lokal + Startseite + Stimmung + Online + Videos + Info + Erscheinungsbild + Farben + Formen + + Zurückspringen + Vorwärts springen + Abspielen + Pause + Like + Shuffle + Sync + Radio starten + Als nächstes wiedergeben + Einreihen + + Schlaf-Timer + Schlaf-Timer beendet + Bis Liedende + Möchten Sie den Schlaf-Timer anhalten? + Equalizer + Schlaf-Timer einstellen + + Abbrechen + Fertig + Bestätigen + Nein + Stop + Ok + Löschen + Umbenennen + Löschen + Zurücksetzen + Löschen + Alle anzeigen + + an + aus + Aus + Unbekannt + Jetzt spielt + + Name der Wiedergabeliste eingeben + Neue Wiedergabeliste + Zur Wiedergabeliste hinzufügen + + Zum Album gehen + + Auf YouTube ansehen + In YouTube Music anhören + Wiedergabeliste auf YouTube ansehen + YouTube Music ist auf deinem Gerät nicht installiert! + + Aus der Warteschlange entfernen + Aus der Wiedergabeliste entfernen + + Auf Startseite verstecken + Möchten Sie dieses Lied wirklich ausblenden? Die Wiedergabezeit und der Cache werden gelöscht. Diese Aktion ist nicht umkehrbar. + Willst du diese Wiedergabeliste wirklich löschen? + + Willst du diese Piped-Sitzung wirklich löschen? + Andere Versionen + Dieses Album hat keine anderen Versionen + + Quelle: Wikipedia + Von Wikipedia unter Creative Commons Namensnennung CC-BY-SA 3.0 + + + + Stimmungen und Genres + Neu veröffentlichte Alben + + Mehr + Dieser Künstler hat noch kein Album veröffentlicht + Dieser Künstler hat noch keine Single veröffentlicht + + Ein Fehler ist aufgetreten + Diese lokale Musikdatei existiert nicht mehr + Ein Netzwerkfehler ist aufgetreten + Kann kein abspielbares Audioformat finden + Die ursprüngliche Videoquelle zu diesem Lied wurde gelöscht + Dieses Lied kann aufgrund von Serverbeschränkungen nicht abgespielt werden + Die zurückgegebene Video-ID stimmt nicht mit der angeforderten überein + Ein unbekannter Wiedergabefehler ist aufgetreten + Bei der Verknüpfung Ihres Piped-Kontos ist ein unbekannter Fehler aufgetreten. Bitte versuchen Sie es erneut. + Liste der Piped-Instanzen ist derzeit nicht verfügbar. + Es ist ein Fehler bei der Initialisierung von Bass Boost aufgetreten. Wahrscheinlich unterstützt Ihr Gerät diese Funktion nicht. Ändern Sie den Bassverstärkungspegel oder versuchen Sie es erneut. + Beim Caching ist ein unbekannter Fehler aufgetreten. Bitte versuchen Sie es erneut. + Kann die URL %1$s nicht öffnen + + Berechtigung abgelehnt, bitte erteilen Sie die Medienberechtigung in den Einstellungen Ihres Geräts. + Einstellungen öffnen + Keine Elemente gefunden + Keine Ergebnisse gefunden. Bitte versuchen Sie eine andere Anfrage oder Kategorie. + + Konnte keine Anwendung zum Surfen im Internet finden + Konnte keine Equalizer-Anwendung finden + Konnte keine Anwendung zum Erstellen von Dokumenten finden + Konnte keine Einstellungen zur Batterieoptimierung finden, bitte ViMusic manuell auf die Whitelist setzen + + Ähnliche Alben + Ähnliche Künstler + Wiedergabelisten, die Ihnen gefallen könnten + %s Abonnenten + + Geben Sie den Liedtext ein + Es konnten kein Liedtext gefunden werden + Liedtext auswählen + Für dieses Lied sind keine synchronisierten Liedtexte verfügbar. + Für dieses Lied ist kein Text verfügbar + Ungültiger synchronisierter Liedtext, rufen Sie den Text erneut ab oder bearbeiten Sie ihn und versuchen Sie es erneut + Unsynchronisierten Liedtext anzeigen + Synchronisierte Liedtexte anzeigen + Zur Verfügung gestellt von lrclib.net & kugou.com + Liedtext bearbeiten + Liedtexte online suchen + Liedext erneut laden + Liedtext von lrclib.net auswählen + Start-Offset einstellen + Verschiebt den synchronisierten Liedtext um die aktuelle Wiedergabezeit + + Geschwindigkeit + Tonhöhe + Geschwindigkeit & Tonhöhe + Warum haben Sie das getan?! + Lautstärke erhöhen + Warteschlange wiederholen + Warteschlange zur Wiedergabeliste hinzufügen + + Id + Itag + Bitrate + Größe + Im Cache + Lautstärke + + Album ansehen + Wiedergabeliste ansehen + + Suchen… + v%1$s von vfsfitvnm + Soziales + Kontakt + GitHub + Den Quellcode anzeigen + Ein Problem melden + Wenn Sie Hilfe bei einem Fehler benötigen, können Sie ihn auf GitHub melden (zum Weiterleiten klicken) + Eine Funktion anfordern oder eine Idee vorschlagen + Sie werden zu GitHub weitergeleitet + + Akzent Farbquelle + Standard + Dynamisch + Material You + + Dunkelheit + Normal + Rein Schwarz + AMOLED + + Design + Hell + Dunkel + System + + Rundheit der Vorschaubilder + Keine + Leicht + Mittel + Stark + Noch stärker + Stärkste + + Text + Schriftart + Systemschriftart verwenden + Verwenden Sie die vom System verwendete Schriftart + Auffüllen der Schrift + Platz um Texte hinzufügen + Sprache + + Sperrbildschirm + Song-Cover anzeigen + Verwenden Sie das Cover des gespielten Songs als Hintergrundbild für den Sperrbildschirm + + Player + Vorheriges-Lied-Taste wenn zusammengeklappt + Zeigt die Schaltfläche für den vorherigen Titel an, wenn der Player zusammengeklappt ist. + Horizontal wischen zum Schließen + Schließt den Player, wenn Sie auf dem eingeklappten Player nach links/rechts wischen. Nützlich für Benutzer, bei denen der Einhandmodus von Android aktiviert ist. + Favorit-Knopf anzeigen + Anzeigen des Knopfes zum Favorisieren direkt im Player + Wischen zum Entfernen eines Titels + Wischen Sie nach links, um ein Element aus der Warteschlange zu entfernen + + Bildschirm an lassen (Texte) + Lässt den Bildschirm eingeschaltet, wenn Liedtexte angezeigt werden + Cache + Wenn der Platz im Cache voll ist, werden die Ressourcen, auf die am längsten nicht mehr zugegriffen wurde, gelöscht. + Bild-Cache + Maximale Größe + %1$s verwendet + %1$s verwendet (%2$s%%) + Song-Cache + Datenbank + + Bereinigen + Pausieren des Wiedergabeverlaufs + Stoppt die Verwendung von Wiedergabeereignissen für die Startseite + Bitte beachten Sie: Dies hat keine Auswirkungen auf das Offline-Caching! + Startseite zurücksetzen + Startseite wird zurückgesetzt + Wiedergabezeit pausieren + Stoppt die Speicherung der Wiedergabedauer. Dadurch wird die Statistik in der Wiedergabeliste \"Meine Top %1$s\" angehalten! + + Sicherung + Persönliche Einstellungen (z. B. das Design) und der Cache sind ausgeschlossen. + Exportieren Sie die Datenbank auf den externen Speicher + Wiederherstellen + Vorhandene Daten werden überschrieben.\nViMusic schließt sich nach der Wiederherstellung der Datenbank automatisch. + Importieren Sie die Datenbank vom externen Speicher + + Sonstiges + Android Auto + Unterstützung für Android Auto aktivieren + Denken Sie daran, \"Unbekannte Quellen\" in den Entwicklereinstellungen von Android Auto zu aktivieren. + Suchverlauf + Pausieren des Suchverlaufs + Weder neue Suchanfragen speichern noch Verlauf anzeigen + Suchverlauf löschen + %1$s Suchanfragen löschen + Der Verlauf ist leer + Integrierte Wiedergabelisten + Top-Wiedergabelistenlänge + Begrenzt die Länge der Wiedergabeliste \'Mein Top x\'. + + Akku-Optimierung + Wenn die Akkulaufzeit optimiert wird, kann die Benachrichtigung über die Wiedergabe plötzlich verschwinden, wenn die Wiedergabe angehalten wird. + Seit Android 12 ist die Deaktivierung der Akku-Optimierung erforderlich, damit die Option \"Nicht-beendbarer Dienst\" zur Verfügung steht. + Akku-Optimierungen ignorieren + Beschränkung bereits aufgehoben + Hintergrundbeschränkungen deaktivieren + Nicht-beendbarer Dienst + Sollte die Wiedergabe in 99,99% der Fälle aufrechterhalten, falls das Ausschalten der Batterieoptimierungen nicht ausreicht. + + Brauchen Sie Hilfe? + In den meisten Fällen ist es nicht die Schuld des Entwicklers (selbst nach dem Einschalten des nicht-beendbaren Dienstes), dass die App im Hintergrund nicht mehr richtig funktioniert. Prüfen Sie, ob Ihr Gerätehersteller Ihre Apps beendet (zum Weiterleiten klicken) + Wenn Sie wirklich glauben, dass etwas mit der App selbst nicht stimmt, gehen Sie auf die Registerkarte Info + Fehlersuche + Achtung: Verwenden Sie diese Tasten als letzten Ausweg, wenn die Audiowiedergabe fehlschlägt + App-Dienste neu laden + App beenden + Abschnitt zur Fehlerbehebung anzeigen + + Dauerhafte Warteschlange + Abgespielte Titel speichern und wiederherstellen + Wiedergabe fortsetzen + Wenn ein kabelgebundenes oder Bluetooth-Gerät angeschlossen ist + Anhalten wenn geschlossen + Wenn Sie die App schließen, wird die Musik nicht mehr abgespielt + + Audio + Stille überspringen + Stille Teile während der Wiedergabe überspringen + Mindestlänge der Stille + Die Mindestzeit, die der Ton still sein muss, um übersprungen zu werden + Damit die Änderungen wirksam werden, muss der Player neu gestartet werden! + Dienst neu starten + Lautstärke-Normalisierung + Einstellen der Lautstärke auf einen festen Wert + Lautstärke Basisverstärkung + Die "Ziel"-Verstärkung für die Lautstärke-Normalisierung + Extreme Lautstärke erkannt (%s), Normalisierung für dieses Lied deaktiviert + Bass boost + Verstärkung der tiefen Frequenzen für ein besseres Hörerlebnis + Pegel der Bassverstärkung + Pegel (0–1000) der Anhebung der tiefen Frequenzen; Verwendung auf eigene Gefahr! + Mit dem System-Equalizer interagieren + + Suchen… + + Basierend auf dem letzten Lied + Trend + Startseite - Datenquelle + + Startseitendaten speichern + Speichert die Startseitendaten für Offlinezugriff + Player-Layout + Klassisch + Modern (neu) + + Piped + Instanz + Zum Auswählen anklicken + Benutzername + Passwort + Anmelden + Sie können Wiedergabelisten hosten und mit ViMusic synchronisieren. Derzeit wird nur Piped unterstützt. + Account hinzufügen + Verknüpfen Sie ein Piped-Konto mit Ihrer Instanz, Ihrem Benutzernamen und Ihrem Passwort. + Mehr erfahren + Sie wissen nicht, was Piped ist oder haben noch kein Account? Klicken Sie hier, um zu den Dokumentationen weitergeleitet zu werden. + Piped-Sitzungen + Benutzerdefinierte Instanz verwenden + Instanz-API-URL + + Piped Sitzung erfolgreich erstellt + Fortschrittsbalkenstil + Statisch + Wellenförmig + Wellenanimationsqualität + Schlecht + Niedrig + Mittel + Hoch + Großartig + Subpixel + + Von der schwarzen Liste entfernen + Zur schwarzen Liste hinzufügen + Schwarze Liste zurücksetzen + Schwarze Liste ist leer + + In den Cache laden + Wischen, um Lied zu löschen + Wenn Sie einen Titel nach links wischen, wird er aus der Datenbank und dem Zwischenspeicher entfernt. + Wischen zum Löschen bestätigen + Wenn Sie zum Löschen wischen, müssen Sie zunächst bestätigen. + + Version + Nach Updates suchen + Sie verwenden derzeit die Version v%1$s + Beim Abrufen von Daten von GitHub ist ein unbekannter Fehler aufgetreten + Eine neue Version ist verfügbar + Weitere Informationen + Sie sind derzeit auf dem neuesten Stand: keine Updates verfügbar + + Dynamische Vorschaubilder + Maximale dynamische Vorschaubildgröße + Die maximale Größe eines Vorschaubildes, wenn ein dynamisches Vorschaubild verwendet wird. + + Stündlich + Täglich + Wöchentlich + Automatisch nach Aktualisierungen suchen + + Automatisch überspringen + Überspringt das aktuelle Lied, wenn ein Fehler auftritt. + %s wegen eines Fehlers übersprungen + Das aktuelle Lied wurde aufgrund eines Fehlers übersprungen. + Small room + Medium room + Large room + Medium hall + Large hall + Plate + + + Entferne %1$s Lied von der schwarzen Liste + Alle %1$s Songs von der Schwarzen Liste entfernen + + + + Wiedergabeereignis %1$s löschen + %1$s Wiedergabeereignisse löschen + + + + %1$d Lied + %1$d Lieder + + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml new file mode 100644 index 0000000..89075ba --- /dev/null +++ b/app/src/main/res/values-es/strings.xml @@ -0,0 +1,422 @@ + + + %1$dm + %1$dh + %1$dd + %1$s restante + %1$s dB + %1$s kbps + %1$s ms + %1$spx + + Más de %1$s + Top %1$s + Mi top %1$s + Ver top %1$s de … + %1$sx + + Últimas 24 horas + Última semana + Último mes + Último año + Todo el tiempo + + Canciones + Listas de reproducción + Álbumes + Artistas + Sencillos + + Favoritos + Sin conexión + Historial + Resumen + Biblioteca + Descubrir + Local + Selecciones rápidas + Estado de ánimo + En línea + Vídeos + Acerca de + Apariencia + Colores + Formas + + Anterior + Siguiente + Reproducir + Pausar + Me gusta + Aleatorio + Sincronizar + Iniciar radio + Reproducir siguiente + Añadir a la cola + + Temporizador + Temporizador finalizado + Hasta el final de la canción + ¿Quieres detener el temporizador? + Ecualizador + Configurar temporizador + + Cancelar + Hecho + Confirmar + No + Detener + Establecer + Ocultar + Renombrar + Eliminar + Restablecer + Limpiar + Ver todo + + activado + desactivado + Desactivado + Desconocido + Reproduciendo ahora + + Introduce el nombre de la lista de reproducción + Nueva lista de reproducción + Añadir a la lista de reproducción + + Ir al álbum + + Ver en YouTube + Abrir en YouTube Music + Ver lista de reproducción en YouTube + ¡YouTube Music no está instalado en tu dispositivo! + + Eliminar de la cola + Eliminar de la lista de reproducción + + Ocultar de \"Selecciones rápidas\" + ¿Realmente quieres ocultar esta canción? Se borrará su tiempo de reproducción y caché.\nEsta acción es irreversible. + ¿Realmente quieres eliminar esta lista de reproducción? + ¿Realmente quieres eliminar esta sesión de Piped? + + Otras versiones + Este álbum no tiene ninguna versión alternativa + + De Wikipedia + De Wikipedia bajo Creative Commons Attribution CC-BY-SA 3.0 + + + + Estados de ánimo y géneros + Álbumes recién lanzados + Más + + Este artista aún no ha lanzado un álbum + Este artista aún no ha lanzado un sencillo + + Ha ocurrido un error + Este archivo de música local ya no existe + Ha ocurrido un error de red + No se pudo encontrar un formato de audio reproducible + La fuente de video original de esta canción ha sido eliminada + Esta canción no se puede reproducir debido a restricciones del servidor + El ID de video devuelto no coincide con el solicitado + Ha ocurrido un error de reproducción desconocido + Hubo un error desconocido al vincular tu cuenta de Piped. Por favor, inténtalo de nuevo. + La lista de instancias de Piped no está disponible actualmente + Hubo un error al inicializar el Bass Boost. Probablemente tu dispositivo no lo admite. Intenta cambiar el nivel de Bass Boost o inténtalo de nuevo. + Ocurrió un error desconocido durante el pre-caché. Por favor, inténtalo de nuevo. + No se puede abrir la URL %1$s + + Permiso denegado, por favor concede permisos de medios en la configuración de tu dispositivo. + Abrir configuración + No se encontraron elementos + No se encontraron resultados. Por favor, intenta con una consulta o categoría diferente + + No se pudo encontrar una aplicación para navegar por internet + No se pudo encontrar una aplicación para ecualizar audio + No se pudo encontrar una aplicación para crear documentos + No se pudo encontrar la configuración de optimización de batería, por favor añade ViMusic a la lista blanca manualmente + + Álbumes relacionados + Artistas similares + Listas de reproducción que te pueden gustar + %s suscriptores + + Introduce la letra + No se encontraron pistas de letra + Elige la pista de letra + Las letras sincronizadas no están disponibles para esta canción + Las letras no están disponibles para esta canción + Letras sincronizadas inválidas, vuelve a buscar o edita las letras e intenta de nuevo + Mostrar letras no sincronizadas + Mostrar letras sincronizadas + Proporcionado por lrclib.net y kugou.com + Editar letras + Buscar letras en línea + Volver a buscar letras + Elegir letras de lrclib.net + Establecer desplazamiento inicial + Desplaza las letras sincronizadas por el tiempo de reproducción actual + + Velocidad + Tono + Velocidad y tono + ¡¿Por qué harías esto?! + Aumento de volumen + Bucle de cola + Añadir cola a la lista de reproducción + + Id + Itag + Tasa de bits + Tamaño + En caché + Volumen + + Ver álbum + Ver lista de reproducción + + Introduce un nombre + v%1$s por vfsfitvnm + Social + Contacto + GitHub + Ver el código fuente + Reportar un problema + Si necesitas ayuda con un error, puedes presentar un problema en GitHub (haz clic para redirigir) + Solicitar una función o sugerir una idea + Serás redirigido a GitHub + + Color de acento + Predeterminado + Dinámico + Material You + + Oscuridad + Normal + Negro puro + AMOLED + + Modo + Claro + Oscuro + Sistema + + Redondez de miniaturas + Ninguna + Ligera + Media + Fuerte + Más fuerte + Máxima + + Texto + Fuente + Usar fuente del sistema + Usar la fuente aplicada por el sistema + Aplicar relleno de fuente + Añade espaciado alrededor de los textos + Idioma + + Pantalla de bloqueo + Mostrar portada de la canción + Usar la portada de la canción en reproducción como fondo de la pantalla de bloqueo + + Reproductor + Botón anterior cuando está contraído + Muestra el botón de canción anterior cuando el reproductor está contraído + Deslizar horizontalmente para cerrar + Cierra el reproductor al deslizar hacia la izquierda/derecha en el reproductor contraído. Útil para usuarios con el modo de una mano de Android activado. + Mostrar botón de me gusta + Mostrar el botón de me gusta directamente en el reproductor + Deslizar para eliminar elemento + Desliza hacia la izquierda para eliminar un elemento de la cola + Mantener pantalla activa (letras) + Mantiene la pantalla activa mientras se muestran las letras + Mostrar barras del sistema (letras) + Desactivar esto habilita la pantalla completa en todo el sistema + + Caché + Cuando la caché se queda sin espacio, se eliminan los recursos a los que no se ha accedido durante más tiempo + Caché de imágenes + Tamaño máximo + %1$s usado + %1$s usado (%2$s%%) + Caché de canciones + Base de datos + + Limpieza + Pausar historial de reproducción + Detiene el uso de eventos de reproducción para selecciones rápidas + Nota: ¡esto no afectará al almacenamiento en caché sin conexión! + Restablecer selecciones rápidas + Las selecciones rápidas están vacías + Pausar tiempo de reproducción + Detiene el guardado del tiempo de reproducción. ¡Esto pausa las estadísticas en la lista de reproducción \'Mi Top %1$s\'! + + Copia de seguridad + Se excluyen las preferencias personales (es decir, el modo de tema) y la caché. + Exportar la base de datos al almacenamiento externo + Restaurar copia de seguridad + Los datos existentes se sobrescribirán.\nViMusic se cerrará automáticamente después de restaurar la base de datos. + Importar la base de datos desde el almacenamiento externo + + Otros + Android Auto + Habilitar soporte para Android Auto + Recuerda habilitar \"Fuentes desconocidas\" en la Configuración de desarrollador de Android Auto. + Historial de búsqueda + Pausar historial de búsqueda + No guarda nuevas consultas de búsqueda ni muestra el historial + Borrar historial de búsqueda + Eliminar %1$s consultas de búsqueda + El historial está vacío + Listas de reproducción integradas + Sincronización automática de listas + Sincroniza automáticamente las listas de reproducción guardadas de YouTube cuando las abres + Longitud de la lista top + Limita la longitud de la lista de reproducción \'Mi top x\' + + Duración del servicio + Si se aplican optimizaciones de batería, la notificación de reproducción puede desaparecer repentinamente cuando está en pausa. + Desde Android 12, se requiere desactivar las optimizaciones de batería para que la opción de servicio invencible esté disponible. + Ignorar optimizaciones de batería + Restricción ya levantada + Desactivar restricciones en segundo plano + Servicio invencible + Debería mantener la reproducción en funcionamiento el 99,99% del tiempo, en caso de que desactivar las optimizaciones de batería no sea suficiente + + ¿Necesitas ayuda? + La mayoría de las veces, no es culpa del desarrollador (incluso después de activar el servicio invencible) que la aplicación deje de funcionar correctamente en segundo plano.\nComprueba si el fabricante de tu dispositivo mata tus aplicaciones (haz clic para redirigir) + Si realmente crees que hay algo mal con la aplicación en sí, ve a la pestaña Acerca de + Solución de problemas + Precaución: usa estos botones como último recurso cuando falla la reproducción de audio + Recargar internos de la aplicación + Forzar cierre de la aplicación + Mostrar sección de solución de problemas + + Cola persistente + Guardar y restaurar canciones en reproducción + Reanudar reproducción + Cuando se conecta un dispositivo con cable o Bluetooth + Detener al cerrar + Cuando cierras la aplicación, la música deja de reproducirse + + Audio + Saltar silencio + Saltar partes silenciosas durante la reproducción + Longitud mínima de silencio + El tiempo mínimo que el audio debe estar en silencio para ser saltado + ¡El reproductor debe reiniciarse para que los cambios sean efectivos! + Reiniciar servicio + Normalización de volumen + Ajustar el volumen a un nivel fijo + Ganancia base de volumen + La ganancia \'objetivo\' para la normalización de volumen + Valor de volumen extremo detectado (%s), desactivando la normalización para esta canción + Refuerzo de graves + Reforzar frecuencias bajas para mejorar la experiencia de escucha + Nivel de refuerzo de graves + Nivel (0–1000) de refuerzo de frecuencias bajas; ¡usar bajo tu propio riesgo! + Enfoque de audio + Si el reproductor debe solicitar el enfoque de audio (multimedia) en todo el sistema + Interactuar con el ecualizador del sistema + + Filtrar… + + Última reproducción + Tendencias + Fuente de selecciones rápidas + Almacenar en caché datos de selecciones rápidas + Guarda los datos de selecciones rápidas en el disco para acceso sin conexión + + Diseño del reproductor + Clásico + Moderno (nuevo) + + Piped + Instancia + Haz clic para seleccionar + Nombre de usuario + Contraseña + Iniciar sesión + Puedes alojar listas de reproducción en otro lugar y sincronizarlas con ViMusic. Actualmente solo admite Piped. + Añadir cuenta + Vincula una cuenta de Piped con tu instancia, nombre de usuario y contraseña. + Más información + ¿No sabes qué es Piped o no tienes una cuenta? Haz clic aquí para ser redirigido a su documentación + Sesiones de Piped + Usar instancia personalizada + URL de API de instancia + Sesión de Piped creada con éxito + + Estilo de barra de reproducción + Estática + Ondulada + Calidad de barra de reproducción ondulada + Pobre + Baja + Media + Alta + Excelente + Subpíxel + + Quitar de la lista negra + Añadir a la lista negra + Restablecer lista negra + Lista negra vacía + + Pre-caché + + Deslizar para ocultar canción + Cuando deslizas una canción hacia la izquierda, se elimina de la base de datos y la caché + Confirmar deslizamiento para ocultar canción + Cuando deslizas para ocultar, primero tienes que confirmar + + Versión + Buscar actualizaciones + Actualmente estás ejecutando la versión v%1$s + Ocurrió un error desconocido al obtener datos de GitHub + Hay una nueva versión disponible + Más información + Estás actualizado: no hay actualizaciones disponibles + + Miniaturas dinámicas + Tamaño máximo de miniatura dinámica + El tamaño máximo de una miniatura cuando se usa una miniatura dinámica + + Cada hora + Diariamente + Semanalmente + Comprobar actualizaciones automáticamente + + Salto automático + Salta la canción actual cuando hay un error. + Se saltó %s debido a un error + Se saltó la canción actual debido a un error + Small room + Medium room + Large room + Medium hall + Large hall + Plate + + + Eliminar %1$s canción de la lista negra + Eliminar todas las %1$s canciones de la lista negra + Eliminar todas las %1$s canciones de la lista negra + + + + Eliminar %1$s evento de reproducción + Eliminar %1$s eventos de reproducción + Eliminar %1$s eventos de reproducción + + + + %1$d canción + %1$d canciones + %1$d canciones + + \ No newline at end of file diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml new file mode 100644 index 0000000..1684908 --- /dev/null +++ b/app/src/main/res/values-et/strings.xml @@ -0,0 +1,427 @@ + + %1$dm + %1$dh + %1$dp + %1$s jäänud + %1$s dB + %1$s kbps + %1$s ms + %1$spx + + Rohkem %1$s + Top %1$s + Minu top %1$s + Vaata top %1$s … + %1$sx + + Viimased 24 tundi + Viimane nädal + Viimane kuu + Viimane aasta + Kõik aeg + + Lood + Playlist`id + Albumid + Artistid + Singlid + + Lemmikud + Offline + Ajalugu + Ülevaade + Muusikakogu + Avasta + Kohalik + Kiirvalikud + Meeleolu + Online + Videod + Teave + Välimus + Värvid + Kujundid + + Tagasi + Edasi + Esita + Paus + Meeldib + Sega + Sünkrooni + Alusta raadiot + Mängi järgmine + Lisa järjekorda + + Unetaimer + Unetaimer lõppes + Kuni loo lõpuni + Kas soovid unetaimeri peatada? + Ekvalaiser + Määra unetaimer + + Tühista + Valmis + Kinnita + Ei + Peata + Määra + Peida + Nimeta ümber + Kustuta + Lähtesta + Tühjenda + Vaata kõiki + + sees + väljas + Väljas + Tundmatu + Praegu mängib + + Sisesta Playlist`i nimi + Uus Playlist + Lisa Playlist`i + + Mine albumisse + + Vaata YouTube\'is + Ava YouTube Music\'is + Vaata Playlist`i YouTube\'is + YouTube Music ei ole sinu seadmesse installitud! + + Eemalda järjekorrast + Eemalda Playlist`ist + + Peida "Kiirvalikutest" + Kas sa tõesti tahad selle loo peita? Selle esitusaeg ja vahemälu kustutatakse.\nSee tegevus on pöördumatu. + Kas sa tõesti tahad selle esitusloendi kustutada? + Kas sa tõesti tahad selle Piped\'i sessiooni kustutada? + + Teised versioonid + Sellel albumil ei ole alternatiivset versiooni + + Wikipeediast + Wikipeediast Creative Commons Attribution CC-BY-SA 3.0 all + + + + Meeleolud ja žanrid + Uued albumid + Rohkem + + See artist ei ole veel albumit välja andnud + See artist ei ole veel singlit välja andnud + + Kohustus tekkis viga + See kohalik muusikafail ei eksisteeri enam + Võrguviga + Mängitavat helivormingut ei leitud + Selle loo originaalvideoallikas on kustutatud + Seda lugu ei saa mängida serveri piirangute tõttu + Tagastatud video ID ei kattu nõutud omaga + Tekkis tundmatu esitamise viga + Tekkis tundmatu viga sinu Piped\'i konto sidumisel. Proovi uuesti. + Piped\'i instantside loend ei ole hetkel saadaval + Tekkis viga Bass Boost\'i initsialiseerimisel. Tõenäoliselt ei toeta sinu seade seda. Proovi bassi tuge muuta või proovi uuesti. + Tekkis viga eelvahemälu loomisel. Proovi uuesti. + Ei saa avada URL\'i %1$s + + Luba keeldus, palun anna meediaõigused seadme seadetes. + Ava seaded + Objekte ei leitud + Tulemusi ei leitud. Proovi teist päringut või kategooriat + + Interneti sirvimiseks ei leitud rakendust + Heli ekvalaiserit ei leitud + Dokumentide loomise rakendust ei leitud + Aku optimeerimise seadeid ei leitud, palun lisa ViMusic käsitsi valgesse nimekirja + + Seotud albumid + Sarnased artistid + Soovitatud Playlist`id + %s jälgijat + + Sisesta laulusõnad + Laulusõnu ei leitud + Vali laulusõnade rada + Sünkroniseeritud laulusõnad pole selle laulu jaoks saadaval + Laulusõnad pole selle laulu jaoks saadaval + Vale sünkroniseeritud laulusõnad, uuenda või muuda laulusõnu ja proovi uuesti + Kuva mitte-sünkroniseeritud laulusõnad + Kuva sünkroniseeritud laulusõnad + Laulusõnad on saadud lrclib.net ja kugou.com + Muuda laulusõnu + Otsi laulusõnu internetis + Uuenda laulusõnu + Vali laulusõnad lrclib.net\'ist + Määra laulusõnade alguse nihke + Nihutab sünkroniseeritud laulusõnu praeguse esitusaaja järgi + + Esituse kiirus + Helikõrgus + Kiirus ja helikõrgus + Miks sa seda teed?! + Heli võimendus + Järjekorra tsükkel + Lisa järjekord Playlist`isse + + ID + Itag + Bitrate + Suurus + Vahemälus + Heli tugevus + + Vaata albumit + Vaata Playlist`i + + Sisesta nimi + v%1$s autoriks vfsfitvnm + Sotsiaalmeedia + Kontakt + GitHub + Vaata allikat + Teata veast + Kui sul on probleem, võid sa teate esitada GitHub\'is (kliki, et suunata) + Küsi funktsiooni või soovita ideed + Sind suunatakse GitHub\'i + + Aktsentvärvi allikas + Vaikimisi + Dünaamiline + Material You + + Tumedus + Normaalne + Puhtalt must + AMOLED + + Režiim + Hele + Tume + Süsteemi + + Miniatuuride ümarus + Pole + Kergelt ümar + Keskmiselt ümar + Raskelt ümar + Veel raskelt ümar + Kõige raskelt ümar + + Tekst + Font + Kasuta süsteemi fontte + Kasutab süsteemi poolt rakendatud fontte + Kohanda fontti paddingut + Lisa ruumi tekstide ümber + Keel + + Lukustuskuva + Kuva laulu kaanepilt + Kasuta mängiva laulu kaanepilti lukustuskuvana + + Mängija + Eelmise loo nupp kokkuvolditud ajal + Kuvab eelmise loo nupu, kui mängija on kokkuvolditud + Pühkige horisontaalselt, et sulgeda + Sulgeb mängija, kui pühid vasakule/paremale kokkuvolditud mängijast. Kasulik, kui Android\'i üheaegne töörežiim on sisse lülitatud. + Kuva "Meeldib" nupp + Kuvab "Meeldib" nupu otse mängijas + Pühkige, et eemaldada element + Pühkige vasakule, et eemaldada element järjekorrast + Hoidke ekraan ärkvel (laulusõnad) + Hoidke ekraan ärkvel, kui kuvatakse laulusõnad + Kuva süsteemi ribad (laulusõnad) + Selle väljalülitamine võimaldab süsteemi tasemel täisekraani + PIP + Automaatne üleminek pildipildis režiimi, kui lülitute taustale + + Vahemälu + Kui vahemälu täitub, kustutatakse kõige kauem mittekasutatud ressursid + Pildi vahemälu + Maksimaalne suurus + %1$s kasutatud + %1$s kasutatud (%2$s%%) + Laulu vahemälu + Pausi vahemälu + Ajutiselt peatab uute laulude lisamise vahemällu + Andmebaas + + Kohandamine + Peata esituse ajalugu + Peatab esituse sündmuste kasutamise kiirvalikute jaoks + Palun pane tähele: see ei mõjuta offline vahemälu! + Lähtesta kiirvalikud + Kiirvalikud on kustutatud + Peata esituse aeg + Peatab esitusaega salvestamise. See peatab statistika "Minu tipp %1$s" esitusloendis! + + Varukoopia + Isiklikud seaded (nt teema režiim) ja vahemälu jäetakse välja. + Ekspordi andmebaas välisele salvestusele + Taasta + Olemasolevad andmed kirjutatakse üle.\nViMusic sulgub automaatselt pärast andmebaasi taastamist. + Impordi andmebaas väliselt salvestuselt + + Muud + Android Auto + Luba Android Auto tugi + Ära unusta lubada "Tundmatud allikad" Android Auto arendaja seadetes. + Otsingu ajalugu + Peata otsingu ajalugu + Ei salvesta uusi otsingupäringuid ega näita ajalugu + Kustuta otsingu ajalugu + Kustuta %1$s otsingupäringut + Ajalugu on tühi + Sisseehitatud esitusloendid + Automaatne esitusloendite sünkroniseerimine + Automaatne salvestatud esitusloendite sünkroniseerimine YouTube\'ist, kui neid avatakse + Top-nimekirja pikkus + Piirab "Minu top x" esitusloendi pikkust + + Teenuse eluiga + Kui aku optimeerimiseid rakendatakse, võib esituse teade äkki kaduda, kui muusika on pausis. + Alates Android 12-st on nõutav aku optimeerimise keelamine, et "Invincible service" valik oleks saadaval. + Ignoreeri aku optimeerimisi + Piirang on juba eemaldatud + Keela taustpiirangud + Invincible service + Peaks hoidma esitust käimas 99.99% ajast, juhul kui aku optimeerimise keelamine ei ole piisav + + Vajad abi? + Enamasti ei ole arendaja süü (isegi kui "Invincible service" on sisse lülitatud), et rakendus ei tööta taustal korralikult.\nKontrolli, kas sinu seadme tootja lõpetab sinu rakendused (kliki suunamiseks) + Kui sa tõeliselt arvad, et rakenduses endas on probleem, vaata "Teave" sektsiooni + Probleemide lahendamine + Hoiatus: kasuta neid nuppe viimases hädas, kui heli esitamine ei toimi + Laadi rakenduse sisemised andmed uuesti + Tappa rakendus + Kuva probleemide lahendamise sektsioon + + Püsiv järjekord + Salvesta ja taasta mängivad lood + Jätka esitust + Kui ühendatud on juhtmega või Bluetooth-seade + Peata, kui suletud + Kui sulged rakenduse, peatub muusika esitamine + + Audio + Jäta vaikuse kohad vahele + Jätab vahele vaiksed osad esitamise ajal + Minimaalne vaikuse pikkus + Minimaalne aeg, mille jooksul audio peab olema vaikne, et see vahele jätta + Mängijat tuleb taaskäivitada, et muudatused kehtiksid! + Taaskäivita teenus + Heli normaliseerimine + Kohandab heli kindlaks tasemeks + Heli põhivõimendus + Sihtvõimendus heli normaliseerimise jaoks + Avastatud äärmuslik heli väärtus (%s), normaliseerimine selle laulu jaoks keelatud + Bassivoimendus + Tugevdab madalaid frekantsse, et parandada kuulamiskogemust + Bassivoimenduse tase + Tase (0–1000) madalate frekantside võimendamiseks; kasuta oma riskil! + Sponsorblock + Blokeerib sponsoreeritud/välisteemalise sisu, kasutades Sponsorblock API + Kaja + Audio fookus + Kas mängija peaks küsima süsteemi tasandi audio (meedia) fookust + Suhtle süsteemi ekvalaiseriga + + Filtreeri… + + Viimati mängitud + Populaarne + Kiirvalikute allikas + Kiirvalikute andmete vahemälu + Salvestab kiirvalikute andmed kettale offline-juurdepääsu jaoks + + Mängija paigutus + Klassikaline + Uus (modernne) + + Piped + Instants + Vajuta, et valida + Kasutajanimi + Parool + Logi sisse + Sa saad esitusloendeid mujal majutada ja sünkroonida neid ViMusic\'iga. Praegu toetab ainult Piped. + Lisa konto + Siduge Piped\'i konto oma instantsiga, kasutajanime ja parooliga. + Uuri rohkem + Ei tea, mis on Piped või sul pole kontot? Kliki siia, et suunata nende dokumentatsioonile + Piped\'i seansid + Kohanda instants + Instantsi API URL + Piped\'i seanss loodi edukalt + + Otsinguriba stiil + Statiline + Laineline + Lainelise otsinguriba kvaliteet + Kehv + Madal + Keskmine + Kõrge + Suurepärane + Alampiksel + + Eemalda mustast nimekirjast + Lisa musta nimekirja + Lähtesta must nimekiri + Must nimekiri on tühi + + Eelvahemälu + + Pühkige, et peita laul + Kui pühkite laulu vasakule, eemaldatakse see andmebaasist ja vahemälust + Kinnita laulu peitmine + Kui pühkite peitmiseks, peate esmalt kinnitama + Peida täiskasvanutele mõeldud lood + Peidab kõik täiskasvanutele mõeldud lood kogu rakenduses. Mängija jätab ka täiskasvanutele mõeldud lood vahele, kui see seade on lubatud. + + Versioon + Kontrolli uuendusi + Sa kasutad versiooni v%1$s + Tekkis tundmatu viga, kui andmeid GitHub\'ist saadakse + Uus versioon on saadaval + Rohkem teavet + Sa oled hetkel värskendatud: uuendusi pole saadaval + + Dünaamilised pisipildid + Maksimaalne dünaamilise pisipildi suurus + Dünaamilise pisipildi kasutamisel maksimaalne pisipildi suurus + + Iga tunni järel + Iga päev + Iga nädal + Automaatne uuenduste kontrollimine + + Automaatselt vahele + Jätab praeguse laulu vahele, kui tekib viga. + Jäteti %s vea tõttu vahele + Jäteti praegune laul vea tõttu vahele + Väike ruum + Keskmine ruum + Suur ruum + Keskmine saal + Suur saal + Plaat + + + Eemalda %1$s laul mustast nimekirjast + Eemalda kõik %1$s laulu mustast nimekirjast + + + + Kustuta %1$s esituse sündmus + Kustuta %1$s esituse sündmused + + + + %1$d laul + %1$d laulu + + diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml new file mode 100644 index 0000000..3fac983 --- /dev/null +++ b/app/src/main/res/values-in/strings.xml @@ -0,0 +1,387 @@ + + + %1$dm + %1$dj + %1$dh + %1$s lagi + %1$s dB + %1$s kbps + %1$s ms + %1$spx + + Lebih banyak dari %1$s + %1$s teratas + %1$s teratas Saya + Lihat %1$s teratas dari … + %1$sx + + 24 jam terakhir + Minggu lalu + Bulan lalu + Tahun lalu + Sepanjang waktu + + Lagu + Daftar Putar + Album + Artis + Singel + + Favorit + Luring + Gambaran Umum + Pustaka + Jelajahi + Lokal + Pilihan cepat + Mood + Daring + Video + Tentang + Penampilan + Warna + Bentuk + + Mundur + Lanjut + Putar + Jeda + Suka + Acak + Sinkronisasi + Mulai radio + Putar berikutnya + Masukkan dalam antrian + + Pengatur waktu tidur + Waktu tidur berakhir + Apakah Anda ingin menghentikan pengatur waktu tidur? + Pengatur suara + Atur pengatur waktu tidur + + Batal + Selesai + Konfirmasi + Tidak + Berenti + Atur + Sembunyikan + Ubah Nama + Hapus + Setel ulang + Bersihkan + Lihat semua + + hidup + mati + Mati + Tidak diketahui + Sedang diputar + + Masukkan nama daftar putar + Daftar putar baru + Tambahkan ke daftar putar + + Buka album + + Tonton di YouTube + Buka di YouTube Music + Tonton daftar putar di YouTube + YouTube Music tidak terinstal di perangkat Anda! + + Hapus dari antrian + Hapus dari daftar putar + + Sembunyikan dari \"Pilihan Cepat\" + Apakah Anda ingin menyembunyikan lagu ini? Waktu pemutaran dan cache-nya akan dihapus.\nTindakan ini tidak dapat diubah. + Apakah Anda ingin menghapus daftar putar ini? + + Versi lain + Album ini tidak memiliki versi alternatif + + Dari Wikipedia + Dari Wikipedia di bawah Creative Commons Attribution CC-BY-SA 3.0 + + + + Mood dan genre + Album yang baru dirilis + + Artis ini belum merilis album + Artis ini belum merilis singel + + Terjadi kesalahan + File musik lokal ini tidak ada lagi + Terjadi kesalahan jaringan + Tidak dapat menemukan format audio yang dapat diputar + Sumber video asli dari lagu ini telah dihapus + Lagu ini tidak dapat diputar karena pembatasan server + Id video yang dikembalikan tidak sesuai dengan yang diminta + Terjadi kesalahan pemutaran yang tidak diketahui + Terjadi kesalahan yang tidak diketahui saat menautkan akun Piped Anda. Silakan coba lagi. + Daftar contoh Piped saat ini tidak tersedia + Terjadi kesalahan dalam menginisialisasi Bass Boost. Mungkin perangkat Anda tidak mendukungnya. Coba ubah tingkat penguat bass atau coba lagi. + Terjadi kesalahan yang tidak diketahui selama pra-caching. Silakan coba lagi. + Tidak dapat membuka url %1$s + + Izin ditolak, berikan izin media di pengaturan perangkat Anda. + Buka pengaturan + Tidak ada item yang ditemukan + Tidak ada hasil yang ditemukan. Silakan coba kueri atau kategori yang berbeda + + Tidak dapat menemukan aplikasi untuk menjelajah internet + Tidak dapat menemukan aplikasi untuk melakukan ekualisasi audio + Tidak dapat menemukan aplikasi untuk membuat dokumen + Tidak dapat menemukan pengaturan optimisasi baterai, harap daftarkan ViMusic secara manual + + Album terkait + Artis serupa + Daftar putar yang mungkin Anda sukai + + Masukkan lirik + Tidak ada trek lirik yang ditemukan + Pilih trek lirik + Lirik yang disinkronkan tidak tersedia untuk lagu ini + Lirik tidak tersedia untuk lagu ini + Lirik yang disinkronkan tidak valid, ambil kembali atau edit lirik dan coba lagi + Menampilkan lirik yang tidak disinkronkan + Menampilkan lirik yang disinkronkan + Disediakan oleh lrclib.net & kugou.com + Edit lirik + Cari lirik secara online + Mengambil lirik lagi + Pilih lirik dari lrclib.net + Atur offset awal + Menyesuaikan lirik yang disinkronkan dengan waktu pemutaran saat ini + + Kecepatan pemutaran + Mengapa Anda melakukan ini?! + Peningkatan volume + Antrian loop + Tambahkan antrian ke daftar putar + + Id + Itag + Bitrate + Ukuran + Tercache + Kebisingan + + Lihat album + Lihat daftar putar + + Masukkan nama + v%1$s oleh vfsfitvnm + Sosial + Kontak + GitHub + Lihat kode sumber + Laporkan masalah + Jika Anda membutuhkan bantuan dengan bug, Anda dapat mengirimkan isu di GitHub (klik untuk diarahkan) + Minta fitur atau usulkan ide + Anda akan diarahkan ke GitHub + + Sumber warna aksen + Default + Dinamis + Material You + + Kegelapan + Normal + Hitam Murni + AMOLED + + Mode + Terang + Gelap + Sistem + + Kebulatan thumbnail + Tidak ada + Ringan + Sedang + Berat + Lebih berat + Terberat + + Teks + Font + Gunakan font sistem + Gunakan font yang diterapkan oleh sistem + Menerapkan padding font + Menambahkan jarak di sekitar teks + Bahasa + + Layar Kunci + Tampilkan sampul lagu + Gunakan sampul lagu yang sedang diputar sebagai wallpaper layar kunci + + Pemutar + Tombol sebelumnya saat terlipat + Tampilkan tombol lagu sebelumnya saat pemutar terlipat + Geser secara horizontal untuk menutup + Tutup pemutar saat menggeser ke kiri/kanan pada pemutar terlipat. Berguna untuk pengguna dengan mode satu tangan Android aktif. + Tampilkan tombol suka + Menampilkan tombol suka di pemutar + Geser untuk menghapus item + Geser ke kiri untuk menghapus item dari antrean + + Cache + Ketika cache kehabisan ruang, sumber daya yang tidak diakses dalam waktu lama akan dihapus + Cache gambar + Ukuran maksimal + %1$s digunakan + %1$s digunakan (%2$s%%) + Cache lagu + Database + + Pembersihan + Jeda riwayat pemutaran + Hentikan pemutaran yang sedang digunakan untuk pilihan cepat + Harap diperhatikan: hal ini tidak akan memengaruhi cache offline! + Setel ulang pilihan cepat + Pilihan cepat telat dibersihkan + Jeda waktu pemutaran + Berhenti menyimpan waktu pemutaran. Ini menghentikan statistik di daftar putar \'%1$s teratas saya\'! + + Cadangkan + Preferensi pribadi (mis. mode tema) dan cache dikecualikan. + Ekspor database ke penyimpanan eksternal + Pulihkan + Data yang ada akan ditimpa.\nViMusic akan otomatis menutup dirinya sendiri setelah memulihkan database. + Impor database dari penyimpanan eksternal + + Lainnya + Android Auto + Aktifkan dukungan Android Auto + Ingat untuk mengaktifkan \"Sumber tidak dikenal\" di Pengaturan Pengembang Android Auto. + Riwayat pencarian + Jeda riwayat pencarian + Tidak menyimpan kueri pencarian baru atau menampilkan riwayat + Bersihkan riwayat pencarian + Hapus %1$s kueri pencarian + Riwayat kosong + Daftar putar bawaan + Panjang daftar teratas + Batas panjang daftar putar \'x teratas saya\' + + Masa pakai layanan + Jika optimisasi baterai diterapkan, notifikasi pemutaran dapat tiba-tiba menghilang saat dijeda. + Sejak Android 12, menonaktifkan optimisasi baterai diperlukan agar opsi layanan yang tak terkalahkan tersedia. + Abaikan optimisasi baterai + Pembatasan sudah dihapus + Nonaktifkan pembatasan latar belakang + Layanan tak terkalahkan + Harus menjaga pemutaran berjalan 99,99% waktu, jika mematikan optimisasi baterai tidak cukup + + Butuh bantuan? + Sebagian besar, bukan kesalahan pengembang (bahkan setelah mengaktifkan layanan tak terkalahkan) bahwa aplikasi berhenti berfungsi dengan baik di latar belakang.\nPeriksa apakah produsen perangkat Anda menonaktifkan aplikasi Anda (klik untuk diarahkan) + Jika Anda benar-benar yakin ada yang salah dengan aplikasi itu sendiri, beralih ke tab Tentang + Perbaikan masalah + Peringatan: gunakan tombol ini sebagai tindakan terakhir saat pemutaran audio gagal + Muat ulang internal aplikasi + Tutup aplikasi + Tampilkan bagian perbaikan masalah + + Antrian persisten + Simpan dan pulihkan lagu yang sedang diputar + Lanjutkan pemutaran + Saat perangkat kabel atau Bluetooth terhubung + Berhenti saat ditutup + Ketika Anda menutup aplikasi, musik berhenti diputar + + Audio + Lewati keheningan + Lewati bagian yang hening selama pemutaran + Panjang keheningan minimum + Waktu minimum audio harus hening untuk dilewati + Pemutar harus di-restart agar perubahan berlaku! + Mulai ulang layanan + Normalisasi kebisingan + Atur volume ke level tetap + Kebisingan dasar + Peningkatan target untuk normalisasi kebisingan + Peningkatan bass + Boost low frequencies to improve listening experience + Bass boost level + Level (0–1000) peningkatan frekuensi rendah; gunakan dengan risiko sendiri! + Interaksi dengan pengatur suara sistem + + Filter… + + Terakhir diputar + Trending + Sumber Pilihan Cepat + + Tata letak pemutar + Klasik + Modern (baru) + + Piped + Instansi + Klik untuk memilih + Nama pengguna + Kata sandi + Masuk + Anda dapat menyimpan daftar putar di tempat lain dan menyinkronkannya dengan ViMusic. Saat ini hanya mendukung Piped. + Tambahkan akun + Hubungkan akun Piped dengan instansi Anda, nama pengguna, dan kata sandi. + Pelajari lebih lanjut + Tidak tahu apa itu Piped atau tidak memiliki akun? Klik di sini untuk diarahkan ke dokumennya + Sesi Piped + Gunakan Instansi Kustom + URL API Instansi + + Gaya bar pencarian + Statis + Bergelombang + Kualitas bar pencarian bergelombang + Sangat rendah + Rendah + Sedang + Tinggi + Hebat + Subpixel + + Hapus dari daftar hitam + Tambahkan ke daftar hitam + Setel ulang daftar hitam + Daftar hitam kosong + + Pra-cache + + Geser untuk menyembunyikan lagu + Ketika Anda menggeser lagu ke kiri, akan menghapus lagu dari database dan cache + Konfirmasi gesek untuk menyembunyikan lagu + Ketika Anda menggeser untuk menyembunyikan, Anda harus mengkonfirmasi terlebih dahulu + + Versi + Periksa pembaruan + Anda saat ini menggunakan versi v%1$s + Kesalahan tidak diketahui terjadi saat mengambil data dari GitHub + Versi baru tersedia + Informasi lebih lanjut + Anda saat ini sudah terbaru: tidak ada pembaruan tersedia + + Thumbnail dinamis + Ukuran maksimal thumbnail dinamis + Ukuran maksimal thumbnail saat thumbnail dinamis digunakan + Small room + Medium room + Large room + Medium hall + Large hall + Plate + + + Hapus semua %1$s lagu dari daftar hitam + + + + Hapus %1$s peristiwa pemutaran + + + + %1$d lagu + + diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000..6f693e9 --- /dev/null +++ b/app/src/main/res/values-ja/strings.xml @@ -0,0 +1,409 @@ + + + %1$d分 + %1$d時間 + %1$d日 + 残り%1$s + %1$s dB + %1$s kbps + %1$s ミリ秒 + %1$spx + + %1$sをもっと見る + Top %1$s + My top %1$s + Top %1$s を表示 + ×%1$s + + 過去24時間 + 1週間 + 1ヶ月 + 1年 + すべて + + + プレイリスト + アルバム + アーティスト + シングル + + お気に入り + オフライン + 概要 + ライブラリー + 見つける + ローカル + おすすめ + ムード + オンライン + 動画 + アプリについて + 外観 + + + + 前へ + 次へ + 再生 + 停止 + お気に入り + シャッフル + 同期 + ラジオを再生 + 次に再生 + キューに追加 + + スリープタイマー + スリープタイマーが終了しました + 曲が終わるまで + スリープタイマーを停止しますか? + イコライザー + スリープタイマーを設定する + + キャンセル + 完了 + 確認 + いいえ + 停止 + 設定する + 削除 + 名前の変更 + 削除 + リセット + 消去 + すべて見る + + オン + オフ + オフ + 未知 + 再生中 + + プレイリスト名を入力してください + 新しいプレイリスト + プレイリストに追加する + + アルバムに移動 + + YouTubeで見る + YouTube Musicで聞く + YouTube でプレイリストを見る + YouTube Music がインストールされていません + + キューから削除 + プレイリストから削除 + + おすすめから削除 + この曲を削除しますか?\n再生時間とキャッシュは消去されます\nこの操作は元に戻すことができません + このプレイリストを削除しますか? + この Piped セッションを削除しますか? + + ほかのバージョン + このアルバムには他のバージョンがありません + + Wikipedia から + From Wikipedia under Creative Commons Attribution CC-BY-SA 3.0 + + + + ムードとジャンル + 新しいアルバム + もっと見る + + このアーティストのアルバムはありません + このアーティストのシングルはありません + + エラーが発生しました + この音楽は存在しません + ネットワークエラーが発生しました + 再生可能な音楽がみつかりませんでした + この曲の元動画は削除されました + この曲はサーバーの制限により再生できません + Video ID が一致しませんでした + 不明な再生エラーが発生しました + Piped アカウントのリンク中に不明なエラーが発生しました\nもう一度試してください + Piped インスタンスのリストは現在利用できません + バスブーストの初期化中にエラーが発生しました\n低音ブーストレベルを変更するか、もう一度試してください + 事前キャッシュ中に不明なエラーが発生しました\nもう一度試してください + %1$sを開けません + + デバイスの設定でメディアの権限をを付与してください + 設定を開く + 見つかりませんでした + 見つかりませんでした\n他のカテゴリーで検索してください + + インターネットを閲覧するためのアプリが見つかりませんでした + イコライザーが見つかりませんでした + ドキュメントを作成するアプリケーションが見つかりませんでした + バッテリー最適化設定が見つかりませんでした\nViMusicを手動で設定してください + + 関連アルバム + 類似したアーティスト + 類似したプレイリスト + %s人の登録者 + + 歌詞を入力してください + 歌詞が見つかりませんでした + 歌詞を選択してください + この曲には同期した歌詞がありません + この曲の歌詞はありません + 同期した歌詞が無効です\n歌詞を再読み込みまたは編集してください + 同期されていない歌詞を表示する + 同期した歌詞を表示する + lrclib.net & kugou.comによって提供されました + 歌詞を編集する + Webで歌詞を検索する + 歌詞を読み込みなおす + lrclib.net から歌詞を選択 + 開始位置の設定 + 同期した歌詞の開始位置を現在の再生時間に設定します + + 再生速度 + ピッチ + 再生速度とピッチ + 低過ぎです + ボリュームブースト + 再生中の曲をループ再生 + キューをプレイリストに追加 + + Id + Itag + ビットレート + サイズ + キャッシュした + ラウンドネス + + アルバムを見る + プレイリストを見る + + キーワードを入力 + v%1$s by vfsfitvnm + 知る + つながる + GitHub + ソースコードを見る + 問題を報告する + バグを見つけたら、GitHub に報告してください + 機能のリクエストまたはアイデアの提案 + GitHub を開きます + + アクセントカラー + デフォルト + ダイナミック + デバイスの設定に合わせる + + 暗さ + 普通 + 真っ黒 + AMOLED + + ダークモード + オフ + オン + デバイスの設定に合わせる + + サムネイルの丸み + なし + 20% + 35%(標準) + 50% + 65% + 80% + + テキスト + フォント + デバイスのフォントを使う + デバイスで設定されたフォントを使います + 余白 + 文字の周りに余白を追加します + 言語 + + ロック画面 + 曲のカバーを表示する + 再生中の曲のカバーをロック画面の壁紙にします + + プレーヤー + 前へボタンを表示する + 折りたたまれいても前へボタンを表示します + スワイプしてプレーヤーを閉じる + 折りたたまれたプレーヤーを左右にスワイプするとプレーヤーを閉じます + お気に入りボタンを表示する + プレーヤーにお気に入りボタンを表示します + スワイプして削除 + スワイプして、キューからアイテムを削除します + 画面を起動したままにする(歌詞) + 歌詞が表示されているときに、画面を起動したままにします + + キャッシュ + キャッシュが保存できなくなったら、古いキャッシュから削除します + 画像のキャッシュ + 最大サイズ + %1$s 使用中 + %1$s 使用中 (%2$s%%) + 曲のキャッシュ + データベース + + 削除 + 再生履歴を一時停止 + クイックピックで使用される再生イベントを停止します + 注意: これはオフライン キャッシュには影響しません + おすすめのリセット + おすすめはありません + 再生時間の保存を一時停止 + 再生時間の保存を停止します\n「My Top %1$s」の統計が一時停止されます + + バックアップ + 個人的な設定(テーマ モードなど)とキャッシュは除外されます + データベースを外部ストレージにエクスポートします + 復元 + 既存のデータは上書きされます\nデータベースの復元後、ViMusic は自動的に終了します + データベースを外部ストレージからインポートします + + その他 + Android Auto + Android Auto を有効化します + Android Autoの開発者設定で、提供元不明のソースを有効にしてください + 検索履歴 + 検索履歴を一時停止 + 検索履歴の保存を停止します + 検索履歴を消去する + %1$s 個の検索履歴を削除します + 履歴はありません + 内臓プレイリスト + Top リストの長さ + My top のプレイリストの長さを設定します + + Service lifetime + バッテリーの最適化が適用されている場合、一時停止時に再生通知が消えることがあります + Android 12 以降、上級サービスオプションを利用するには、バッテリーの最適化を無効にする必要があります + バッテリーの最適化を無効にする + バッテリー最適化はすでに無効です + バックグラウンドの制限を無効にする + 上級サービス + バッテリーの最適化をオフにするだけでは不十分な場合、再生を継続させます + + ヘルプが必要ですか? + ほとんどの場合、アプリがバックグラウンドで上級サービスをオンにしても正常に動作しなくなるのは開発者のせいではありません\nデバイスの製造元がアプリを強制終了していないか確認する + アプリ自体に問題があると本当に思う場合は、「アプリについて」から、GitHubで報告してください + トラブルシューティング + 注意: これらのボタンは、アプリがエラーが発生した時の最終手段として使用してください + アプリをリロードする + アプリを終了する + トラブルシューティングを表示する + + アプリが閉じてもキューを残す + 再生中の曲の保存と復元をします + 再生を再開する + 有線またはBluetoothデバイスが接続された場合に、再生を再開します + 再生を停止する + アプリが終了したら、再生を停止します + + オーディオ + 無音部分をスキップ + 再生中に無音部分をスキップする + 最低無音時間 + 音声がスキップされる最低無音時間 + 変更を適用するには、プレーヤーを再起動する必要があります + プレーヤーを再起動する + ラウドネスノーマライズ + 音量を一定レベルに調整します + ラウドネスの基本音量 + ラウドネスノーマライズのターゲットゲイン + 極端なラウドネスの値が検出されました (%s).\この曲のノーマライズをオフにします + バスブースト + 低音域を強化します + バスブーストレベル + 自己責任でブーストレベルを0から1000の間で指定してください + システムイコライザーを開く + + フィルター + + 最後に再生した曲 + トレンド + おすすめの取得元 + おすすめのデータを一時保存する + オフラインでもおすすめの曲を聞けるように一時保存します + + プレーヤーのレイアウト + クラシック + モダン (新しい) + + Piped + 一覧 + タップして選択してください + ユーザーネーム + パスワード + ログイン + プレイリストを別の場所で保存し、ViMusic と同期することができます\n現在、Piped のみをサポートしています + アカウントを追加する + Piped アカウントにリンクします + もっと知る + Piped を知らない、またはアカウントをお持ちでない場合は、ここをクリックするとPiped のドキュメントを開きます + Piped セッション + カスタムインスタンスを使用する + インスタンスAPIのURL + Piped セッションが正常に作成されました + + 再生バーのスタイル + 普通 + 波状 + 波状再生バーの品質 + 25% + 40% + 55% + 70% + 85% + 100% + + ブラックリストから削除する + ブラックリストに追加する + ブラックリストをリセットする + ブラックリストはありません + + プリキャッシュ + + スワイプして曲を削除 + 曲を左にスワイプすると、データベースとキャッシュから削除されます + スワイプを確認する + スワイプして削除する場合は、確認する必要があります + + バージョン + アップデートを確認する + バージョン v%1$s を実行しています + GitHubからのデータ取得中に不明なエラーが発生しました + 新しいバージョンが使用可能です + 詳細情報 + 実行しているバージョンが最新です + + ダイナミックサムネイル + ダイナミックサムネイルの最大サイズ + ダイナミックサムネイル使用時のサムネイルの最大サイズを設定します + + 毎時間 + 毎日 + 毎週 + アップデートの自動チェック + + 自動スキップ + エラーが発生したときに再生中の曲をスキップする + エラーが発生したため %s がスキップされました + エラーが発生したため再生中の曲をスキップしました + Small room + Medium room + Large room + Medium hall + Large hall + Plate + + + ブラックリストから %1$s の曲をすべて削除する + + + + %1$s の再生イベントを削除する + + + + %1$d 曲 + + diff --git a/app/src/main/res/values-night-v29/themes.xml b/app/src/main/res/values-night-v29/themes.xml new file mode 100644 index 0000000..4403073 --- /dev/null +++ b/app/src/main/res/values-night-v29/themes.xml @@ -0,0 +1,15 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index 0167352..ae39020 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -1,4 +1,4 @@ - + diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml new file mode 100644 index 0000000..1b1a931 --- /dev/null +++ b/app/src/main/res/values-nl/strings.xml @@ -0,0 +1,433 @@ + + + %1$dm + %1$du + %1$dd + nog %1$s + %1$s dB + %1$s kb/s + %1$s ms + %1$s pixels + + Meer van %1$s + Top %1$s + Mijn top %1$s + Laat top %1$s zien van… + %1$sx + + Afgelopen 24 uur + Afgelopen week + Afgelopen maand + Afgelopen jaar + Sinds het begin + + Songs + Afspeellijsten + Albums + Artiesten + Singles + + Favorieten + Offline + Geschiedenis + Overzicht + Bibliotheek + Ontdekken + Lokaal + Snelle keuzes + Stemming + Online + Video\'s + Over + Uiterlijk + Kleuren + Vormen + + Spring terug + Spring vooruit + Afspelen + Pauzeren + Like + Shuffle + Synchroniseer + Start radio + Als volgende afspelen + Zet in wachtrij + + Slaaptimer + Slaaptimer voorbij + Totdat het nummer eindigt + Wil je de timer stoppen? + Equalizer + Slaaptimer instellen + + Annuleer + Klaar + Bevestigen + Nee + Stop + Instellen + Verbergen + Hernoemen + Verwijderen + Reset + Wissen + Toon alles + + aan + uit + Uit + Onbekend + Speelt nu + + Geef de naam van de afspeellijst + Nieuwe afspeellijst + Toevoegen aan afspeellijst + + Ga naar album + + Bekijk op YouTube + Open in YouTube Music + Bekijk playlist op YouTube + YouTube Music is niet geïnstalleerd op je apparaat! + + Verwijder van wachtrij + Verwijder van playlist + + Verberg van \"Snelle keuzes\" + Wil je echt dit nummer verbergen? De afspeeltijd en cache worden weggegooid. Deze actie is onomkeerbaar. + Wil je deze afspeellijst echt verwijderen? + Wil je deze Piped sessie echt verwijderen? + + Overige versies + Dit album heeft geen alternatieve versies + + Van Wikipedia + Van Wikipedia onder Creative Commons Naamsvermelding CC-BY-SA 3.0 + + + + Stemmingen en genres + Nieuwe uitgebrachte albums + Meer + + Deze artiest heeft geen albums uitbebracht + Deze artiest heeft geen singles uitbebracht + + Een error heeft zich voorgedaan + Dit lokale muziekbestand bestaat niet meer + Een netwerkfout heeft zich voorgedaan + Kon geen afspeelbaar audio formaat vinden + De originele videobron van dit nummer is verwijderd + Dit nummer kan niet worden afgespeeld door server restricties + De teruggegeven video ID is niet dezelfde als de gevraagde ID + Een onbekende afspeelfout heeft zich voorgedaan + Het linken met je Piped account is mislukt. Probeer het opnieuw. + De lijst van Piped instanties is niet beschikbaar + Er was een error bij het initialiseren van bass boost. Waarschijnlijk ondersteunt je apparaat het niet. Probeer het niveau aan te passen of probeer opnieuw. + Een onbekende error heeft zich voorgedaan bij het pre-cachen. Probeer het opnieuw. + Kon url %1$s niet openen + + Permissie afgewezen, verleen media permissie in de instellingen van je apparaat. + Open instellingen + Geen items gevonden + Geen resultaten gevonden. Probeer een andere zoekterm of categorie + + Kan geen applicatie vinden om het internet te browsen + Kan geen applicatie vinden om audio bij te stellen + Kan geen applicatie vinden om bestanden te maken + Kan geen batterij optimalisatie instellingen vinden, maak handmatig een uitzondering voor ViMusic + + Gerelateerde albums + Vergelijkbare artiesten + Afspeelijsten die je misschien leuk vindt + %s abonnees + + Voer de songtekst in + Geen songtekst gevonden + Kies songtekst + Gesynchroniseerde songtekst niet beschikbaar + Songtekst niet beschikbaar + Ongeldige gesynchroniseerde songtekst, laad opnieuw in of bewerk de songtekst en probeer het opnieuw + Laat niet-gesynchroniseerde songtekst zien + Laat gesynchroniseerde songtekst zien + Verstrekt door lrclib.net & kugou.com + Bewerk songtekst + Zoek online naar songtekst + Laad songtekst opnieuw in + Kies songtekst van lrclib.net + Stel startpunt in + Laat de gesynchroniseerde songtekst beginnen vanaf de huidige tijd + + Snelheid + Toonhoogte + Snelheid en toonhoogte + Waarom zou je dat doen?! + Volume-boost + Wachtrij herhalen + Voeg wachtrij toe aan afspeellijst + + Id + Itag + Bitrate + Grootte + Gecachet + Luidheid + Herlaad + + Toon album + Toon afspeellijst + + Zoek… + v%1$s door vfsfitvnm + Sociaal + Contact + GitHub + Toon broncode + Meld probleem + Als je hulp nodig hebt kun je een probleem melden op GitHub (klik om door te verwijzen) + Verzoek een functie of suggereer een idee + Je wordt doorverwezen naar GitHub + + Bron voor accentkleur + Standaard + Dynamisch + Material You + + Donkerheid + Normaal + PuurZwart + AMOLED + + Modus + Licht + Donker + Systeem + + Rondheid van miniaturen + Geen + Licht + Gemiddeld + Sterk + Nog sterker + Sterkste + + Tekst + Lettertype + Gebruik systeemlettertype + Gebruik het lettertype van het systeem + Gebruik marges rond lettertype + Voegt afstand toe tussen de tekst + Taal + + Vergrendelscherm + Toon albumhoes + Gebruik de albumhoes van het afspelende nummer als achtergrond op het vergrendelscherm + + Speler + Vorige knop op ingeklapte speler + Laat de knop om naar het vorige nummer te gaan zien op de ingeklapte speler + Swipe horizontaal om te sluiten + Sluit de speler zodra je horizontaal swipet. Handig voor gebruikers met de één-hand modus van Android. + Toon like knop + Laat de like knop direct in de speler zien + Swipe om item te verwijderen + Swipe naar links om een nummer uit de wachtrij te halen + Houd scherm wakker (songtekst) + Houdt het scherm aan terwijl je de songteksten leest + Toon systeembalken (songtekst) + Zet dit uit voor systeembreed volledig scherm + PIP + Zet automatisch beeld-in-beeldmodus aan wanneer de app in de achtergrond is + + Cache + Wanneer de cache vol raakt worden de bestanden die het langst niet zijn gebruikt verwijderd + Afbeeldingcache + Maximale grootte + %1$s gebruikt + %1$s gebruikt (%2$s%%) + Songcache + Pauzeer cache + Voeg tijdelijk geen nieuwe nummers toe aan de cache + Database + + Opruimen + Pauzeer afspeelgeschiedenis + Stopt de verzameling van afspeelgebeurtenissen voor Snelle keuzes + Let op: dit verandert niets aan offline cachen! + Reset Snelle keuzes + Snelle keuzes geleegd + Pauzeer afspeeltijd + Stopt met het opslaan van afspeeltijd. Dit pauzeert de statistieken in de \'Mijn top %1$s\' afspeellijst + + Back-up + Persoonlijke voorkeuren (bijvoorbeeld het thema) en de cache worden niet opgeslagen. + Exporteer de database naar de externe opslag + Herstellen + Bestaande data zal worden vervangen. ViMusic sluit zichzelf automatisch nadat de database is hersteld. + Importeer de database van de externe opslag + + Overig + Android Auto + Zet Android Auto support aan + Vergeet niet om \"Onbekende bronnen\" aan te zetten in de Developer Settings van Android Auto. + Zoekgeschiedenis + Pauzeer zoekgeschiedenis + Sla nieuwe zoektermen niet op en toon geen geschiedenis + Verwijder zoekgeschiedenis + Verwijder %1$s zoektermen + Geschiedenis is leeg + Ingebouwde afspeellijsten + Synchroniseer afspeellijsten automatisch + Synchroniseert opgeslagen afspeellijsten van YouTube wanneer je ze opent + Lengte Top afspeellijst + Limiteert de lengte van de \'Mijn top x\' afspeellijst + + Levensduur van de service + Als batterijoptimalisatie is toegepast kan de afspeelnotificatie prompt verdwijnen wanneer de speler is gepauzeerd. + Sinds Android 12 is het uitzetten van batterijoptimalisatie verplicht om de onaantastbare service aan te kunnen zetten. + Zet batterijoptimalisatie uit + Restrictie opgeheven + Zet achtergrondrestricties uit + Onaantastbare service + Dit zou het afspelen 99.99% van de tijd in stand moeten houden, voor wanneer het uitzetten van batterijoptimalisatie niet genoeg is + + Hulp nodig? + Vaak is het niet de fout van de ontwikkelaar (zelfs na het aanzetten van de onaantastbare service) dat de app soms stopt goed te werken in de achtergrond. Controleer of jouw fabrikant jouw apps ongevraagd beëindigt (klik om door te sturen) + Als je echt denkt dat er iets mis is met de app zelf, ga dan naar het Over tabblad + Foutopsporing + Voorzichtig: gebruik deze knoppen alleen als een laatste hoop als het afspelen niet werkt + Herlaad interne onderdelen van de app + Beëindig app + Laat debug logs zien + Logs + Laat foutopsporing zien + + Blijvende wachtrij + Wachtrij opslaan en herstellen + Hervat afspelen + Zodra een bedraad of Bluetooth apparaat is aangesloten + Stop bij sluiten + Wanneer je de app afsluit stopt de muziek met afspelen + + Audio + Sla stilte over + Sla stille gedeeltes over tijdens het afspelen + Minimale duur stilte + De minimale tijd dat de muziek stil moet zijn om over te worden geslagen + De speler moet worden herstart om de wijzigingen te bevestigen. + Herstart service + Normalisering + Pas het volume van de audio aan naar een vast niveau + Basisversterking + De doelwaarde voor de normalisering + Extreme versterking gedetecteerd (%s), normalisatie wordt uitgeschakeld voor dit nummer + Bass boost + Versterk lage frequenties om luisterervaring te verbeteren + Bass boost niveau + Niveau (0–1000) van het versterken van lage frequenties; gebruik op eigen risico! + Sponsorblock + Blokkeert gesponsorde/offtopic content d.m.v. de Sponsorblock API + Reverb + Audiofocus + Wanneer dit aanstaat vraagt de app systeembreed audiofocus + Gebruik de equalizer van het systeem + + Filteren… + + Laatst afgespeeld + Trending + Bron voor Snelle keuzes + Snelle keuzes opslaan + Slaat de Snelle keuzes op voor offline toegang + + Lay-out van de speler + Klassiek + Modern (nieuw) + + Piped + Instantie + Klik om te selecteren + Gebruikersnaam + Wachtwoord + Login + Je kan playlists elders bewaren en synchroniseren met ViMusic. Ondersteunt momenteel alleen Piped. + Account toevoegen + Link een Piped account met jouw instantie, gebruikersnaam en wachtwoord + Meer informatie + Geen idee wat Piped is of geen account? Klik hier om doorgestuurd te worden naar de documentatie + Piped sessies + Gebruik andere instantie + API URL van de instantie + Piped sessie succesvol gestart + + Stijl van de zoekbalk + Statisch + Golvend + Kwaliteit van de golvende zoekbalk + Slecht + Laag + Gemiddeld + Hoog + Fantastisch + Subpixel + + Haal van de zwarte lijst af + Voeg toe aan de zwarte lijst + Reset zwarte lijst + Zwarte lijst leeg + + Van tevoren cachen + + Swipe om song te verbergen + Wanneer je een song naar links swipet wordt deze verwijderd uit de database en cache + Bevestig swipen + Als je een song swipet word je eerst om bevestiging gevraagd + Verberg expliciete nummers + Verbergt alle expliciete nummers in de hele app. De speler zal deze nummers ook overslaan terwijl deze instelling aanstaat. + + Versie + Zoek naar updates + Je gebruikt momenteel versie v%1$s + Een onbekende fout heeft zich voorgedaan tijdens het verkrijgen van data bij GitHub + Er is een nieuwe versie beschikbaar + Meer informatie + Je bent up-to-date: er is geen update beschikbaar + + Dynamische miniaturen + Maximale dynamische miniatuurgrootte + De maximale grootte van een miniatuur wanneer een dynamische miniatuur wordt gebruikt + + Uurlijks + Dagelijks + Wekelijks + Check automatisch voor updates + + Autoskip + De speler slaat het huidige nummer over wanneer er zich een error voordoet. + %s overgeslagen vanwege een error + Het huidige nummer is overgeslagen vanwege een error + Small room + Medium room + Large room + Medium hall + Large hall + Plate + + Tabbladen + + + Haal %1$s nummer van de zwarte lijst + Haal %1$s nummers van de zwarte lijst + + + + Verwijder %1$s afspeelgebeurtenis + Verwijder %1$s afspeelgebeurtenissen + + + + %1$d nummer + %1$d nummers + + \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000..faa296b --- /dev/null +++ b/app/src/main/res/values-ru/strings.xml @@ -0,0 +1,430 @@ + + + %1$dмин + %1$dч + %1$dд + Осталось %1$s + %1$s дБ + %1$s кбит/с + %1$s мс + %1$spx + + Больше от %1$s + Топ %1$s + Мой топ %1$s + Просмотр топа %1$s из … + %1$sx + + Последние 24 часа + Последняя неделя + Последний месяц + Последний год + Все время + + Музыка + Плейлисты + Альбомы + Исполнители + Синглы + + Избранное + Оффлайн + История + Обзор + Библиотека + Исследуйте + Локальное + Главное + Настроение + Онлайн + Видео + О приложении + Внешний вид + Цвета + Формы + + Назад + Вперед + Играть + Пауза + Нравится + Перемешать + Синхронизировать + Запустить радио + Играть следующую + Добавить в очередь + + Таймер сна + Таймер сна завершен + До окончания песни + Остановить таймер сна? + Эквалайзер + Установить таймер сна + + Отмена + Готово + Подтвердить + Нет + Стоп + Установить + Скрыть + Переименовать + Удалить + Сбросить + Очистить + Посмотреть все + + вкл + выкл + Выкл + Неизвестно + Сейчас играет + + Введите название плейлиста + Новый плейлист + Добавить в плейлист + + Перейти к альбому + + Смотреть на YouTube + Открыть в YouTube Music + Смотреть плейлист на YouTube + YouTube Music не установлен на вашем устройстве! + + Удалить из очереди + Удалить из плейлиста + + Скрыть из \"Быстрого выбора\" + Вы действительно хотите скрыть эту песню? Ее время воспроизведения и кеш будут очищены.\nЭто действие необратимо. + Вы действительно хотите удалить этот плейлист? + Вы действительно хотите удалить эту сессию Piped? + Другие версии + Этот альбом не имеет альтернативной версии + + Из Википедии + Из Википедии под лицензией Creative Commons Attribution CC-BY-SA 3.0 + « + » + + Настроения и жанры + Новые альбомы + Еще + + Этот исполнитель еще не выпустил альбом + Этот исполнитель еще не выпустил сингл + + Произошла ошибка + Этот локальный музыкальный файл больше не существует + Произошла ошибка сети + Не удалось найти воспроизводимый аудиоформат + Исходный видеофайл этой песни был удален + Эту песню нельзя воспроизвести из-за ограничений на сервере + ID возвращенного видео не совпадает с запрашиваемым + Произошла неизвестная ошибка воспроизведения + Произошла ошибка при привязке вашей учетной записи Piped. Попробуйте еще раз. + Список экземпляров Piped в данный момент недоступен + Произошла ошибка инициализации Bass Boost. Возможно, ваше устройство не поддерживает его. Попробуйте изменить уровень усиления басов или повторите попытку. + Произошла ошибка при предварительном кэшировании. Попробуйте еще раз. + Не удается открыть URL %1$s + + Доступ к медиа отклонен, предоставьте разрешения в настройках вашего устройства. + Открыть настройки + Элементы не найдены + Результаты не найдены. Попробуйте другой запрос или категорию + + Не удалось найти приложение для просмотра интернета + Не удалось найти приложение для настройки эквалайзера + Не удалось найти приложение для создания документов + Не удалось найти настройки оптимизации батареи, добавьте ViMusic в белый список вручную + + Похожие альбомы + Похожие исполнители + Рекомендуемые плейлисты + %s подписчиков + + Введите текст песни + Тексты песен не найдены + Выберите трек с текстом + Синхронизированные тексты для этой песни недоступны + Тексты для этой песни недоступны + Неверные синхронизированные тексты, повторите получение или измените текст и попробуйте снова + Показать несинхронизированные тексты + Показать синхронизированные тексты + Тексты предоставлены lrclib.net и kugou.com + Редактировать тексты + Искать тексты онлайн + Повторно получить тексты + Выбрать текст из lrclib.net + Установить смещение начала текста + Смещение синхронизированных текстов на текущую позицию воспроизведения + + Скорость воспроизведения + Тональность + Скорость и тональность + Зачем ты это делаешь?! + Усиление громкости + Зацикливание очереди + Добавить очередь в плейлист + + ID + Itag + Битрейт + Размер + В кэше + Громкость + + Просмотр альбома + Просмотр плейлиста + + Введите имя + v%1$s от vfsfitvnm + Социальные сети + Контакты + GitHub + Просмотреть исходный код + Сообщить о проблеме + Если вам нужна помощь с ошибкой, вы можете оставить запрос на GitHub (клик для перехода) + Запросить функцию или предложить идею + Вы будете перенаправлены на GitHub + + Цветовая схема + По умолчанию + Динамический + Material You + + Темнота + Нормальная + Чистый черный + AMOLED + + Тема + Светлая + Темная + Системная + + Скругленность миниатюр + Нет + Легкая + Средняя + Сильная + Еще сильнее + Максимальная + + Текст + Шрифт + Использовать системный шрифт + Использовать шрифт, установленный системой + Применить отступы для шрифта + Добавить отступы вокруг текста + Язык + + Экран блокировки + Показывать обложку песни + Использовать обложку играющей песни в качестве обоев экрана блокировки + + Плеер + Кнопка предыдущей песни в свернутом виде + Отображает кнопку предыдущей песни, когда плеер свернут + Проведите горизонтально для закрытия + Закрывает плеер при проведении влево/вправо по свернутому плееру. Полезно для пользователей с включенным режимом работы одной рукой в Android. + Показать кнопку "Нравится" + Показывает кнопку "Нравится" непосредственно в плеере + Проведите для удаления элемента + Проведите влево, чтобы удалить элемент из очереди + Держать экран включенным (тексты) + Держит экран включенным, пока отображаются тексты + Показывать системные панели (тексты) + Отключение этого параметра включает полноэкранный режим на уровне системы + PIP + Автоматически переходит в режим "Картинка в картинке" при переключении в фоновый режим + + Кэш + Когда кэш заканчивается, ресурсы, которые не использовались дольше всего, очищаются + Кэш изображений + Максимальный размер + %1$s использовано + %1$s использовано (%2$s%%) + Кэш песен + Приостановить кэширование + Временно приостанавливает добавление новых песен в кэш + База данных + + Очистка + Приостановить историю воспроизведений + Останавливает использование событий воспроизведения для быстрого выбора + Обратите внимание: это не повлияет на оффлайн-кэширование! + Сбросить быстрый выбор + Быстрый выбор очищен + Приостановить время воспроизведения + Останавливает сохранение времени воспроизведения. Это приостанавливает статистику в плейлисте "Мой топ %1$s"! + + Резервное копирование + Персональные настройки (например, тема) и кэш исключаются. + Экспортировать базу данных на внешнее хранилище + Восстановить + Существующие данные будут перезаписаны.\nViMusic автоматически закроется после восстановления базы данных. + Импортировать базу данных с внешнего хранилища + + Прочее + Android Auto + Включить поддержку Android Auto + Не забудьте включить "Неизвестные источники" в настройках разработчика Android Auto. + История поиска + Приостановить историю поиска + Не сохраняет новые поисковые запросы и не показывает историю + Очистить историю поиска + Удалить %1$s поисковых запросов + История пуста + Встроенные плейлисты + Автосинхронизация плейлистов + Автоматически синхронизирует сохраненные плейлисты с YouTube при их открытии + Длина топ-листа + Ограничивает длину плейлиста "Мой топ %1$s" + + Время работы сервиса + Если применены оптимизации батареи, уведомление о воспроизведении может внезапно исчезнуть, когда музыка на паузе. + Начиная с Android 12, для работы опции "Невозможно остановить" требуется отключить оптимизацию батареи. + Игнорировать оптимизации батареи + Ограничение уже снято + Отключить фоновое ограничение + Невозможность остановить сервис + Должен поддерживать воспроизведение 99.99% времени, если отключение оптимизаций батареи недостаточно + + Нужна помощь? + В большинстве случаев проблема не связана с разработчиком (даже если включен "Невозможно остановить"), что приложение перестает нормально работать в фоне.\nПроверьте, не убивает ли производитель вашего устройства приложения (клик для перехода) + Если вы действительно думаете, что проблема в самом приложении, загляните в раздел "О приложении" + Поиск и устранение неисправностей + Внимание: используйте эти кнопки в последнюю очередь, когда воспроизведение аудио не работает + Перезагрузить внутренности приложения + Завершить приложение + Показать раздел устранения неполадок + + Постоянная очередь + Сохранить и восстановить воспроизводимые песни + Возобновить воспроизведение + Когда подключено проводное или Bluetooth-устройство + Остановить при закрытии + Когда вы закрываете приложение, музыка перестает играть + + Аудио + Пропускать тишину + Пропускает тихие части во время воспроизведения + Минимальная длина тишины + Минимальное время, на протяжении которого аудио должно быть тихим, чтобы его пропустили + Для применения изменений плеер нужно перезапустить! + Перезапустить сервис + Нормализация громкости + Регулирует громкость до фиксированного уровня + Базовое усиление громкости + Целевое усиление для нормализации громкости + Обнаружено экстремальное значение громкости (%s), нормализация для этой песни отключена + Усиление басов + Усиление низких частот для улучшения прослушивания + Уровень усиления басов + Уровень (0–1000) усиления низких частот; используйте на свой страх и риск! + Sponsorblock + Пропускает рекламный контент или контент не в тему используя Sponsorblock API + Реверберация + Фокус аудио + Нужно ли плееру запрашивать системный медиафокус + Открыть встроенный эквалайзер + + Фильтр… + + Последнее воспроизведение + Популярное + Источник быстрого выбора + Кэш данных быстрого выбора + Сохраняет данные быстрого выбора на диск для доступа в оффлайн-режиме + + Макет плеера + Классический + Современный (новый) + + Piped + Экземпляр + Клик для выбора + Имя пользователя + Пароль + Войти + Вы можете размещать плейлисты где угодно и синхронизировать их с ViMusic. В данный момент поддерживается только Piped. + Добавить учетную запись + Привязать учетную запись Piped к вашему экземпляру, имени пользователя и паролю. + Узнать больше + Не знаете, что такое Piped, или у вас нет учетной записи? Нажмите сюда для перехода к документации + Сессии Piped + Использовать пользовательский экземпляр + URL API экземпляра + Сессия Piped успешно создана + + Стиль полосы поиска + Статичная + Волнистая + Качество волнистой полосы поиска + Низкое + Пониженное + Среднее + Высокое + Отличное + Субпиксельное + + Удалить из черного списка + Добавить в черный список + Сбросить черный список + Черный список пуст + + Предварительное кэширование + + Проведите, чтобы скрыть песню + Когда вы проводите по песне влево, она удаляется из базы данных и кэша + Подтвердить скрытие песни + Когда вы проводите для скрытия, вам сначала нужно подтвердить + Скрыть откровенные песни + Скрывает все откровенные песни по всему приложению. Плеер также будет пропускать такие песни при их обнаружении, если эта настройка включена. + + Версия + Проверить обновления + Вы используете версию v%1$s + Произошла ошибка при получении данных с GitHub + Доступна новая версия + Подробнее + Вы используете актуальную версию: обновлений нет + + Динамические миниатюры + Максимальный размер динамической миниатюры + Максимальный размер миниатюры при использовании динамической миниатюры + + Ежечасно + Ежедневно + Еженедельно + Автоматически проверять обновления + + Автопропуск + Пропускает текущую песню при возникновении ошибки. + Пропущено %s из-за ошибки + Пропущена текущая песня из-за ошибки + Маленькая комната + Средняя комната + Большая комната + Средний зал + Большой зал + Пластина + + + Удалить %1$s песню из черного списка + Удалить все %1$s песен из черного списка + Удалить все %1$s песен из черного списка + + + + Удалить %1$s событие воспроизведения + Удалить %1$s событий воспроизведения + Удалить %1$s событий воспроизведения + + + + %1$d песня + %1$d песни + %1$d песен + + diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml new file mode 100644 index 0000000..014e0bf --- /dev/null +++ b/app/src/main/res/values-tr/strings.xml @@ -0,0 +1,407 @@ + + + %1$dm + %1$dh + %1$dd + %1$s kaldı + %1$s dB + %1$s kbps + %1$s ms + %1$spx + + %1$s\'dan daha fazlası + En iyi %1$s + En iyi %1$s\'im + En iyi %1$s\'ı görüntüle… + %1$sx + + Son 24 saat + Geçen hafta + Geçen ay + Geçen yıl + Tüm zamanlar + + Şarkılar + Oynatma listeleri + Albümler + Sanatçılar + Tekler + + Favoriler + Çevrimdışı + Genel Bakış + Kütüphane + Keşfet + Yerel + Hızlı seçimler + Ruh hali + Çevrimiçi + Videolar + Hakkında + Görünüm + Renkler + Şekiller + + Geri atla + İleri atla + Oynat + Duraklat + Beğen + Karıştır + Eşitle + Radyoyu başlat + Sonrakini oyna + Kuyruğa Al + + Uyku zamanlayıcısı + Uyku zamanlayıcısı sona erdi + Şarkı bitene kadar + Uyku zamanlayıcısını durdurmak istiyor musunuz? + Dengeleyici + Uyku zamanlayıcısını ayarlayın + + İptal + Bitti + Onayla + Hayır + Durdur + Set + Gizle + Yeniden adlandır + Sil + Sıfırla + Temizle + Tümünü görüntüle + + açık + kapalı + Kapalı + Bilinmiyor + Şimdi oynatılıyor + + Oynatma listesi adını girin + Yeni oynatma listesi + Oynatma listesine ekle + + Albüme git + + YouTube\'da izle + YouTube Müzik\'te aç + Oynatma listesini YouTube\'da izle + YouTube Müzik cihazınızda yüklü değil! + + Kuyruktan kaldır + Oynatma listesinden kaldır + + \"Hızlı seçimler\" den gizle + Bu şarkıyı gerçekten gizlemek istiyor musun? Oynatma süresi ve önbelleği silinecek.\nBu eylem geri alınamaz. + Bu oynatma listesini gerçekten silmek istiyor musunuz? + + Diğer sürümler + Bu albümün alternatif bir sürümü yok + + Wikipedia\'dan + Wikipedia\'dan Creative Commons Attribution CC-BY-SA 3.0 altında alınmıştır + + + + Ruh halleri ve türleri + Yeni çıkan albümler + + Bu sanatçı henüz bir albüm çıkarmadı + Bu sanatçı henüz bir single yayımlamadı + + Bir hata oluştu + Bu yerel müzik dosyası artık mevcut değil + Bir ağ hatası oluştu + Oynatılabilir bir ses formatı bulunamadı + Bu şarkının orijinal video kaynağı silindi + Sunucu kısıtlamaları nedeniyle bu şarkı çalınamıyor + Döndürülen video kimliği istenenle eşleşmiyor + Bilinmeyen bir oynatma hatası oluştu + Piped hesabınız bağlanırken bilinmeyen bir hata oluştu. Lütfen tekrar deneyin. + Piped örnek listesi şu anda kullanılamıyor + Bass Boost başlatılırken bir hata oluştu. Muhtemelen cihazınız desteklemiyordur. Bas yükseltme seviyesini değiştirmeyi deneyin veya tekrar deneyin. + Önbelleğe alma sırasında bilinmeyen bir hata oluştu. Lütfen tekrar deneyin. + %1$s bağlantısı açılamıyor + + İzin reddedildi, lütfen cihazınızın ayarlarında medya izinleri verin. + Ayarları açın + Öğe bulunamadı + Sonuç bulunamadı. Lütfen farklı bir sorgu veya kategori deneyin + + İnternette gezinmek için bir uygulama bulunamadı + Sesi eşitleyecek bir uygulama bulunamadı + Belge oluşturmak için bir uygulama bulunamadı + Pil optimizasyon ayarları bulunamadı, lütfen ViMusic\'u manuel olarak beyaz listeye alın + + İlgili albümler + Benzer sanatçılar + Beğenebileceğiniz oynatma listeleri + %s abone + + Şarkı sözlerini girin + Şarkı sözü izi bulunamadı + Şarkı sözü parçası seç + Bu şarkı için senkronize şarkı sözleri mevcut değil + Bu şarkı için şarkı sözleri mevcut değil + Geçersiz senkronize şarkı sözleri, şarkı sözlerini yeniden getir veya düzenle ve tekrar dene + Senkronize edilmemiş şarkı sözlerini göster + Senkronize şarkı sözlerini göster + Irclib.net & kugou.com tarafından sağlanmıştır + Şarkı sözlerini düzenle + Şarkı sözlerini internette ara + Şarkı sözlerini tekrar getir + Şarkı sözlerini lrclib.net\'ten seç + Başlangıç ofsetini ayarla + Senkronize edilmiş şarkı sözlerini mevcut çalma süresine göre dengeler + + Hız + Eğim + Hız ve perde + Bunu neden yaptın?! + Ses artışı + Kuyruk döngüsü + Oynatma listesine kuyruk ekle + + Id + Itag + Bit hızı + Boyut + Önbelleğe Alındı + Ses yüksekliği + + Albümü görüntüle + Oynatma listesini görüntüle + + Bir ad girin + v%1$s by vfsfitvnm + Sosyal + İletişim + GitHub + Kaynak kodunu görüntüle + Sorun bildir + Bir hatayla ilgili yardıma ihtiyacınız varsa GitHub\'da bir sorun bildirebilirsiniz (yönlendirmek için tıklayın) + Bir özellik istey veya bir fikir öner + GitHub\'a yönlendirileceksiniz + + Vurgu rengi kaynağı + Varsayılan + Dinamik + Material You + + Karanlık + Normal + Saf Siyah + AMOLED + + Mod + Açık + Koyu + Sistem + + Küçük resim yuvarlaklığı + Yok + Açık + Orta + Ağır + Daha da ağır + En ağır + + Metin + Yazı tipi + Sistem yazı tipini kullan + Sistem tarafından uygulanan yazı tipini kullanın + Yazı tipi dolgusu uygula + Metinlerin etrafına boşluk ekleme + Dil + + Ekranı kilitle + Şarkı kapağını göster + Çalan şarkının kapağını kilit ekranı duvar kağıdı olarak kullanın + + Oyuncu + Daraltılmış durumdayken önceki düğme + Oynatıcı daraltılmış durumdayken önceki şarkı düğmesini gösterir + Kapatmak için yatay olarak kaydırın + Daraltılmış oyuncu üzerinde sola/sağa kaydırırken oyuncuyu kapatır. Android\'in tek elle modu etkin olan kullanıcılar için kullanışlıdır. + Beğen düğmesini göster + Beğen düğmesini doğrudan oynatıcıda göster + Ürünü kaldırmak için kaydırın + Kuyruktan bir öğeyi kaldırmak için sola kaydırın + + Önbellek + Önbellekte yer kalmadığında, en uzun süre erişilemeyen kaynaklar temizlenir + Görüntü önbelleği + Maksimum boyut + %1$s kullanıldı + %1$s kullanıldı (%2$s%%) + Şarkı önbelleği + Veritabanı + + Temizleme + Oynatma geçmişini duraklat + Hızlı seçimler için kullanılan oynatma etkinliklerini durdurur + Lütfen unutmayın: Bu, çevrimdışı önbelleğe almayı etkilemez! + Hızlı seçimleri sıfırla + Hızlı seçimler temizlendi + Oynatma süresini duraklat + Oynatma süresinin kaydedilmesini durdurur. Bu, \'En İyi %1$s\' oyna listesindeki istatistikleri duraklatır! + + Yedekle + Kişisel tercihler (yani tema modu) ve önbellek hariç tutulur. + Veritabanını harici depolamaya aktar + Geri Yükle + Mevcut verilerin üzerine yazılacak.\nViMusic, veritabanını geri yükledikten sonra otomatik olarak kendini kapatacak. + Veritabanını harici depolama alanından içe aktar + + Diğer + Android Auto + Android Auto desteğini etkinleştir + Android Auto Geliştirici Ayarlarında \"Bilinmeyen kaynaklar\" seçeneğini etkinleştirmeyi unutmayın. + Arama geçmişi + Arama geçmişini duraklat + Ne yeni aranan sorguları kaydet ne de geçmişi göster + Arama geçmişini temizle + %1$s arama sorgusunu sil + Geçmiş boş + Yerleşik oynatma listeleri + Üst liste uzunluğu + \'En iyi x\' oynatma listesinin uzunluğunu sınırlar + + Hizmet ömrü + Pil optimizasyonları uygulanırsa, duraklatıldığında oynatma bildirimi aniden kaybolabilir. + Android 12\'den bu yana, yenilmez hizmet seçeneğinin kullanılabilmesi için pil optimizasyonlarının devre dışı bırakılması gerekiyor. + Pil optimizasyonlarını yoksay + Kısıtlama zaten kaldırıldı + Arka plan kısıtlamalarını devre dışı bırak + Yenilmez hizmet + Pil optimizasyonlarını kapatmanın yeterli olmaması durumunda oynatmayı % 99,99 oranında devam ettirmelidir + + Yardıma mı ihtiyacınız var? + Çoğu zaman, uygulamanın arka planda düzgün çalışmaması geliştiricinin hatası değildir (yenilmez hizmeti açtıktan sonra bile).\nCihaz üreticinizin uygulamalarınızı öldürüp öldürmediğini kontrol edin (yönlendirmek için tıklayın) + Uygulamanın kendisinde gerçekten bir sorun olduğunu düşünüyorsanız Hakkında sekmesine gidin + Sorun giderme + Dikkat: ses ıoynatma başarısız olduğunda bu düğmeleri son çare olarak kullanın + Uygulama dahili numaralarını yeniden yükle + Uygulamayı sonlandır + Sorun giderme bölümünü göster + + Kalıcı kuyruk + Çalan şarkıları kaydet ve geri yükle + Oynatmaya devam et + Kablolu veya Bluetooth cihazı bağlandığında + Kapalıyken durdur + Uygulamayı kapattığınızda müzik oynatmayı durdurur + + Ses + Sessizliği atla + Oynatma sırasında sessiz parçaları atla + Minimum sessizlik uzunluğu + Sesin atlanması için sessiz olması gereken minimum süre + Değişikliklerin etkili olması için oyuncu yeniden başlatılmalıdır! + Hizmeti yeniden başlat + Ses yüksekliği normalleştirme + Sesi sabit bir seviyeye ayarlayın + Gürültü taban kazancı + Yükseklik normalizasyonu için \'hedef\' kazancı + Aşırı ses yüksekliği değeri algılandı (%s), bu şarkı için normalleştirme kapatılıyor + Bas artışı + Dinleme deneyimini iyileştirmek için düşük frekansları artırın + Bas artışı seviyesi + Düşük frekansları artırma seviyesi (0 -1000); kendi riski altında kullanın! + Sistem ekolayzırı ile etkileşim + + Filtrele… + + Son Oynanan + Popüler + Hızlı Seçim Kaynağı + Hızlı seçim verilerini önbelleğe al + Çevrimdışı erişim için hızlı seçim verilerini diske kaydeder + + Oyuncu düzeni + Klasik + Modern (yeni) + + Piped + Örnek + Seçmek için tıklayın + Kullanıcı adı + Parola + Giriş + Oynatma listelerini başka bir yerde barındırabilir ve bunları ViMusic ile senkronize edebilirsiniz. Şu anda yalnızca Piped\'ı destekliyor. + Hesap ekle + Piped hesabınızı oturumunuz, kullanıcı adınız ve şifrenizle bağlayın. + Daha fazla bilgi edinin + Piped\'ın ne olduğunu bilmiyor musunuz veya bir hesabınız yok mu? Dokümanlarına yönlendirilmek için buraya tıklayın + Piped oturumları + Özel Örnek Kullan + Örnek API Bağlantısı + + Bar tarzı ara + Statik + Dalgalı + Dalgalı arama barı kalitesi + Zayıf + Düşük + Orta + Yüksek + Harika + Altpiksel + + Kara listeden kaldır + Kara listeye ekle + Kara listeyi sıfırla + Kara liste boş + + Önbellek + + Şarkıyı gizlemek için kaydır + Bir şarkıyı sola kaydırdığınızda, veritabanından ve önbellekten kaldırılır + Şarkıyı gizlemek için kaydırmayı onayla + Gizlemek için kaydırdığınızda önce onaylamanız gerekir + + Sürüm + Güncellemeleri kontrol edin + Şu anda v%1$s sürümünü kullanıyorsunuz + GitHub\'dan veri alınırken bilinmeyen bir hata oluştu + Yeni bir sürüm mevcut + Daha fazla bilgi + Şu anda güncelsiniz: güncelleme yok + + Dinamik küçük resimler + Maksimum dinamik küçük resim boyutu + Dinamik bir küçük resim kullanıldığında küçük resmin maksimum boyutu + + Saatlik + Günlük + Haftalık + Güncellemeleri otomatik olarak kontrol et + + Otomatik atla + Bir hata olduğunda mevcut şarkıyı atlar. + Bir hata nedeniyle %s atlandı + Bir hata nedeniyle mevcut şarkı atlandı + Small room + Medium room + Large room + Medium hall + Large hall + Plate + + + %1$s şarkıyı kara listeden kaldır + Tüm %1$s şarkıyı kara listeden kaldır + + + + %1$s oynatma olayını sil + %1$s oynatma olayını sil + + + + %1$d şarkı + %1$d şarkı + + diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml new file mode 100644 index 0000000..486c583 --- /dev/null +++ b/app/src/main/res/values-zh/strings.xml @@ -0,0 +1,427 @@ + + + %1$dm + %1$dh + %1$dd + 剩余 %1$s + %1$s dB + %1$s kbps + %1$s ms + %1$spx + + 更多来自 %1$s + 热门 %1$s + 我的热门 %1$s + 查看热门 %1$s… + %1$s 倍 + + 过去24小时 + 过去一周 + 过去一月 + 过去一年 + 所有时间 + + 歌曲 + 播放列表 + 专辑 + 艺术家 + 单曲 + + 收藏 + 离线 + 历史 + 概览 + + 发现 + 本地 + 快速选择 + 心情 + 在线 + 视频 + 关于 + 外观 + 颜色 + 形状 + + 后退 + 前进 + 播放 + 暂停 + 喜欢 + 随机播放 + 同步 + 播放电台 + 下一首播放 + 加入队列 + + 睡眠定时器 + 睡眠定时器结束 + 直到歌曲结束 + 您要停止睡眠定时器吗? + 均衡器 + 设置睡眠定时器 + + 取消 + 完成 + 确认 + + 停止 + 设置 + 隐藏 + 重命名 + 删除 + 重置 + 清除 + 查看全部 + + + + 关闭 + 未知 + 正在播放 + + 输入播放列表名称 + 新建播放列表 + 添加到播放列表 + + 前往专辑 + + 在 YouTube 上观看 + 在 YouTube Music 中打开 + 在 YouTube 上观看播放列表 + 您的设备上未安装 YouTube Music! + + 从队列中移除 + 从播放列表中移除 + + 从“快速选择”中隐藏 + 您确定要隐藏这首歌吗?它的播放时间和缓存将被清除。\n此操作不可逆。 + 您确定要删除此播放列表吗? + 您确定要删除此 Piped 会话吗? + + 其他版本 + 此专辑没有其他版本 + + 来自维基百科 + 来自维基百科,遵循知识共享署名 CC-BY-SA 3.0 许可协议 + + + + 心情和类型 + 新发布的专辑 + 更多 + + 此艺术家尚未发布专辑 + 此艺术家尚未发布单曲 + + 发生错误 + 此本地音乐文件不再存在 + 发生网络错误 + 找不到可播放的音频格式 + 此歌曲的原始视频源已被删除 + 由于服务器限制,此歌曲无法播放 + 返回的视频 ID 与请求的 ID 不匹配 + 发生未知播放错误 + 链接您的 Piped 帐户时发生未知错误。请再试一次。 + 当前不可用 Piped 实例列表 + 初始化低音增强时发生错误。可能您的设备不支持。尝试更改低音增强级别或再试一次。 + 预缓存时发生未知错误。请再试一次。 + 无法打开网址 %1$s + + 权限被拒绝,请在设备的设置中授予媒体权限。 + 打开设置 + 未找到项目 + 未找到结果。请尝试不同的查询或类别 + + 找不到浏览互联网的应用程序 + 找不到均衡音频的应用程序 + 找不到创建文档的应用程序 + 找不到电池优化设置,请手动将 ViMusic 加入白名单 + + 相关专辑 + 相似艺术家 + 您可能喜欢的播放列表 + %s 订阅者 + + 输入歌词 + 未找到歌词轨迹 + 选择歌词轨迹 + 此歌曲无可用的同步歌词 + 此歌曲无可用的歌词 + 同步歌词无效,请重新获取或编辑歌词后再试 + 显示非同步歌词 + 显示同步歌词 + 由 lrclib.net & kugou.com 提供 + 编辑歌词 + 在线搜索歌词 + 重新获取歌词 + 从lrclib.net选择歌词 + 设置起始偏移量 + 将同步歌词偏移至当前播放时间 + + 播放速度 + 音调 + 速度和音调 + 你为什么要这样做?! + 音量增强 + 队列循环 + 将队列添加到播放列表 + + 编号 + 标签 + 比特率 + 大小 + 已缓存 + 响度 + + 查看专辑 + 查看播放列表 + + 输入名称 + v%1$s 由 vfsfitvnm 制作 + 社交 + 联系 + GitHub + 查看源代码 + 报告问题 + 如果你需要帮助解决一个错误,你可以在 GitHub 上提交问题 (点击跳转) + 请求功能或提出建议 + 你将被重定向到 GitHub + + 强调色来源 + 默认 + 动态 + Material You + + 暗度 + 正常 + 纯黑 + AMOLED + + 模式 + 浅色 + 深色 + 系统 + + 缩略图圆角 + + 轻微 + 中等 + 较重 + 更重 + 最重 + + 文本 + 字体 + 使用系统字体 + 使用系统应用的字体 + 应用字体内边距 + 在文本周围添加间距 + 语言 + + 锁屏 + 显示歌曲封面 + 使用播放中的歌曲封面作为锁屏壁纸 + + 播放器 + 折叠时显示上一曲按钮 + 播放器折叠时显示上一曲按钮 + 横向滑动关闭 + 在折叠播放器上左右滑动可关闭播放器。对启用安卓单手模式的用户很有用 + 显示喜欢按钮 + 在播放器中直接显示喜欢按钮 + 滑动移除项目 + 向左滑动以从队列中移除项目 + 歌词时保持屏幕唤醒 + 歌词显示时保持屏幕唤醒 + 显示系统栏(歌词) + 关闭此选项可启用系统级全屏 + PIP + 切换到后台时自动切换到画中画模式 + + 缓存 + 当缓存空间耗尽时,最久未被访问的资源将被清除 + 图片缓存 + 最大尺寸 + %1$s 已使用 + %1$s 已使用 (%2$s%%) + 歌曲缓存 + 停止缓存 + 临时停止添加新的歌曲到缓存 + 数据库 + + 清理 + 暂停播放历史 + 停止使用播放事件进行快速选择 + 请注意:这不会影响离线缓存! + 重置快速选择 + 快速选择已清空 + 暂停播放时间 + 停止保存播放时间。这将暂停 “我的热门 %1$s” 播放列表中的统计数据! + + 备份 + 个人偏好(如主题模式)和缓存将被排除 + 将数据库导出到外部存储 + 恢复 + 现有数据将被覆盖。\n恢复数据库后,ViMusic 将自动关闭 + 从外部存储导入数据库 + + 其他 + Android Auto + 启用 Android Auto 支持 + 请记得在 Android Auto 的开发者设置中启用 “未知来源”。 + 搜索历史 + 暂停搜索历史 + 不保存新的搜索查询,也不显示历史记录 + 清除搜索历史 + 删除 %1$s 个搜索查询 + 历史记录为空 + 内置播放列表 + 自动同步播放列表 + 打开时自动同步保存的YouTube播放列表 + 热门列表长度 + 限制 “我的热门 x” 播放列表的长度 + + 服务时长 + 如果应用了电池优化,暂停时播放通知可能会突然消失。 + 从 Android 12 开始,需要禁用电池优化才能启用不可杀服务选项。 + 忽略电池优化 + 限制已解除 + 禁用后台限制 + 不可杀服务 + 在禁用电池优化仍不够的情况下,应保证 99.99% 的时间内播放不中断 + + 需要帮助? + 大多数情况下,即使启用了不可杀服务,应用在后台停止工作也不是开发者的错。\n检查你的设备制造商是否杀死了你的应用(点击跳转) + 如果你真的认为应用本身有问题,请前往 “关于” 标签 + 故障排除 + 警告:当音频播放失败时,请最后尝试使用这些按钮 + 重新加载应用内部 + 终止应用 + 查看故障日志 + 日志 + 显示故障排除部分 + + 持久队列 + 保存并恢复播放的歌曲 + 恢复播放 + 当连接有线或蓝牙设备时 + 关闭时停止播放 + 当你关闭应用时,音乐停止播放 + + 音频 + 跳过静音 + 在播放时跳过静音部分 + 最短静音长度 + 要跳过的音频必须达到的最短静音时间 + 需要重新启动播放器才能使更改生效! + 重新启动服务 + 响度标准化 + 将音量调整到固定水平 + 响度基础增益 + 响度标准化的目标增益 + 检测到极端响度值 (%s),将关闭此歌曲的响度标准化 + 低音增强 + 增强低频以提升听觉体验 + 低音增强级别 + 增强低频的级别 (0–1000),使用需谨慎! + 屏蔽赞助商 + 使用 Sponsorblock API 的数据来屏蔽赞助/无关内容 + 混响 + 音频焦点 + 播放器是否应请求系统范围的音频(媒体)焦点 + 与系统均衡器进行交互 + + 过滤器… + + 最近播放 + 趋势 + 快速选择源 + 缓存快速选择数据 + 将快速选择数据保存到磁盘,以便离线访问 + + 播放器布局 + 经典 + 现代 (新) + + Piped + 实例 + 点击选择 + 用户名 + 密码 + 登录 + 您可以在其他地方托管播放列表,并与 ViMusic 同步。目前仅支持 Piped。 + 添加账户 + 链接一个 Piped 账户,输入您的实例、用户名和密码。 + 了解更多 + 不了解 Piped 是什么或者没有账户?点击这里查看相关文档。 + Piped 会话 + 使用自定义实例 + 实例 API URL + Piped 会话创建成功 + + 进度条样式 + 静态 + 波形 + 波形进度条质量 + + 一般 + 中等 + + 极高 + 亚像素 + + 从黑名单中移除 + 添加到黑名单 + 重置黑名单 + 黑名单为空 + + 预缓存 + + 滑动隐藏歌曲 + 当你向左滑动一首歌曲时,它将从数据库和缓存中移除 + 确认滑动隐藏歌曲 + 在执行隐藏操作前,需要先确认 + 隐藏带有脏话的歌曲 + 在整个应用程序中隐藏所有带有脏话的歌曲。当启用此设置时,播放器还会跳过播放时遇到的带有脏话的歌曲。 + + 版本 + 检查更新 + 当前运行版本为 v%1$s + 从 GitHub 获取数据时发生未知错误 + 有新版本可用 + 更多信息 + 当前已是最新版本,无需更新 + + 动态缩略图 + 最大动态缩略图大小 + 使用动态缩略图时的最大尺寸 + + 每小时 + 每天 + 每周 + 自动检查更新 + + 自动跳过 + 当发生错误时跳过当前歌曲 + 由于错误,已跳过 %s + 由于错误,已跳过当前歌曲 + 小房间 + 中等房间 + 大房间 + 中等大厅 + 大厅 + 板式混响 + + + 从黑名单中移除所有 %1$s 首歌曲 + + + + 删除 %1$s 次播放事件 + + + + %1$d 首歌曲 + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index cc7e329..57fad31 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,5 +1,4 @@ - #4046bf - #ffffff + #08041D diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..1829038 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,433 @@ + + + %1$dm + %1$dh + %1$dd + %1$s left + %1$s dB + %1$s kbps + %1$s ms + %1$spx + + More from %1$s + Top %1$s + My top %1$s + View top %1$s of … + %1$sx + + Past 24 hours + Past week + Past month + Past year + All time + + Songs + Playlists + Albums + Artists + Singles + + Favorites + Offline + History + Overview + Library + Discover + Local + Quick picks + Mood + Online + Videos + About + Appearance + Colors + Shapes + + Skip back + Skip forward + Play + Pause + Like + Shuffle + Sync + Start radio + Play next + Enqueue + + Sleep timer + Sleep timer ended + Until song ends + Do you want to stop the sleep timer? + Equalizer + Set sleep timer + + Cancel + Done + Confirm + No + Stop + Set + Hide + Rename + Delete + Reset + Clear + View all + + on + off + Off + Unknown + Now playing + + Enter the playlist name + New playlist + Add to playlist + + Go to album + + Watch on YouTube + Open in YouTube Music + Watch playlist on YouTube + YouTube Music is not installed on your device! + + Remove from queue + Remove from playlist + + Hide from \"Quick picks\" + Do you really want to hide this song? Its playback time and cache will be wiped.\nThis action is irreversible. + Do you really want to delete this playlist? + Do you really want to delete this Piped session? + + Other versions + This album doesn\'t have any alternative version + + From Wikipedia + From Wikipedia under Creative Commons Attribution CC-BY-SA 3.0 + + + + Moods and genres + New released albums + More + + This artist hasn\'t released an album yet + This artist hasn\'t released a single yet + + An error has occurred + This local music file does not exist anymore + A network error has occurred + Couldn\'t find a playable audio format + The original video source of this song has been deleted + This song cannot be played due to server restrictions + The returned video ID doesn\'t match the requested one + An unknown playback error has occurred + There was an unknown error linking your Piped account. Please try again. + Piped instance list currently unavailable + There was an error initializing Bass Boost. Probably your device doesn\'t support it. Try changing the bass boost level or try again. + An unknown error occurred during pre-caching. Please try again. + Can\'t open url %1$s + + Permission declined, please grant media permissions in the settings of your device. + Open settings + No items found + No results found. Please try a different query or category + + Couldn\'t find an application to browse the internet + Couldn\'t find an application to equalize audio + Couldn\'t find an application to create documents + Couldn\'t find battery optimization settings, please whitelist ViMusic manually + + Related albums + Similar artists + Playlists you might like + %s subscribers + + Enter the lyrics + No lyric tracks could be found + Choose lyric track + Synchronized lyrics are not available for this song + Lyrics are not available for this song + Invalid synchronized lyrics, refetch or edit the lyrics and try again + Show unsynchronized lyrics + Show synchronized lyrics + Provided by lrclib.net & kugou.com + Edit lyrics + Search lyrics online + Fetch lyrics again + Pick lyrics from lrclib.net + Set start offset + Offsets the synchronized lyrics by the current playback time + + Speed + Pitch + Speed & pitch + Why would you do this?! + Volume boost + Queue loop + Add queue to playlist + + Id + Itag + Bitrate + Size + Cached + Loudness + Reload + + View album + View playlist + + Enter a name + v%1$s by vfsfitvnm + Social + Contact + GitHub + View the source code + Report an issue + If you need help with a bug you can file an issue on GitHub (click to redirect) + Request a feature or suggest an idea + You will be redirected to GitHub + + Accent color source + Default + Dynamic + Material You + + Darkness + Normal + PureBlack + AMOLED + + Mode + Light + Dark + System + + Thumbnail roundness + None + Light + Medium + Heavy + Even heavier + Heaviest + + Text + Font + Use system font + Use the font applied by the system + Apply font padding + Add spacing around texts + Language + + Lock screen + Show song cover + Use the playing song cover as the lockscreen wallpaper + + Player + Previous button while collapsed + Shows the previous song button while the player is collapsed + Swipe horizontally to close + Closes the player when swiping left/right on the collapsed player. Useful for users with Android\'s one-handed mode enabled. + Show like button + Show the like button directly in the player + Swipe to remove item + Swipe left to remove an item from the queue + Keep screen awake (lyrics) + Keeps the screen awake while the lyrics are showing + Show system bars (lyrics) + Turning this off enables system-wide full-screen + PIP + Automatically switches to Picture-In-Picure mode when switching to the background + + Cache + When the cache runs out of space, the resources that haven\'t been accessed for the longest time are cleared + Image cache + Max size + %1$s used + %1$s used (%2$s%%) + Song cache + Pause cache + Temporarily pauses new songs being added to cache + Database + + Cleanup + Pause playback history + Stops playback events being used for quick picks + Please note: this won\'t affect offline caching! + Reset quick picks + Quick picks are cleared + Pause playback time + Stops playback time from being saved. This pauses the statistics in the \'My Top %1$s\' playlist! + + Backup + Personal preferences (i.e. the theme mode) and the cache are excluded. + Export the database to the external storage + Restore + Existing data will be overwritten.\nViMusic will automatically close itself after restoring the database. + Import the database from the external storage + + Other + Android Auto + Enable Android Auto support + Remember to enable \"Unknown sources\" in the Developer Settings of Android Auto. + Search history + Pause search history + Neither save new searched queries nor show history + Clear search history + Delete %1$s search queries + History is empty + Built-in playlists + Auto-sync playlists + Automatically syncs saved playlists from YouTube when you open them + Top list length + Limits the length of the \'My top x\' playlist + + Service lifetime + If battery optimizations are applied, the playback notification can suddenly disappear when paused. + Since Android 12, disabling battery optimizations is required for the invincible service option to be available. + Ignore battery optimizations + Restriction already lifted + Disable background restrictions + Invincible service + Should keep the playback going 99.99% of the time, in case turning off the battery optimizations is not enough + + Need help? + Most of the time, it is not the developer\'s fault (even after turning on invincible service) that the app stops working properly in the background.\nCheck if your device manufacturer kills your apps (click to redirect) + If you really think there is something wrong with the app itself, hop on to the About tab + Troubleshooting + Caution: use these buttons as a last resort when audio playback fails + Reload app internals + Kill app + View debug logs + Logs + Show troubleshoot section + + Persistent queue + Save and restore playing songs + Resume playback + When a wired or Bluetooth device is connected + Stop when closed + When you close the app, the music stops playing + + Audio + Skip silence + Skip silent parts during playback + Minimum silence length + The minimum time the audio has to be silent to get skipped + Player has to be restarted for the changes to be effective! + Restart service + Loudness normalization + Adjust the volume to a fixed level + Loudness base gain + The \'target\' gain for the loudness normalization + Extreme loudness value detected (%s), turning off normalization for this song + Bass boost + Boost low frequencies to improve listening experience + Bass boost level + Level (0–1000) of boosting low frequencies; use at own risk! + Sponsorblock + Blocks sponsored/offtopic content using data from the Sponsorblock API + Reverb + Audio focus + Whether the player should request system-wide audio (media) focus + Interact with the system equalizer + + Filter… + + Last Played + Trending + Quick Picks Source + Cache quick picks data + Saves the quick picks data to disk for offline access + + Player layout + Classic + Modern (new) + + Piped + Instance + Click to select + Username + Password + Login + You can host playlists elsewhere and synchronize them with ViMusic. Currently only supports Piped. + Add account + Link a Piped account with your instance, username and password. + Learn more + Don\'t know what Piped is or don\'t have an account? Click here to get redirected to their docs + Piped sessions + Use Custom Instance + Instance API URL + Piped session created successfully + + Seek bar style + Static + Wavy + Wavy seek bar quality + Poor + Low + Medium + High + Great + Subpixel + + Remove from blacklist + Add to blacklist + Reset blacklist + Blacklist empty + + Pre cache + + Swipe to hide song + When you swipe a song to the left, it gets removed from the database and cache + Confirm swipe to hide song + When you swipe to hide, you first have to confirm + Hide explicit songs + Hides all explicit songs, throughout the entire app. The player will also skip explicit songs when it encounters one and this setting is enabled. + + Version + Check for updates + You\'re currently running version v%1$s + An unknown error occurred while fetching data from GitHub + A new version is available + More information + You\'re currently up-to-date: no updates available + + Dynamic thumbnails + Max dynamic thumbnail size + The maximum size of a thumbnail when a dynamic thumbnail is used + + Hourly + Daily + Weekly + Automatically check for updates + + Autoskip + Skips the current song when there is an error. + Skipped %s because of an error + Skipped the current song because of an error + Small room + Medium room + Large room + Medium hall + Large hall + Plate + + Tabs + + + Remove %1$s song from the blacklist + Remove all %1$s songs from the blacklist + + + + Delete %1$s playback event + Delete %1$s playback events + + + + %1$d song + %1$d songs + + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 2035917..224f917 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,4 +1,4 @@ - + diff --git a/app/src/main/res/xml/allowed_media_browser_callers.xml b/app/src/main/res/xml/allowed_media_browser_callers.xml new file mode 100644 index 0000000..4b35234 --- /dev/null +++ b/app/src/main/res/xml/allowed_media_browser_callers.xml @@ -0,0 +1,27 @@ + + + + 19:75:b2:f1:71:77:bc:89:a5:df:f3:1f:9e:64:a6:ca:e2:81:a5:3d:c1:d1:d5:9b:1d:14:7f:e1:c8:2a:fa:00 + 70:81:1a:3e:ac:fd:2e:83:e1:8d:a9:bf:ed:e5:2d:f1:6c:e9:1f:2e:69:a4:4d:21:f1:8a:b6:69:91:13:07:71 + fd:b0:0c:43:db:de:8b:51:cb:31:2a:a8:1d:3b:5f:a1:77:13:ad:b9:4b:28:f5:98:d7:7f:8e:b8:9d:ac:ee:df + + + + 69:d0:72:16:9a:2c:6b:2f:5a:cc:59:0c:e4:33:a1:1a:c3:df:55:1a:df:ee:5d:5f:63:c0:83:b7:22:76:2e:19 + 85:cd:59:73:54:1b:e6:f4:77:d8:47:a0:bc:c6:aa:25:27:68:4b:81:9c:d5:96:85:29:66:4c:b0:71:57:b6:fe + + + + 19:75:b2:f1:71:77:bc:89:a5:df:f3:1f:9e:64:a6:ca:e2:81:a5:3d:c1:d1:d5:9b:1d:14:7f:e1:c8:2a:fa:00 + + + + 19:75:b2:f1:71:77:bc:89:a5:df:f3:1f:9e:64:a6:ca:e2:81:a5:3d:c1:d1:d5:9b:1d:14:7f:e1:c8:2a:fa:00 + f0:fd:6c:5b:41:0f:25:cb:25:c3:b5:33:46:c8:97:2f:ae:30:f8:ee:74:11:df:91:04:80:ad:6b:2d:60:db:83 + + + + 17:E2:81:11:06:2F:97:A8:60:79:7A:83:70:5B:F8:2C:7C:C0:29:35:56:6D:46:22:BC:4E:CF:EE:1B:EB:F8:15 + 74:B6:FB:F7:10:E8:D9:0D:44:D3:40:12:58:89:B4:23:06:A6:2C:43:79:D0:E5:A6:62:20:E3:A6:8A:BF:90:E2 + + \ No newline at end of file diff --git a/app/vendor/KetchumSDK_Community_20240307.jar b/app/vendor/KetchumSDK_Community_20240307.jar new file mode 100644 index 0000000000000000000000000000000000000000..e8d6c995c323f4b77f17df5fd7b89be6e0c5aad8 GIT binary patch literal 25437 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.configureEach { - kotlinOptions { - if (project.findProperty("enableComposeCompilerReports") == "true") { - arrayOf("reports", "metrics").forEach { - freeCompilerArgs = freeCompilerArgs + listOf( - "-P", "plugin:androidx.compose.compiler.plugins.kotlin:${it}Destination=${project.buildDir.absolutePath}/compose_metrics" - ) - } - } - } - } -} diff --git a/compose-persist/.gitignore b/compose-persist/.gitignore deleted file mode 100644 index 42afabf..0000000 --- a/compose-persist/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/compose-persist/build.gradle.kts b/compose-persist/build.gradle.kts deleted file mode 100644 index ac8ccf6..0000000 --- a/compose-persist/build.gradle.kts +++ /dev/null @@ -1,46 +0,0 @@ -plugins { - id("com.android.library") - kotlin("android") -} - -android { - namespace = "it.vfsfitvnm.compose.persist" - compileSdk = 33 - - defaultConfig { - minSdk = 21 - targetSdk = 33 - } - - buildTypes { - release { - isMinifyEnabled = true - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) - } - } - - sourceSets.all { - kotlin.srcDir("src/$name/kotlin") - } - - buildFeatures { - compose = true - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() - } - - kotlinOptions { - jvmTarget = "1.8" - } -} - -dependencies { - implementation(libs.compose.foundation) -} diff --git a/compose-persist/src/main/kotlin/it/vfsfitvnm/compose/persist/Persist.kt b/compose-persist/src/main/kotlin/it/vfsfitvnm/compose/persist/Persist.kt deleted file mode 100644 index 65eb114..0000000 --- a/compose-persist/src/main/kotlin/it/vfsfitvnm/compose/persist/Persist.kt +++ /dev/null @@ -1,26 +0,0 @@ -package it.vfsfitvnm.compose.persist - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalContext - -@Suppress("UNCHECKED_CAST") -@Composable -fun persist(tag: String, initialValue: T): MutableState { - val context = LocalContext.current - - return remember { - context.persistMap?.getOrPut(tag) { mutableStateOf(initialValue) } as? MutableState - ?: mutableStateOf(initialValue) - } -} - -@Composable -fun persistList(tag: String): MutableState> = - persist(tag = tag, initialValue = emptyList()) - -@Composable -fun persist(tag: String): MutableState = - persist(tag = tag, initialValue = null) diff --git a/compose-persist/src/main/kotlin/it/vfsfitvnm/compose/persist/PersistMap.kt b/compose-persist/src/main/kotlin/it/vfsfitvnm/compose/persist/PersistMap.kt deleted file mode 100644 index 2cb0b5c..0000000 --- a/compose-persist/src/main/kotlin/it/vfsfitvnm/compose/persist/PersistMap.kt +++ /dev/null @@ -1,3 +0,0 @@ -package it.vfsfitvnm.compose.persist - -typealias PersistMap = HashMap diff --git a/compose-persist/src/main/kotlin/it/vfsfitvnm/compose/persist/PersistMapCleanup.kt b/compose-persist/src/main/kotlin/it/vfsfitvnm/compose/persist/PersistMapCleanup.kt deleted file mode 100644 index c236622..0000000 --- a/compose-persist/src/main/kotlin/it/vfsfitvnm/compose/persist/PersistMapCleanup.kt +++ /dev/null @@ -1,19 +0,0 @@ -package it.vfsfitvnm.compose.persist - -import android.app.Activity -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.ui.platform.LocalContext - -@Composable -fun PersistMapCleanup(tagPrefix: String) { - val context = LocalContext.current - - DisposableEffect(context) { - onDispose { - if (context.findOwner()?.isChangingConfigurations == false) { - context.persistMap?.keys?.removeAll { it.startsWith(tagPrefix) } - } - } - } -} diff --git a/compose-persist/src/main/kotlin/it/vfsfitvnm/compose/persist/PersistMapOwner.kt b/compose-persist/src/main/kotlin/it/vfsfitvnm/compose/persist/PersistMapOwner.kt deleted file mode 100644 index a6bec64..0000000 --- a/compose-persist/src/main/kotlin/it/vfsfitvnm/compose/persist/PersistMapOwner.kt +++ /dev/null @@ -1,5 +0,0 @@ -package it.vfsfitvnm.compose.persist - -interface PersistMapOwner { - val persistMap: PersistMap -} diff --git a/compose-persist/src/main/kotlin/it/vfsfitvnm/compose/persist/Utils.kt b/compose-persist/src/main/kotlin/it/vfsfitvnm/compose/persist/Utils.kt deleted file mode 100644 index b91c4eb..0000000 --- a/compose-persist/src/main/kotlin/it/vfsfitvnm/compose/persist/Utils.kt +++ /dev/null @@ -1,16 +0,0 @@ -package it.vfsfitvnm.compose.persist - -import android.content.Context -import android.content.ContextWrapper - -val Context.persistMap: PersistMap? - get() = findOwner()?.persistMap - -internal inline fun Context.findOwner(): T? { - var context = this - while (context is ContextWrapper) { - if (context is T) return context - context = context.baseContext - } - return null -} diff --git a/compose-reordering/.gitignore b/compose-reordering/.gitignore deleted file mode 100644 index 42afabf..0000000 --- a/compose-reordering/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/compose-reordering/build.gradle.kts b/compose-reordering/build.gradle.kts deleted file mode 100644 index f2ae44b..0000000 --- a/compose-reordering/build.gradle.kts +++ /dev/null @@ -1,47 +0,0 @@ -plugins { - id("com.android.library") - kotlin("android") -} - -android { - namespace = "it.vfsfitvnm.compose.reordering" - compileSdk = 33 - - defaultConfig { - minSdk = 21 - targetSdk = 33 - } - - buildTypes { - release { - isMinifyEnabled = true - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) - } - } - - sourceSets.all { - kotlin.srcDir("src/$name/kotlin") - } - - buildFeatures { - compose = true - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() - } - - kotlinOptions { - freeCompilerArgs += "-Xcontext-receivers" - jvmTarget = "1.8" - } -} - -dependencies { - implementation(libs.compose.foundation) -} diff --git a/compose-reordering/src/main/AndroidManifest.xml b/compose-reordering/src/main/AndroidManifest.xml deleted file mode 100644 index 10728cc..0000000 --- a/compose-reordering/src/main/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/compose-reordering/src/main/kotlin/it/vfsfitvnm/compose/reordering/AnimatablesPool.kt b/compose-reordering/src/main/kotlin/it/vfsfitvnm/compose/reordering/AnimatablesPool.kt deleted file mode 100644 index f0a968c..0000000 --- a/compose-reordering/src/main/kotlin/it/vfsfitvnm/compose/reordering/AnimatablesPool.kt +++ /dev/null @@ -1,38 +0,0 @@ -package it.vfsfitvnm.compose.reordering - -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.AnimationVector -import androidx.compose.animation.core.TwoWayConverter -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock - -class AnimatablesPool( - private val size: Int, - private val initialValue: T, - typeConverter: TwoWayConverter -) { - private val values = MutableList(size) { - Animatable(initialValue = initialValue, typeConverter = typeConverter) - } - - private val mutex = Mutex() - - init { - require(size > 0) - } - - suspend fun acquire(): Animatable? { - return mutex.withLock { - if (values.isNotEmpty()) values.removeFirst() else null - } - } - - suspend fun release(animatable: Animatable) { - mutex.withLock { - if (values.size < size) { - animatable.snapTo(initialValue) - values.add(animatable) - } - } - } -} diff --git a/compose-reordering/src/main/kotlin/it/vfsfitvnm/compose/reordering/AnimateItemPlacement.kt b/compose-reordering/src/main/kotlin/it/vfsfitvnm/compose/reordering/AnimateItemPlacement.kt deleted file mode 100644 index 69a3859..0000000 --- a/compose-reordering/src/main/kotlin/it/vfsfitvnm/compose/reordering/AnimateItemPlacement.kt +++ /dev/null @@ -1,10 +0,0 @@ -package it.vfsfitvnm.compose.reordering - -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.lazy.LazyItemScope -import androidx.compose.ui.Modifier - -context(LazyItemScope) -@ExperimentalFoundationApi -fun Modifier.animateItemPlacement(reorderingState: ReorderingState) = - if (reorderingState.draggingIndex == -1) animateItemPlacement() else this diff --git a/compose-reordering/src/main/kotlin/it/vfsfitvnm/compose/reordering/ReorderingLazyColumn.kt b/compose-reordering/src/main/kotlin/it/vfsfitvnm/compose/reordering/ReorderingLazyColumn.kt deleted file mode 100644 index e970e7e..0000000 --- a/compose-reordering/src/main/kotlin/it/vfsfitvnm/compose/reordering/ReorderingLazyColumn.kt +++ /dev/null @@ -1,40 +0,0 @@ -@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") - -package it.vfsfitvnm.compose.reordering - -import androidx.compose.foundation.gestures.FlingBehavior -import androidx.compose.foundation.gestures.ScrollableDefaults -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp - -@Composable -fun ReorderingLazyColumn( - reorderingState: ReorderingState, - modifier: Modifier = Modifier, - contentPadding: PaddingValues = PaddingValues(0.dp), - reverseLayout: Boolean = false, - verticalArrangement: Arrangement.Vertical = - if (!reverseLayout) Arrangement.Top else Arrangement.Bottom, - horizontalAlignment: Alignment.Horizontal = Alignment.Start, - flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), - userScrollEnabled: Boolean = true, - content: LazyListScope.() -> Unit -) { - ReorderingLazyList( - modifier = modifier, - reorderingState = reorderingState, - contentPadding = contentPadding, - flingBehavior = flingBehavior, - horizontalAlignment = horizontalAlignment, - verticalArrangement = verticalArrangement, - isVertical = true, - reverseLayout = reverseLayout, - userScrollEnabled = userScrollEnabled, - content = content - ) -} diff --git a/compose-reordering/src/main/kotlin/it/vfsfitvnm/compose/reordering/ReorderingLazyList.kt b/compose-reordering/src/main/kotlin/it/vfsfitvnm/compose/reordering/ReorderingLazyList.kt deleted file mode 100644 index 8752a51..0000000 --- a/compose-reordering/src/main/kotlin/it/vfsfitvnm/compose/reordering/ReorderingLazyList.kt +++ /dev/null @@ -1,293 +0,0 @@ -@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") - -package it.vfsfitvnm.compose.reordering - -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.OverscrollEffect -import androidx.compose.foundation.checkScrollableContainerConstraints -import androidx.compose.foundation.clipScrollableContainer -import androidx.compose.foundation.gestures.FlingBehavior -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.ScrollableDefaults -import androidx.compose.foundation.gestures.scrollable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.calculateEndPadding -import androidx.compose.foundation.layout.calculateStartPadding -import androidx.compose.foundation.lazy.DataIndex -import androidx.compose.foundation.lazy.LazyListBeyondBoundsInfo -import androidx.compose.foundation.lazy.LazyListItemPlacementAnimator -import androidx.compose.foundation.lazy.LazyListItemProvider -import androidx.compose.foundation.lazy.LazyListMeasureResult -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.LazyMeasuredItem -import androidx.compose.foundation.lazy.LazyMeasuredItemProvider -import androidx.compose.foundation.lazy.layout.LazyLayout -import androidx.compose.foundation.lazy.layout.LazyLayoutMeasureScope -import androidx.compose.foundation.lazy.layout.lazyLayoutSemantics -import androidx.compose.foundation.lazy.lazyListBeyondBoundsModifier -import androidx.compose.foundation.lazy.lazyListPinningModifier -import androidx.compose.foundation.lazy.measureLazyList -import androidx.compose.foundation.lazy.rememberLazyListItemProvider -import androidx.compose.foundation.lazy.rememberLazyListSemanticState -import androidx.compose.foundation.overscroll -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.snapshots.Snapshot -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.MeasureResult -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.unit.Constraints -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.constrainHeight -import androidx.compose.ui.unit.constrainWidth -import androidx.compose.ui.unit.offset - -@OptIn(ExperimentalFoundationApi::class) -@Composable -internal fun ReorderingLazyList( - modifier: Modifier, - reorderingState: ReorderingState, - contentPadding: PaddingValues, - reverseLayout: Boolean, - isVertical: Boolean, - flingBehavior: FlingBehavior, - userScrollEnabled: Boolean, - horizontalAlignment: Alignment.Horizontal? = null, - verticalArrangement: Arrangement.Vertical? = null, - verticalAlignment: Alignment.Vertical? = null, - horizontalArrangement: Arrangement.Horizontal? = null, - content: LazyListScope.() -> Unit -) { - val overscrollEffect = ScrollableDefaults.overscrollEffect() - val itemProvider = rememberLazyListItemProvider(reorderingState.lazyListState, content) - val semanticState = - rememberLazyListSemanticState(reorderingState.lazyListState, itemProvider, reverseLayout, isVertical) - val beyondBoundsInfo = reorderingState.lazyListBeyondBoundsInfo - val scope = rememberCoroutineScope() - val placementAnimator = remember(reorderingState.lazyListState, isVertical) { - LazyListItemPlacementAnimator(scope, isVertical) - } - reorderingState.lazyListState.placementAnimator = placementAnimator - - val measurePolicy = rememberLazyListMeasurePolicy( - itemProvider, - reorderingState.lazyListState, - beyondBoundsInfo, - overscrollEffect, - contentPadding, - reverseLayout, - isVertical, - horizontalAlignment, - verticalAlignment, - horizontalArrangement, - verticalArrangement, - placementAnimator - ) - - val orientation = if (isVertical) Orientation.Vertical else Orientation.Horizontal - LazyLayout( - modifier = modifier - .then(reorderingState.lazyListState.remeasurementModifier) - .then(reorderingState.lazyListState.awaitLayoutModifier) - .lazyLayoutSemantics( - itemProvider = itemProvider, - state = semanticState, - orientation = orientation, - userScrollEnabled = userScrollEnabled - ) - .clipScrollableContainer(orientation) - .lazyListBeyondBoundsModifier(reorderingState.lazyListState, beyondBoundsInfo, reverseLayout) - .lazyListPinningModifier(reorderingState.lazyListState, beyondBoundsInfo) - .overscroll(overscrollEffect) - .scrollable( - orientation = orientation, - reverseDirection = ScrollableDefaults.reverseDirection( - LocalLayoutDirection.current, - orientation, - reverseLayout - ), - interactionSource = reorderingState.lazyListState.internalInteractionSource, - flingBehavior = flingBehavior, - state = reorderingState.lazyListState, - overscrollEffect = overscrollEffect, - enabled = userScrollEnabled - ), - prefetchState = reorderingState.lazyListState.prefetchState, - measurePolicy = measurePolicy, - itemProvider = itemProvider - ) -} - -@ExperimentalFoundationApi -@Composable -private fun rememberLazyListMeasurePolicy( - itemProvider: LazyListItemProvider, - state: LazyListState, - beyondBoundsInfo: LazyListBeyondBoundsInfo, - overscrollEffect: OverscrollEffect, - contentPadding: PaddingValues, - reverseLayout: Boolean, - isVertical: Boolean, - horizontalAlignment: Alignment.Horizontal? = null, - verticalAlignment: Alignment.Vertical? = null, - horizontalArrangement: Arrangement.Horizontal? = null, - verticalArrangement: Arrangement.Vertical? = null, - placementAnimator: LazyListItemPlacementAnimator -) = remember MeasureResult>( - state, - beyondBoundsInfo, - overscrollEffect, - contentPadding, - reverseLayout, - isVertical, - horizontalAlignment, - verticalAlignment, - horizontalArrangement, - verticalArrangement, - placementAnimator -) { - { containerConstraints -> - checkScrollableContainerConstraints( - containerConstraints, - if (isVertical) Orientation.Vertical else Orientation.Horizontal - ) - - val startPadding = - if (isVertical) { - contentPadding.calculateLeftPadding(layoutDirection).roundToPx() - } else { - contentPadding.calculateStartPadding(layoutDirection).roundToPx() - } - - val endPadding = - if (isVertical) { - contentPadding.calculateRightPadding(layoutDirection).roundToPx() - } else { - contentPadding.calculateEndPadding(layoutDirection).roundToPx() - } - val topPadding = contentPadding.calculateTopPadding().roundToPx() - val bottomPadding = contentPadding.calculateBottomPadding().roundToPx() - val totalVerticalPadding = topPadding + bottomPadding - val totalHorizontalPadding = startPadding + endPadding - val totalMainAxisPadding = if (isVertical) totalVerticalPadding else totalHorizontalPadding - val beforeContentPadding = when { - isVertical && !reverseLayout -> topPadding - isVertical && reverseLayout -> bottomPadding - !isVertical && !reverseLayout -> startPadding - else -> endPadding - } - val afterContentPadding = totalMainAxisPadding - beforeContentPadding - val contentConstraints = - containerConstraints.offset(-totalHorizontalPadding, -totalVerticalPadding) - - state.density = this - - itemProvider.itemScope.setMaxSize( - width = contentConstraints.maxWidth, - height = contentConstraints.maxHeight - ) - - val spaceBetweenItemsDp = if (isVertical) { - requireNotNull(verticalArrangement).spacing - } else { - requireNotNull(horizontalArrangement).spacing - } - val spaceBetweenItems = spaceBetweenItemsDp.roundToPx() - - val itemsCount = itemProvider.itemCount - - val mainAxisAvailableSize = if (isVertical) { - containerConstraints.maxHeight - totalVerticalPadding - } else { - containerConstraints.maxWidth - totalHorizontalPadding - } - val visualItemOffset = if (!reverseLayout || mainAxisAvailableSize > 0) { - IntOffset(startPadding, topPadding) - } else { - IntOffset( - if (isVertical) startPadding else startPadding + mainAxisAvailableSize, - if (isVertical) topPadding + mainAxisAvailableSize else topPadding - ) - } - - val measuredItemProvider = LazyMeasuredItemProvider( - contentConstraints, - isVertical, - itemProvider, - this - ) { index, key, placeables -> - val spacing = if (index.value == itemsCount - 1) 0 else spaceBetweenItems - LazyMeasuredItem( - index = index.value, - placeables = placeables, - isVertical = isVertical, - horizontalAlignment = horizontalAlignment, - verticalAlignment = verticalAlignment, - layoutDirection = layoutDirection, - reverseLayout = reverseLayout, - beforeContentPadding = beforeContentPadding, - afterContentPadding = afterContentPadding, - spacing = spacing, - visualOffset = visualItemOffset, - key = key, - placementAnimator = placementAnimator - ) - } - state.premeasureConstraints = measuredItemProvider.childConstraints - - val firstVisibleItemIndex: DataIndex - val firstVisibleScrollOffset: Int - Snapshot.withoutReadObservation { - firstVisibleItemIndex = DataIndex(state.firstVisibleItemIndex) - firstVisibleScrollOffset = state.firstVisibleItemScrollOffset - } - - measureLazyList( - itemsCount = itemsCount, - itemProvider = measuredItemProvider, - mainAxisAvailableSize = mainAxisAvailableSize, - beforeContentPadding = beforeContentPadding, - afterContentPadding = afterContentPadding, - spaceBetweenItems = spaceBetweenItems, - firstVisibleItemIndex = firstVisibleItemIndex, - firstVisibleItemScrollOffset = firstVisibleScrollOffset, - scrollToBeConsumed = state.scrollToBeConsumed, - constraints = contentConstraints, - isVertical = isVertical, - headerIndexes = itemProvider.headerIndexes, - verticalArrangement = verticalArrangement, - horizontalArrangement = horizontalArrangement, - reverseLayout = reverseLayout, - density = this, - placementAnimator = placementAnimator, - beyondBoundsInfo = beyondBoundsInfo, - layout = { width, height, placement -> - layout( - containerConstraints.constrainWidth(width + totalHorizontalPadding), - containerConstraints.constrainHeight(height + totalVerticalPadding), - emptyMap(), - placement - ) - } - ).also { - state.applyMeasureResult(it) - refreshOverscrollInfo(overscrollEffect, it) - } - } -} - -@OptIn(ExperimentalFoundationApi::class) -private fun refreshOverscrollInfo( - overscrollEffect: OverscrollEffect, - result: LazyListMeasureResult -) { - val canScrollForward = result.canScrollForward - val canScrollBackward = (result.firstVisibleItem?.index ?: 0) != 0 || - result.firstVisibleItemScrollOffset != 0 - - overscrollEffect.isEnabled = canScrollForward || canScrollBackward -} diff --git a/compose-reordering/src/main/kotlin/it/vfsfitvnm/compose/reordering/ReorderingState.kt b/compose-reordering/src/main/kotlin/it/vfsfitvnm/compose/reordering/ReorderingState.kt deleted file mode 100644 index 11f4dc9..0000000 --- a/compose-reordering/src/main/kotlin/it/vfsfitvnm/compose/reordering/ReorderingState.kt +++ /dev/null @@ -1,225 +0,0 @@ -@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") - -package it.vfsfitvnm.compose.reordering - -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.AnimationVector1D -import androidx.compose.animation.core.VectorConverter -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.lazy.LazyListBeyondBoundsInfo -import androidx.compose.foundation.lazy.LazyListItemInfo -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.input.pointer.PointerInputChange -import kotlin.math.roundToInt -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -@Stable -class ReorderingState( - val lazyListState: LazyListState, - val coroutineScope: CoroutineScope, - private val lastIndex: Int, - internal val onDragStart: () -> Unit, - internal val onDragEnd: (Int, Int) -> Unit, - private val extraItemCount: Int -) { - private lateinit var lazyListBeyondBoundsInfoInterval: LazyListBeyondBoundsInfo.Interval - internal val lazyListBeyondBoundsInfo = LazyListBeyondBoundsInfo() - internal val offset = Animatable(0, Int.VectorConverter) - - internal var draggingIndex by mutableStateOf(-1) - internal var reachedIndex by mutableStateOf(-1) - internal var draggingItemSize by mutableStateOf(0) - - lateinit var itemInfo: LazyListItemInfo - - private var previousItemSize = 0 - private var nextItemSize = 0 - - private var overscrolled = 0 - - internal var indexesToAnimate = mutableStateMapOf>() - private var animatablesPool: AnimatablesPool? = null - - val isDragging: Boolean - get() = draggingIndex != -1 - - fun onDragStart(index: Int) { - overscrolled = 0 - itemInfo = lazyListState.layoutInfo.visibleItemsInfo.find { - it.index == index + extraItemCount - } ?: return - onDragStart.invoke() - draggingIndex = index - reachedIndex = index - draggingItemSize = itemInfo.size - - nextItemSize = draggingItemSize - previousItemSize = -draggingItemSize - - offset.updateBounds( - lowerBound = -index * draggingItemSize, - upperBound = (lastIndex - index) * draggingItemSize - ) - - lazyListBeyondBoundsInfoInterval = - lazyListBeyondBoundsInfo.addInterval(index + extraItemCount, index + extraItemCount) - - val size = - lazyListState.layoutInfo.viewportEndOffset - lazyListState.layoutInfo.viewportStartOffset - - animatablesPool = AnimatablesPool(size / draggingItemSize + 2, 0, Int.VectorConverter) - } - - fun onDrag(change: PointerInputChange, dragAmount: Offset) { - if (!isDragging) return - change.consume() - - val delta = when (lazyListState.layoutInfo.orientation) { - Orientation.Vertical -> dragAmount.y - Orientation.Horizontal -> dragAmount.x - }.roundToInt() - - val targetOffset = offset.value + delta - - coroutineScope.launch { - offset.snapTo(targetOffset) - } - - if (targetOffset > nextItemSize) { - if (reachedIndex < lastIndex) { - reachedIndex += 1 - nextItemSize += draggingItemSize - previousItemSize += draggingItemSize - - val indexToAnimate = reachedIndex - if (draggingIndex < reachedIndex) 0 else 1 - - coroutineScope.launch { - val animatable = indexesToAnimate.getOrPut(indexToAnimate) { - animatablesPool?.acquire() ?: return@launch - } - - if (draggingIndex < reachedIndex) { - animatable.snapTo(0) - animatable.animateTo(-draggingItemSize) - } else { - animatable.snapTo(draggingItemSize) - animatable.animateTo(0) - } - - indexesToAnimate.remove(indexToAnimate) - animatablesPool?.release(animatable) - } - } - } else if (targetOffset < previousItemSize) { - if (reachedIndex > 0) { - reachedIndex -= 1 - previousItemSize -= draggingItemSize - nextItemSize -= draggingItemSize - - val indexToAnimate = reachedIndex + if (draggingIndex > reachedIndex) 0 else 1 - - coroutineScope.launch { - val animatable = indexesToAnimate.getOrPut(indexToAnimate) { - animatablesPool?.acquire() ?: return@launch - } - - if (draggingIndex > reachedIndex) { - animatable.snapTo(0) - animatable.animateTo(draggingItemSize) - } else { - animatable.snapTo(-draggingItemSize) - animatable.animateTo(0) - } - indexesToAnimate.remove(indexToAnimate) - animatablesPool?.release(animatable) - } - } - } else { - val offsetInViewPort = targetOffset + itemInfo.offset - overscrolled - - val topOverscroll = lazyListState.layoutInfo.viewportStartOffset + - lazyListState.layoutInfo.beforeContentPadding - offsetInViewPort - - val bottomOverscroll = lazyListState.layoutInfo.viewportEndOffset - - lazyListState.layoutInfo.afterContentPadding - offsetInViewPort - itemInfo.size - - if (topOverscroll > 0) { - overscroll(topOverscroll) - } else if (bottomOverscroll < 0) { - overscroll(bottomOverscroll) - } - } - } - - fun onDragEnd() { - if (!isDragging) return - - coroutineScope.launch { - offset.animateTo((previousItemSize + nextItemSize) / 2) - - withContext(Dispatchers.Main) { - onDragEnd.invoke(draggingIndex, reachedIndex) - } - - if (areEquals()) { - draggingIndex = -1 - reachedIndex = -1 - draggingItemSize = 0 - offset.snapTo(0) - } - - lazyListBeyondBoundsInfo.removeInterval(lazyListBeyondBoundsInfoInterval) - animatablesPool = null - } - } - - private fun overscroll(overscroll: Int) { - lazyListState.dispatchRawDelta(-overscroll.toFloat()) - coroutineScope.launch { - offset.snapTo(offset.value - overscroll) - } - overscrolled -= overscroll - } - - private fun areEquals(): Boolean { - return lazyListState.layoutInfo.visibleItemsInfo.find { - it.index + extraItemCount == draggingIndex - }?.key == lazyListState.layoutInfo.visibleItemsInfo.find { - it.index + extraItemCount == reachedIndex - }?.key - } -} - -@Composable -fun rememberReorderingState( - lazyListState: LazyListState, - key: Any, - onDragEnd: (Int, Int) -> Unit, - onDragStart: () -> Unit = {}, - extraItemCount: Int = 0 -): ReorderingState { - val coroutineScope = rememberCoroutineScope() - - return remember(key) { - ReorderingState( - lazyListState = lazyListState, - coroutineScope = coroutineScope, - lastIndex = if (key is List<*>) key.lastIndex else lazyListState.layoutInfo.totalItemsCount, - onDragStart = onDragStart, - onDragEnd = onDragEnd, - extraItemCount = extraItemCount, - ) - } -} diff --git a/compose-routing/.gitignore b/compose-routing/.gitignore deleted file mode 100644 index 42afabf..0000000 --- a/compose-routing/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/compose-routing/build.gradle.kts b/compose-routing/build.gradle.kts deleted file mode 100644 index b33472c..0000000 --- a/compose-routing/build.gradle.kts +++ /dev/null @@ -1,48 +0,0 @@ -plugins { - id("com.android.library") - kotlin("android") -} - -android { - namespace = "it.vfsfitvnm.compose.routing" - compileSdk = 33 - - defaultConfig { - minSdk = 21 - targetSdk = 33 - } - - buildTypes { - release { - isMinifyEnabled = true - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) - } - } - - sourceSets.all { - kotlin.srcDir("src/$name/kotlin") - } - - buildFeatures { - compose = true - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() - } - - kotlinOptions { - freeCompilerArgs += "-Xcontext-receivers" - jvmTarget = "1.8" - } -} - -dependencies { - implementation(libs.compose.activity) - implementation(libs.compose.foundation) -} diff --git a/compose-routing/src/main/AndroidManifest.xml b/compose-routing/src/main/AndroidManifest.xml deleted file mode 100644 index 10728cc..0000000 --- a/compose-routing/src/main/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/compose-routing/src/main/kotlin/it/vfsfitvnm/compose/routing/GlobalRoute.kt b/compose-routing/src/main/kotlin/it/vfsfitvnm/compose/routing/GlobalRoute.kt deleted file mode 100644 index 920343b..0000000 --- a/compose-routing/src/main/kotlin/it/vfsfitvnm/compose/routing/GlobalRoute.kt +++ /dev/null @@ -1,14 +0,0 @@ -package it.vfsfitvnm.compose.routing - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import kotlinx.coroutines.flow.MutableSharedFlow - -internal val globalRouteFlow = MutableSharedFlow>>(extraBufferCapacity = 1) - -@Composable -fun OnGlobalRoute(block: suspend (Pair>) -> Unit) { - LaunchedEffect(Unit) { - globalRouteFlow.collect(block) - } -} diff --git a/compose-routing/src/main/kotlin/it/vfsfitvnm/compose/routing/Route.kt b/compose-routing/src/main/kotlin/it/vfsfitvnm/compose/routing/Route.kt deleted file mode 100644 index 5c6b02c..0000000 --- a/compose-routing/src/main/kotlin/it/vfsfitvnm/compose/routing/Route.kt +++ /dev/null @@ -1,79 +0,0 @@ -@file:Suppress("UNCHECKED_CAST") - -package it.vfsfitvnm.compose.routing - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.saveable.SaverScope -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.first - -@Immutable -open class Route internal constructor(val tag: String) { - override fun equals(other: Any?): Boolean { - return when { - this === other -> true - other is Route -> tag == other.tag - else -> false - } - } - - override fun hashCode(): Int { - return tag.hashCode() - } - - object Saver : androidx.compose.runtime.saveable.Saver { - override fun restore(value: String): Route? = value.takeIf(String::isNotEmpty)?.let(::Route) - override fun SaverScope.save(value: Route?): String = value?.tag ?: "" - } -} - -@Immutable -class Route0(tag: String) : Route(tag) { - context(RouteHandlerScope) - @Composable - operator fun invoke(content: @Composable () -> Unit) { - if (this == route) { - content() - } - } - - fun global() { - globalRouteFlow.tryEmit(this to emptyArray()) - } -} - -@Immutable -class Route1(tag: String) : Route(tag) { - context(RouteHandlerScope) - @Composable - operator fun invoke(content: @Composable (P0) -> Unit) { - if (this == route) { - content(parameters[0] as P0) - } - } - - fun global(p0: P0) { - globalRouteFlow.tryEmit(this to arrayOf(p0)) - } - - suspend fun ensureGlobal(p0: P0) { - globalRouteFlow.subscriptionCount.filter { it > 0 }.first() - globalRouteFlow.emit(this to arrayOf(p0)) - } -} - -@Immutable -class Route2(tag: String) : Route(tag) { - context(RouteHandlerScope) - @Composable - operator fun invoke(content: @Composable (P0, P1) -> Unit) { - if (this == route) { - content(parameters[0] as P0, parameters[1] as P1) - } - } - - fun global(p0: P0, p1: P1) { - globalRouteFlow.tryEmit(this to arrayOf(p0, p1)) - } -} diff --git a/compose-routing/src/main/kotlin/it/vfsfitvnm/compose/routing/RouteHandler.kt b/compose-routing/src/main/kotlin/it/vfsfitvnm/compose/routing/RouteHandler.kt deleted file mode 100644 index 212fd58..0000000 --- a/compose-routing/src/main/kotlin/it/vfsfitvnm/compose/routing/RouteHandler.kt +++ /dev/null @@ -1,98 +0,0 @@ -package it.vfsfitvnm.compose.routing - -import androidx.activity.compose.BackHandler -import androidx.activity.compose.LocalOnBackPressedDispatcherOwner -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedContentScope -import androidx.compose.animation.ContentTransform -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.core.updateTransition -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier - -@ExperimentalAnimationApi -@Composable -fun RouteHandler( - modifier: Modifier = Modifier, - listenToGlobalEmitter: Boolean = false, - handleBackPress: Boolean = true, - transitionSpec: AnimatedContentScope.() -> ContentTransform = { - when { - isStacking -> defaultStacking - isStill -> defaultStill - else -> defaultUnstacking - } - }, - content: @Composable RouteHandlerScope.() -> Unit -) { - var route by rememberSaveable(stateSaver = Route.Saver) { - mutableStateOf(null) - } - - RouteHandler( - route = route, - onRouteChanged = { route = it }, - listenToGlobalEmitter = listenToGlobalEmitter, - handleBackPress = handleBackPress, - transitionSpec = transitionSpec, - modifier = modifier, - content = content - ) -} - -@ExperimentalAnimationApi -@Composable -fun RouteHandler( - route: Route?, - onRouteChanged: (Route?) -> Unit, - modifier: Modifier = Modifier, - listenToGlobalEmitter: Boolean = false, - handleBackPress: Boolean = true, - transitionSpec: AnimatedContentScope.() -> ContentTransform = { - when { - isStacking -> defaultStacking - isStill -> defaultStill - else -> defaultUnstacking - } - }, - content: @Composable RouteHandlerScope.() -> Unit -) { - val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher - - val parameters = rememberSaveable { - arrayOfNulls(2) - } - - val scope = remember(route) { - RouteHandlerScope( - route = route, - parameters = parameters, - push = onRouteChanged, - pop = { if (handleBackPress) backDispatcher?.onBackPressed() else onRouteChanged(null) } - ) - } - - if (listenToGlobalEmitter && route == null) { - OnGlobalRoute { (newRoute, newParameters) -> - newParameters.forEachIndexed(parameters::set) - onRouteChanged(newRoute) - } - } - - BackHandler(enabled = handleBackPress && route != null) { - onRouteChanged(null) - } - - updateTransition(targetState = scope, label = null).AnimatedContent( - transitionSpec = transitionSpec, - contentKey = RouteHandlerScope::route, - modifier = modifier, - ) { - it.content() - } -} diff --git a/compose-routing/src/main/kotlin/it/vfsfitvnm/compose/routing/RouteHandlerScope.kt b/compose-routing/src/main/kotlin/it/vfsfitvnm/compose/routing/RouteHandlerScope.kt deleted file mode 100644 index 17429af..0000000 --- a/compose-routing/src/main/kotlin/it/vfsfitvnm/compose/routing/RouteHandlerScope.kt +++ /dev/null @@ -1,35 +0,0 @@ -package it.vfsfitvnm.compose.routing - -import android.annotation.SuppressLint -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable - -@Stable -class RouteHandlerScope( - val route: Route?, - val parameters: Array, - private val push: (Route?) -> Unit, - val pop: () -> Unit, -) { - @SuppressLint("ComposableNaming") - @Composable - inline fun host(content: @Composable () -> Unit) { - if (route == null) { - content() - } - } - - operator fun Route.invoke() { - push(this) - } - - operator fun Route.invoke(p0: P0) { - parameters[0] = p0 - invoke() - } - - operator fun Route.invoke(p0: P0, p1: P1) { - parameters[1] = p1 - invoke(p0) - } -} diff --git a/compose-routing/src/main/kotlin/it/vfsfitvnm/compose/routing/Transitions.kt b/compose-routing/src/main/kotlin/it/vfsfitvnm/compose/routing/Transitions.kt deleted file mode 100644 index 3be3f19..0000000 --- a/compose-routing/src/main/kotlin/it/vfsfitvnm/compose/routing/Transitions.kt +++ /dev/null @@ -1,46 +0,0 @@ -package it.vfsfitvnm.compose.routing - -import androidx.compose.animation.AnimatedContentScope -import androidx.compose.animation.ContentTransform -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleOut - -@ExperimentalAnimationApi -val defaultStacking = ContentTransform( - initialContentExit = scaleOut(targetScale = 0.9f) + fadeOut(), - targetContentEnter = fadeIn(), - targetContentZIndex = 1f -) - -@ExperimentalAnimationApi -val defaultUnstacking = ContentTransform( - initialContentExit = fadeOut(), - targetContentEnter = EnterTransition.None, - targetContentZIndex = 0f -) - -@ExperimentalAnimationApi -val defaultStill = ContentTransform( - initialContentExit = scaleOut(targetScale = 0.9f) + fadeOut(), - targetContentEnter = fadeIn(), - targetContentZIndex = 1f -) - -@ExperimentalAnimationApi -inline val AnimatedContentScope.isStacking: Boolean - get() = initialState.route == null && targetState.route != null - -@ExperimentalAnimationApi -inline val AnimatedContentScope.isUnstacking: Boolean - get() = initialState.route != null && targetState.route == null - -@ExperimentalAnimationApi -inline val AnimatedContentScope.isStill: Boolean - get() = initialState.route == null && targetState.route == null - -@ExperimentalAnimationApi -inline val AnimatedContentScope.isUnknown: Boolean - get() = initialState.route != null && targetState.route != null diff --git a/compose/persist/build.gradle.kts b/compose/persist/build.gradle.kts new file mode 100644 index 0000000..7d99af4 --- /dev/null +++ b/compose/persist/build.gradle.kts @@ -0,0 +1,34 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "app.vimusic.compose.persist" + compileSdk = 35 + + defaultConfig { + minSdk = 21 + } + + sourceSets.all { + kotlin.srcDir("src/$name/kotlin") + } +} + +kotlin { + jvmToolchain(libs.versions.jvm.get().toInt()) + + task("testClasses") +} + +dependencies { + implementation(platform(libs.compose.bom)) + implementation(libs.compose.foundation) + + implementation(libs.kotlin.immutable) + + detektPlugins(libs.detekt.compose) + detektPlugins(libs.detekt.formatting) +} diff --git a/compose-persist/src/main/AndroidManifest.xml b/compose/persist/src/main/AndroidManifest.xml similarity index 100% rename from compose-persist/src/main/AndroidManifest.xml rename to compose/persist/src/main/AndroidManifest.xml diff --git a/compose/persist/src/main/kotlin/app/vimusic/compose/persist/Persist.kt b/compose/persist/src/main/kotlin/app/vimusic/compose/persist/Persist.kt new file mode 100644 index 0000000..e71ed5c --- /dev/null +++ b/compose/persist/src/main/kotlin/app/vimusic/compose/persist/Persist.kt @@ -0,0 +1,33 @@ +package app.vimusic.compose.persist + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.SnapshotMutationPolicy +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.structuralEqualityPolicy +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Suppress("UNCHECKED_CAST") +@Composable +fun persist( + tag: String, + initialValue: T, + policy: SnapshotMutationPolicy = structuralEqualityPolicy() +): MutableState { + val persistMap = LocalPersistMap.current + + return remember(persistMap) { + persistMap?.map?.getOrPut(tag) { mutableStateOf(initialValue, policy) } as? MutableState + ?: mutableStateOf(initialValue, policy) + } +} + +@Composable +fun persistList(tag: String): MutableState> = + persist(tag = tag, initialValue = persistentListOf()) + +@Composable +fun persist(tag: String): MutableState = + persist(tag = tag, initialValue = null) diff --git a/compose/persist/src/main/kotlin/app/vimusic/compose/persist/PersistMap.kt b/compose/persist/src/main/kotlin/app/vimusic/compose/persist/PersistMap.kt new file mode 100644 index 0000000..0569fda --- /dev/null +++ b/compose/persist/src/main/kotlin/app/vimusic/compose/persist/PersistMap.kt @@ -0,0 +1,16 @@ +package app.vimusic.compose.persist + +import android.util.Log +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.compositionLocalOf + +@JvmInline +value class PersistMap(val map: MutableMap> = hashMapOf()) { + fun clean(prefix: String) = map.keys.removeAll { it.startsWith(prefix) } +} + +val LocalPersistMap = compositionLocalOf { + Log.e("PersistMap", "Tried to reference uninitialized PersistMap, stacktrace:") + runCatching { error("Stack:") }.exceptionOrNull()?.printStackTrace() + null +} diff --git a/compose/persist/src/main/kotlin/app/vimusic/compose/persist/PersistMapCleanup.kt b/compose/persist/src/main/kotlin/app/vimusic/compose/persist/PersistMapCleanup.kt new file mode 100644 index 0000000..2e3e2ab --- /dev/null +++ b/compose/persist/src/main/kotlin/app/vimusic/compose/persist/PersistMapCleanup.kt @@ -0,0 +1,30 @@ +package app.vimusic.compose.persist + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.platform.LocalContext + +@Composable +fun PersistMapCleanup(prefix: String) { + val context = LocalContext.current + val persistMap = LocalPersistMap.current + + DisposableEffect(persistMap) { + onDispose { + if (context.findActivityNullable()?.isChangingConfigurations == false) + persistMap?.clean(prefix) + } + } +} + +fun Context.findActivityNullable(): Activity? { + var current = this + while (current is ContextWrapper) { + if (current is Activity) return current + current = current.baseContext + } + return null +} diff --git a/compose/preferences/build.gradle.kts b/compose/preferences/build.gradle.kts new file mode 100644 index 0000000..b431cb4 --- /dev/null +++ b/compose/preferences/build.gradle.kts @@ -0,0 +1,37 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "app.vimusic.compose.preferences" + compileSdk = 35 + + defaultConfig { + minSdk = 21 + } + + sourceSets.all { + kotlin.srcDir("src/$name/kotlin") + } +} + +kotlin { + jvmToolchain(libs.versions.jvm.get().toInt()) + + task("testClasses") +} + +dependencies { + implementation(platform(libs.compose.bom)) + implementation(libs.compose.foundation) + + implementation(libs.core.ktx) + + implementation(libs.kotlin.coroutines) + api(libs.ktor.serialization.json) + + detektPlugins(libs.detekt.compose) + detektPlugins(libs.detekt.formatting) +} diff --git a/compose/preferences/src/main/AndroidManifest.xml b/compose/preferences/src/main/AndroidManifest.xml new file mode 100644 index 0000000..e100076 --- /dev/null +++ b/compose/preferences/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/compose/preferences/src/main/kotlin/app/vimusic/compose/preferences/PreferencesHolders.kt b/compose/preferences/src/main/kotlin/app/vimusic/compose/preferences/PreferencesHolders.kt new file mode 100644 index 0000000..a51128b --- /dev/null +++ b/compose/preferences/src/main/kotlin/app/vimusic/compose/preferences/PreferencesHolders.kt @@ -0,0 +1,176 @@ +package app.vimusic.compose.preferences + +import android.app.Application +import android.content.Context +import android.content.SharedPreferences +import android.content.SharedPreferences.OnSharedPreferenceChangeListener +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.snapshots.Snapshot +import androidx.core.content.edit +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty + +private val coroutineScope = CoroutineScope(Dispatchers.IO + CoroutineName("PreferencesHolders")) + +private val canWriteState get() = !Snapshot.current.readOnly && !Snapshot.current.root.readOnly + +@Stable +data class SharedPreferencesProperty( + private val name: String? = null, + private val get: SharedPreferences.(key: String) -> T, + private val set: SharedPreferences.Editor.(key: String, value: T) -> Unit, + private val default: T +) : ReadWriteProperty { + private val state = mutableStateOf(default) + val stateFlow = MutableStateFlow(default) + private var listener: OnSharedPreferenceChangeListener? = null + + private fun setState(newValue: T) { + state.value = newValue + stateFlow.update { newValue } + } + + private inline val KProperty<*>.key get() = this@SharedPreferencesProperty.name ?: name + + override fun getValue(thisRef: PreferencesHolder, property: KProperty<*>): T { + if (listener == null && canWriteState) { + setState(thisRef.get(property.key)) + + listener = OnSharedPreferenceChangeListener { preferences, key -> + if (key != property.key || !canWriteState) return@OnSharedPreferenceChangeListener + + preferences.get(property.key).let { + if (it != state.value) setState(it) + } + } + + thisRef.registerOnSharedPreferenceChangeListener(listener) + } + return state.value + } + + override fun setValue(thisRef: PreferencesHolder, property: KProperty<*>, value: T) = + coroutineScope.launch { + thisRef.edit(commit = true) { + set(property.key, value) + } + }.let { } +} + +/** + * A snapshottable, thread-safe, compose-first, extensible SharedPreferences wrapper that supports + * virtually all types, and if it doesn't, one could simply type + * `fun myNewType(...) = SharedPreferencesProperty(...)` and start implementing. Starts off as given + * defaultValue until we are allowed to subscribe to SharedPreferences. Caution: the type of the + * preference has to be [Stable], otherwise UB will occur. + */ +open class PreferencesHolder( + application: Application, + name: String, + mode: Int = Context.MODE_PRIVATE +) : SharedPreferences by application.getSharedPreferences(name, mode) { + fun boolean( + defaultValue: Boolean, + name: String? = null + ) = SharedPreferencesProperty( + get = { getBoolean(it, defaultValue) }, + set = { k, v -> putBoolean(k, v) }, + default = defaultValue, + name = name + ) + + fun string( + defaultValue: String, + name: String? = null + ) = SharedPreferencesProperty( + get = { getString(it, null) ?: defaultValue }, + set = { k, v -> putString(k, v) }, + default = defaultValue, + name = name + ) + + fun int( + defaultValue: Int, + name: String? = null + ) = SharedPreferencesProperty( + get = { getInt(it, defaultValue) }, + set = { k, v -> putInt(k, v) }, + default = defaultValue, + name = name + ) + + fun float( + defaultValue: Float, + name: String? = null + ) = SharedPreferencesProperty( + get = { getFloat(it, defaultValue) }, + set = { k, v -> putFloat(k, v) }, + default = defaultValue, + name = name + ) + + fun long( + defaultValue: Long, + name: String? = null + ) = SharedPreferencesProperty( + get = { getLong(it, defaultValue) }, + set = { k, v -> putLong(k, v) }, + default = defaultValue, + name = name + ) + + inline fun > enum( + defaultValue: T, + name: String? = null + ) = SharedPreferencesProperty( + get = { + getString(it, null) + ?.let { runCatching { enumValueOf(it) }.getOrNull() } + ?: defaultValue + }, + set = { k, v -> putString(k, v.name) }, + default = defaultValue, + name = name + ) + + fun stringSet( + defaultValue: Set, + name: String? = null + ) = SharedPreferencesProperty( + get = { getStringSet(it, null) ?: defaultValue }, + set = { k, v -> putStringSet(k, v) }, + default = defaultValue, + name = name + ) + + @PublishedApi + internal val defaultJson = Json { + isLenient = true + prettyPrint = false + ignoreUnknownKeys = true + encodeDefaults = true + } + + inline fun json( + defaultValue: Serializable, + name: String? = null, + json: Json = defaultJson + ): SharedPreferencesProperty = SharedPreferencesProperty( + get = { k -> + getString(k, json.encodeToString(defaultValue))?.let { json.decodeFromString(it) } + ?: defaultValue + }, + set = { k, v -> putString(k, json.encodeToString(v)) }, + default = defaultValue, + name = name + ) +} diff --git a/compose/reordering/build.gradle.kts b/compose/reordering/build.gradle.kts new file mode 100644 index 0000000..d463410 --- /dev/null +++ b/compose/reordering/build.gradle.kts @@ -0,0 +1,32 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "app.vimusic.compose.reordering" + compileSdk = 35 + + defaultConfig { + minSdk = 21 + } + + kotlinOptions { + freeCompilerArgs = freeCompilerArgs + listOf("-Xcontext-receivers") + } +} + +kotlin { + jvmToolchain(libs.versions.jvm.get().toInt()) + + task("testClasses") +} + +dependencies { + implementation(platform(libs.compose.bom)) + implementation(libs.compose.foundation) + + detektPlugins(libs.detekt.compose) + detektPlugins(libs.detekt.formatting) +} diff --git a/compose/reordering/src/main/AndroidManifest.xml b/compose/reordering/src/main/AndroidManifest.xml new file mode 100644 index 0000000..568741e --- /dev/null +++ b/compose/reordering/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/compose/reordering/src/main/kotlin/app/vimusic/compose/reordering/AnimatablesPool.kt b/compose/reordering/src/main/kotlin/app/vimusic/compose/reordering/AnimatablesPool.kt new file mode 100644 index 0000000..081cd83 --- /dev/null +++ b/compose/reordering/src/main/kotlin/app/vimusic/compose/reordering/AnimatablesPool.kt @@ -0,0 +1,30 @@ +package app.vimusic.compose.reordering + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector +import androidx.compose.animation.core.TwoWayConverter +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class AnimatablesPool( + private val initialValue: T, + private val typeConverter: TwoWayConverter, + private val visibilityThreshold: T? = null +) { + private val animatables = mutableListOf>() + private val mutex = Mutex() + + suspend fun acquire() = mutex.withLock { + animatables.removeFirstOrNull() ?: Animatable( + initialValue = initialValue, + typeConverter = typeConverter, + visibilityThreshold = visibilityThreshold, + label = "AnimatablesPool: Animatable" + ) + } + + suspend fun release(animatable: Animatable) = mutex.withLock { + animatable.snapTo(initialValue) + animatables += animatable + } +} diff --git a/compose/reordering/src/main/kotlin/app/vimusic/compose/reordering/AnimateItemPlacement.kt b/compose/reordering/src/main/kotlin/app/vimusic/compose/reordering/AnimateItemPlacement.kt new file mode 100644 index 0000000..1c199e6 --- /dev/null +++ b/compose/reordering/src/main/kotlin/app/vimusic/compose/reordering/AnimateItemPlacement.kt @@ -0,0 +1,13 @@ +package app.vimusic.compose.reordering + +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed + +context(LazyItemScope) +fun Modifier.animateItemPlacement(reorderingState: ReorderingState) = this.composed { + if (reorderingState.draggingIndex == -1) this.animateItem( + fadeInSpec = null, + fadeOutSpec = null + ) else this +} diff --git a/compose-reordering/src/main/kotlin/it/vfsfitvnm/compose/reordering/DraggedItem.kt b/compose/reordering/src/main/kotlin/app/vimusic/compose/reordering/DraggedItem.kt similarity index 51% rename from compose-reordering/src/main/kotlin/it/vfsfitvnm/compose/reordering/DraggedItem.kt rename to compose/reordering/src/main/kotlin/app/vimusic/compose/reordering/DraggedItem.kt index d626e29..114df31 100644 --- a/compose-reordering/src/main/kotlin/it/vfsfitvnm/compose/reordering/DraggedItem.kt +++ b/compose/reordering/src/main/kotlin/app/vimusic/compose/reordering/DraggedItem.kt @@ -1,14 +1,23 @@ -package it.vfsfitvnm.compose.reordering +package app.vimusic.compose.reordering +import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.layout.offset +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.layout.LocalPinnableContainer +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex fun Modifier.draggedItem( reorderingState: ReorderingState, - index: Int + index: Int, + draggedElevation: Dp = 4.dp ): Modifier = when (reorderingState.draggingIndex) { -1 -> this index -> offset { @@ -17,11 +26,12 @@ fun Modifier.draggedItem( Orientation.Horizontal -> IntOffset(reorderingState.offset.value, 0) } }.zIndex(1f) + else -> offset { - val offset = when (index) { + val offset = when (index) { in reorderingState.indexesToAnimate -> reorderingState.indexesToAnimate.getValue(index).value in (reorderingState.draggingIndex + 1)..reorderingState.reachedIndex -> -reorderingState.draggingItemSize - in reorderingState.reachedIndex until reorderingState.draggingIndex -> reorderingState.draggingItemSize + in reorderingState.reachedIndex.. reorderingState.draggingItemSize else -> 0 } when (reorderingState.lazyListState.layoutInfo.orientation) { @@ -29,4 +39,20 @@ fun Modifier.draggedItem( Orientation.Horizontal -> IntOffset(offset, 0) } } +}.composed { + val container = LocalPinnableContainer.current + val elevation by animateDpAsState( + targetValue = if (reorderingState.draggingIndex == index) draggedElevation else 0.dp, + label = "" + ) + + DisposableEffect(reorderingState.draggingIndex) { + val handle = if (reorderingState.draggingIndex == index) container?.pin() else null + + onDispose { + handle?.release() + } + } + + this.shadow(elevation = elevation) } diff --git a/compose-reordering/src/main/kotlin/it/vfsfitvnm/compose/reordering/Reorder.kt b/compose/reordering/src/main/kotlin/app/vimusic/compose/reordering/Reorder.kt similarity index 63% rename from compose-reordering/src/main/kotlin/it/vfsfitvnm/compose/reordering/Reorder.kt rename to compose/reordering/src/main/kotlin/app/vimusic/compose/reordering/Reorder.kt index 7b7d591..036f1f7 100644 --- a/compose-reordering/src/main/kotlin/it/vfsfitvnm/compose/reordering/Reorder.kt +++ b/compose/reordering/src/main/kotlin/app/vimusic/compose/reordering/Reorder.kt @@ -1,7 +1,6 @@ -package it.vfsfitvnm.compose.reordering +package app.vimusic.compose.reordering import androidx.compose.foundation.gestures.detectDragGestures -import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.PointerInputChange @@ -11,34 +10,25 @@ import androidx.compose.ui.input.pointer.pointerInput private fun Modifier.reorder( reorderingState: ReorderingState, index: Int, - detectDragGestures: DetectDragGestures, -): Modifier = pointerInput(reorderingState) { + detectDragGestures: DetectDragGestures +) = this.pointerInput(reorderingState) { with(detectDragGestures) { detectDragGestures( onDragStart = { reorderingState.onDragStart(index) }, onDrag = reorderingState::onDrag, onDragEnd = reorderingState::onDragEnd, - onDragCancel = reorderingState::onDragEnd, + onDragCancel = reorderingState::onDragEnd ) } } fun Modifier.reorder( - reorderingState: ReorderingState, - index: Int, -): Modifier = reorder( - reorderingState = reorderingState, - index = index, - detectDragGestures = PointerInputScope::detectDragGestures, -) - -fun Modifier.reorderAfterLongPress( reorderingState: ReorderingState, index: Int -): Modifier = reorder( +) = this.reorder( reorderingState = reorderingState, index = index, - detectDragGestures = PointerInputScope::detectDragGesturesAfterLongPress, + detectDragGestures = PointerInputScope::detectDragGestures ) private fun interface DetectDragGestures { diff --git a/compose/reordering/src/main/kotlin/app/vimusic/compose/reordering/ReorderingState.kt b/compose/reordering/src/main/kotlin/app/vimusic/compose/reordering/ReorderingState.kt new file mode 100644 index 0000000..e85e6e6 --- /dev/null +++ b/compose/reordering/src/main/kotlin/app/vimusic/compose/reordering/ReorderingState.kt @@ -0,0 +1,222 @@ +package app.vimusic.compose.reordering + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.VectorConverter +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.lazy.LazyListItemInfo +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerInputChange +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.math.roundToInt + +@Stable +class ReorderingState( + val lazyListState: LazyListState, + val coroutineScope: CoroutineScope, + private val lastIndex: Int, + internal val onDragStart: () -> Unit, + internal val onDragEnd: (Int, Int) -> Unit, + private val extraItemCount: Int +) { + internal val offset = Animatable(0, Int.VectorConverter) + + internal var draggingIndex by mutableIntStateOf(-1) + internal var reachedIndex by mutableIntStateOf(-1) + internal var draggingItemSize by mutableIntStateOf(0) + + private lateinit var itemInfo: LazyListItemInfo + + private var previousItemSize = 0 + private var nextItemSize = 0 + + private var overscrolled = 0 + + internal var indexesToAnimate = mutableStateMapOf>() + private var animatablesPool: AnimatablesPool? = null + + val isDragging: Boolean + get() = draggingIndex != -1 + + fun onDragStart(index: Int) { + overscrolled = 0 + itemInfo = lazyListState.layoutInfo.visibleItemsInfo + .find { it.index == index + extraItemCount } ?: return + + onDragStart() + draggingIndex = index + reachedIndex = index + draggingItemSize = itemInfo.size + + nextItemSize = draggingItemSize + previousItemSize = -draggingItemSize + + offset.updateBounds( + lowerBound = -index * draggingItemSize, + upperBound = (lastIndex - index) * draggingItemSize + ) + + animatablesPool = AnimatablesPool( + initialValue = 0, + typeConverter = Int.VectorConverter + ) + } + + @Suppress("CyclomaticComplexMethod") + fun onDrag(change: PointerInputChange, dragAmount: Offset) { + if (!isDragging) return + + change.consume() + + val delta = when (lazyListState.layoutInfo.orientation) { + Orientation.Vertical -> dragAmount.y + Orientation.Horizontal -> dragAmount.x + }.roundToInt() + + val targetOffset = offset.value + delta + + coroutineScope.launch { offset.snapTo(targetOffset) } + + when { + targetOffset > nextItemSize -> { + if (reachedIndex < lastIndex) { + reachedIndex += 1 + nextItemSize += draggingItemSize + previousItemSize += draggingItemSize + + val indexToAnimate = reachedIndex - if (draggingIndex < reachedIndex) 0 else 1 + + coroutineScope.launch { + val animatable = indexesToAnimate.getOrPut(indexToAnimate) { + animatablesPool?.acquire() ?: return@launch + } + + if (draggingIndex < reachedIndex) { + animatable.snapTo(0) + animatable.animateTo(-draggingItemSize) + } else { + animatable.snapTo(draggingItemSize) + animatable.animateTo(0) + } + + indexesToAnimate.remove(indexToAnimate) + animatablesPool?.release(animatable) + } + } + } + + targetOffset < previousItemSize -> { + if (reachedIndex > 0) { + reachedIndex -= 1 + previousItemSize -= draggingItemSize + nextItemSize -= draggingItemSize + + val indexToAnimate = reachedIndex + if (draggingIndex > reachedIndex) 0 else 1 + + coroutineScope.launch { + val animatable = indexesToAnimate.getOrPut(indexToAnimate) { + animatablesPool?.acquire() ?: return@launch + } + + if (draggingIndex > reachedIndex) { + animatable.snapTo(0) + animatable.animateTo(draggingItemSize) + } else { + animatable.snapTo(-draggingItemSize) + animatable.animateTo(0) + } + indexesToAnimate.remove(indexToAnimate) + animatablesPool?.release(animatable) + } + } + } + + else -> { + val offsetInViewPort = targetOffset + itemInfo.offset - overscrolled + + val topOverscroll = lazyListState.layoutInfo.viewportStartOffset + + lazyListState.layoutInfo.beforeContentPadding - offsetInViewPort + val bottomOverscroll = lazyListState.layoutInfo.viewportEndOffset - + lazyListState.layoutInfo.afterContentPadding - offsetInViewPort - itemInfo.size + + if (topOverscroll > 0) overscroll(topOverscroll) else if (bottomOverscroll < 0) + overscroll(bottomOverscroll) + } + } + } + + fun onDragEnd() { + if (!isDragging) return + + coroutineScope.launch { + offset.animateTo((previousItemSize + nextItemSize) / 2) + + withContext(Dispatchers.Main) { onDragEnd(draggingIndex, reachedIndex) } + + if (areEquals()) { + draggingIndex = -1 + reachedIndex = -1 + draggingItemSize = 0 + offset.snapTo(0) + } + + animatablesPool = null + } + } + + private fun overscroll(overscroll: Int) { + val newHeight = itemInfo.offset - overscroll + @Suppress("ComplexCondition") + if ( + !(overscroll > 0 && newHeight <= lazyListState.layoutInfo.viewportEndOffset) && + !(overscroll < 0 && newHeight >= lazyListState.layoutInfo.viewportStartOffset) + ) return + + coroutineScope.launch { + lazyListState.scrollBy(-overscroll.toFloat()) + offset.snapTo(offset.value - overscroll) + } + overscrolled -= overscroll + } + + private fun areEquals() = lazyListState.layoutInfo.visibleItemsInfo.find { + it.index + extraItemCount == draggingIndex + }?.key == lazyListState.layoutInfo.visibleItemsInfo.find { + it.index + extraItemCount == reachedIndex + }?.key +} + +@Composable +fun rememberReorderingState( + lazyListState: LazyListState, + key: Any, + onDragEnd: (Int, Int) -> Unit, + onDragStart: () -> Unit = {}, + extraItemCount: Int = 0 +): ReorderingState { + val coroutineScope = rememberCoroutineScope() + + return remember(key) { + ReorderingState( + lazyListState = lazyListState, + coroutineScope = coroutineScope, + lastIndex = if (key is List<*>) key.lastIndex else lazyListState.layoutInfo.totalItemsCount, + onDragStart = onDragStart, + onDragEnd = onDragEnd, + extraItemCount = extraItemCount + ) + } +} diff --git a/compose/routing/build.gradle.kts b/compose/routing/build.gradle.kts new file mode 100644 index 0000000..ed0823e --- /dev/null +++ b/compose/routing/build.gradle.kts @@ -0,0 +1,35 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.parcelize) +} + +android { + namespace = "app.vimusic.compose.routing" + compileSdk = 35 + + defaultConfig { + minSdk = 21 + } + + kotlinOptions { + freeCompilerArgs = freeCompilerArgs + listOf("-Xcontext-receivers") + } +} + +kotlin { + jvmToolchain(libs.versions.jvm.get().toInt()) + + task("testClasses") +} + +dependencies { + implementation(platform(libs.compose.bom)) + implementation(libs.compose.activity) + implementation(libs.compose.foundation) + implementation(libs.compose.animation) + + detektPlugins(libs.detekt.compose) + detektPlugins(libs.detekt.formatting) +} diff --git a/compose/routing/src/main/AndroidManifest.xml b/compose/routing/src/main/AndroidManifest.xml new file mode 100644 index 0000000..568741e --- /dev/null +++ b/compose/routing/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/compose/routing/src/main/kotlin/app/vimusic/compose/routing/GlobalRoute.kt b/compose/routing/src/main/kotlin/app/vimusic/compose/routing/GlobalRoute.kt new file mode 100644 index 0000000..799a358 --- /dev/null +++ b/compose/routing/src/main/kotlin/app/vimusic/compose/routing/GlobalRoute.kt @@ -0,0 +1,57 @@ +package app.vimusic.compose.routing + +import androidx.activity.compose.PredictiveBackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableSharedFlow + +typealias RouteRequestDefinition = Pair> + +@JvmInline +value class RouteRequest private constructor(private val def: RouteRequestDefinition) { + constructor(route: Route, args: Array) : this(route to args) + + val route get() = def.first + val args get() = def.second + + operator fun component1() = route + operator fun component2() = args +} + +internal val globalRouteFlow = MutableSharedFlow(extraBufferCapacity = 1) + +@Composable +fun CallbackPredictiveBackHandler( + enabled: Boolean, + onStart: () -> Unit, + onProgress: (Float) -> Unit, + onFinish: () -> Unit, + onCancel: () -> Unit +) = PredictiveBackHandler(enabled = enabled) { progress -> + onStart() + + // The meaning of CancellationException is different here (normally CancellationExceptions should be rethrowed) + @Suppress("SwallowedException") + try { + progress.collect { + onProgress(it.progress) + } + onFinish() + } catch (e: CancellationException) { + onCancel() + } +} + +@Composable +fun OnGlobalRoute(block: suspend (RouteRequest) -> Unit) { + val currentBlock by rememberUpdatedState(block) + + LaunchedEffect(Unit) { + globalRouteFlow.collect { + currentBlock(it) + } + } +} diff --git a/compose/routing/src/main/kotlin/app/vimusic/compose/routing/RootRouter.kt b/compose/routing/src/main/kotlin/app/vimusic/compose/routing/RootRouter.kt new file mode 100644 index 0000000..e4dadd9 --- /dev/null +++ b/compose/routing/src/main/kotlin/app/vimusic/compose/routing/RootRouter.kt @@ -0,0 +1,195 @@ +package app.vimusic.compose.routing + +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.core.SeekableTransitionState +import androidx.compose.animation.core.rememberTransition +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier + +typealias TransitionScope = AnimatedContentTransitionScope +typealias TransitionSpec = TransitionScope.() -> ContentTransform + +private val defaultTransitionSpec: TransitionSpec = { + when { + isStacking -> defaultStacking + isStill -> defaultStill + else -> defaultUnstacking + } +} + +@Composable +fun RouteHandler( + modifier: Modifier = Modifier, + listenToGlobalEmitter: Boolean = true, + transitionSpec: TransitionSpec = defaultTransitionSpec, + content: @Composable RouteHandlerScope.() -> Unit +) { + var child: Route? by rememberSaveable { mutableStateOf(null) } + + RouteHandler( + child = child, + setChild = { child = it }, + listenToGlobalEmitter = listenToGlobalEmitter, + transitionSpec = transitionSpec, + modifier = modifier, + content = content + ) +} + +interface Router { + operator fun Route0.invoke() + operator fun Route1.invoke(p0: P0) + operator fun Route2.invoke(p0: P0, p1: P1) + operator fun Route3.invoke(p0: P0, p1: P1, p2: P2) + operator fun Route4.invoke(p0: P0, p1: P1, p2: P2, p3: P3) + + val pop: () -> Unit + val push: (Route?) -> Unit +} + +@Stable +class RootRouter : Router { + private inline fun route(block: RouteHandlerScope.() -> Unit?) = current?.block() ?: Unit + + var current: RouteHandlerScope? by mutableStateOf(null) + + override val pop = { + route { + pop() + } + } + + override val push = { route: Route? -> + route { + replace(route) + } + } + + override operator fun Route0.invoke() = push(this) + + override operator fun Route1.invoke(p0: P0) = route { + args[0] = p0 + push(this@invoke) + } + + override operator fun Route2.invoke(p0: P0, p1: P1) = route { + args[0] = p0 + args[1] = p1 + push(this@invoke) + } + + override operator fun Route3.invoke(p0: P0, p1: P1, p2: P2) = route { + args[0] = p0 + args[1] = p1 + args[2] = p2 + push(this@invoke) + } + + override operator fun Route4.invoke( + p0: P0, + p1: P1, + p2: P2, + p3: P3 + ) = route { + args[0] = p0 + args[1] = p1 + args[2] = p2 + args[3] = p3 + push(this@invoke) + } +} + +@JvmInline +value class RootRouterOwner internal constructor(val router: RootRouter) + +val LocalRouteHandler = compositionLocalOf { null } + +@Composable +fun ProvideRootRouter(content: @Composable RootRouterOwner.() -> Unit) = + LocalRouteHandler.current.let { current -> + if (current == null) { + val newHandler = RootRouter() + CompositionLocalProvider(LocalRouteHandler provides newHandler) { + content(RootRouterOwner(newHandler)) + } + } else content(RootRouterOwner(current)) + } + +@Composable +private fun RouteHandler( + child: Route?, + setChild: (Route?) -> Unit, + modifier: Modifier = Modifier, + listenToGlobalEmitter: Boolean = true, + transitionSpec: TransitionSpec = defaultTransitionSpec, + content: @Composable RouteHandlerScope.() -> Unit +) = ProvideRootRouter { + val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher + val parameters = rememberSaveable { arrayOfNulls(4) } + + if (listenToGlobalEmitter && child == null) OnGlobalRoute { (route, args) -> + args.forEachIndexed(parameters::set) + setChild(route) + } + + var predictiveBackProgress: Float? by remember { mutableStateOf(null) } + CallbackPredictiveBackHandler( + enabled = child != null, + onStart = { predictiveBackProgress = 0f }, + onProgress = { predictiveBackProgress = it }, + onFinish = { + predictiveBackProgress = null + setChild(null) + }, + onCancel = { + predictiveBackProgress = null + } + ) + + fun Route?.scope() = RouteHandlerScope( + child = this, + args = parameters, + replace = setChild, + pop = { backDispatcher?.onBackPressed() }, + root = router + ) + + val transitionState = remember { SeekableTransitionState(child) } + + if (predictiveBackProgress == null) LaunchedEffect(child) { + if (transitionState.currentState != child) transitionState.animateTo(child) + } else LaunchedEffect(predictiveBackProgress) { + transitionState.seekTo( + fraction = predictiveBackProgress ?: 0f, + targetState = null + ) + } + + rememberTransition( + transitionState = transitionState, + label = null + ).AnimatedContent( + transitionSpec = transitionSpec, + modifier = modifier + ) { + val scope = remember(it) { it.scope() } + + LaunchedEffect(predictiveBackProgress, scope) { + if (predictiveBackProgress == null && scope.child == null) router.current = scope + } + + scope.content() + } +} diff --git a/compose/routing/src/main/kotlin/app/vimusic/compose/routing/Route.kt b/compose/routing/src/main/kotlin/app/vimusic/compose/routing/Route.kt new file mode 100644 index 0000000..bd8ecca --- /dev/null +++ b/compose/routing/src/main/kotlin/app/vimusic/compose/routing/Route.kt @@ -0,0 +1,113 @@ +@file:Suppress("UNCHECKED_CAST") + +package app.vimusic.compose.routing + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.parcelize.Parcelize + +@Parcelize +@Immutable +sealed class Route : Parcelable { + abstract val tag: String + + override fun equals(other: Any?) = when { + this === other -> true + other is Route -> tag == other.tag + else -> false + } + + override fun hashCode() = tag.hashCode() + + protected fun global(args: Array) = globalRouteFlow.tryEmit( + RouteRequest( + route = this, + args = args + ) + ) + + protected suspend fun ensureGlobal(args: Array) { + globalRouteFlow.subscriptionCount.filter { it > 0 }.first() + globalRouteFlow.emit( + RouteRequest( + route = this, + args = args + ) + ) + } +} + +@Immutable +class Route0(override val tag: String) : Route() { + context(RouteHandlerScope) + @Composable + operator fun invoke(content: @Composable () -> Unit) { + if (this == child) content() + } + + fun global() = global(emptyArray()) + suspend fun ensureGlobal() = ensureGlobal(emptyArray()) +} + +@Immutable +class Route1(override val tag: String) : Route() { + context(RouteHandlerScope) + @Composable + operator fun invoke(content: @Composable (P0) -> Unit) { + if (this == child) content(args[0] as P0) + } + + fun global(p0: P0) = global(arrayOf(p0)) + suspend fun ensureGlobal(p0: P0) = ensureGlobal(arrayOf(p0)) +} + +@Immutable +class Route2(override val tag: String) : Route() { + context(RouteHandlerScope) + @Composable + operator fun invoke(content: @Composable (P0, P1) -> Unit) { + if (this == child) content( + args[0] as P0, + args[1] as P1 + ) + } + + fun global(p0: P0, p1: P1) = global(arrayOf(p0, p1)) + suspend fun ensureGlobal(p0: P0, p1: P1) = ensureGlobal(arrayOf(p0, p1)) +} + +@Immutable +class Route3(override val tag: String) : Route() { + context(RouteHandlerScope) + @Composable + operator fun invoke(content: @Composable (P0, P1, P2) -> Unit) { + if (this == child) content( + args[0] as P0, + args[1] as P1, + args[2] as P2 + ) + } + + fun global(p0: P0, p1: P1, p2: P2) = global(arrayOf(p0, p1, p2)) + suspend fun ensureGlobal(p0: P0, p1: P1, p2: P2) = ensureGlobal(arrayOf(p0, p1, p2)) +} + +@Immutable +class Route4(override val tag: String) : Route() { + context(RouteHandlerScope) + @Composable + operator fun invoke(content: @Composable (P0, P1, P2, P3) -> Unit) { + if (this == child) content( + args[0] as P0, + args[1] as P1, + args[2] as P2, + args[3] as P3 + ) + } + + fun global(p0: P0, p1: P1, p2: P2, p3: P3) = global(arrayOf(p0, p1, p2, p3)) + suspend fun ensureGlobal(p0: P0, p1: P1, p2: P2, p3: P3) = ensureGlobal(arrayOf(p0, p1, p2, p3)) +} diff --git a/compose/routing/src/main/kotlin/app/vimusic/compose/routing/RouteHandlerScope.kt b/compose/routing/src/main/kotlin/app/vimusic/compose/routing/RouteHandlerScope.kt new file mode 100644 index 0000000..56666f9 --- /dev/null +++ b/compose/routing/src/main/kotlin/app/vimusic/compose/routing/RouteHandlerScope.kt @@ -0,0 +1,18 @@ +package app.vimusic.compose.routing + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable + +@Stable +class RouteHandlerScope( + val child: Route?, + val args: Array, + val replace: (Route?) -> Unit, + override val pop: () -> Unit, + val root: RootRouter +) : Router by root { + @Composable + inline fun Content(content: @Composable () -> Unit) { + if (child == null) content() + } +} diff --git a/compose/routing/src/main/kotlin/app/vimusic/compose/routing/Transitions.kt b/compose/routing/src/main/kotlin/app/vimusic/compose/routing/Transitions.kt new file mode 100644 index 0000000..9d4b240 --- /dev/null +++ b/compose/routing/src/main/kotlin/app/vimusic/compose/routing/Transitions.kt @@ -0,0 +1,34 @@ +package app.vimusic.compose.routing + +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleOut + +val defaultStacking = ContentTransform( + initialContentExit = scaleOut(targetScale = 0.9f) + fadeOut(), + targetContentEnter = fadeIn(), + targetContentZIndex = 1f +) + +val defaultUnstacking = ContentTransform( + initialContentExit = scaleOut(targetScale = 1.1f) + fadeOut(), + targetContentEnter = EnterTransition.None, + targetContentZIndex = 0f +) + +val defaultStill = ContentTransform( + initialContentExit = scaleOut(targetScale = 0.9f) + fadeOut(), + targetContentEnter = fadeIn(), + targetContentZIndex = 1f +) + +val TransitionScope<*>.isStacking: Boolean + get() = initialState == null && targetState != null + +val TransitionScope<*>.isUnstacking: Boolean + get() = initialState != null && targetState == null + +val TransitionScope<*>.isStill: Boolean + get() = initialState == null && targetState == null diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts new file mode 100644 index 0000000..62eb008 --- /dev/null +++ b/core/data/build.gradle.kts @@ -0,0 +1,32 @@ +plugins { + alias(libs.plugins.kotlin.android) + alias(libs.plugins.android.library) +} + +android { + namespace = "app.vimusic.core.data" + compileSdk = 35 + + defaultConfig { + minSdk = 21 + } + + sourceSets.all { + kotlin.srcDir("src/$name/kotlin") + } +} + +kotlin { + jvmToolchain(libs.versions.jvm.get().toInt()) + + task("testClasses") +} + +dependencies { + implementation(libs.core.ktx) + + api(libs.kotlin.datetime) + + detektPlugins(libs.detekt.compose) + detektPlugins(libs.detekt.formatting) +} diff --git a/core/data/src/main/AndroidManifest.xml b/core/data/src/main/AndroidManifest.xml new file mode 100644 index 0000000..e100076 --- /dev/null +++ b/core/data/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/AlbumSortBy.kt b/core/data/src/main/kotlin/app/vimusic/core/data/enums/AlbumSortBy.kt similarity index 63% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/AlbumSortBy.kt rename to core/data/src/main/kotlin/app/vimusic/core/data/enums/AlbumSortBy.kt index 4d99975..552e098 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/AlbumSortBy.kt +++ b/core/data/src/main/kotlin/app/vimusic/core/data/enums/AlbumSortBy.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.vimusic.enums +package app.vimusic.core.data.enums enum class AlbumSortBy { Title, diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/ArtistSortBy.kt b/core/data/src/main/kotlin/app/vimusic/core/data/enums/ArtistSortBy.kt similarity index 59% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/ArtistSortBy.kt rename to core/data/src/main/kotlin/app/vimusic/core/data/enums/ArtistSortBy.kt index 2df4053..2868aca 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/ArtistSortBy.kt +++ b/core/data/src/main/kotlin/app/vimusic/core/data/enums/ArtistSortBy.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.vimusic.enums +package app.vimusic.core.data.enums enum class ArtistSortBy { Name, diff --git a/core/data/src/main/kotlin/app/vimusic/core/data/enums/BuiltInPlaylist.kt b/core/data/src/main/kotlin/app/vimusic/core/data/enums/BuiltInPlaylist.kt new file mode 100644 index 0000000..925517a --- /dev/null +++ b/core/data/src/main/kotlin/app/vimusic/core/data/enums/BuiltInPlaylist.kt @@ -0,0 +1,8 @@ +package app.vimusic.core.data.enums + +enum class BuiltInPlaylist(val sortable: Boolean) { + Favorites(sortable = true), + Offline(sortable = true), + Top(sortable = false), + History(sortable = false) +} diff --git a/core/data/src/main/kotlin/app/vimusic/core/data/enums/CoilDiskCacheSize.kt b/core/data/src/main/kotlin/app/vimusic/core/data/enums/CoilDiskCacheSize.kt new file mode 100644 index 0000000..866c3b2 --- /dev/null +++ b/core/data/src/main/kotlin/app/vimusic/core/data/enums/CoilDiskCacheSize.kt @@ -0,0 +1,13 @@ +package app.vimusic.core.data.enums + +import app.vimusic.core.data.utils.mb + +@Suppress("unused", "EnumEntryName") +enum class CoilDiskCacheSize(val bytes: Long) { + `64MB`(bytes = 64.mb), + `128MB`(bytes = 128.mb), + `256MB`(bytes = 256.mb), + `512MB`(bytes = 512.mb), + `1GB`(bytes = 1024.mb), + `2GB`(bytes = 2048.mb) +} diff --git a/core/data/src/main/kotlin/app/vimusic/core/data/enums/ExoPlayerDiskCacheSize.kt b/core/data/src/main/kotlin/app/vimusic/core/data/enums/ExoPlayerDiskCacheSize.kt new file mode 100644 index 0000000..09b89c1 --- /dev/null +++ b/core/data/src/main/kotlin/app/vimusic/core/data/enums/ExoPlayerDiskCacheSize.kt @@ -0,0 +1,17 @@ +package app.vimusic.core.data.enums + +import app.vimusic.core.data.utils.mb + +@Suppress("EnumEntryName", "unused") +enum class ExoPlayerDiskCacheSize(val bytes: Long) { + `32MB`(bytes = 32.mb), + `64MB`(bytes = 64.mb), + `128MB`(bytes = 128.mb), + `256MB`(bytes = 256.mb), + `512MB`(bytes = 512.mb), + `1GB`(bytes = 1024.mb), + `2GB`(bytes = 2048.mb), + `4GB`(bytes = 4096.mb), + `8GB`(bytes = 8192.mb), + Unlimited(bytes = 0) +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/PlaylistSortBy.kt b/core/data/src/main/kotlin/app/vimusic/core/data/enums/PlaylistSortBy.kt similarity index 66% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/PlaylistSortBy.kt rename to core/data/src/main/kotlin/app/vimusic/core/data/enums/PlaylistSortBy.kt index 52c62ea..902e9f2 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/PlaylistSortBy.kt +++ b/core/data/src/main/kotlin/app/vimusic/core/data/enums/PlaylistSortBy.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.vimusic.enums +package app.vimusic.core.data.enums enum class PlaylistSortBy { Name, diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/SongSortBy.kt b/core/data/src/main/kotlin/app/vimusic/core/data/enums/SongSortBy.kt similarity index 64% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/SongSortBy.kt rename to core/data/src/main/kotlin/app/vimusic/core/data/enums/SongSortBy.kt index ff9889a..8c331c6 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/SongSortBy.kt +++ b/core/data/src/main/kotlin/app/vimusic/core/data/enums/SongSortBy.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.vimusic.enums +package app.vimusic.core.data.enums enum class SongSortBy { PlayTime, diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/SortOrder.kt b/core/data/src/main/kotlin/app/vimusic/core/data/enums/SortOrder.kt similarity index 82% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/SortOrder.kt rename to core/data/src/main/kotlin/app/vimusic/core/data/enums/SortOrder.kt index 769f02e..df8a326 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/SortOrder.kt +++ b/core/data/src/main/kotlin/app/vimusic/core/data/enums/SortOrder.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.vimusic.enums +package app.vimusic.core.data.enums enum class SortOrder { Ascending, diff --git a/core/data/src/main/kotlin/app/vimusic/core/data/utils/Bytes.kt b/core/data/src/main/kotlin/app/vimusic/core/data/utils/Bytes.kt new file mode 100644 index 0000000..102bcb3 --- /dev/null +++ b/core/data/src/main/kotlin/app/vimusic/core/data/utils/Bytes.kt @@ -0,0 +1,3 @@ +package app.vimusic.core.data.utils + +val Int.mb get() = this * 1_048_576L diff --git a/core/data/src/main/kotlin/app/vimusic/core/data/utils/CallValidator.kt b/core/data/src/main/kotlin/app/vimusic/core/data/utils/CallValidator.kt new file mode 100644 index 0000000..3d7d871 --- /dev/null +++ b/core/data/src/main/kotlin/app/vimusic/core/data/utils/CallValidator.kt @@ -0,0 +1,141 @@ +package app.vimusic.core.data.utils + +import android.Manifest +import android.content.Context +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.content.res.XmlResourceParser +import android.os.Process +import androidx.annotation.XmlRes +import java.security.MessageDigest + +/** + * Stateful caller validator for Android intents, based on XML caller data + */ +class CallValidator( + context: Context, + @XmlRes callerList: Int +) { + private val packageManager = context.packageManager + + private val whitelist = runCatching { + context.resources.getXml(callerList) + }.getOrNull()?.let(Whitelist::parse) ?: Whitelist() + private val systemSignature = getPackageInfo("android")?.signature + + private val cache = mutableMapOf, Boolean>() + + fun canCall(pak: String, uid: Int) = cache.getOrPut(pak to uid) cache@{ + val info = getPackageInfo(pak) ?: return@cache false + if (info.applicationInfo?.uid != uid) return@cache false + val signature = info.signature ?: return@cache false + + val permissions = info.requestedPermissions?.filterIndexed { index, _ -> + info + .requestedPermissionsFlags + ?.getOrNull(index) + ?.let { it and PackageInfo.REQUESTED_PERMISSION_GRANTED != 0 } == true + } + + when { + uid == Process.myUid() -> true + uid == Process.SYSTEM_UID -> true + signature == systemSignature -> true + whitelist.isWhitelisted(pak, signature) -> true + permissions != null && Manifest.permission.MEDIA_CONTENT_CONTROL in permissions -> true + permissions != null && Manifest.permission.BIND_NOTIFICATION_LISTENER_SERVICE in permissions -> true + else -> false + } + } + + @Suppress("DEPRECATION") // backwards compat + private fun getPackageInfo( + pak: String, + flags: Int = PackageManager.GET_SIGNATURES or PackageManager.GET_PERMISSIONS + ) = runCatching { + packageManager.getPackageInfo( + /* packageName = */ pak, + /* flags = */ flags + ) + }.getOrNull() + + @Suppress("DEPRECATION") // backwards compat + private val PackageInfo.signature + get() = signatures?.let { signatures -> + if (signatures.size != 1) null + else signatures.firstOrNull()?.toByteArray()?.sha256 + } + + @Suppress("ImplicitDefaultLocale") // not relevant + private val ByteArray.sha256: String? + get() = runCatching { + val md = MessageDigest.getInstance("SHA256") + md.update(this) + md.digest() + }.getOrNull()?.joinToString(":") { String.format("%02x", it) } +} + +@JvmInline +value class Whitelist(private val map: WhitelistMap = mapOf()) { + companion object { + fun parse(parser: XmlResourceParser) = Whitelist( + buildMap { + runCatching { + var event = parser.next() + + while (event != XmlResourceParser.END_DOCUMENT) { + if (event == XmlResourceParser.START_TAG && parser.name == "signature") + putV2Tag(parser) + + event = parser.next() + } + } + } + ) + + private fun MutableMap>.putV2Tag(parser: XmlResourceParser) = + runCatching { + val pak = parser.getAttributeValue( + /* namespace = */ null, + /* name = */ "package" + ) + val keys = buildSet { + var event = parser.next() + while (event != XmlResourceParser.END_TAG) { + add( + Key( + release = parser.getAttributeBooleanValue( + /* namespace = */ null, + /* attribute = */ "release", + /* defaultValue = */ false + ), + signature = parser + .nextText() + .replace(WHITESPACE_REGEX, "") + .lowercase() + ) + ) + + event = parser.next() + } + } + + put( + key = pak, + value = keys + ) + } + } + + fun isWhitelisted(pak: String, signature: String) = + map[pak]?.first { it.signature == signature } != null + + data class Key( + val signature: String, + val release: Boolean + ) +} + +typealias WhitelistMap = Map> + +private val WHITESPACE_REGEX = "\\s|\\n".toRegex() diff --git a/core/data/src/main/kotlin/app/vimusic/core/data/utils/RingBuffer.kt b/core/data/src/main/kotlin/app/vimusic/core/data/utils/RingBuffer.kt new file mode 100644 index 0000000..6f8475e --- /dev/null +++ b/core/data/src/main/kotlin/app/vimusic/core/data/utils/RingBuffer.kt @@ -0,0 +1,54 @@ +package app.vimusic.core.data.utils + +import android.net.Uri +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +open class RingBuffer(val size: Int, private val init: (index: Int) -> T) : Iterable { + private val list = MutableList(size, init) + + @get:Synchronized + @set:Synchronized + private var index = 0 + + operator fun get(index: Int) = list.getOrNull(index) + operator fun plusAssign(element: T) { + list[index++ % size] = element + } + + override fun iterator() = list.iterator() + + fun clear() = list.indices.forEach { + list[it] = init(it) + } +} + +class UriCache(size: Int = 16) { + private val buffer = RingBuffer?>(size) { null } + + data class CachedUri internal constructor( + val key: Key, + val meta: Meta, + val uri: Uri, + val validUntil: Instant? + ) + + operator fun get(key: Key) = buffer.find { + it != null && + it.key == key && + (it.validUntil == null || it.validUntil > Clock.System.now()) + } + + fun push( + key: Key, + meta: Meta, + uri: Uri, + validUntil: Instant? + ) { + if (validUntil != null && validUntil <= Clock.System.now()) return + + buffer += CachedUri(key, meta, uri, validUntil) + } + + fun clear() = buffer.clear() +} diff --git a/core/data/src/main/kotlin/app/vimusic/core/data/utils/Versions.kt b/core/data/src/main/kotlin/app/vimusic/core/data/utils/Versions.kt new file mode 100644 index 0000000..01e45a0 --- /dev/null +++ b/core/data/src/main/kotlin/app/vimusic/core/data/utils/Versions.kt @@ -0,0 +1,22 @@ +package app.vimusic.core.data.utils + +inline val String.version get() = Version( + removePrefix("v") + .split(".") + .mapNotNull { it.toIntOrNull() } +) + +@JvmInline +value class Version(private val parts: List) { + val major get() = parts.firstOrNull() + val minor get() = parts.getOrNull(1) + val patch get() = parts.getOrNull(2) + + companion object { + private val comparator = compareBy { it.major } then + compareBy { it.minor } then + compareBy { it.patch } + } + + operator fun compareTo(other: Version) = comparator.compare(this, other) +} diff --git a/core/material-compat/build.gradle.kts b/core/material-compat/build.gradle.kts new file mode 100644 index 0000000..c432760 --- /dev/null +++ b/core/material-compat/build.gradle.kts @@ -0,0 +1,34 @@ +plugins { + alias(libs.plugins.kotlin.android) + alias(libs.plugins.android.library) +} + +android { + namespace = "com.google.android.material" + compileSdk = 35 + + defaultConfig { + minSdk = 21 + } + + sourceSets.all { + kotlin.srcDir("src/$name/kotlin") + } + + kotlinOptions { + freeCompilerArgs = freeCompilerArgs + listOf("-Xcontext-receivers") + } +} + +dependencies { + implementation(projects.core.ui) + + detektPlugins(libs.detekt.compose) + detektPlugins(libs.detekt.formatting) +} + +kotlin { + jvmToolchain(libs.versions.jvm.get().toInt()) + + task("testClasses") +} diff --git a/core/material-compat/src/main/kotlin/com/google/android/material/color/DynamicColors.kt b/core/material-compat/src/main/kotlin/com/google/android/material/color/DynamicColors.kt new file mode 100644 index 0000000..74f16cd --- /dev/null +++ b/core/material-compat/src/main/kotlin/com/google/android/material/color/DynamicColors.kt @@ -0,0 +1,9 @@ +package com.google.android.material.color + +import app.vimusic.core.ui.utils.isAtLeastAndroid12 + +@Suppress("unused") +object DynamicColors { + @JvmStatic + fun isDynamicColorAvailable() = isAtLeastAndroid12 +} diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts new file mode 100644 index 0000000..cd3399a --- /dev/null +++ b/core/ui/build.gradle.kts @@ -0,0 +1,48 @@ +plugins { + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.parcelize) +} + +android { + namespace = "app.vimusic.core.ui" + compileSdk = 35 + + defaultConfig { + minSdk = 21 + } + + sourceSets.all { + kotlin.srcDir("src/$name/kotlin") + } + + kotlinOptions { + freeCompilerArgs = freeCompilerArgs + listOf("-Xcontext-receivers") + } +} + +dependencies { + implementation(projects.core.data) + + implementation(libs.core.ktx) + + implementation(platform(libs.compose.bom)) + implementation(libs.compose.animation) + implementation(libs.compose.foundation) + implementation(libs.compose.shimmer) + implementation(libs.compose.ui) + implementation(libs.compose.ui.util) + implementation(libs.compose.ui.fonts) + implementation(libs.compose.material3) + implementation(libs.palette) + + detektPlugins(libs.detekt.compose) + detektPlugins(libs.detekt.formatting) +} + +kotlin { + jvmToolchain(libs.versions.jvm.get().toInt()) + + task("testClasses") +} diff --git a/core/ui/src/main/AndroidManifest.xml b/core/ui/src/main/AndroidManifest.xml new file mode 100644 index 0000000..e100076 --- /dev/null +++ b/core/ui/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/core/ui/src/main/kotlin/app/vimusic/core/ui/Appearance.kt b/core/ui/src/main/kotlin/app/vimusic/core/ui/Appearance.kt new file mode 100644 index 0000000..9f03a68 --- /dev/null +++ b/core/ui/src/main/kotlin/app/vimusic/core/ui/Appearance.kt @@ -0,0 +1,121 @@ +package app.vimusic.core.ui + +import android.app.Activity +import android.graphics.Bitmap +import android.os.Parcelable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.Dp +import androidx.core.view.WindowCompat +import app.vimusic.core.ui.utils.isAtLeastAndroid6 +import app.vimusic.core.ui.utils.isAtLeastAndroid8 +import app.vimusic.core.ui.utils.isCompositionLaunched +import app.vimusic.core.ui.utils.roundedShape +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize + +@Parcelize +@Immutable +data class Appearance( + val colorPalette: ColorPalette, + val typography: Typography, + val thumbnailShapeCorners: ParcelableDp +) : Parcelable { + @IgnoredOnParcel + val thumbnailShape = thumbnailShapeCorners.roundedShape + operator fun component4() = thumbnailShape +} + +val LocalAppearance = staticCompositionLocalOf { error("No appearance provided") } + +@Composable +inline fun rememberAppearance( + vararg keys: Any = arrayOf(Unit), + isDark: Boolean = isSystemInDarkTheme(), + crossinline provide: (isSystemInDarkTheme: Boolean) -> Appearance +) = rememberSaveable(keys, isCompositionLaunched(), isDark) { + mutableStateOf(provide(isDark)) +} + +@Composable +fun appearance( + source: ColorSource, + mode: ColorMode, + darkness: Darkness, + materialAccentColor: Color?, + sampleBitmap: Bitmap?, + fontFamily: BuiltInFontFamily, + applyFontPadding: Boolean, + thumbnailRoundness: Dp, + isSystemInDarkTheme: Boolean = isSystemInDarkTheme() +): Appearance { + val isDark = remember(mode, isSystemInDarkTheme) { + mode == ColorMode.Dark || (mode == ColorMode.System && isSystemInDarkTheme) + } + + val colorPalette = rememberSaveable( + source, + darkness, + isDark, + materialAccentColor, + sampleBitmap + ) { + colorPaletteOf( + source = source, + darkness = darkness, + isDark = isDark, + materialAccentColor = materialAccentColor, + sampleBitmap = sampleBitmap + ) + } + + return rememberAppearance( + colorPalette, + fontFamily, + applyFontPadding, + thumbnailRoundness, + isDark = isDark + ) { + Appearance( + colorPalette = colorPalette, + typography = typographyOf( + color = colorPalette.text, + fontFamily = fontFamily, + applyFontPadding = applyFontPadding + ), + thumbnailShapeCorners = thumbnailRoundness + ) + }.value +} + +fun Activity.setSystemBarAppearance(isDark: Boolean) { + with(WindowCompat.getInsetsController(window, window.decorView.rootView)) { + isAppearanceLightStatusBars = !isDark + isAppearanceLightNavigationBars = !isDark + } + + val color = (if (isDark) Color.Transparent else Color.Black.copy(alpha = 0.2f)).toArgb() + + // TODO: Android now expects a background behind the system bars as well + @Suppress("DEPRECATION") + if (!isAtLeastAndroid6) window.statusBarColor = color + @Suppress("DEPRECATION") + if (!isAtLeastAndroid8) window.navigationBarColor = color +} + +@Composable +fun Activity.SystemBarAppearance(palette: ColorPalette) = LaunchedEffect(palette) { + withContext(Dispatchers.Main) { + setSystemBarAppearance(palette.isDark) + } +} diff --git a/core/ui/src/main/kotlin/app/vimusic/core/ui/ColorPalette.kt b/core/ui/src/main/kotlin/app/vimusic/core/ui/ColorPalette.kt new file mode 100644 index 0000000..0fa9ab8 --- /dev/null +++ b/core/ui/src/main/kotlin/app/vimusic/core/ui/ColorPalette.kt @@ -0,0 +1,269 @@ +package app.vimusic.core.ui + +import android.graphics.Bitmap +import android.os.Parcel +import android.os.Parcelable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.palette.graphics.Palette +import kotlinx.parcelize.Parceler +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.WriteWith + +typealias ParcelableColor = @WriteWith Color +typealias ParcelableDp = @WriteWith Dp + +@Parcelize +@Immutable +data class ColorPalette( + val background0: ParcelableColor, + val background1: ParcelableColor, + val background2: ParcelableColor, + val accent: ParcelableColor, + val onAccent: ParcelableColor, + val red: ParcelableColor = Color(0xffbf4040), + val blue: ParcelableColor = Color(0xff4472cf), + val yellow: ParcelableColor = Color(0xfffff176), + val text: ParcelableColor, + val textSecondary: ParcelableColor, + val textDisabled: ParcelableColor, + val isDefault: Boolean, + val isDark: Boolean +) : Parcelable + +private val defaultAccentColor = Color(0xff3e44ce).hsl + +val defaultLightPalette = ColorPalette( + background0 = Color(0xfffdfdfe), + background1 = Color(0xfff8f8fc), + background2 = Color(0xffeaeaf5), + text = Color(0xff212121), + textSecondary = Color(0xff656566), + textDisabled = Color(0xff9d9d9d), + accent = defaultAccentColor.color, + onAccent = Color.White, + isDefault = true, + isDark = false +) + +val defaultDarkPalette = ColorPalette( + background0 = Color(0xff16171d), + background1 = Color(0xff1f2029), + background2 = Color(0xff2b2d3b), + text = Color(0xffe1e1e2), + textSecondary = Color(0xffa3a4a6), + textDisabled = Color(0xff6f6f73), + accent = defaultAccentColor.color, + onAccent = Color.White, + isDefault = true, + isDark = true +) + +private fun lightColorPalette(accent: Hsl) = lightColorPalette( + hue = accent.hue, + saturation = accent.saturation +) + +private fun lightColorPalette(hue: Float, saturation: Float) = ColorPalette( + background0 = Color.hsl( + hue = hue, + saturation = saturation.coerceAtMost(0.1f), + lightness = 0.925f + ), + background1 = Color.hsl( + hue = hue, + saturation = saturation.coerceAtMost(0.3f), + lightness = 0.90f + ), + background2 = Color.hsl( + hue = hue, + saturation = saturation.coerceAtMost(0.4f), + lightness = 0.85f + ), + text = Color.hsl( + hue = hue, + saturation = saturation.coerceAtMost(0.02f), + lightness = 0.12f + ), + textSecondary = Color.hsl( + hue = hue, + saturation = saturation.coerceAtMost(0.1f), + lightness = 0.40f + ), + textDisabled = Color.hsl( + hue = hue, + saturation = saturation.coerceAtMost(0.2f), + lightness = 0.65f + ), + accent = Color.hsl( + hue = hue, + saturation = saturation.coerceAtMost(0.5f), + lightness = 0.5f + ), + onAccent = Color.White, + isDefault = false, + isDark = false +) + +private fun darkColorPalette(accent: Hsl, darkness: Darkness) = darkColorPalette( + hue = accent.hue, + saturation = accent.saturation, + darkness = darkness +) + +private fun darkColorPalette( + hue: Float, + saturation: Float, + darkness: Darkness +) = ColorPalette( + background0 = if (darkness == Darkness.Normal) Color.hsl( + hue = hue, + saturation = saturation.coerceAtMost(0.1f), + lightness = 0.10f + ) else Color.Black, + background1 = if (darkness == Darkness.Normal) Color.hsl( + hue = hue, + saturation = saturation.coerceAtMost(0.3f), + lightness = 0.15f + ) else Color.Black, + background2 = if (darkness == Darkness.Normal) Color.hsl( + hue = hue, + saturation = saturation.coerceAtMost(0.4f), + lightness = 0.2f + ) else Color.Black, + text = Color.hsl( + hue = hue, + saturation = saturation.coerceAtMost(0.02f), + lightness = 0.88f + ), + textSecondary = Color.hsl( + hue = hue, + saturation = saturation.coerceAtMost(0.1f), + lightness = 0.65f + ), + textDisabled = Color.hsl( + hue = hue, + saturation = saturation.coerceAtMost(0.2f), + lightness = 0.40f + ), + accent = Color.hsl( + hue = hue, + saturation = saturation.coerceAtMost(if (darkness == Darkness.AMOLED) 0.4f else 0.5f), + lightness = 0.5f + ), + onAccent = Color.White, + isDefault = false, + isDark = true +) + +fun accentColorOf( + source: ColorSource, + isDark: Boolean, + materialAccentColor: Color?, + sampleBitmap: Bitmap? +) = when (source) { + ColorSource.Default -> defaultAccentColor + ColorSource.Dynamic -> sampleBitmap?.let { dynamicAccentColorOf(it, isDark) } + ?: defaultAccentColor + + ColorSource.MaterialYou -> materialAccentColor?.hsl ?: defaultAccentColor +} + +fun dynamicAccentColorOf( + bitmap: Bitmap, + isDark: Boolean +): Hsl? { + val palette = Palette + .from(bitmap) + .maximumColorCount(8) + .addFilter(if (isDark) ({ _, hsl -> hsl[0] !in 36f..100f }) else null) + .generate() + + val hsl = if (isDark) { + palette.dominantSwatch ?: Palette + .from(bitmap) + .maximumColorCount(8) + .generate() + .dominantSwatch + } else { + palette.dominantSwatch + }?.hsl ?: return null + + val arr = if (hsl[1] < 0.08) + palette.swatches + .map(Palette.Swatch::getHsl) + .sortedByDescending(FloatArray::component2) + .find { it[1] != 0f } + ?: hsl + else hsl + + return arr.hsl +} + +fun ColorPalette.amoled() = if (isDark) { + val (hue, saturation) = accent.hsl + + copy( + background0 = Color.hsl( + hue = hue, + saturation = saturation.coerceAtMost(0.1f), + lightness = 0.10f + ), + background1 = Color.hsl( + hue = hue, + saturation = saturation.coerceAtMost(0.3f), + lightness = 0.15f + ), + background2 = Color.hsl( + hue = hue, + saturation = saturation.coerceAtMost(0.4f), + lightness = 0.2f + ) + ) +} else this + +fun colorPaletteOf( + source: ColorSource, + darkness: Darkness, + isDark: Boolean, + materialAccentColor: Color?, + sampleBitmap: Bitmap? +): ColorPalette { + val accentColor = accentColorOf( + source = source, + isDark = isDark, + materialAccentColor = materialAccentColor, + sampleBitmap = sampleBitmap + ) + + return (if (isDark) darkColorPalette(accentColor, darkness) else lightColorPalette(accentColor)) + .copy(isDefault = accentColor == defaultAccentColor) +} + +inline val ColorPalette.isPureBlack get() = background0 == Color.Black +inline val ColorPalette.collapsedPlayerProgressBar + get() = if (isPureBlack) defaultDarkPalette.background0 else background2 +inline val ColorPalette.favoritesIcon get() = if (isDefault) red else accent +inline val ColorPalette.shimmer get() = if (isDefault) Color(0xff838383) else accent +inline val ColorPalette.primaryButton get() = if (isPureBlack) Color(0xff272727) else background2 + +@Suppress("UnusedReceiverParameter") +inline val ColorPalette.overlay get() = Color.Black.copy(alpha = 0.75f) + +@Suppress("UnusedReceiverParameter") +inline val ColorPalette.onOverlay get() = defaultDarkPalette.text + +@Suppress("UnusedReceiverParameter") +inline val ColorPalette.onOverlayShimmer get() = defaultDarkPalette.shimmer + +object ColorParceler : Parceler { + override fun Color.write(parcel: Parcel, flags: Int) = parcel.writeLong(value.toLong()) + override fun create(parcel: Parcel) = Color(parcel.readLong()) +} + +object DpParceler : Parceler { + override fun Dp.write(parcel: Parcel, flags: Int) = parcel.writeFloat(value) + override fun create(parcel: Parcel) = parcel.readFloat().dp +} diff --git a/core/ui/src/main/kotlin/app/vimusic/core/ui/Dimensions.kt b/core/ui/src/main/kotlin/app/vimusic/core/ui/Dimensions.kt new file mode 100644 index 0000000..6bfda19 --- /dev/null +++ b/core/ui/src/main/kotlin/app/vimusic/core/ui/Dimensions.kt @@ -0,0 +1,46 @@ +package app.vimusic.core.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.dp + +object Dimensions { + object Thumbnails { + val album = 108.dp + val artist = 92.dp + val song = 54.dp + val playlist = album + + val player = Player + + object Player { + val song + @Composable get() = with(LocalConfiguration.current) { + minOf(screenHeightDp, screenWidthDp) + }.dp + } + } + + val thumbnails = Thumbnails + + object Items { + val moodHeight = 64.dp + val headerHeight = 140.dp + val collapsedPlayerHeight = 64.dp + + val verticalPadding = 8.dp + val horizontalPadding = 12.dp + + val gap = 4.dp + } + + val items = Items + + object NavigationRail { + val width = 64.dp + val widthLandscape = 128.dp + val iconOffset = 6.dp + } + + val navigationRail = NavigationRail +} diff --git a/core/ui/src/main/kotlin/app/vimusic/core/ui/Enums.kt b/core/ui/src/main/kotlin/app/vimusic/core/ui/Enums.kt new file mode 100644 index 0000000..09dc672 --- /dev/null +++ b/core/ui/src/main/kotlin/app/vimusic/core/ui/Enums.kt @@ -0,0 +1,34 @@ +package app.vimusic.core.ui + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import app.vimusic.core.ui.utils.roundedShape + +enum class ThumbnailRoundness(val dp: Dp) { + None(0.dp), + Light(2.dp), + Medium(8.dp), + Heavy(12.dp), + Heavier(16.dp), + Heaviest(18.dp); + + val shape get() = dp.roundedShape +} + +enum class ColorSource { + Default, + Dynamic, + MaterialYou +} + +enum class ColorMode { + System, + Light, + Dark +} + +enum class Darkness { + Normal, + AMOLED, + PureBlack +} diff --git a/core/ui/src/main/kotlin/app/vimusic/core/ui/Hsl.kt b/core/ui/src/main/kotlin/app/vimusic/core/ui/Hsl.kt new file mode 100644 index 0000000..1e8e25a --- /dev/null +++ b/core/ui/src/main/kotlin/app/vimusic/core/ui/Hsl.kt @@ -0,0 +1,40 @@ +package app.vimusic.core.ui + +import androidx.compose.runtime.saveable.SaverScope +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.core.graphics.ColorUtils + +@Suppress("NOTHING_TO_INLINE") +@JvmInline +value class Hsl(@PublishedApi internal val raw: FloatArray) { + object Saver : androidx.compose.runtime.saveable.Saver { + override fun restore(value: FloatArray) = value.hsl + override fun SaverScope.save(value: Hsl) = value.raw + } + + init { + assert(raw.size == 3) { "Invalid Hsl value! Expected size: 3, actual size: ${raw.size}" } + } + + inline val hue get() = raw[0] + inline val saturation get() = raw[1] + inline val lightness get() = raw[2] + + inline val color + get() = Color.hsl( + hue = hue, + saturation = saturation, + lightness = lightness + ) + + inline operator fun component1() = hue + inline operator fun component2() = saturation + inline operator fun component3() = lightness +} + +val FloatArray.hsl get() = Hsl(raw = this) +val Color.hsl + get() = FloatArray(3) + .apply { ColorUtils.colorToHSL(this@Color.toArgb(), this) } + .hsl diff --git a/core/ui/src/main/kotlin/app/vimusic/core/ui/Ripple.kt b/core/ui/src/main/kotlin/app/vimusic/core/ui/Ripple.kt new file mode 100644 index 0000000..c8f6e51 --- /dev/null +++ b/core/ui/src/main/kotlin/app/vimusic/core/ui/Ripple.kt @@ -0,0 +1,49 @@ +package app.vimusic.core.ui + +import androidx.compose.material.ripple.RippleAlpha +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.RippleConfiguration +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun rippleConfiguration(appearance: Appearance = LocalAppearance.current) = remember( + appearance.colorPalette.text, + appearance.colorPalette.isDark +) { + val (colorPalette) = appearance + + RippleConfiguration( + color = if (colorPalette.isDark && colorPalette.text.luminance() < 0.5) Color.White + else colorPalette.text, + rippleAlpha = when { + colorPalette.isDark -> DarkThemeRippleAlpha + colorPalette.text.luminance() > 0.5f -> LightThemeHighContrastRippleAlpha + else -> LightThemeLowContrastRippleAlpha + } + ) +} + +private val LightThemeHighContrastRippleAlpha = RippleAlpha( + pressedAlpha = 0.24f, + focusedAlpha = 0.24f, + draggedAlpha = 0.16f, + hoveredAlpha = 0.08f +) + +private val LightThemeLowContrastRippleAlpha = RippleAlpha( + pressedAlpha = 0.12f, + focusedAlpha = 0.12f, + draggedAlpha = 0.08f, + hoveredAlpha = 0.04f +) + +private val DarkThemeRippleAlpha = RippleAlpha( + pressedAlpha = 0.10f, + focusedAlpha = 0.12f, + draggedAlpha = 0.08f, + hoveredAlpha = 0.04f +) diff --git a/core/ui/src/main/kotlin/app/vimusic/core/ui/Shimmer.kt b/core/ui/src/main/kotlin/app/vimusic/core/ui/Shimmer.kt new file mode 100644 index 0000000..68f5456 --- /dev/null +++ b/core/ui/src/main/kotlin/app/vimusic/core/ui/Shimmer.kt @@ -0,0 +1,29 @@ +package app.vimusic.core.ui + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import com.valentinilk.shimmer.defaultShimmerTheme + +@Composable +fun shimmerTheme() = remember { + defaultShimmerTheme.copy( + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 800, + easing = LinearEasing, + delayMillis = 250 + ), + repeatMode = RepeatMode.Restart + ), + shaderColors = listOf( + Color.Unspecified.copy(alpha = 0.25f), + Color.White.copy(alpha = 0.50f), + Color.Unspecified.copy(alpha = 0.25f) + ) + ) +} diff --git a/core/ui/src/main/kotlin/app/vimusic/core/ui/Typography.kt b/core/ui/src/main/kotlin/app/vimusic/core/ui/Typography.kt new file mode 100644 index 0000000..cfbb04b --- /dev/null +++ b/core/ui/src/main/kotlin/app/vimusic/core/ui/Typography.kt @@ -0,0 +1,180 @@ +package app.vimusic.core.ui + +import android.os.Parcel +import android.os.Parcelable +import android.util.Log +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.PlatformTextStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.googlefonts.Font +import androidx.compose.ui.text.googlefonts.GoogleFont +import androidx.compose.ui.text.googlefonts.isAvailableOnDevice +import androidx.compose.ui.unit.sp +import kotlinx.parcelize.Parceler +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.parcelableCreator + +@Parcelize +@Immutable +data class Typography( + internal val style: TextStyle, + internal val fontFamily: BuiltInFontFamily +) : Parcelable { + val xxs by lazy { style.copy(fontSize = 12.sp) } + val xs by lazy { style.copy(fontSize = 14.sp) } + val s by lazy { style.copy(fontSize = 16.sp) } + val m by lazy { style.copy(fontSize = 18.sp) } + val l by lazy { style.copy(fontSize = 20.sp) } + val xxl by lazy { style.copy(fontSize = 32.sp) } + + fun copy(color: Color) = Typography( + style = style.copy(color = color), + fontFamily = fontFamily + ) + + companion object : Parceler { + override fun Typography.write(parcel: Parcel, flags: Int) = SavedTypography( + color = style.color, + fontFamily = fontFamily, + includeFontPadding = style.platformStyle?.paragraphStyle?.includeFontPadding ?: false + ).writeToParcel(parcel, flags) + + override fun create(parcel: Parcel) = + parcelableCreator().createFromParcel(parcel).let { + typographyOf( + color = it.color, + fontFamily = it.fontFamily, + applyFontPadding = it.includeFontPadding + ) + } + } +} + +@Parcelize +data class SavedTypography( + val color: ParcelableColor, + val fontFamily: BuiltInFontFamily, + val includeFontPadding: Boolean +) : Parcelable + +private val googleFontsProvider = GoogleFont.Provider( + providerAuthority = "com.google.android.gms.fonts", + providerPackage = "com.google.android.gms", + certificates = R.array.com_google_android_gms_fonts_certs +) + +@Composable +fun googleFontsAvailable(): Boolean { + val context = LocalContext.current + + return runCatching { + googleFontsProvider.isAvailableOnDevice(context.applicationContext) + }.getOrElse { + it.printStackTrace() + if (it is IllegalStateException) Log.e( + "Typography", + "Google Fonts certificates don't match. Is the user using a VPN?" + ) + false + } +} + +private val poppinsFonts = listOf( + Font( + resId = R.font.poppins_w300, + weight = FontWeight.Light + ), + Font( + resId = R.font.poppins_w400, + weight = FontWeight.Normal + ), + Font( + resId = R.font.poppins_w500, + weight = FontWeight.Medium + ), + Font( + resId = R.font.poppins_w600, + weight = FontWeight.SemiBold + ), + Font( + resId = R.font.poppins_w700, + weight = FontWeight.Bold + ) +) + +private val poppinsFontFamily = FontFamily(poppinsFonts) + +@Parcelize +enum class BuiltInFontFamily(internal val googleFont: GoogleFont?) : Parcelable { + Poppins(null), + Roboto(GoogleFont("Roboto")), + Montserrat(GoogleFont("Montserrat")), + Nunito(GoogleFont("Nunito")), + Rubik(GoogleFont("Rubik")), + System(null); + + companion object : Parceler { + override fun BuiltInFontFamily.write(parcel: Parcel, flags: Int) = parcel.writeString(name) + override fun create(parcel: Parcel) = BuiltInFontFamily.valueOf(parcel.readString()!!) + } +} + +private fun googleFontsFamilyFrom(font: BuiltInFontFamily) = font.googleFont?.let { + FontFamily( + listOf( + Font( + googleFont = it, + fontProvider = googleFontsProvider, + weight = FontWeight.Light + ), + Font( + googleFont = it, + fontProvider = googleFontsProvider, + weight = FontWeight.Normal + ), + Font( + googleFont = it, + fontProvider = googleFontsProvider, + weight = FontWeight.Medium + ), + Font( + googleFont = it, + fontProvider = googleFontsProvider, + weight = FontWeight.SemiBold + ), + Font( + googleFont = it, + fontProvider = googleFontsProvider, + weight = FontWeight.Bold + ) + ) + poppinsFonts + ) +} + +fun typographyOf( + color: Color, + fontFamily: BuiltInFontFamily, + applyFontPadding: Boolean +): Typography { + val textStyle = TextStyle( + fontFamily = when { + fontFamily == BuiltInFontFamily.System -> FontFamily.Default + fontFamily.googleFont != null -> googleFontsFamilyFrom(fontFamily) + else -> poppinsFontFamily + }, + fontWeight = FontWeight.Normal, + color = color, + platformStyle = PlatformTextStyle(includeFontPadding = applyFontPadding) + ) + + return Typography( + style = textStyle, + fontFamily = fontFamily + ) +} diff --git a/core/ui/src/main/kotlin/app/vimusic/core/ui/utils/Audio.kt b/core/ui/src/main/kotlin/app/vimusic/core/ui/utils/Audio.kt new file mode 100644 index 0000000..7ec8c0e --- /dev/null +++ b/core/ui/src/main/kotlin/app/vimusic/core/ui/utils/Audio.kt @@ -0,0 +1,46 @@ +package app.vimusic.core.ui.utils + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.media.AudioManager +import android.os.Bundle +import androidx.core.content.ContextCompat +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.callbackFlow + +fun Context.streamVolumeFlow( + stream: Int = AudioManager.STREAM_MUSIC, + @ContextCompat.RegisterReceiverFlags + flags: Int = ContextCompat.RECEIVER_NOT_EXPORTED +) = callbackFlow { + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val extras = intent.extras?.volumeChangedIntentBundle ?: return + if (stream == extras.streamType) trySend(extras.value) + } + } + + ContextCompat.registerReceiver( + /* context = */ this@Context, + /* receiver = */ receiver, + /* filter = */ IntentFilter(VolumeChangedIntentBundleAccessor.ACTION), + /* flags = */ flags + ) + awaitClose { unregisterReceiver(receiver) } +} + +class VolumeChangedIntentBundleAccessor(val bundle: Bundle = Bundle()) : BundleAccessor { + companion object { + const val ACTION = "android.media.VOLUME_CHANGED_ACTION" + + fun bundle(block: VolumeChangedIntentBundleAccessor.() -> Unit) = + VolumeChangedIntentBundleAccessor().apply(block).bundle + } + + var streamType by bundle.int("android.media.EXTRA_VOLUME_STREAM_TYPE") + var value by bundle.int("android.media.EXTRA_VOLUME_STREAM_VALUE") +} + +inline val Bundle.volumeChangedIntentBundle get() = VolumeChangedIntentBundleAccessor(this) diff --git a/core/ui/src/main/kotlin/app/vimusic/core/ui/utils/Bundle.kt b/core/ui/src/main/kotlin/app/vimusic/core/ui/utils/Bundle.kt new file mode 100644 index 0000000..fc54d2c --- /dev/null +++ b/core/ui/src/main/kotlin/app/vimusic/core/ui/utils/Bundle.kt @@ -0,0 +1,294 @@ +package app.vimusic.core.ui.utils + +import android.annotation.SuppressLint +import android.app.SearchManager +import android.content.Intent +import android.media.audiofx.AudioEffect +import android.os.Bundle +import android.provider.MediaStore +import androidx.annotation.IntDef +import kotlin.properties.PropertyDelegateProvider +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty + +/** + * Marker interface that marks a class as Bundle accessor + */ +interface BundleAccessor + +private inline fun Bundle.bundleDelegate( + name: String? = null, + crossinline get: Bundle.(String) -> T, + crossinline set: Bundle.(k: String, v: T) -> Unit +) = PropertyDelegateProvider> { _, property -> + val actualName = name ?: property.name + + object : ReadWriteProperty { + override fun getValue(thisRef: BundleAccessor, property: KProperty<*>) = + get(this@Bundle, actualName) + + override fun setValue(thisRef: BundleAccessor, property: KProperty<*>, value: T) = + set(this@Bundle, actualName, value) + } +} + +context(BundleAccessor) +val Bundle.boolean get() = boolean() + +context(BundleAccessor) +fun Bundle.boolean(name: String? = null) = bundleDelegate( + name = name, + get = { getBoolean(it) }, + set = { k, v -> putBoolean(k, v) } +) + +context(BundleAccessor) +val Bundle.byte get() = byte() + +context(BundleAccessor) +fun Bundle.byte(name: String? = null) = bundleDelegate( + name = name, + get = { getByte(it) }, + set = { k, v -> putByte(k, v) } +) + +context(BundleAccessor) +val Bundle.char get() = char() + +context(BundleAccessor) +fun Bundle.char(name: String? = null) = bundleDelegate( + name = name, + get = { getChar(it) }, + set = { k, v -> putChar(k, v) } +) + +context(BundleAccessor) +val Bundle.short get() = short() + +context(BundleAccessor) +fun Bundle.short(name: String? = null) = bundleDelegate( + name = name, + get = { getShort(it) }, + set = { k, v -> putShort(k, v) } +) + +context(BundleAccessor) +val Bundle.int get() = int() + +context(BundleAccessor) +fun Bundle.int(name: String? = null) = bundleDelegate( + name = name, + get = { getInt(it) }, + set = { k, v -> putInt(k, v) } +) + +context(BundleAccessor) +val Bundle.long get() = long() + +context(BundleAccessor) +fun Bundle.long(name: String? = null) = bundleDelegate( + name = name, + get = { getLong(it) }, + set = { k, v -> putLong(k, v) } +) + +context(BundleAccessor) +val Bundle.float get() = float() + +context(BundleAccessor) +fun Bundle.float(name: String? = null) = bundleDelegate( + name = name, + get = { getFloat(it) }, + set = { k, v -> putFloat(k, v) } +) + +context(BundleAccessor) +val Bundle.double get() = double() + +context(BundleAccessor) +fun Bundle.double(name: String? = null) = bundleDelegate( + name = name, + get = { getDouble(it) }, + set = { k, v -> putDouble(k, v) } +) + +context(BundleAccessor) +val Bundle.string get() = string() + +context(BundleAccessor) +fun Bundle.string(name: String? = null) = bundleDelegate( + name = name, + get = { getString(it) }, + set = { k, v -> putString(k, v) } +) + +context(BundleAccessor) +val Bundle.intList get() = intList() + +context(BundleAccessor) +fun Bundle.intList(name: String? = null) = bundleDelegate( + name = name, + get = { getIntegerArrayList(it) }, + set = { k, v -> putIntegerArrayList(k, v) } +) + +context(BundleAccessor) +val Bundle.stringList get() = stringList() + +context(BundleAccessor) +fun Bundle.stringList(name: String? = null) = bundleDelegate?>( + name = name, + get = { getStringArrayList(it) }, + set = { k, v -> putStringArrayList(k, v?.let { ArrayList(it) }) } +) + +context(BundleAccessor) +val Bundle.booleanArray get() = booleanArray() + +context(BundleAccessor) +fun Bundle.booleanArray(name: String? = null) = bundleDelegate( + name = name, + get = { getBooleanArray(it) }, + set = { k, v -> putBooleanArray(k, v) } +) + +context(BundleAccessor) +val Bundle.byteArray get() = byteArray() + +context(BundleAccessor) +fun Bundle.byteArray(name: String? = null) = bundleDelegate( + name = name, + get = { getByteArray(it) }, + set = { k, v -> putByteArray(k, v) } +) + +context(BundleAccessor) +val Bundle.shortArray get() = shortArray() + +context(BundleAccessor) +fun Bundle.shortArray(name: String? = null) = bundleDelegate( + name = name, + get = { getShortArray(it) }, + set = { k, v -> putShortArray(k, v) } +) + +context(BundleAccessor) +val Bundle.charArray get() = charArray() + +context(BundleAccessor) +fun Bundle.charArray(name: String? = null) = bundleDelegate( + name = name, + get = { getCharArray(it) }, + set = { k, v -> putCharArray(k, v) } +) + +context(BundleAccessor) +val Bundle.intArray get() = intArray() + +context(BundleAccessor) +fun Bundle.intArray(name: String? = null) = bundleDelegate( + name = name, + get = { getIntArray(it) }, + set = { k, v -> putIntArray(k, v) } +) + +context(BundleAccessor) +val Bundle.floatArray get() = floatArray() + +context(BundleAccessor) +fun Bundle.floatArray(name: String? = null) = bundleDelegate( + name = name, + get = { getFloatArray(it) }, + set = { k, v -> putFloatArray(k, v) } +) + +context(BundleAccessor) +val Bundle.doubleArray get() = doubleArray() + +context(BundleAccessor) +fun Bundle.doubleArray(name: String? = null) = bundleDelegate( + name = name, + get = { getDoubleArray(it) }, + set = { k, v -> putDoubleArray(k, v) } +) + +context(BundleAccessor) +val Bundle.stringArray get() = stringArray() + +context(BundleAccessor) +fun Bundle.stringArray(name: String? = null) = bundleDelegate( + name = name, + get = { getStringArray(it) }, + set = { k, v -> putStringArray(k, v) } +) + +class SongBundleAccessor(val extras: Bundle = Bundle()) : BundleAccessor { + companion object { + fun bundle(block: SongBundleAccessor.() -> Unit) = SongBundleAccessor().apply(block).extras + } + + var albumId by extras.string + var durationText by extras.string + var artistNames by extras.stringList + var artistIds by extras.stringList + var explicit by extras.boolean + var isFromPersistentQueue by extras.boolean +} + +inline val Bundle.songBundle get() = SongBundleAccessor(this) + +class ActivityIntentBundleAccessor(val extras: Bundle = Bundle()) : BundleAccessor { + companion object { + fun bundle(block: ActivityIntentBundleAccessor.() -> Unit) = ActivityIntentBundleAccessor().apply(block).extras + } + + var query by extras.string(SearchManager.QUERY) + var text by extras.string(Intent.EXTRA_TEXT) + var mediaFocus by extras.string(MediaStore.EXTRA_MEDIA_FOCUS) + + var album by extras.string(MediaStore.EXTRA_MEDIA_ALBUM) + var artist by extras.string(MediaStore.EXTRA_MEDIA_ARTIST) + var genre by extras.string("android.intent.extra.genre") + var playlist by extras.string("android.intent.extra.playlist") + var title by extras.string(MediaStore.EXTRA_MEDIA_TITLE) +} + +inline val Bundle.activityIntentBundle get() = ActivityIntentBundleAccessor(this) + +@Retention(AnnotationRetention.SOURCE) +@Target( + AnnotationTarget.FIELD, + AnnotationTarget.FUNCTION, + AnnotationTarget.VALUE_PARAMETER, + AnnotationTarget.LOCAL_VARIABLE, + AnnotationTarget.TYPE, + AnnotationTarget.PROPERTY_GETTER, + AnnotationTarget.PROPERTY_SETTER, + AnnotationTarget.PROPERTY +) +@IntDef( + AudioEffect.CONTENT_TYPE_MUSIC, + AudioEffect.CONTENT_TYPE_MOVIE, + AudioEffect.CONTENT_TYPE_GAME, + AudioEffect.CONTENT_TYPE_VOICE +) +annotation class ContentType + +class EqualizerIntentBundleAccessor(val extras: Bundle = Bundle()) : BundleAccessor { + companion object { + fun bundle(block: EqualizerIntentBundleAccessor.() -> Unit) = + EqualizerIntentBundleAccessor().apply(block).extras + } + + var audioSession by extras.int(AudioEffect.EXTRA_AUDIO_SESSION) + var packageName by extras.string(AudioEffect.EXTRA_PACKAGE_NAME) + var contentType by extras.int(AudioEffect.EXTRA_CONTENT_TYPE) + @ContentType + get + + @SuppressLint("SupportAnnotationUsage") + @ContentType + set +} + +inline val Bundle.equalizerIntentBundle get() = EqualizerIntentBundleAccessor(this) diff --git a/core/ui/src/main/kotlin/app/vimusic/core/ui/utils/Configuration.kt b/core/ui/src/main/kotlin/app/vimusic/core/ui/utils/Configuration.kt new file mode 100644 index 0000000..cb7787a --- /dev/null +++ b/core/ui/src/main/kotlin/app/vimusic/core/ui/utils/Configuration.kt @@ -0,0 +1,59 @@ +package app.vimusic.core.ui.utils + +import android.content.res.Configuration +import android.os.Build +import androidx.annotation.ChecksSdkIntAtLeast +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalConfiguration + +val isLandscape + @Composable + @ReadOnlyComposable + get() = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + +@get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.M) +inline val isAtLeastAndroid6 + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + +@get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.N) +inline val isAtLeastAndroid7 + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N + +@get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O) +inline val isAtLeastAndroid8 + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O + +@get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.P) +inline val isAtLeastAndroid9 + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O + +@get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.Q) +inline val isAtLeastAndroid10 + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + +@get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.R) +inline val isAtLeastAndroid11 + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R + +@get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S) +inline val isAtLeastAndroid12 + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + +@get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.TIRAMISU) +inline val isAtLeastAndroid13 + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + +@Composable +fun isCompositionLaunched(): Boolean { + var isLaunched by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + isLaunched = true + } + return isLaunched +} diff --git a/core/ui/src/main/kotlin/app/vimusic/core/ui/utils/Dp.kt b/core/ui/src/main/kotlin/app/vimusic/core/ui/utils/Dp.kt new file mode 100644 index 0000000..9366864 --- /dev/null +++ b/core/ui/src/main/kotlin/app/vimusic/core/ui/utils/Dp.kt @@ -0,0 +1,6 @@ +package app.vimusic.core.ui.utils + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.unit.Dp + +inline val Dp.roundedShape get() = RoundedCornerShape(this) diff --git a/core/ui/src/main/kotlin/app/vimusic/core/ui/utils/Pixels.kt b/core/ui/src/main/kotlin/app/vimusic/core/ui/utils/Pixels.kt new file mode 100644 index 0000000..93b4bc2 --- /dev/null +++ b/core/ui/src/main/kotlin/app/vimusic/core/ui/utils/Pixels.kt @@ -0,0 +1,29 @@ +@file:Suppress("NOTHING_TO_INLINE") + +package app.vimusic.core.ui.utils + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import kotlin.math.roundToInt + +@JvmInline +value class Px(val value: Int) { + inline val dp @Composable get() = dp(LocalDensity.current) + inline fun dp(density: Density) = with(density) { value.toDp() } +} + +inline val Int.px inline get() = Px(value = this) +inline val Float.px inline get() = roundToInt().px + +inline val Dp.px: Int + @Composable + inline get() = with(LocalDensity.current) { roundToPx() } + +inline val TextUnit.dp + @Composable + inline get() = dp(LocalDensity.current) + +inline fun TextUnit.dp(density: Density) = with(density) { toDp() } diff --git a/core/ui/src/main/kotlin/app/vimusic/core/ui/utils/Saver.kt b/core/ui/src/main/kotlin/app/vimusic/core/ui/utils/Saver.kt new file mode 100644 index 0000000..26815c0 --- /dev/null +++ b/core/ui/src/main/kotlin/app/vimusic/core/ui/utils/Saver.kt @@ -0,0 +1,32 @@ +package app.vimusic.core.ui.utils + +import android.os.Parcelable +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.toMutableStateList +import kotlinx.coroutines.flow.MutableStateFlow + +fun stateFlowSaver() = stateFlowSaverOf( + from = { it }, + to = { it } +) + +inline fun stateFlowSaverOf( + crossinline from: (Saveable) -> Type, + crossinline to: (Type) -> Saveable +) = object : Saver, Saveable> { + override fun restore(value: Saveable) = MutableStateFlow(from(value)) + override fun SaverScope.save(value: MutableStateFlow) = to(value.value) +} + +inline fun stateListSaver() = listSaver, T>( + save = { it.toList() }, + restore = { it.toMutableStateList() } +) + +inline fun > enumSaver() = object : Saver { + override fun restore(value: String) = enumValues().first { it.name == value } + override fun SaverScope.save(value: E) = value.name +} diff --git a/core/ui/src/main/res/font/poppins_w300.ttf b/core/ui/src/main/res/font/poppins_w300.ttf new file mode 100644 index 0000000000000000000000000000000000000000..2ab022196b0fad3910d38ae050ab6814be931799 GIT binary patch literal 159848 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 literal 0 HcmV?d00001 diff --git a/core/ui/src/main/res/font/poppins_w400.ttf b/core/ui/src/main/res/font/poppins_w400.ttf new file mode 100644 index 0000000000000000000000000000000000000000..9f0c71b70a49664ced448c63edc9c4ff2bf8cf4a GIT binary patch literal 158240 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%0AC7VmlS@LZ-TEZV}oirP&oT11@@uCp8_l( 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 + + + @array/com_google_android_gms_fonts_certs_dev + @array/com_google_android_gms_fonts_certs_prod + + + + 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 + + + diff --git a/detekt.yml b/detekt.yml new file mode 100644 index 0000000..5e08180 --- /dev/null +++ b/detekt.yml @@ -0,0 +1,126 @@ +config: + validation: true + warningsAsErrors: false + +Compose: + ComposableAnnotationNaming: + active: true + CompositionLocalAllowlist: + active: false + CompositionLocalNaming: + active: true + ContentEmitterReturningValues: + active: true + DefaultsVisibility: + active: true + ModifierClickableOrder: + active: true + ModifierComposable: + active: true + ModifierMissing: + active: true + ignoreAnnotated: + - app.vimusic.android.ui.screens.Route + ModifierNaming: + active: true + ModifierNotUsedAtRoot: + active: true + ModifierReused: + active: true + ModifierWithoutDefault: + active: true + MultipleEmitters: + active: true + MutableParams: + active: true + ComposableNaming: + active: true + ComposableParamOrder: + active: true + PreviewAnnotationNaming: + active: true + PreviewPublic: + active: true + RememberMissing: + active: true + RememberContentMissing: + active: true + UnstableCollections: + active: true + ViewModelForwarding: + active: true + ViewModelInjection: + active: true + +complexity: + ComplexCondition: + active: false + CyclomaticComplexMethod: + ignoreAnnotated: + - androidx.compose.runtime.Composable + LongParameterList: + ignoreAnnotated: + - androidx.compose.runtime.Composable + ignoreDefaultParameters: true + ignoreDataClasses: true + LongMethod: + active: false + TooManyFunctions: + excludes: + - '**/util/**' + - '**/utils/**' + +exceptions: + SwallowedException: + ignoredExceptionTypes: + - ActivityNotFoundException + +formatting: + AnnotationOnSeparateLine: + active: true + ignoreAnnotated: + - kotlinx.serialization.Serializable + CommentWrapping: + # Because argument names in comment are a thing: Java API's do not support named arguments + active: false + EnumEntryNameCase: + active: false # Handled by Android Lint + Indentation: + active: false # Idea/Android Studio handles indentation differently + MultiLineIfElse: + active: false + TrailingCommaOnCallSite: + active: true + useTrailingCommaOnCallSite: false + TrailingCommaOnDeclarationSite: + active: true + useTrailingCommaOnDeclarationSite: false + +naming: + EnumNaming: + active: false # Handled by Android Lint + FunctionNaming: + ignoreAnnotated: + - androidx.compose.runtime.Composable + MatchingDeclarationName: + active: false + TopLevelPropertyNaming: + constantPattern: '[A-Z][_A-Z0-9]*' + +style: + DestructuringDeclarationWithTooManyEntries: + active: false + ForbiddenComment: + active: false + MagicNumber: + active: false + MaxLineLength: + active: false # Overlaps with MaximumLineLength, ktlint preferred because of auto-correct + ModifierOrder: + active: false # Overlaps with ModifierOrdering, ktlint preferred because of auto-correct + NewLineAtEndOfFile: + active: false # Overlaps with FinalNewline, ktlint preferred because of auto-correct + ReturnCount: + active: false + ThrowsCount: + active: false diff --git a/gradle.properties b/gradle.properties index a0153bd..48bce2c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,8 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.caching=true +org.gradle.configuration-cache=true +org.gradle.parallel=true android.useAndroidX=true android.enableJetifier=false kotlin.code.style=official -android.enableR8.fullMode=true +android.nonTransitiveRClass=true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..911e917 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,88 @@ +[versions] +kotlin = "2.0.20" +ksp = "2.0.20-1.0.24" + +jvm = "17" +agp = "8.6.0" + +room = "2.6.1" +media3 = "1.4.1" +ktor = "3.0.0" +detekt = "1.23.6" +workmanager = "2.9.1" +credentials = "1.5.0-alpha06" +coil = "3.0.0-rc01" + +[plugins] +android_application = { id = "com.android.application", version.ref = "agp" } +android_library = { id = "com.android.library", version.ref = "agp" } +android_lint = { id = "com.android.lint", version.ref = "agp" } +kotlin_jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlin_android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin_serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +kotlin_compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin_parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } + +[libraries] +core_ktx = { module = "androidx.core:core-ktx", version = "1.13.1" } + +kotlin_coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version = "1.9.0" } +kotlin_datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version = "0.6.1" } +kotlin_immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version = "0.3.7" } + +compose_bom = { module = "androidx.compose:compose-bom", version = "2024.10.00" } +compose_animation = { module = "androidx.compose.animation:animation" } +compose_foundation = { module = "androidx.compose.foundation:foundation" } +compose_ui = { module = "androidx.compose.ui:ui" } +compose_ui_util = { module = "androidx.compose.ui:ui-util" } +compose_ui_fonts = { module = "androidx.compose.ui:ui-text-google-fonts" } +compose_material3 = { module = "androidx.compose.material3:material3", version = "1.3.0" } + +compose_activity = { module = "androidx.activity:activity-compose", version = "1.9.3" } +compose_shimmer = { module = "com.valentinilk.shimmer:compose-shimmer", version = "1.3.0" } +compose_lottie = { module = "com.airbnb.android:lottie-compose", version = "6.4.1" } + +coil_compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } +coil_ktor = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } + +room = { module = "androidx.room:room-ktx", version.ref = "room" } +room_compiler = { module = "androidx.room:room-compiler", version.ref = "room" } + +exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } +exoplayer_workmanager = { module = "androidx.media3:media3-exoplayer-workmanager", version.ref = "media3" } +media3_session = { module = "androidx.media3:media3-session", version.ref = "media3" } +media = { module = "androidx.media:media", version = "1.7.0" } + +workmanager = { module = "androidx.work:work-runtime", version.ref = "workmanager" } +workmanager_ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workmanager" } + +#noinspection CredentialDependency ==> thank you Android Lint, I added the dependency and this still flags! +credentials = { module = "androidx.credentials:credentials", version.ref = "credentials" } +credentials_play = { module = "androidx.credentials:credentials-play-services-auth", version.ref = "credentials" } + +ktor_http = { module = "io.ktor:ktor-http", version.ref = "ktor" } + +ktor_client_core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor_client_cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } +ktor_client_okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } +ktor_client_content_negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor_client_logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } +ktor_client_encoding = { module = "io.ktor:ktor-client-encoding", version.ref = "ktor" } +ktor_client_serialization = { module = "io.ktor:ktor-client-serialization", version.ref = "ktor" } +ktor_serialization_json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } + +rhino = { module = "org.mozilla:rhino", version = "1.7.15" } +log4j = { module = "org.apache.logging.log4j:log4j-api", version = "2.3" } +slf4j = { module = "org.slf4j:slf4j-api", version = "2.0.16" } +logback = { module = "com.github.tony19:logback-android", version = "3.0.0" } + +brotli = { module = "org.brotli:dec", version = "0.1.2" } +palette = { module = "androidx.palette:palette", version = "1.0.0" } +monet = { module = "com.github.KieronQuinn:MonetCompat", version = "0.4.1" } + +desugaring = { module = "com.android.tools:desugar_jdk_libs", version = "2.1.2" } + +detekt_compose = { module = "io.nlopez.compose.rules:detekt", version = "0.4.5" } +detekt_formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e708b1c023ec8b20f512888fe07c5bd3ff77bb8f..e6441136f3d4ba8a0da8d277868979cfbc8ad796 100644 GIT binary patch literal 43453 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 yu?e%!_ptzGB|8GrCfu%p?(_ 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

NnX)EH+Nua)3Y(c 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

K7UpUK$>l@ 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 E2O(~oYXATM diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index fada095..df97d72 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ -#Wed Jul 06 23:33:16 CEST 2022 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip distributionPath=wrapper/dists -zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 index 4f906e0..1aa94a4 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -17,67 +17,99 @@ # ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar @@ -87,9 +119,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,88 +130,120 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=`expr $i + 1` + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index 107acd3..25da30d 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -14,7 +14,7 @@ @rem limitations under the License. @rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +25,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,13 +41,13 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -56,11 +57,11 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -75,13 +76,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/innertube/.gitignore b/innertube/.gitignore deleted file mode 100644 index 42afabf..0000000 --- a/innertube/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/Innertube.kt b/innertube/src/main/kotlin/it/vfsfitvnm/innertube/Innertube.kt deleted file mode 100644 index 2443610..0000000 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/Innertube.kt +++ /dev/null @@ -1,203 +0,0 @@ -package it.vfsfitvnm.innertube - -import io.ktor.client.HttpClient -import io.ktor.client.engine.okhttp.OkHttp -import io.ktor.client.plugins.BrowserUserAgent -import io.ktor.client.plugins.compression.ContentEncoding -import io.ktor.client.plugins.compression.brotli -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.client.plugins.defaultRequest -import io.ktor.client.request.HttpRequestBuilder -import io.ktor.client.request.header -import io.ktor.http.ContentType -import io.ktor.http.HttpHeaders -import io.ktor.serialization.kotlinx.json.json -import it.vfsfitvnm.innertube.models.NavigationEndpoint -import it.vfsfitvnm.innertube.models.Runs -import it.vfsfitvnm.innertube.models.Thumbnail -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.json.Json - -object Innertube { - val client = HttpClient(OkHttp) { - BrowserUserAgent() - - expectSuccess = true - - install(ContentNegotiation) { - @OptIn(ExperimentalSerializationApi::class) - json(Json { - ignoreUnknownKeys = true - explicitNulls = false - encodeDefaults = true - }) - } - - install(ContentEncoding) { - brotli() - } - - defaultRequest { - url(scheme = "https", host ="music.youtube.com") { - headers.append(HttpHeaders.ContentType, ContentType.Application.Json.toString()) - headers.append("X-Goog-Api-Key", "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8") - parameters.append("prettyPrint", "false") - } - } - } - - internal const val browse = "/youtubei/v1/browse" - internal const val next = "/youtubei/v1/next" - internal const val player = "/youtubei/v1/player" - internal const val queue = "/youtubei/v1/music/get_queue" - internal const val search = "/youtubei/v1/search" - internal const val searchSuggestions = "/youtubei/v1/music/get_search_suggestions" - - internal const val musicResponsiveListItemRendererMask = "musicResponsiveListItemRenderer(flexColumns,fixedColumns,thumbnail,navigationEndpoint)" - internal const val musicTwoRowItemRendererMask = "musicTwoRowItemRenderer(thumbnailRenderer,title,subtitle,navigationEndpoint)" - const val playlistPanelVideoRendererMask = "playlistPanelVideoRenderer(title,navigationEndpoint,longBylineText,shortBylineText,thumbnail,lengthText)" - - internal fun HttpRequestBuilder.mask(value: String = "*") = - header("X-Goog-FieldMask", value) - - data class Info( - val name: String?, - val endpoint: T? - ) { - @Suppress("UNCHECKED_CAST") - constructor(run: Runs.Run) : this( - name = run.text, - endpoint = run.navigationEndpoint?.endpoint as T? - ) - } - - @JvmInline - value class SearchFilter(val value: String) { - companion object { - val Song = SearchFilter("EgWKAQIIAWoKEAkQBRAKEAMQBA%3D%3D") - val Video = SearchFilter("EgWKAQIQAWoKEAkQChAFEAMQBA%3D%3D") - val Album = SearchFilter("EgWKAQIYAWoKEAkQChAFEAMQBA%3D%3D") - val Artist = SearchFilter("EgWKAQIgAWoKEAkQChAFEAMQBA%3D%3D") - val CommunityPlaylist = SearchFilter("EgeKAQQoAEABagoQAxAEEAoQCRAF") - val FeaturedPlaylist = SearchFilter("EgeKAQQoADgBagwQDhAKEAMQBRAJEAQ%3D") - } - } - - sealed class Item { - abstract val thumbnail: Thumbnail? - abstract val key: String - } - - data class SongItem( - val info: Info?, - val authors: List>?, - val album: Info?, - val durationText: String?, - override val thumbnail: Thumbnail? - ) : Item() { - override val key get() = info!!.endpoint!!.videoId!! - - companion object - } - - data class VideoItem( - val info: Info?, - val authors: List>?, - val viewsText: String?, - val durationText: String?, - override val thumbnail: Thumbnail? - ) : Item() { - override val key get() = info!!.endpoint!!.videoId!! - - val isOfficialMusicVideo: Boolean - get() = info - ?.endpoint - ?.watchEndpointMusicSupportedConfigs - ?.watchEndpointMusicConfig - ?.musicVideoType == "MUSIC_VIDEO_TYPE_OMV" - - val isUserGeneratedContent: Boolean - get() = info - ?.endpoint - ?.watchEndpointMusicSupportedConfigs - ?.watchEndpointMusicConfig - ?.musicVideoType == "MUSIC_VIDEO_TYPE_UGC" - - companion object - } - - data class AlbumItem( - val info: Info?, - val authors: List>?, - val year: String?, - override val thumbnail: Thumbnail? - ) : Item() { - override val key get() = info!!.endpoint!!.browseId!! - - companion object - } - - data class ArtistItem( - val info: Info?, - val subscribersCountText: String?, - override val thumbnail: Thumbnail? - ) : Item() { - override val key get() = info!!.endpoint!!.browseId!! - - companion object - } - - data class PlaylistItem( - val info: Info?, - val channel: Info?, - val songCount: Int?, - override val thumbnail: Thumbnail? - ) : Item() { - override val key get() = info!!.endpoint!!.browseId!! - - companion object - } - - data class ArtistPage( - val name: String?, - val description: String?, - val thumbnail: Thumbnail?, - val shuffleEndpoint: NavigationEndpoint.Endpoint.Watch?, - val radioEndpoint: NavigationEndpoint.Endpoint.Watch?, - val songs: List?, - val songsEndpoint: NavigationEndpoint.Endpoint.Browse?, - val albums: List?, - val albumsEndpoint: NavigationEndpoint.Endpoint.Browse?, - val singles: List?, - val singlesEndpoint: NavigationEndpoint.Endpoint.Browse?, - ) - - data class PlaylistOrAlbumPage( - val title: String?, - val authors: List>?, - val year: String?, - val thumbnail: Thumbnail?, - val url: String?, - val songsPage: ItemsPage?, - val otherVersions: List? - ) - - data class NextPage( - val itemsPage: ItemsPage?, - val playlistId: String?, - val params: String? = null, - val playlistSetVideoId: String? = null - ) - - data class RelatedPage( - val songs: List? = null, - val playlists: List? = null, - val albums: List? = null, - val artists: List? = null, - ) - - data class ItemsPage( - val items: List?, - val continuation: String? - ) -} diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/Context.kt b/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/Context.kt deleted file mode 100644 index 369d7f0..0000000 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/Context.kt +++ /dev/null @@ -1,53 +0,0 @@ -package it.vfsfitvnm.innertube.models - -import kotlinx.serialization.Serializable - -@Serializable -data class Context( - val client: Client, - val thirdParty: ThirdParty? = null, -) { - @Serializable - data class Client( - val clientName: String, - val clientVersion: String, - val platform: String, - val hl: String = "en", - val visitorData: String = "CgtEUlRINDFjdm1YayjX1pSaBg%3D%3D", - val androidSdkVersion: Int? = null, - val userAgent: String? = null - ) - - @Serializable - data class ThirdParty( - val embedUrl: String, - ) - - companion object { - val DefaultWeb = Context( - client = Client( - clientName = "WEB_REMIX", - clientVersion = "1.20220918", - platform = "DESKTOP", - ) - ) - - val DefaultAndroid = Context( - client = Client( - clientName = "ANDROID_MUSIC", - clientVersion = "5.28.1", - platform = "MOBILE", - androidSdkVersion = 30, - userAgent = "com.google.android.apps.youtube.music/5.28.1 (Linux; U; Android 11) gzip" - ) - ) - - val DefaultAgeRestrictionBypass = Context( - client = Client( - clientName = "TVHTML5_SIMPLY_EMBEDDED_PLAYER", - clientVersion = "2.0", - platform = "TV" - ) - ) - } -} diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/GridRenderer.kt b/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/GridRenderer.kt deleted file mode 100644 index 2a000cf..0000000 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/GridRenderer.kt +++ /dev/null @@ -1,13 +0,0 @@ -package it.vfsfitvnm.innertube.models - -import kotlinx.serialization.Serializable - -@Serializable -data class GridRenderer( - val items: List?, -) { - @Serializable - data class Item( - val musicTwoRowItemRenderer: MusicTwoRowItemRenderer? - ) -} diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/MusicTwoRowItemRenderer.kt b/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/MusicTwoRowItemRenderer.kt deleted file mode 100644 index 17f755d..0000000 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/MusicTwoRowItemRenderer.kt +++ /dev/null @@ -1,11 +0,0 @@ -package it.vfsfitvnm.innertube.models - -import kotlinx.serialization.Serializable - -@Serializable -data class MusicTwoRowItemRenderer( - val navigationEndpoint: NavigationEndpoint?, - val thumbnailRenderer: ThumbnailRenderer?, - val title: Runs?, - val subtitle: Runs?, -) diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/NavigationEndpoint.kt b/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/NavigationEndpoint.kt deleted file mode 100644 index 91efa5c..0000000 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/NavigationEndpoint.kt +++ /dev/null @@ -1,203 +0,0 @@ -package it.vfsfitvnm.innertube.models - -import kotlinx.serialization.Serializable - -/** - * watchPlaylistEndpoint: params, playlistId - * watchEndpoint: params, playlistId, videoId, index - * browseEndpoint: params, browseId - * searchEndpoint: params, query - */ -//@Serializable -//data class NavigationEndpoint( -// @JsonNames("watchEndpoint", "watchPlaylistEndpoint", "navigationEndpoint", "browseEndpoint", "searchEndpoint") -// val endpoint: Endpoint -//) { -// @Serializable -// data class Endpoint( -// val params: String?, -// val playlistId: String?, -// val videoId: String?, -// val index: Int?, -// val browseId: String?, -// val query: String?, -// val watchEndpointMusicSupportedConfigs: WatchEndpointMusicSupportedConfigs?, -// val browseEndpointContextSupportedConfigs: BrowseEndpointContextSupportedConfigs?, -// ) { -// @Serializable -// data class WatchEndpointMusicSupportedConfigs( -// val watchEndpointMusicConfig: WatchEndpointMusicConfig -// ) { -// @Serializable -// data class WatchEndpointMusicConfig( -// val musicVideoType: String -// ) -// } -// -// @Serializable -// data class BrowseEndpointContextSupportedConfigs( -// val browseEndpointContextMusicConfig: BrowseEndpointContextMusicConfig -// ) { -// @Serializable -// data class BrowseEndpointContextMusicConfig( -// val pageType: String -// ) -// } -// } -//} - -@Serializable -data class NavigationEndpoint( - val watchEndpoint: Endpoint.Watch?, - val watchPlaylistEndpoint: Endpoint.WatchPlaylist?, - val browseEndpoint: Endpoint.Browse?, - val searchEndpoint: Endpoint.Search?, -) { - val endpoint: Endpoint? - get() = watchEndpoint ?: browseEndpoint ?: watchPlaylistEndpoint ?: searchEndpoint - - @Serializable - sealed class Endpoint { - @Serializable - data class Watch( - val params: String? = null, - val playlistId: String? = null, - val videoId: String? = null, - val index: Int? = null, - val playlistSetVideoId: String? = null, - val watchEndpointMusicSupportedConfigs: WatchEndpointMusicSupportedConfigs? = null, - ) : Endpoint() { - val type: String? - get() = watchEndpointMusicSupportedConfigs - ?.watchEndpointMusicConfig - ?.musicVideoType - - @Serializable - data class WatchEndpointMusicSupportedConfigs( - val watchEndpointMusicConfig: WatchEndpointMusicConfig? - ) { - - @Serializable - data class WatchEndpointMusicConfig( - val musicVideoType: String? - ) - } - } - - @Serializable - data class WatchPlaylist( - val params: String?, - val playlistId: String?, - ) : Endpoint() - - @Serializable - data class Browse( - val params: String? = null, - val browseId: String? = null, - val browseEndpointContextSupportedConfigs: BrowseEndpointContextSupportedConfigs? = null, - ) : Endpoint() { - val type: String? - get() = browseEndpointContextSupportedConfigs - ?.browseEndpointContextMusicConfig - ?.pageType - - @Serializable - data class BrowseEndpointContextSupportedConfigs( - val browseEndpointContextMusicConfig: BrowseEndpointContextMusicConfig - ) { - - @Serializable - data class BrowseEndpointContextMusicConfig( - val pageType: String - ) - } - } - - @Serializable - data class Search( - val params: String?, - val query: String, - ) : Endpoint() - } -} - -//@Serializable(with = NavigationEndpoint.Serializer::class) -//sealed class NavigationEndpoint { -// @Serializable -// data class Watch( -// val watchEndpoint: Data -// ) : NavigationEndpoint() { -// @Serializable -// data class Data( -// val params: String?, -// val playlistId: String, -// val videoId: String, -//// val index: Int? -// val watchEndpointMusicSupportedConfigs: WatchEndpointMusicSupportedConfigs, -// ) -// -// @Serializable -// data class WatchEndpointMusicSupportedConfigs( -// val watchEndpointMusicConfig: WatchEndpointMusicConfig -// ) { -// @Serializable -// data class WatchEndpointMusicConfig( -// val musicVideoType: String -// ) -// } -// } -// -// @Serializable -// data class WatchPlaylist( -// val watchPlaylistEndpoint: Data -// ) : NavigationEndpoint() { -// @Serializable -// data class Data( -// val params: String?, -// val playlistId: String, -// ) -// } -// -// @Serializable -// data class Browse( -// val browseEndpoint: Data -// ) : NavigationEndpoint() { -// @Serializable -// data class Data( -// val params: String?, -// val browseId: String, -// val browseEndpointContextSupportedConfigs: BrowseEndpointContextSupportedConfigs, -// ) -// -// @Serializable -// data class BrowseEndpointContextSupportedConfigs( -// val browseEndpointContextMusicConfig: BrowseEndpointContextMusicConfig -// ) { -// @Serializable -// data class BrowseEndpointContextMusicConfig( -// val pageType: String -// ) -// } -// } -// -// @Serializable -// data class Search( -// val searchEndpoint: Data -// ) : NavigationEndpoint() { -// @Serializable -// data class Data( -// val params: String?, -// val query: String, -// ) -// } -// -// object Serializer : JsonContentPolymorphicSerializer(NavigationEndpoint::class) { -// override fun selectDeserializer(element: JsonElement) = when { -// "watchEndpoint" in element.jsonObject -> Watch.serializer() -// "watchPlaylistEndpoint" in element.jsonObject -> WatchPlaylist.serializer() -// "browseEndpoint" in element.jsonObject -> Browse.serializer() -// "searchEndpoint" in element.jsonObject -> Search.serializer() -// else -> TODO() -// } -// } -//} diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/Runs.kt b/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/Runs.kt deleted file mode 100644 index c2a9463..0000000 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/Runs.kt +++ /dev/null @@ -1,31 +0,0 @@ -package it.vfsfitvnm.innertube.models - -import kotlinx.serialization.Serializable - -@Serializable -data class Runs( - val runs: List = listOf() -) { - val text: String - get() = runs.joinToString("") { it.text ?: "" } - - fun splitBySeparator(): List> { - return runs.flatMapIndexed { index, run -> - when { - index == 0 || index == runs.lastIndex -> listOf(index) - run.text == " • " -> listOf(index - 1, index + 1) - else -> emptyList() - } - }.windowed(size = 2, step = 2) { (from, to) -> runs.slice(from..to) }.let { - it.ifEmpty { - listOf(runs) - } - } - } - - @Serializable - data class Run( - val text: String?, - val navigationEndpoint: NavigationEndpoint?, - ) -} diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/Thumbnail.kt b/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/Thumbnail.kt deleted file mode 100644 index 7db4281..0000000 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/Thumbnail.kt +++ /dev/null @@ -1,21 +0,0 @@ -package it.vfsfitvnm.innertube.models - -import kotlinx.serialization.Serializable - -@Serializable -data class Thumbnail( - val url: String, - val height: Int?, - val width: Int? -) { - val isResizable: Boolean - get() = !url.startsWith("https://i.ytimg.com") - - fun size(size: Int): String { - return when { - url.startsWith("https://lh3.googleusercontent.com") -> "$url-w$size-h$size" - url.startsWith("https://yt3.ggpht.com") -> "$url-s$size" - else -> url - } - } -} diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/ThumbnailRenderer.kt b/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/ThumbnailRenderer.kt deleted file mode 100644 index 1153a4f..0000000 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/ThumbnailRenderer.kt +++ /dev/null @@ -1,22 +0,0 @@ -package it.vfsfitvnm.innertube.models - -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonNames - -@OptIn(ExperimentalSerializationApi::class) -@Serializable -data class ThumbnailRenderer( - @JsonNames("croppedSquareThumbnailRenderer") - val musicThumbnailRenderer: MusicThumbnailRenderer? -) { - @Serializable - data class MusicThumbnailRenderer( - val thumbnail: Thumbnail? - ) { - @Serializable - data class Thumbnail( - val thumbnails: List? - ) - } -} diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/TwoColResults.kt b/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/TwoColResults.kt deleted file mode 100644 index 459e7c0..0000000 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/TwoColResults.kt +++ /dev/null @@ -1,14 +0,0 @@ -package it.vfsfitvnm.innertube.models - -import kotlinx.serialization.Serializable - -@Serializable -data class TwoColResults ( - val secondaryContents: SecondaryContents?, - val tabs: List? -) { - @Serializable - data class SecondaryContents ( - val sectionListRenderer: SectionListRenderer? - ) -} \ No newline at end of file diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/bodies/ContinuationBody.kt b/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/bodies/ContinuationBody.kt deleted file mode 100644 index 946dd11..0000000 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/bodies/ContinuationBody.kt +++ /dev/null @@ -1,10 +0,0 @@ -package it.vfsfitvnm.innertube.models.bodies - -import it.vfsfitvnm.innertube.models.Context -import kotlinx.serialization.Serializable - -@Serializable -data class ContinuationBody( - val context: Context = Context.DefaultWeb, - val continuation: String, -) diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/bodies/PlayerBody.kt b/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/bodies/PlayerBody.kt deleted file mode 100644 index b09dd2f..0000000 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/bodies/PlayerBody.kt +++ /dev/null @@ -1,11 +0,0 @@ -package it.vfsfitvnm.innertube.models.bodies - -import it.vfsfitvnm.innertube.models.Context -import kotlinx.serialization.Serializable - -@Serializable -data class PlayerBody( - val context: Context = Context.DefaultAndroid, - val videoId: String, - val playlistId: String? = null -) diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/AlbumPage.kt b/innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/AlbumPage.kt deleted file mode 100644 index 378f96b..0000000 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/AlbumPage.kt +++ /dev/null @@ -1,36 +0,0 @@ -package it.vfsfitvnm.innertube.requests - -import io.ktor.http.Url -import it.vfsfitvnm.innertube.Innertube -import it.vfsfitvnm.innertube.models.NavigationEndpoint -import it.vfsfitvnm.innertube.models.bodies.BrowseBody - -suspend fun Innertube.albumPage(body: BrowseBody): Result? { - return playlistPage(body)?.map { album -> - album.url?.let { Url(it).parameters["list"] }?.let { playlistId -> - playlistPage(BrowseBody(browseId = "VL$playlistId"))?.getOrNull()?.let { playlist -> - album.copy(songsPage = playlist.songsPage) - } - } ?: album - }?.map { album -> - val albumInfo = Innertube.Info( - name = album.title, - endpoint = NavigationEndpoint.Endpoint.Browse( - browseId = body.browseId, - params = body.params - ) - ) - - album.copy( - songsPage = album.songsPage?.copy( - items = album.songsPage.items?.map { song -> - song.copy( - authors = song.authors ?: album.authors, - album = albumInfo, - thumbnail = album.thumbnail - ) - } - ) - ) - } -} diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/ArtistPage.kt b/innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/ArtistPage.kt deleted file mode 100644 index 7b59519..0000000 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/ArtistPage.kt +++ /dev/null @@ -1,106 +0,0 @@ -package it.vfsfitvnm.innertube.requests - -import io.ktor.client.call.body -import io.ktor.client.request.post -import io.ktor.client.request.setBody -import it.vfsfitvnm.innertube.Innertube -import it.vfsfitvnm.innertube.models.BrowseResponse -import it.vfsfitvnm.innertube.models.MusicCarouselShelfRenderer -import it.vfsfitvnm.innertube.models.MusicShelfRenderer -import it.vfsfitvnm.innertube.models.SectionListRenderer -import it.vfsfitvnm.innertube.models.bodies.BrowseBody -import it.vfsfitvnm.innertube.utils.findSectionByTitle -import it.vfsfitvnm.innertube.utils.from -import it.vfsfitvnm.innertube.utils.runCatchingNonCancellable - -suspend fun Innertube.artistPage(body: BrowseBody): Result? = - runCatchingNonCancellable { - val response = client.post(browse) { - setBody(body) - mask("contents,header") - }.body() - - fun findSectionByTitle(text: String): SectionListRenderer.Content? { - return response - .contents - ?.singleColumnBrowseResultsRenderer - ?.tabs - ?.get(0) - ?.tabRenderer - ?.content - ?.sectionListRenderer - ?.findSectionByTitle(text) - } - - val songsSection = findSectionByTitle("Songs")?.musicShelfRenderer - val albumsSection = findSectionByTitle("Albums")?.musicCarouselShelfRenderer - val singlesSection = findSectionByTitle("Singles")?.musicCarouselShelfRenderer - - Innertube.ArtistPage( - name = response - .header - ?.musicImmersiveHeaderRenderer - ?.title - ?.text, - description = response - .header - ?.musicImmersiveHeaderRenderer - ?.description - ?.text, - thumbnail = (response - .header - ?.musicImmersiveHeaderRenderer - ?.foregroundThumbnail - ?: response - .header - ?.musicImmersiveHeaderRenderer - ?.thumbnail) - ?.musicThumbnailRenderer - ?.thumbnail - ?.thumbnails - ?.getOrNull(0), - shuffleEndpoint = response - .header - ?.musicImmersiveHeaderRenderer - ?.playButton - ?.buttonRenderer - ?.navigationEndpoint - ?.watchEndpoint, - radioEndpoint = response - .header - ?.musicImmersiveHeaderRenderer - ?.startRadioButton - ?.buttonRenderer - ?.navigationEndpoint - ?.watchEndpoint, - songs = songsSection - ?.contents - ?.mapNotNull(MusicShelfRenderer.Content::musicResponsiveListItemRenderer) - ?.mapNotNull(Innertube.SongItem::from), - songsEndpoint = songsSection - ?.bottomEndpoint - ?.browseEndpoint, - albums = albumsSection - ?.contents - ?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer) - ?.mapNotNull(Innertube.AlbumItem::from), - albumsEndpoint = albumsSection - ?.header - ?.musicCarouselShelfBasicHeaderRenderer - ?.moreContentButton - ?.buttonRenderer - ?.navigationEndpoint - ?.browseEndpoint, - singles = singlesSection - ?.contents - ?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer) - ?.mapNotNull(Innertube.AlbumItem::from), - singlesEndpoint = singlesSection - ?.header - ?.musicCarouselShelfBasicHeaderRenderer - ?.moreContentButton - ?.buttonRenderer - ?.navigationEndpoint - ?.browseEndpoint, - ) - } diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/ItemsPage.kt b/innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/ItemsPage.kt deleted file mode 100644 index 38ac213..0000000 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/ItemsPage.kt +++ /dev/null @@ -1,97 +0,0 @@ -package it.vfsfitvnm.innertube.requests - -import io.ktor.client.call.body -import io.ktor.client.request.post -import io.ktor.client.request.setBody -import it.vfsfitvnm.innertube.Innertube -import it.vfsfitvnm.innertube.models.BrowseResponse -import it.vfsfitvnm.innertube.models.ContinuationResponse -import it.vfsfitvnm.innertube.models.GridRenderer -import it.vfsfitvnm.innertube.models.MusicResponsiveListItemRenderer -import it.vfsfitvnm.innertube.models.MusicShelfRenderer -import it.vfsfitvnm.innertube.models.MusicTwoRowItemRenderer -import it.vfsfitvnm.innertube.models.bodies.BrowseBody -import it.vfsfitvnm.innertube.models.bodies.ContinuationBody -import it.vfsfitvnm.innertube.utils.runCatchingNonCancellable - -suspend fun Innertube.itemsPage( - body: BrowseBody, - fromMusicResponsiveListItemRenderer: (MusicResponsiveListItemRenderer) -> T? = { null }, - fromMusicTwoRowItemRenderer: (MusicTwoRowItemRenderer) -> T? = { null }, -) = runCatchingNonCancellable { - val response = client.post(browse) { - setBody(body) -// mask("contents.singleColumnBrowseResultsRenderer.tabs.tabRenderer.content.sectionListRenderer.contents(musicPlaylistShelfRenderer(continuations,contents.$musicResponsiveListItemRendererMask),gridRenderer(continuations,items.$musicTwoRowItemRendererMask))") - }.body() - - val sectionListRendererContent = response - .contents - ?.singleColumnBrowseResultsRenderer - ?.tabs - ?.firstOrNull() - ?.tabRenderer - ?.content - ?.sectionListRenderer - ?.contents - ?.firstOrNull() - - itemsPageFromMusicShelRendererOrGridRenderer( - musicShelfRenderer = sectionListRendererContent - ?.musicPlaylistShelfRenderer, - gridRenderer = sectionListRendererContent - ?.gridRenderer, - fromMusicResponsiveListItemRenderer = fromMusicResponsiveListItemRenderer, - fromMusicTwoRowItemRenderer = fromMusicTwoRowItemRenderer, - ) -} - -suspend fun Innertube.itemsPage( - body: ContinuationBody, - fromMusicResponsiveListItemRenderer: (MusicResponsiveListItemRenderer) -> T? = { null }, - fromMusicTwoRowItemRenderer: (MusicTwoRowItemRenderer) -> T? = { null }, -) = runCatchingNonCancellable { - val response = client.post(browse) { - setBody(body) -// mask("contents.singleColumnBrowseResultsRenderer.tabs.tabRenderer.content.sectionListRenderer.contents(musicPlaylistShelfRenderer(continuations,contents.$musicResponsiveListItemRendererMask),gridRenderer(continuations,items.$musicTwoRowItemRendererMask))") - }.body() - - itemsPageFromMusicShelRendererOrGridRenderer( - musicShelfRenderer = response - .continuationContents - ?.musicShelfContinuation, - gridRenderer = null, - fromMusicResponsiveListItemRenderer = fromMusicResponsiveListItemRenderer, - fromMusicTwoRowItemRenderer = fromMusicTwoRowItemRenderer, - ) -} - -private fun itemsPageFromMusicShelRendererOrGridRenderer( - musicShelfRenderer: MusicShelfRenderer?, - gridRenderer: GridRenderer?, - fromMusicResponsiveListItemRenderer: (MusicResponsiveListItemRenderer) -> T?, - fromMusicTwoRowItemRenderer: (MusicTwoRowItemRenderer) -> T?, -): Innertube.ItemsPage? { - return if (musicShelfRenderer != null) { - Innertube.ItemsPage( - continuation = musicShelfRenderer - .continuations - ?.firstOrNull() - ?.nextContinuationData - ?.continuation, - items = musicShelfRenderer - .contents - ?.mapNotNull(MusicShelfRenderer.Content::musicResponsiveListItemRenderer) - ?.mapNotNull(fromMusicResponsiveListItemRenderer) - ) - } else if (gridRenderer != null) { - Innertube.ItemsPage( - continuation = null, - items = gridRenderer - .items - ?.mapNotNull(GridRenderer.Item::musicTwoRowItemRenderer) - ?.mapNotNull(fromMusicTwoRowItemRenderer) - ) - } else { - null - } -} diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/Player.kt b/innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/Player.kt deleted file mode 100644 index d2fa6a4..0000000 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/Player.kt +++ /dev/null @@ -1,67 +0,0 @@ -package it.vfsfitvnm.innertube.requests - -import io.ktor.client.call.body -import io.ktor.client.request.get -import io.ktor.client.request.post -import io.ktor.client.request.setBody -import io.ktor.http.ContentType -import io.ktor.http.contentType -import it.vfsfitvnm.innertube.Innertube -import it.vfsfitvnm.innertube.models.Context -import it.vfsfitvnm.innertube.models.PlayerResponse -import it.vfsfitvnm.innertube.models.bodies.PlayerBody -import it.vfsfitvnm.innertube.utils.runCatchingNonCancellable -import kotlinx.serialization.Serializable - -suspend fun Innertube.player(body: PlayerBody) = runCatchingNonCancellable { - val response = client.post(player) { - setBody(body) - mask("playabilityStatus.status,playerConfig.audioConfig,streamingData.adaptiveFormats,videoDetails.videoId") - }.body() - - if (response.playabilityStatus?.status == "OK") { - response - } else { - @Serializable - data class AudioStream( - val url: String, - val bitrate: Long - ) - - @Serializable - data class PipedResponse( - val audioStreams: List - ) - - val safePlayerResponse = client.post(player) { - setBody( - body.copy( - context = Context.DefaultAgeRestrictionBypass.copy( - thirdParty = Context.ThirdParty( - embedUrl = "https://www.youtube.com/watch?v=${body.videoId}" - ) - ), - ) - ) - mask("playabilityStatus.status,playerConfig.audioConfig,streamingData.adaptiveFormats,videoDetails.videoId") - }.body() - - if (safePlayerResponse.playabilityStatus?.status != "OK") { - return@runCatchingNonCancellable response - } - - val audioStreams = client.get("https://watchapi.whatever.social/streams/${body.videoId}") { - contentType(ContentType.Application.Json) - }.body().audioStreams - - safePlayerResponse.copy( - streamingData = safePlayerResponse.streamingData?.copy( - adaptiveFormats = safePlayerResponse.streamingData.adaptiveFormats?.map { adaptiveFormat -> - adaptiveFormat.copy( - url = audioStreams.find { it.bitrate == adaptiveFormat.bitrate }?.url - ) - } - ) - ) - } -} diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/PlaylistPage.kt b/innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/PlaylistPage.kt deleted file mode 100644 index 9c600ab..0000000 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/PlaylistPage.kt +++ /dev/null @@ -1,108 +0,0 @@ -package it.vfsfitvnm.innertube.requests - -import io.ktor.client.call.body -import io.ktor.client.request.post -import io.ktor.client.request.setBody -import it.vfsfitvnm.innertube.Innertube -import it.vfsfitvnm.innertube.models.BrowseResponse -import it.vfsfitvnm.innertube.models.ContinuationResponse -import it.vfsfitvnm.innertube.models.MusicCarouselShelfRenderer -import it.vfsfitvnm.innertube.models.MusicShelfRenderer -import it.vfsfitvnm.innertube.models.bodies.BrowseBody -import it.vfsfitvnm.innertube.models.bodies.ContinuationBody -import it.vfsfitvnm.innertube.utils.from -import it.vfsfitvnm.innertube.utils.runCatchingNonCancellable -import it.vfsfitvnm.innertube.models.Continuation -import it.vfsfitvnm.innertube.models.MusicResponsiveListItemRenderer - -suspend fun Innertube.playlistPage(body: BrowseBody) = runCatchingNonCancellable { - val response = client.post(browse) { - setBody(body) - mask("contents.twoColumnBrowseResultsRenderer(tabs.tabRenderer.content.sectionListRenderer.contents.musicResponsiveHeaderRenderer(title,subtitle,thumbnail),secondaryContents.sectionListRenderer.contents(musicPlaylistShelfRenderer(continuations,contents.$musicResponsiveListItemRendererMask),musicCarouselShelfRenderer.contents.$musicTwoRowItemRendererMask)),microformat") - }.body() - - val musicDetailHeaderRenderer = response - .contents - ?.twoColumnBrowseResultsRenderer - ?.tabs - ?.firstOrNull() - ?.tabRenderer - ?.content - ?.sectionListRenderer - ?.contents - ?.firstOrNull() - ?.musicResponsiveHeaderRenderer - - val sectionListRendererContents = response - .contents - ?.twoColumnBrowseResultsRenderer - ?.secondaryContents - ?.sectionListRenderer - ?.contents - - val musicShelfRenderer = sectionListRendererContents - ?.firstOrNull() - ?.musicPlaylistShelfRenderer - - val musicCarouselShelfRenderer = sectionListRendererContents - ?.getOrNull(1) - ?.musicCarouselShelfRenderer - - Innertube.PlaylistOrAlbumPage( - title = musicDetailHeaderRenderer - ?.title - ?.text, - thumbnail = musicDetailHeaderRenderer - ?.thumbnail - ?.musicThumbnailRenderer - ?.thumbnail - ?.thumbnails - ?.firstOrNull(), - authors = musicDetailHeaderRenderer - ?.subtitle - ?.splitBySeparator() - ?.getOrNull(1) - ?.map(Innertube::Info), - year = musicDetailHeaderRenderer - ?.subtitle - ?.splitBySeparator() - ?.getOrNull(2) - ?.firstOrNull() - ?.text, - url = response - .microformat - ?.microformatDataRenderer - ?.urlCanonical, - songsPage = musicShelfRenderer - ?.toSongsPage(), - otherVersions = musicCarouselShelfRenderer - ?.contents - ?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer) - ?.mapNotNull(Innertube.AlbumItem::from) - ) -} - -suspend fun Innertube.playlistPage(body: ContinuationBody) = runCatchingNonCancellable { - val response = client.post(browse) { - setBody(body) - mask("continuationContents.musicPlaylistShelfContinuation(continuations,contents.$musicResponsiveListItemRendererMask)") - }.body() - - response - .continuationContents - ?.musicShelfContinuation - ?.toSongsPage() -} - -private fun MusicShelfRenderer?.toSongsPage() = - Innertube.ItemsPage( - items = this - ?.contents - ?.mapNotNull(MusicShelfRenderer.Content::musicResponsiveListItemRenderer) - ?.mapNotNull(Innertube.SongItem::from), - continuation = this - ?.continuations - ?.firstOrNull() - ?.nextContinuationData - ?.continuation - ) diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/Queue.kt b/innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/Queue.kt deleted file mode 100644 index 9ea86ea..0000000 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/Queue.kt +++ /dev/null @@ -1,29 +0,0 @@ -package it.vfsfitvnm.innertube.requests - -import io.ktor.client.call.body -import io.ktor.client.request.post -import io.ktor.client.request.setBody -import it.vfsfitvnm.innertube.Innertube -import it.vfsfitvnm.innertube.models.GetQueueResponse -import it.vfsfitvnm.innertube.models.bodies.QueueBody -import it.vfsfitvnm.innertube.utils.from -import it.vfsfitvnm.innertube.utils.runCatchingNonCancellable - -suspend fun Innertube.queue(body: QueueBody) = runCatchingNonCancellable { - val response = client.post(queue) { - setBody(body) - mask("queueDatas.content.$playlistPanelVideoRendererMask") - }.body() - - response - .queueDatas - ?.mapNotNull { queueData -> - queueData - .content - ?.playlistPanelVideoRenderer - ?.let(Innertube.SongItem::from) - } -} - -suspend fun Innertube.song(videoId: String): Result? = - queue(QueueBody(videoIds = listOf(videoId)))?.map { it?.firstOrNull() } diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/SearchPage.kt b/innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/SearchPage.kt deleted file mode 100644 index 6e57d28..0000000 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/SearchPage.kt +++ /dev/null @@ -1,62 +0,0 @@ -package it.vfsfitvnm.innertube.requests - -import io.ktor.client.call.body -import io.ktor.client.request.post -import io.ktor.client.request.setBody -import it.vfsfitvnm.innertube.Innertube -import it.vfsfitvnm.innertube.models.ContinuationResponse -import it.vfsfitvnm.innertube.models.MusicShelfRenderer -import it.vfsfitvnm.innertube.models.SearchResponse -import it.vfsfitvnm.innertube.models.bodies.ContinuationBody -import it.vfsfitvnm.innertube.models.bodies.SearchBody -import it.vfsfitvnm.innertube.utils.runCatchingNonCancellable - -suspend fun Innertube.searchPage( - body: SearchBody, - fromMusicShelfRendererContent: (MusicShelfRenderer.Content) -> T? -) = runCatchingNonCancellable { - val response = client.post(search) { - setBody(body) - mask("contents.tabbedSearchResultsRenderer.tabs.tabRenderer.content.sectionListRenderer.contents.musicShelfRenderer(continuations,contents.$musicResponsiveListItemRendererMask)") - }.body() - - response - .contents - ?.tabbedSearchResultsRenderer - ?.tabs - ?.firstOrNull() - ?.tabRenderer - ?.content - ?.sectionListRenderer - ?.contents - ?.lastOrNull() - ?.musicShelfRenderer - ?.toItemsPage(fromMusicShelfRendererContent) -} - -suspend fun Innertube.searchPage( - body: ContinuationBody, - fromMusicShelfRendererContent: (MusicShelfRenderer.Content) -> T? -) = runCatchingNonCancellable { - val response = client.post(search) { - setBody(body) - mask("continuationContents.musicShelfContinuation(continuations,contents.$musicResponsiveListItemRendererMask)") - }.body() - - response - .continuationContents - ?.musicShelfContinuation - ?.toItemsPage(fromMusicShelfRendererContent) -} - -private fun MusicShelfRenderer?.toItemsPage(mapper: (MusicShelfRenderer.Content) -> T?) = - Innertube.ItemsPage( - items = this - ?.contents - ?.mapNotNull(mapper), - continuation = this - ?.continuations - ?.firstOrNull() - ?.nextContinuationData - ?.continuation - ) diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/SearchSuggestions.kt b/innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/SearchSuggestions.kt deleted file mode 100644 index bb00be9..0000000 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/SearchSuggestions.kt +++ /dev/null @@ -1,29 +0,0 @@ -package it.vfsfitvnm.innertube.requests - -import io.ktor.client.call.body -import io.ktor.client.request.post -import io.ktor.client.request.setBody -import it.vfsfitvnm.innertube.Innertube -import it.vfsfitvnm.innertube.models.SearchSuggestionsResponse -import it.vfsfitvnm.innertube.models.bodies.SearchSuggestionsBody -import it.vfsfitvnm.innertube.utils.runCatchingNonCancellable - -suspend fun Innertube.searchSuggestions(body: SearchSuggestionsBody) = runCatchingNonCancellable { - val response = client.post(searchSuggestions) { - setBody(body) - mask("contents.searchSuggestionsSectionRenderer.contents.searchSuggestionRenderer.navigationEndpoint.searchEndpoint.query") - }.body() - - response - .contents - ?.firstOrNull() - ?.searchSuggestionsSectionRenderer - ?.contents - ?.mapNotNull { content -> - content - .searchSuggestionRenderer - ?.navigationEndpoint - ?.searchEndpoint - ?.query - } -} diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/utils/FromMusicTwoRowItemRenderer.kt b/innertube/src/main/kotlin/it/vfsfitvnm/innertube/utils/FromMusicTwoRowItemRenderer.kt deleted file mode 100644 index 15dc097..0000000 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/utils/FromMusicTwoRowItemRenderer.kt +++ /dev/null @@ -1,76 +0,0 @@ -package it.vfsfitvnm.innertube.utils - -import it.vfsfitvnm.innertube.Innertube -import it.vfsfitvnm.innertube.models.MusicTwoRowItemRenderer - -fun Innertube.AlbumItem.Companion.from(renderer: MusicTwoRowItemRenderer): Innertube.AlbumItem? { - return Innertube.AlbumItem( - info = renderer - .title - ?.runs - ?.firstOrNull() - ?.let(Innertube::Info), - authors = null, - year = renderer - .subtitle - ?.runs - ?.lastOrNull() - ?.text, - thumbnail = renderer - .thumbnailRenderer - ?.musicThumbnailRenderer - ?.thumbnail - ?.thumbnails - ?.firstOrNull() - ).takeIf { it.info?.endpoint?.browseId != null } -} - -fun Innertube.ArtistItem.Companion.from(renderer: MusicTwoRowItemRenderer): Innertube.ArtistItem? { - return Innertube.ArtistItem( - info = renderer - .title - ?.runs - ?.firstOrNull() - ?.let(Innertube::Info), - subscribersCountText = renderer - .subtitle - ?.runs - ?.firstOrNull() - ?.text, - thumbnail = renderer - .thumbnailRenderer - ?.musicThumbnailRenderer - ?.thumbnail - ?.thumbnails - ?.firstOrNull() - ).takeIf { it.info?.endpoint?.browseId != null } -} - -fun Innertube.PlaylistItem.Companion.from(renderer: MusicTwoRowItemRenderer): Innertube.PlaylistItem? { - return Innertube.PlaylistItem( - info = renderer - .title - ?.runs - ?.firstOrNull() - ?.let(Innertube::Info), - channel = renderer - .subtitle - ?.runs - ?.getOrNull(2) - ?.let(Innertube::Info), - songCount = renderer - .subtitle - ?.runs - ?.getOrNull(4) - ?.text - ?.split(' ') - ?.firstOrNull() - ?.toIntOrNull(), - thumbnail = renderer - .thumbnailRenderer - ?.musicThumbnailRenderer - ?.thumbnail - ?.thumbnails - ?.firstOrNull() - ).takeIf { it.info?.endpoint?.browseId != null } -} diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/utils/FromPlaylistPanelVideoRenderer.kt b/innertube/src/main/kotlin/it/vfsfitvnm/innertube/utils/FromPlaylistPanelVideoRenderer.kt deleted file mode 100644 index 92ea20c..0000000 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/utils/FromPlaylistPanelVideoRenderer.kt +++ /dev/null @@ -1,35 +0,0 @@ -package it.vfsfitvnm.innertube.utils - -import it.vfsfitvnm.innertube.Innertube -import it.vfsfitvnm.innertube.models.PlaylistPanelVideoRenderer - -fun Innertube.SongItem.Companion.from(renderer: PlaylistPanelVideoRenderer): Innertube.SongItem? { - return Innertube.SongItem( - info = Innertube.Info( - name = renderer - .title - ?.text, - endpoint = renderer - .navigationEndpoint - ?.watchEndpoint - ), - authors = renderer - .longBylineText - ?.splitBySeparator() - ?.getOrNull(0) - ?.map(Innertube::Info), - album = renderer - .longBylineText - ?.splitBySeparator() - ?.getOrNull(1) - ?.getOrNull(0) - ?.let(Innertube::Info), - thumbnail = renderer - .thumbnail - ?.thumbnails - ?.getOrNull(0), - durationText = renderer - .lengthText - ?.text - ).takeIf { it.info?.endpoint?.videoId != null } -} diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/utils/Utils.kt b/innertube/src/main/kotlin/it/vfsfitvnm/innertube/utils/Utils.kt deleted file mode 100644 index 6a2acf8..0000000 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/utils/Utils.kt +++ /dev/null @@ -1,50 +0,0 @@ -package it.vfsfitvnm.innertube.utils - -import io.ktor.utils.io.CancellationException -import it.vfsfitvnm.innertube.Innertube -import it.vfsfitvnm.innertube.models.SectionListRenderer - -internal fun SectionListRenderer.findSectionByTitle(text: String): SectionListRenderer.Content? { - return contents?.find { content -> - val title = content - .musicCarouselShelfRenderer - ?.header - ?.musicCarouselShelfBasicHeaderRenderer - ?.title - ?: content - .musicShelfRenderer - ?.title - - title - ?.runs - ?.firstOrNull() - ?.text == text - } -} - -internal fun SectionListRenderer.findSectionByStrapline(text: String): SectionListRenderer.Content? { - return contents?.find { content -> - content - .musicCarouselShelfRenderer - ?.header - ?.musicCarouselShelfBasicHeaderRenderer - ?.strapline - ?.runs - ?.firstOrNull() - ?.text == text - } -} - -internal inline fun runCatchingNonCancellable(block: () -> R): Result? { - val result = runCatching(block) - return when (result.exceptionOrNull()) { - is CancellationException -> null - else -> result - } -} - -infix operator fun Innertube.ItemsPage?.plus(other: Innertube.ItemsPage) = - other.copy( - items = (this?.items?.plus(other.items ?: emptyList()) - ?: other.items)?.distinctBy(Innertube.Item::key) - ) diff --git a/innertube/src/test/kotlin/Test.kt b/innertube/src/test/kotlin/Test.kt deleted file mode 100644 index 124168c..0000000 --- a/innertube/src/test/kotlin/Test.kt +++ /dev/null @@ -1,10 +0,0 @@ -import kotlinx.coroutines.runBlocking -import org.junit.Test - -class Test { - @Test - @Throws(Exception::class) - fun test() = runBlocking { - - } -} diff --git a/ktor-client-brotli/.gitignore b/ktor-client-brotli/.gitignore deleted file mode 100644 index 42afabf..0000000 --- a/ktor-client-brotli/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/ktor-client-brotli/build.gradle.kts b/ktor-client-brotli/build.gradle.kts index 564d1d3..0c9b8b9 100644 --- a/ktor-client-brotli/build.gradle.kts +++ b/ktor-client-brotli/build.gradle.kts @@ -1,12 +1,16 @@ plugins { - kotlin("jvm") -} - -sourceSets.all { - java.srcDir("src/$name/kotlin") + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.android.lint) } dependencies { implementation(libs.ktor.client.encoding) implementation(libs.brotli) + + detektPlugins(libs.detekt.compose) + detektPlugins(libs.detekt.formatting) +} + +kotlin { + jvmToolchain(libs.versions.jvm.get().toInt()) } \ No newline at end of file diff --git a/ktor-client-brotli/src/main/kotlin/io/ktor/client/plugins/compression/brotli.kt b/ktor-client-brotli/src/main/kotlin/io/ktor/client/plugins/compression/Brotli.kt similarity index 59% rename from ktor-client-brotli/src/main/kotlin/io/ktor/client/plugins/compression/brotli.kt rename to ktor-client-brotli/src/main/kotlin/io/ktor/client/plugins/compression/Brotli.kt index 558751c..5939170 100644 --- a/ktor-client-brotli/src/main/kotlin/io/ktor/client/plugins/compression/brotli.kt +++ b/ktor-client-brotli/src/main/kotlin/io/ktor/client/plugins/compression/Brotli.kt @@ -1,5 +1,5 @@ package io.ktor.client.plugins.compression -fun ContentEncoding.Config.brotli(quality: Float? = null) { +fun ContentEncodingConfig.brotli(quality: Float? = null) { customEncoder(BrotliEncoder, quality) } diff --git a/ktor-client-brotli/src/main/kotlin/io/ktor/client/plugins/compression/BrotliEncoder.kt b/ktor-client-brotli/src/main/kotlin/io/ktor/client/plugins/compression/BrotliEncoder.kt index dca2ad1..c941ccf 100644 --- a/ktor-client-brotli/src/main/kotlin/io/ktor/client/plugins/compression/BrotliEncoder.kt +++ b/ktor-client-brotli/src/main/kotlin/io/ktor/client/plugins/compression/BrotliEncoder.kt @@ -1,17 +1,34 @@ package io.ktor.client.plugins.compression +import io.ktor.util.ContentEncoder +import io.ktor.util.Encoder import io.ktor.utils.io.ByteReadChannel +import io.ktor.utils.io.ByteWriteChannel import io.ktor.utils.io.jvm.javaio.toByteReadChannel import io.ktor.utils.io.jvm.javaio.toInputStream -import kotlinx.coroutines.CoroutineScope import org.brotli.dec.BrotliInputStream +import kotlin.coroutines.CoroutineContext -internal object BrotliEncoder : ContentEncoder { +internal object BrotliEncoder : ContentEncoder, Encoder by Brotli { override val name: String = "br" +} - override fun CoroutineScope.encode(source: ByteReadChannel) = +private object Brotli : Encoder { + private fun encode(): Nothing = error("BrotliOutputStream not available (https://github.com/google/brotli/issues/715)") - override fun CoroutineScope.decode(source: ByteReadChannel): ByteReadChannel = - BrotliInputStream(source.toInputStream()).toByteReadChannel() + override fun decode( + source: ByteReadChannel, + coroutineContext: CoroutineContext + ) = BrotliInputStream(source.toInputStream()).toByteReadChannel() + + override fun encode( + source: ByteReadChannel, + coroutineContext: CoroutineContext + ) = encode() + + override fun encode( + source: ByteWriteChannel, + coroutineContext: CoroutineContext + ) = encode() } diff --git a/kugou/.gitignore b/kugou/.gitignore deleted file mode 100644 index 42afabf..0000000 --- a/kugou/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/kugou/src/main/kotlin/it/vfsfitvnm/kugou/KuGou.kt b/kugou/src/main/kotlin/it/vfsfitvnm/kugou/KuGou.kt deleted file mode 100644 index 8c1875e..0000000 --- a/kugou/src/main/kotlin/it/vfsfitvnm/kugou/KuGou.kt +++ /dev/null @@ -1,213 +0,0 @@ -package it.vfsfitvnm.kugou - -import io.ktor.client.HttpClient -import io.ktor.client.call.body -import io.ktor.client.engine.okhttp.OkHttp -import io.ktor.client.plugins.BrowserUserAgent -import io.ktor.client.plugins.compression.ContentEncoding -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.client.plugins.defaultRequest -import io.ktor.client.request.get -import io.ktor.client.request.parameter -import io.ktor.http.ContentType -import io.ktor.http.encodeURLParameter -import io.ktor.serialization.kotlinx.json.json -import io.ktor.util.decodeBase64String -import it.vfsfitvnm.kugou.models.DownloadLyricsResponse -import it.vfsfitvnm.kugou.models.SearchLyricsResponse -import it.vfsfitvnm.kugou.models.SearchSongResponse -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.json.Json - -object KuGou { - @OptIn(ExperimentalSerializationApi::class) - private val client by lazy { - HttpClient(OkHttp) { - BrowserUserAgent() - - expectSuccess = true - - install(ContentNegotiation) { - val feature = Json { - ignoreUnknownKeys = true - explicitNulls = false - encodeDefaults = true - } - - json(feature) - json(feature, ContentType.Text.Html) - json(feature, ContentType.Text.Plain) - } - - install(ContentEncoding) { - gzip() - deflate() - } - - defaultRequest { - url("https://krcs.kugou.com") - } - } - } - - suspend fun lyrics(artist: String, title: String, duration: Long): Result? { - return runCatching { - val keyword = keyword(artist, title) - val infoByKeyword = searchSong(keyword) - - if (infoByKeyword.isNotEmpty()) { - var tolerance = 0 - - while (tolerance <= 5) { - for (info in infoByKeyword) { - if (info.duration >= duration - tolerance && info.duration <= duration + tolerance) { - searchLyricsByHash(info.hash).firstOrNull()?.let { candidate -> - return@runCatching downloadLyrics(candidate.id, candidate.accessKey).normalize() - } - } - } - - tolerance++ - } - } - - searchLyricsByKeyword(keyword).firstOrNull()?.let { candidate -> - return@runCatching downloadLyrics(candidate.id, candidate.accessKey).normalize() - } - - null - }.recoverIfCancelled() - } - - private suspend fun downloadLyrics(id: Long, accessKey: String): Lyrics { - return client.get("/download") { - parameter("ver", 1) - parameter("man", "yes") - parameter("client", "pc") - parameter("fmt", "lrc") - parameter("id", id) - parameter("accesskey", accessKey) - }.body().content.decodeBase64String().let(::Lyrics) - } - - private suspend fun searchLyricsByHash(hash: String): List { - return client.get("/search") { - parameter("ver", 1) - parameter("man", "yes") - parameter("client", "mobi") - parameter("hash", hash) - }.body().candidates - } - - private suspend fun searchLyricsByKeyword(keyword: String): List { - return client.get("/search") { - parameter("ver", 1) - parameter("man", "yes") - parameter("client", "mobi") - url.encodedParameters.append("keyword", keyword.encodeURLParameter(spaceToPlus = false)) - }.body().candidates - } - - private suspend fun searchSong(keyword: String): List { - return client.get("https://mobileservice.kugou.com/api/v3/search/song") { - parameter("version", 9108) - parameter("plat", 0) - parameter("pagesize", 8) - parameter("showtype", 0) - url.encodedParameters.append("keyword", keyword.encodeURLParameter(spaceToPlus = false)) - }.body().data.info - } - - private fun keyword(artist: String, title: String): String { - val (newTitle, featuring) = title.extract(" (feat. ", ')') - - val newArtist = (if (featuring.isEmpty()) artist else "$artist, $featuring") - .replace(", ", "、") - .replace(" & ", "、") - .replace(".", "") - - return "$newArtist - $newTitle" - } - - private fun String.extract(startDelimiter: String, endDelimiter: Char): Pair { - val startIndex = indexOf(startDelimiter) - - if (startIndex == -1) return this to "" - - val endIndex = indexOf(endDelimiter, startIndex) - - if (endIndex == -1) return this to "" - - return removeRange( - startIndex, - endIndex + 1 - ) to substring(startIndex + startDelimiter.length, endIndex) - } - - @JvmInline - value class Lyrics(val value: String) : CharSequence by value { - val sentences: List> - get() = mutableListOf(0L to "").apply { - for (line in value.trim().lines()) { - try { - val position = line.take(10).run { - get(8).digitToInt() * 10L + - get(7).digitToInt() * 100 + - get(5).digitToInt() * 1000 + - get(4).digitToInt() * 10000 + - get(2).digitToInt() * 60 * 1000 + - get(1).digitToInt() * 600 * 1000 - } - - add(position to line.substring(10)) - } catch (_: Throwable) { - } - } - } - - fun normalize(): Lyrics { - var toDrop = 0 - var maybeToDrop = 0 - - val text = value.replace("\r\n", "\n").trim() - - for (line in text.lineSequence()) { - if (line.startsWith("[ti:") || - line.startsWith("[ar:") || - line.startsWith("[al:") || - line.startsWith("[by:") || - line.startsWith("[hash:") || - line.startsWith("[sign:") || - line.startsWith("[qq:") || - line.startsWith("[total:") || - line.startsWith("[offset:") || - line.startsWith("[id:") || - line.containsAt("]Written by:", 9) || - line.containsAt("]Lyrics by:", 9) || - line.containsAt("]Composed by:", 9) || - line.containsAt("]Producer:", 9) || - line.containsAt("]作曲 : ", 9) || - line.containsAt("]作词 : ", 9) - ) { - toDrop += line.length + 1 + maybeToDrop - maybeToDrop = 0 - } else { - if (maybeToDrop == 0) { - maybeToDrop = line.length + 1 - } else { - maybeToDrop = 0 - break - } - } - } - - return Lyrics(text.drop(toDrop + maybeToDrop).removeHtmlEntities()) - } - - private fun String.containsAt(charSequence: CharSequence, startIndex: Int): Boolean = - regionMatches(startIndex, charSequence, 0, charSequence.length) - - private fun String.removeHtmlEntities(): String = - replace("'", "'") - } -} diff --git a/kugou/src/main/kotlin/it/vfsfitvnm/kugou/Result.kt b/kugou/src/main/kotlin/it/vfsfitvnm/kugou/Result.kt deleted file mode 100644 index 7d89d5c..0000000 --- a/kugou/src/main/kotlin/it/vfsfitvnm/kugou/Result.kt +++ /dev/null @@ -1,10 +0,0 @@ -package it.vfsfitvnm.kugou - -import kotlin.coroutines.cancellation.CancellationException - -internal fun Result.recoverIfCancelled(): Result? { - return when (exceptionOrNull()) { - is CancellationException -> null - else -> this - } -} diff --git a/kugou/src/test/kotlin/Test.kt b/kugou/src/test/kotlin/Test.kt deleted file mode 100644 index 47c0114..0000000 --- a/kugou/src/test/kotlin/Test.kt +++ /dev/null @@ -1,11 +0,0 @@ -import kotlinx.coroutines.runBlocking -import org.junit.Test - -class Test { - @Test - @Throws(Exception::class) - fun test() { - runBlocking { - } - } -} diff --git a/providers/common/build.gradle.kts b/providers/common/build.gradle.kts new file mode 100644 index 0000000..85d0ca0 --- /dev/null +++ b/providers/common/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.android.lint) +} + +dependencies { + implementation(libs.kotlin.coroutines) + api(libs.kotlin.datetime) + + implementation(libs.ktor.http) + implementation(libs.ktor.serialization.json) + + detektPlugins(libs.detekt.compose) + detektPlugins(libs.detekt.formatting) +} + +kotlin { + jvmToolchain(libs.versions.jvm.get().toInt()) +} diff --git a/providers/common/src/main/kotlin/app/vimusic/providers/utils/Coroutines.kt b/providers/common/src/main/kotlin/app/vimusic/providers/utils/Coroutines.kt new file mode 100644 index 0000000..2973bac --- /dev/null +++ b/providers/common/src/main/kotlin/app/vimusic/providers/utils/Coroutines.kt @@ -0,0 +1,6 @@ +package app.vimusic.providers.utils + +import kotlinx.coroutines.CancellationException + +inline fun runCatchingCancellable(block: () -> T) = + runCatching(block).takeIf { it.exceptionOrNull() !is CancellationException } diff --git a/providers/common/src/main/kotlin/app/vimusic/providers/utils/Serializers.kt b/providers/common/src/main/kotlin/app/vimusic/providers/utils/Serializers.kt new file mode 100644 index 0000000..4c2c992 --- /dev/null +++ b/providers/common/src/main/kotlin/app/vimusic/providers/utils/Serializers.kt @@ -0,0 +1,35 @@ +package app.vimusic.providers.utils + +import io.ktor.http.Url +import kotlinx.datetime.LocalDateTime +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import java.util.UUID + +object UrlSerializer : KSerializer { + override val descriptor = PrimitiveSerialDescriptor("Url", PrimitiveKind.STRING) + override fun deserialize(decoder: Decoder) = Url(decoder.decodeString()) + override fun serialize(encoder: Encoder, value: Url) = encoder.encodeString(value.toString()) +} + +typealias SerializableUrl = @Serializable(with = UrlSerializer::class) Url + +object Iso8601DateSerializer : KSerializer { + override val descriptor = PrimitiveSerialDescriptor("Iso8601LocalDateTime", PrimitiveKind.STRING) + override fun deserialize(decoder: Decoder) = LocalDateTime.parse(decoder.decodeString().removeSuffix("Z")) + override fun serialize(encoder: Encoder, value: LocalDateTime) = encoder.encodeString(value.toString()) +} + +typealias SerializableIso8601Date = @Serializable(with = Iso8601DateSerializer::class) LocalDateTime + +object UUIDSerializer : KSerializer { + override val descriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING) + override fun deserialize(decoder: Decoder): UUID = UUID.fromString(decoder.decodeString()) + override fun serialize(encoder: Encoder, value: UUID) = encoder.encodeString(value.toString()) +} + +typealias SerializableUUID = @Serializable(with = UUIDSerializer::class) UUID diff --git a/kugou/build.gradle.kts b/providers/github/build.gradle.kts similarity index 58% rename from kugou/build.gradle.kts rename to providers/github/build.gradle.kts index 709f0a3..5eb4332 100644 --- a/kugou/build.gradle.kts +++ b/providers/github/build.gradle.kts @@ -1,22 +1,24 @@ plugins { - kotlin("jvm") - @Suppress("DSL_SCOPE_VIOLATION") + alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.serialization) -} - -sourceSets.all { - java.srcDir("src/$name/kotlin") + alias(libs.plugins.android.lint) } dependencies { + implementation(projects.providers.common) + implementation(libs.kotlin.coroutines) implementation(libs.ktor.client.core) implementation(libs.ktor.client.cio) implementation(libs.ktor.client.content.negotiation) - implementation(libs.ktor.client.encoding) implementation(libs.ktor.client.serialization) implementation(libs.ktor.serialization.json) - testImplementation(testLibs.junit) + detektPlugins(libs.detekt.compose) + detektPlugins(libs.detekt.formatting) +} + +kotlin { + jvmToolchain(libs.versions.jvm.get().toInt()) } diff --git a/providers/github/src/main/kotlin/app/vimusic/providers/github/GitHub.kt b/providers/github/src/main/kotlin/app/vimusic/providers/github/GitHub.kt new file mode 100644 index 0000000..bdd3c14 --- /dev/null +++ b/providers/github/src/main/kotlin/app/vimusic/providers/github/GitHub.kt @@ -0,0 +1,55 @@ +package app.vimusic.providers.github + +import io.ktor.client.HttpClient +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.accept +import io.ktor.client.request.parameter +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json + +private const val API_VERSION = "2022-11-28" +private const val CONTENT_TYPE = "application" +private const val CONTENT_SUBTYPE = "vnd.github+json" + +object GitHub { + internal val httpClient by lazy { + HttpClient(CIO) { + val contentType = ContentType(CONTENT_TYPE, CONTENT_SUBTYPE) + + install(ContentNegotiation) { + val json = Json { + ignoreUnknownKeys = true + } + + json(json) + json( + json = json, + contentType = contentType + ) + } + + defaultRequest { + url("https://api.github.com") + headers["X-GitHub-Api-Version"] = API_VERSION + + accept(contentType) + contentType(ContentType.Application.Json) + } + + expectSuccess = true + } + } + + fun HttpRequestBuilder.withPagination(size: Int, page: Int) { + require(page > 0) { "GitHub error: invalid page ($page), pagination starts at page 1" } + require(size > 0) { "GitHub error: invalid page size ($size), a page has to have at least a single item" } + + parameter("per_page", size) + parameter("page", page) + } +} diff --git a/providers/github/src/main/kotlin/app/vimusic/providers/github/models/Reactions.kt b/providers/github/src/main/kotlin/app/vimusic/providers/github/models/Reactions.kt new file mode 100644 index 0000000..55f143d --- /dev/null +++ b/providers/github/src/main/kotlin/app/vimusic/providers/github/models/Reactions.kt @@ -0,0 +1,26 @@ +package app.vimusic.providers.github.models + +import app.vimusic.providers.utils.SerializableUrl +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Reactions( + val url: SerializableUrl, + @SerialName("total_count") + val count: Int, + @SerialName("+1") + val likes: Int, + @SerialName("-1") + val dislikes: Int, + @SerialName("laugh") + val laughs: Int, + val confused: Int, + @SerialName("heart") + val hearts: Int, + @SerialName("hooray") + val hoorays: Int, + val eyes: Int, + @SerialName("rocket") + val rockets: Int +) diff --git a/providers/github/src/main/kotlin/app/vimusic/providers/github/models/Release.kt b/providers/github/src/main/kotlin/app/vimusic/providers/github/models/Release.kt new file mode 100644 index 0000000..81065f6 --- /dev/null +++ b/providers/github/src/main/kotlin/app/vimusic/providers/github/models/Release.kt @@ -0,0 +1,71 @@ +package app.vimusic.providers.github.models + +import app.vimusic.providers.utils.SerializableIso8601Date +import app.vimusic.providers.utils.SerializableUrl +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Release( + val id: Int, + @SerialName("node_id") + val nodeId: String, + val url: SerializableUrl, + @SerialName("html_url") + val frontendUrl: SerializableUrl, + @SerialName("assets_url") + val assetsUrl: SerializableUrl, + @SerialName("tag_name") + val tag: String, + val name: String? = null, + @SerialName("body") + val markdown: String? = null, + val draft: Boolean, + @SerialName("prerelease") + val preRelease: Boolean, + @SerialName("created_at") + val createdAt: SerializableIso8601Date, + @SerialName("published_at") + val publishedAt: SerializableIso8601Date? = null, + val author: SimpleUser, + val assets: List = emptyList(), + @SerialName("body_html") + val html: String? = null, + @SerialName("body_text") + val text: String? = null, + @SerialName("discussion_url") + val discussionUrl: SerializableUrl? = null, + val reactions: Reactions? = null +) { + @Serializable + data class Asset( + val url: SerializableUrl, + @SerialName("browser_download_url") + val downloadUrl: SerializableUrl, + val id: Int, + @SerialName("node_id") + val nodeId: String, + val name: String, + val label: String? = null, + val state: State, + @SerialName("content_type") + val contentType: String, + val size: Long, + @SerialName("download_count") + val downloads: Int, + @SerialName("created_at") + val createdAt: SerializableIso8601Date, + @SerialName("updated_at") + val updatedAt: SerializableIso8601Date, + val uploader: SimpleUser? = null + ) { + @Serializable + enum class State { + @SerialName("uploaded") + Uploaded, + + @SerialName("open") + Open + } + } +} diff --git a/providers/github/src/main/kotlin/app/vimusic/providers/github/models/SimpleUser.kt b/providers/github/src/main/kotlin/app/vimusic/providers/github/models/SimpleUser.kt new file mode 100644 index 0000000..3de2bf3 --- /dev/null +++ b/providers/github/src/main/kotlin/app/vimusic/providers/github/models/SimpleUser.kt @@ -0,0 +1,43 @@ +package app.vimusic.providers.github.models + +import app.vimusic.providers.utils.SerializableUrl +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SimpleUser( + val name: String? = null, + val email: String? = null, + val login: String, + val id: Int, + @SerialName("node_id") + val nodeId: String, + @SerialName("avatar_url") + val avatarUrl: SerializableUrl, + @SerialName("gravatar_id") + val gravatarId: String? = null, + val url: SerializableUrl, + @SerialName("html_url") + val frontendUrl: SerializableUrl, + @SerialName("followers_url") + val followersUrl: SerializableUrl, + @SerialName("following_url") + val followingUrl: SerializableUrl, + @SerialName("gists_url") + val gistsUrl: SerializableUrl, + @SerialName("starred_url") + val starredUrl: SerializableUrl, + @SerialName("subscriptions_url") + val subscriptionsUrl: SerializableUrl, + @SerialName("organizations_url") + val organizationsUrl: SerializableUrl, + @SerialName("repos_url") + val reposUrl: SerializableUrl, + @SerialName("events_url") + val eventsUrl: SerializableUrl, + @SerialName("received_events_url") + val receivedEventsUrl: SerializableUrl, + val type: String, + @SerialName("site_admin") + val admin: Boolean +) diff --git a/providers/github/src/main/kotlin/app/vimusic/providers/github/requests/Releases.kt b/providers/github/src/main/kotlin/app/vimusic/providers/github/requests/Releases.kt new file mode 100644 index 0000000..c699290 --- /dev/null +++ b/providers/github/src/main/kotlin/app/vimusic/providers/github/requests/Releases.kt @@ -0,0 +1,18 @@ +package app.vimusic.providers.github.requests + +import app.vimusic.providers.github.GitHub +import app.vimusic.providers.github.models.Release +import app.vimusic.providers.utils.runCatchingCancellable +import io.ktor.client.call.body +import io.ktor.client.request.get + +suspend fun GitHub.releases( + owner: String, + repo: String, + page: Int = 1, + pageSize: Int = 30 +) = runCatchingCancellable { + httpClient.get("repos/$owner/$repo/releases") { + withPagination(page = page, size = pageSize) + }.body>() +} diff --git a/providers/innertube/build.gradle.kts b/providers/innertube/build.gradle.kts new file mode 100644 index 0000000..95ff661 --- /dev/null +++ b/providers/innertube/build.gradle.kts @@ -0,0 +1,32 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.android.lint) +} + +dependencies { + implementation(projects.ktorClientBrotli) + implementation(projects.providers.common) + + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.okhttp) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.client.logging) + implementation(libs.ktor.client.encoding) + implementation(libs.ktor.client.serialization) + implementation(libs.ktor.serialization.json) + + implementation(libs.rhino) + implementation(libs.log4j) + + detektPlugins(libs.detekt.compose) + detektPlugins(libs.detekt.formatting) +} + +kotlin { + jvmToolchain(libs.versions.jvm.get().toInt()) + + compilerOptions { + freeCompilerArgs.addAll("-Xcontext-receivers") + } +} diff --git a/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/Innertube.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/Innertube.kt new file mode 100644 index 0000000..9f63368 --- /dev/null +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/Innertube.kt @@ -0,0 +1,358 @@ +package app.vimusic.providers.innertube + +import app.vimusic.providers.innertube.models.MusicNavigationButtonRenderer +import app.vimusic.providers.innertube.models.NavigationEndpoint +import app.vimusic.providers.innertube.models.Runs +import app.vimusic.providers.innertube.models.Thumbnail +import app.vimusic.providers.innertube.models.UserAgents +import app.vimusic.providers.utils.runCatchingCancellable +import io.ktor.client.HttpClient +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.plugins.api.createClientPlugin +import io.ktor.client.plugins.compression.ContentEncoding +import io.ktor.client.plugins.compression.brotli +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logging +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.HttpSendPipeline +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.headers +import io.ktor.client.request.host +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.http.parameters +import io.ktor.http.parseQueryString +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.slf4j.LoggerFactory + +object Innertube { + private var javascriptChallenge: JavaScriptChallenge? = null + + private val javascriptClient = HttpClient(OkHttp) { + expectSuccess = true + + install(ContentEncoding) { + brotli(1.0f) + gzip(0.9f) + deflate(0.8f) + } + + install(Logging) + + defaultRequest { + header("User-Agent", UserAgents.DESKTOP) + } + } + + private val OriginInterceptor = createClientPlugin("OriginInterceptor") { + client.sendPipeline.intercept(HttpSendPipeline.State) { + context.headers { + val host = if (context.host == "youtubei.googleapis.com") "www.youtube.com" else context.host + val origin = "${context.url.protocol.name}://$host" + append("host", host) + append("x-origin", origin) + append("origin", origin) + } + } + } + + val logger = LoggerFactory.getLogger(Innertube::class.java) + val client = HttpClient(OkHttp) { + expectSuccess = true + + install(ContentNegotiation) { + json( + Json { + ignoreUnknownKeys = true + explicitNulls = false + encodeDefaults = true + } + ) + } + + install(ContentEncoding) { + brotli(1.0f) + gzip(0.9f) + deflate(0.8f) + } + + install(Logging) { + level = LogLevel.INFO + } + + install(OriginInterceptor) + + defaultRequest { + url(scheme = "https", host = "music.youtube.com") { + contentType(ContentType.Application.Json) + headers { + append("X-Goog-Api-Key", API_KEY) + } + parameters { + append("prettyPrint", "false") + append("key", API_KEY) + } + } + } + } + + private suspend fun getJavaScriptChallenge(): JavaScriptChallenge? { + if (javascriptChallenge != null) return javascriptChallenge + + val iframe = javascriptClient.get("https://www.youtube.com/iframe_api").bodyAsText() + val version = "player\\\\?/([0-9a-fA-F]{8})\\\\?/".toRegex() + .matchEntire(iframe) + ?.groups + ?.get(1) + ?.value + ?.trim() + ?.takeIf { it.isNotBlank() } ?: return null + + val sourceFile = javascriptClient + .get("https://www.youtube.com/s/player/$version/player_ias.vflset/en_US/base.js") + .bodyAsText() + val timestamp = "(?:signatureTimestamp|sts):(\\d{5})".toRegex() + .matchEntire(sourceFile) + ?.groups + ?.get(1) + ?.value + ?.trim() + ?.takeIf { it.isNotBlank() } ?: return null + val functionName = "(\\w+)=function\\(a\\)\\{a=a.split\\(\"\"\\);\\w+".toRegex() + .matchEntire(sourceFile) + ?.groups + ?.get(1) + ?.value + ?.trim() + ?.takeIf { it.isNotBlank() } ?: return null + + return JavaScriptChallenge( + source = sourceFile, + timestamp = timestamp, + functionName = functionName + ).also { javascriptChallenge = it } + } + + // TODO: not stable as of right now, is the implementation correct? + suspend fun decodeSignatureCipher(cipher: String): String? = runCatchingCancellable { + val params = parseQueryString(cipher) + val signature = params["s"] ?: return@runCatchingCancellable null + val signatureParam = params["sp"] ?: return@runCatchingCancellable null + val url = params["url"] ?: return@runCatchingCancellable null + + val actualSignature = getJavaScriptChallenge()?.decode(signature) + ?: return@runCatchingCancellable null + "$url&$signatureParam=$actualSignature" + }?.onFailure { it.printStackTrace() }?.getOrNull() + + suspend fun getSignatureTimestamp(): String? = runCatchingCancellable { + getJavaScriptChallenge()?.timestamp + }?.onFailure { it.printStackTrace() }?.getOrNull() + + private const val API_KEY = "AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30" + + private const val BASE = "/youtubei/v1" + internal const val BROWSE = "$BASE/browse" + internal const val NEXT = "$BASE/next" + internal const val PLAYER = "https://youtubei.googleapis.com/youtubei/v1/player" + internal const val PLAYER_MUSIC = "$BASE/player" + internal const val QUEUE = "$BASE/music/get_queue" + internal const val SEARCH = "$BASE/search" + internal const val SEARCH_SUGGESTIONS = "$BASE/music/get_search_suggestions" + internal const val MUSIC_RESPONSIVE_LIST_ITEM_RENDERER_MASK = + "musicResponsiveListItemRenderer(flexColumns,fixedColumns,thumbnail,navigationEndpoint,badges)" + internal const val MUSIC_TWO_ROW_ITEM_RENDERER_MASK = + "musicTwoRowItemRenderer(thumbnailRenderer,title,subtitle,navigationEndpoint)" + + @Suppress("MaximumLineLength") + internal const val PLAYLIST_PANEL_VIDEO_RENDERER_MASK = + "playlistPanelVideoRenderer(title,navigationEndpoint,longBylineText,shortBylineText,thumbnail,lengthText,badges)" + + internal fun HttpRequestBuilder.mask(value: String = "*") = + header("X-Goog-FieldMask", value) + + @Serializable + data class Info( + val name: String?, + val endpoint: T? + ) { + @Suppress("UNCHECKED_CAST") + constructor(run: Runs.Run) : this( + name = run.text, + endpoint = run.navigationEndpoint?.endpoint as T? + ) + } + + @JvmInline + value class SearchFilter(val value: String) { + companion object { + val Song = SearchFilter("EgWKAQIIAWoOEAMQBBAJEAoQBRAQEBU%3D") + val Video = SearchFilter("EgWKAQIQAWoOEAMQBBAJEAoQBRAQEBU%3D") + val Album = SearchFilter("EgWKAQIYAWoOEAMQBBAJEAoQBRAQEBU%3D") + val Artist = SearchFilter("EgWKAQIgAWoOEAMQBBAJEAoQBRAQEBU%3D") + val CommunityPlaylist = SearchFilter("EgeKAQQoAEABag4QAxAEEAkQChAFEBAQFQ%3D%3D") + } + } + + sealed class Item { + abstract val thumbnail: Thumbnail? + abstract val key: String + } + + @Serializable + data class SongItem( + val info: Info?, + val authors: List>?, + val album: Info?, + val durationText: String?, + val explicit: Boolean, + override val thumbnail: Thumbnail? + ) : Item() { + override val key get() = info!!.endpoint!!.videoId!! + + companion object + } + + data class VideoItem( + val info: Info?, + val authors: List>?, + val viewsText: String?, + val durationText: String?, + override val thumbnail: Thumbnail? + ) : Item() { + override val key get() = info!!.endpoint!!.videoId!! + + val isOfficialMusicVideo: Boolean + get() = info + ?.endpoint + ?.watchEndpointMusicSupportedConfigs + ?.watchEndpointMusicConfig + ?.musicVideoType == "MUSIC_VIDEO_TYPE_OMV" + + companion object + } + + @Serializable + data class AlbumItem( + val info: Info?, + val authors: List>?, + val year: String?, + override val thumbnail: Thumbnail? + ) : Item() { + override val key get() = info!!.endpoint!!.browseId!! + + companion object + } + + @Serializable + data class ArtistItem( + val info: Info?, + val subscribersCountText: String?, + override val thumbnail: Thumbnail? + ) : Item() { + override val key get() = info!!.endpoint!!.browseId!! + + companion object + } + + @Serializable + data class PlaylistItem( + val info: Info?, + val channel: Info?, + val songCount: Int?, + override val thumbnail: Thumbnail? + ) : Item() { + override val key get() = info!!.endpoint!!.browseId!! + + companion object + } + + data class ArtistPage( + val name: String?, + val description: String?, + val thumbnail: Thumbnail?, + val shuffleEndpoint: NavigationEndpoint.Endpoint.Watch?, + val radioEndpoint: NavigationEndpoint.Endpoint.Watch?, + val songs: List?, + val songsEndpoint: NavigationEndpoint.Endpoint.Browse?, + val albums: List?, + val albumsEndpoint: NavigationEndpoint.Endpoint.Browse?, + val singles: List?, + val singlesEndpoint: NavigationEndpoint.Endpoint.Browse?, + val subscribersCountText: String? + ) + + data class PlaylistOrAlbumPage( + val title: String?, + val description: String?, + val authors: List>?, + val year: String?, + val thumbnail: Thumbnail?, + val url: String?, + val songsPage: ItemsPage?, + val otherVersions: List?, + val otherInfo: String? + ) + + data class NextPage( + val itemsPage: ItemsPage?, + val playlistId: String?, + val params: String? = null, + val playlistSetVideoId: String? = null + ) + + @Serializable + data class RelatedPage( + val songs: List? = null, + val playlists: List? = null, + val albums: List? = null, + val artists: List? = null + ) + + data class DiscoverPage( + val newReleaseAlbums: List, + val moods: List, + val trending: Trending + ) { + data class Trending( + val songs: List, + val endpoint: NavigationEndpoint.Endpoint.Browse? + ) + } + + data class Mood( + val title: String, + val items: List + ) { + data class Item( + val title: String, + val stripeColor: Long, + val endpoint: NavigationEndpoint.Endpoint.Browse + ) : Innertube.Item() { + override val thumbnail get() = null + override val key + get() = "${endpoint.browseId.orEmpty()}${endpoint.params?.let { "/$it" }.orEmpty()}" + + companion object + } + } + + fun MusicNavigationButtonRenderer.toMood(): Mood.Item? { + return Mood.Item( + title = buttonText.runs.firstOrNull()?.text ?: return null, + stripeColor = solid?.leftStripeColor ?: return null, + endpoint = clickCommand.browseEndpoint ?: return null + ) + } + + data class ItemsPage( + val items: List?, + val continuation: String? + ) +} diff --git a/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/JavaScriptChallenge.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/JavaScriptChallenge.kt new file mode 100644 index 0000000..d6c6c61 --- /dev/null +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/JavaScriptChallenge.kt @@ -0,0 +1,27 @@ +package app.vimusic.providers.innertube + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.mozilla.javascript.Context +import org.mozilla.javascript.Function + +data class JavaScriptChallenge( + val timestamp: String, + val source: String, + val functionName: String +) { + private val cache = mutableMapOf() + private val mutex = Mutex() + + suspend fun decode(cipher: String) = mutex.withLock { + cache.getOrPut(cipher) { + with(Context.enter()) { + optimizationLevel = -1 + val scope = initSafeStandardObjects() + evaluateString(scope, source, functionName, 1, null) + val function = scope.get(functionName, scope) as Function + function.call(this, scope, scope, arrayOf(cipher)).toString() + } + } + } +} diff --git a/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/Badge.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/Badge.kt new file mode 100644 index 0000000..4d78de2 --- /dev/null +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/Badge.kt @@ -0,0 +1,18 @@ +package app.vimusic.providers.innertube.models + +import kotlinx.serialization.Serializable + +@Serializable +data class Badge( + val musicInlineBadgeRenderer: MusicInlineBadgeRenderer? +) { + @Serializable + data class MusicInlineBadgeRenderer( + val icon: MusicNavigationButtonRenderer.Icon + ) +} + +val List?.isExplicit + get() = this?.find { + it.musicInlineBadgeRenderer?.icon?.iconType == "MUSIC_EXPLICIT_BADGE" + } != null diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/BrowseResponse.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/BrowseResponse.kt similarity index 76% rename from innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/BrowseResponse.kt rename to providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/BrowseResponse.kt index 06519d2..238daea 100644 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/BrowseResponse.kt +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/BrowseResponse.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.innertube.models +package app.vimusic.providers.innertube.models import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable @@ -13,22 +13,24 @@ data class BrowseResponse( @Serializable data class Contents( val singleColumnBrowseResultsRenderer: Tabs?, - val twoColumnBrowseResultsRenderer: TwoColResults?, val sectionListRenderer: SectionListRenderer?, + val twoColumnBrowseResultsRenderer: TwoColumnBrowseResultsRenderer? ) @Serializable - data class Header @OptIn(ExperimentalSerializationApi::class) constructor( + @OptIn(ExperimentalSerializationApi::class) + data class Header( @JsonNames("musicVisualHeaderRenderer") val musicImmersiveHeaderRenderer: MusicImmersiveHeaderRenderer?, - val musicDetailHeaderRenderer: MusicDetailHeaderRenderer?, + val musicDetailHeaderRenderer: MusicDetailHeaderRenderer? ) { @Serializable data class MusicDetailHeaderRenderer( val title: Runs?, + val description: Runs?, val subtitle: Runs?, val secondSubtitle: Runs?, - val thumbnail: ThumbnailRenderer?, + val thumbnail: ThumbnailRenderer? ) @Serializable @@ -38,7 +40,8 @@ data class BrowseResponse( val startRadioButton: StartRadioButton?, val thumbnail: ThumbnailRenderer?, val foregroundThumbnail: ThumbnailRenderer?, - val title: Runs? + val title: Runs?, + val subscriptionButton: SubscriptionButton? ) { @Serializable data class PlayButton( @@ -49,6 +52,11 @@ data class BrowseResponse( data class StartRadioButton( val buttonRenderer: ButtonRenderer? ) + + @Serializable + data class SubscriptionButton( + val subscribeButtonRenderer: SubscribeButtonRenderer? + ) } } diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/ButtonRenderer.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/ButtonRenderer.kt similarity index 50% rename from innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/ButtonRenderer.kt rename to providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/ButtonRenderer.kt index 12d8507..c04f5af 100644 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/ButtonRenderer.kt +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/ButtonRenderer.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.innertube.models +package app.vimusic.providers.innertube.models import kotlinx.serialization.Serializable @@ -6,3 +6,8 @@ import kotlinx.serialization.Serializable data class ButtonRenderer( val navigationEndpoint: NavigationEndpoint? ) + +@Serializable +data class SubscribeButtonRenderer( + val subscriberCountText: Runs? +) diff --git a/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/Context.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/Context.kt new file mode 100644 index 0000000..277d85d --- /dev/null +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/Context.kt @@ -0,0 +1,171 @@ +package app.vimusic.providers.innertube.models + +import io.ktor.client.request.headers +import io.ktor.http.HttpMessageBuilder +import io.ktor.http.parameters +import io.ktor.http.userAgent +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import java.util.Locale + +@Serializable +data class Context( + val client: Client, + val thirdParty: ThirdParty? = null, + val user: User? = User() +) { + @Serializable + data class Client( + val clientName: String, + val clientVersion: String, + val platform: String? = null, + val hl: String = "en", + val gl: String = "US", + val visitorData: String = DEFAULT_VISITOR_DATA, + val androidSdkVersion: Int? = null, + val userAgent: String? = null, + val referer: String? = null, + val deviceMake: String? = null, + val deviceModel: String? = null, + val osName: String? = null, + val osVersion: String? = null, + val acceptHeader: String? = null, + val timeZone: String? = "UTC", + val utcOffsetMinutes: Int? = 0, + @Transient + val apiKey: String? = null + ) + + @Serializable + data class ThirdParty( + val embedUrl: String + ) + + @Serializable + data class User( + val lockedSafetyMode: Boolean = false + ) + + context(HttpMessageBuilder) + fun apply() { + client.userAgent?.let { userAgent(it) } + + headers { + client.referer?.let { append("Referer", it) } + append("X-Youtube-Bootstrap-Logged-In", "false") + append("X-YouTube-Client-Name", client.clientName) + append("X-YouTube-Client-Version", client.clientVersion) + client.apiKey?.let { append("X-Goog-Api-Key", it) } + } + + parameters { + client.apiKey?.let { append("key", it) } + } + } + + companion object { + private val Context.withLang: Context get() { + val locale = Locale.getDefault() + + return copy( + client = client.copy( + hl = locale + .toLanguageTag() + .replace("-Hant", "") + .takeIf { it in validLanguageCodes } ?: "en", + gl = locale + .country + .takeIf { it in validCountryCodes } ?: "US" + ) + ) + } + const val DEFAULT_VISITOR_DATA = "CgtsZG1ySnZiQWtSbyiMjuGSBg%3D%3D" + + val DefaultWeb get() = DefaultWebNoLang.withLang + + val DefaultWebNoLang = Context( + client = Client( + clientName = "WEB_REMIX", + clientVersion = "1.20220606.03.00", + platform = "DESKTOP", + userAgent = UserAgents.DESKTOP, + referer = "https://music.youtube.com/" + ) + ) + + val DefaultWebOld = Context( + client = Client( + clientName = "WEB", + clientVersion = "2.20240509.00.00", + platform = "DESKTOP", + userAgent = UserAgents.DESKTOP, + referer = "https://music.youtube.com/", + apiKey = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8" + ) + ) + + val DefaultIOS = Context( + client = Client( + clientName = "IOS", + clientVersion = "19.29.1", + deviceMake = "Apple", + deviceModel = "iPhone16,2", + osName = "iOS", + osVersion = "17.5.1.21F90", + acceptHeader = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + userAgent = UserAgents.IOS, + apiKey = "AIzaSyB-63vPrdThhKuerbB2N_l7Kwwcxj6yUAc" + ) + ) + + val DefaultAndroid = Context( + client = Client( + clientName = "ANDROID", + clientVersion = "17.36.4", + platform = "MOBILE", + androidSdkVersion = 30, + userAgent = UserAgents.ANDROID, + apiKey = "AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w" + ) + ) + + val DefaultAndroidMusic = Context( + client = Client( + clientName = "ANDROID_MUSIC", + clientVersion = "5.22.1", + platform = "MOBILE", + androidSdkVersion = 30, + userAgent = UserAgents.ANDROID_MUSIC, + apiKey = "AIzaSyAOghZGza2MQSZkY_zfZ370N-PUdXEo8AI" + ) + ) + + val DefaultAgeRestrictionBypass = Context( + client = Client( + clientName = "TVHTML5_SIMPLY_EMBEDDED_PLAYER", + clientVersion = "2.0", + platform = "TV", + userAgent = UserAgents.PLAYSTATION + ) + ) + } +} + +// @formatter:off +@Suppress("MaximumLineLength") +val validLanguageCodes = + listOf("af", "az", "id", "ms", "ca", "cs", "da", "de", "et", "en-GB", "en", "es", "es-419", "eu", "fil", "fr", "fr-CA", "gl", "hr", "zu", "is", "it", "sw", "lt", "hu", "nl", "nl-NL", "no", "or", "uz", "pl", "pt-PT", "pt", "ro", "sq", "sk", "sl", "fi", "sv", "bo", "vi", "tr", "bg", "ky", "kk", "mk", "mn", "ru", "sr", "uk", "el", "hy", "iw", "ur", "ar", "fa", "ne", "mr", "hi", "bn", "pa", "gu", "ta", "te", "kn", "ml", "si", "th", "lo", "my", "ka", "am", "km", "zh-CN", "zh-TW", "zh-HK", "ja", "ko") + +@Suppress("MaximumLineLength") +val validCountryCodes = + listOf("DZ", "AR", "AU", "AT", "AZ", "BH", "BD", "BY", "BE", "BO", "BA", "BR", "BG", "KH", "CA", "CL", "HK", "CO", "CR", "HR", "CY", "CZ", "DK", "DO", "EC", "EG", "SV", "EE", "FI", "FR", "GE", "DE", "GH", "GR", "GT", "HN", "HU", "IS", "IN", "ID", "IQ", "IE", "IL", "IT", "JM", "JP", "JO", "KZ", "KE", "KR", "KW", "LA", "LV", "LB", "LY", "LI", "LT", "LU", "MK", "MY", "MT", "MX", "ME", "MA", "NP", "NL", "NZ", "NI", "NG", "NO", "OM", "PK", "PA", "PG", "PY", "PE", "PH", "PL", "PT", "PR", "QA", "RO", "RU", "SA", "SN", "RS", "SG", "SK", "SI", "ZA", "ES", "LK", "SE", "CH", "TW", "TZ", "TH", "TN", "TR", "UG", "UA", "AE", "GB", "US", "UY", "VE", "VN", "YE", "ZW") +// @formatter:on + +@Suppress("MaximumLineLength") +object UserAgents { + const val DESKTOP = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.157 Safari/537.36" + const val ANDROID = "com.google.android.youtube/17.36.4 (Linux; U; Android 11) gzip" + const val ANDROID_MUSIC = "com.google.android.youtube/19.29.1 (Linux; U; Android 11) gzip" + const val PLAYSTATION = "Mozilla/5.0 (PlayStation 4 5.55) AppleWebKit/601.2 (KHTML, like Gecko)" + const val IOS = "com.google.ios.youtube/19.29.1 (iPhone16,2; U; CPU iOS 17_5_1 like Mac OS X;)" +} diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/Continuation.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/Continuation.kt similarity index 89% rename from innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/Continuation.kt rename to providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/Continuation.kt index 61f8099..912ffa9 100644 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/Continuation.kt +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/Continuation.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.innertube.models +package app.vimusic.providers.innertube.models import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/ContinuationResponse.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/ContinuationResponse.kt similarity index 77% rename from innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/ContinuationResponse.kt rename to providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/ContinuationResponse.kt index 9f321c3..3706365 100644 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/ContinuationResponse.kt +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/ContinuationResponse.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.innertube.models +package app.vimusic.providers.innertube.models import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable @@ -7,12 +7,12 @@ import kotlinx.serialization.json.JsonNames @OptIn(ExperimentalSerializationApi::class) @Serializable data class ContinuationResponse( - val continuationContents: ContinuationContents?, + val continuationContents: ContinuationContents? ) { @Serializable data class ContinuationContents( @JsonNames("musicPlaylistShelfContinuation") val musicShelfContinuation: MusicShelfRenderer?, - val playlistPanelContinuation: NextResponse.MusicQueueRenderer.Content.PlaylistPanelRenderer?, + val playlistPanelContinuation: NextResponse.MusicQueueRenderer.Content.PlaylistPanelRenderer? ) } diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/GetQueueResponse.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/GetQueueResponse.kt similarity index 60% rename from innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/GetQueueResponse.kt rename to providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/GetQueueResponse.kt index 02a3899..4925b53 100644 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/GetQueueResponse.kt +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/GetQueueResponse.kt @@ -1,10 +1,12 @@ -package it.vfsfitvnm.innertube.models +package app.vimusic.providers.innertube.models +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class GetQueueResponse( - val queueDatas: List?, + @SerialName("queueDatas") + val queueData: List? ) { @Serializable data class QueueData( diff --git a/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/GridRenderer.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/GridRenderer.kt new file mode 100644 index 0000000..113c025 --- /dev/null +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/GridRenderer.kt @@ -0,0 +1,25 @@ +package app.vimusic.providers.innertube.models + +import kotlinx.serialization.Serializable + +@Serializable +data class GridRenderer( + val items: List?, + val header: Header? +) { + @Serializable + data class Item( + val musicNavigationButtonRenderer: MusicNavigationButtonRenderer?, + val musicTwoRowItemRenderer: MusicTwoRowItemRenderer? + ) + + @Serializable + data class Header( + val gridHeaderRenderer: GridHeaderRenderer? + ) + + @Serializable + data class GridHeaderRenderer( + val title: Runs? + ) +} diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/MusicCarouselShelfRenderer.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/MusicCarouselShelfRenderer.kt similarity index 82% rename from innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/MusicCarouselShelfRenderer.kt rename to providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/MusicCarouselShelfRenderer.kt index f7f1d16..36140a9 100644 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/MusicCarouselShelfRenderer.kt +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/MusicCarouselShelfRenderer.kt @@ -1,16 +1,17 @@ -package it.vfsfitvnm.innertube.models +package app.vimusic.providers.innertube.models import kotlinx.serialization.Serializable @Serializable data class MusicCarouselShelfRenderer( val header: Header?, - val contents: List?, + val contents: List? ) { @Serializable data class Content( val musicTwoRowItemRenderer: MusicTwoRowItemRenderer?, val musicResponsiveListItemRenderer: MusicResponsiveListItemRenderer?, + val musicNavigationButtonRenderer: MusicNavigationButtonRenderer? = null ) @Serializable @@ -23,7 +24,7 @@ data class MusicCarouselShelfRenderer( data class MusicCarouselShelfBasicHeaderRenderer( val moreContentButton: MoreContentButton?, val title: Runs?, - val strapline: Runs?, + val strapline: Runs? ) { @Serializable data class MoreContentButton( diff --git a/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/MusicNavigationButtonRenderer.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/MusicNavigationButtonRenderer.kt new file mode 100644 index 0000000..93c80f9 --- /dev/null +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/MusicNavigationButtonRenderer.kt @@ -0,0 +1,29 @@ +package app.vimusic.providers.innertube.models + +import kotlinx.serialization.Serializable + +@Serializable +data class MusicNavigationButtonRenderer( + val buttonText: Runs, + val solid: Solid?, + val iconStyle: IconStyle?, + val clickCommand: NavigationEndpoint +) { + val isMood: Boolean + get() = clickCommand.browseEndpoint?.browseId == "FEmusic_moods_and_genres_category" + + @Serializable + data class Solid( + val leftStripeColor: Long + ) + + @Serializable + data class IconStyle( + val icon: Icon + ) + + @Serializable + data class Icon( + val iconType: String + ) +} diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/MusicResponsiveListItemRenderer.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/MusicResponsiveListItemRenderer.kt similarity index 90% rename from innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/MusicResponsiveListItemRenderer.kt rename to providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/MusicResponsiveListItemRenderer.kt index bbd178e..f30be58 100644 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/MusicResponsiveListItemRenderer.kt +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/MusicResponsiveListItemRenderer.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.innertube.models +package app.vimusic.providers.innertube.models import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable @@ -11,6 +11,7 @@ data class MusicResponsiveListItemRenderer( val flexColumns: List, val thumbnail: ThumbnailRenderer?, val navigationEndpoint: NavigationEndpoint?, + val badges: List? ) { @Serializable data class FlexColumn( diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/MusicShelfRenderer.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/MusicShelfRenderer.kt similarity index 71% rename from innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/MusicShelfRenderer.kt rename to providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/MusicShelfRenderer.kt index dab934c..3c967a8 100644 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/MusicShelfRenderer.kt +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/MusicShelfRenderer.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.innertube.models +package app.vimusic.providers.innertube.models import kotlinx.serialization.Serializable @@ -7,30 +7,27 @@ data class MusicShelfRenderer( val bottomEndpoint: NavigationEndpoint?, val contents: List?, val continuations: List?, - val title: Runs?, - val thumbnail: ThumbnailRenderer?, - val subtitle: Runs? + val title: Runs? ) { @Serializable data class Content( - val musicResponsiveListItemRenderer: MusicResponsiveListItemRenderer?, + val musicResponsiveListItemRenderer: MusicResponsiveListItemRenderer? ) { val runs: Pair, List>> - get() = (musicResponsiveListItemRenderer + get() = musicResponsiveListItemRenderer ?.flexColumns ?.firstOrNull() ?.musicResponsiveListItemFlexColumnRenderer ?.text ?.runs - ?: emptyList()) to - (musicResponsiveListItemRenderer + .orEmpty() to + musicResponsiveListItemRenderer ?.flexColumns - ?.getOrNull(1) + ?.let { it.getOrNull(1) ?: it.lastOrNull() } ?.musicResponsiveListItemFlexColumnRenderer ?.text ?.splitBySeparator() - ?: emptyList() - ) + .orEmpty() val thumbnail: Thumbnail? get() = musicResponsiveListItemRenderer diff --git a/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/MusicTwoRowItemRenderer.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/MusicTwoRowItemRenderer.kt new file mode 100644 index 0000000..7a3eeac --- /dev/null +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/MusicTwoRowItemRenderer.kt @@ -0,0 +1,26 @@ +package app.vimusic.providers.innertube.models + +import kotlinx.serialization.Serializable + +@Serializable +data class MusicTwoRowItemRenderer( + val navigationEndpoint: NavigationEndpoint?, + val thumbnailRenderer: ThumbnailRenderer?, + val title: Runs?, + val subtitle: Runs?, + val thumbnailOverlay: ThumbnailOverlay? +) { + val isPlaylist: Boolean + get() = navigationEndpoint?.browseEndpoint?.browseEndpointContextSupportedConfigs + ?.browseEndpointContextMusicConfig?.pageType == "MUSIC_PAGE_TYPE_PLAYLIST" + + val isAlbum: Boolean + get() = navigationEndpoint?.browseEndpoint?.browseEndpointContextSupportedConfigs + ?.browseEndpointContextMusicConfig?.pageType == "MUSIC_PAGE_TYPE_ALBUM" || + navigationEndpoint?.browseEndpoint?.browseEndpointContextSupportedConfigs + ?.browseEndpointContextMusicConfig?.pageType == "MUSIC_PAGE_TYPE_AUDIOBOOK" + + val isArtist: Boolean + get() = navigationEndpoint?.browseEndpoint?.browseEndpointContextSupportedConfigs + ?.browseEndpointContextMusicConfig?.pageType == "MUSIC_PAGE_TYPE_ARTIST" +} diff --git a/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/NavigationEndpoint.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/NavigationEndpoint.kt new file mode 100644 index 0000000..49d9815 --- /dev/null +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/NavigationEndpoint.kt @@ -0,0 +1,77 @@ +package app.vimusic.providers.innertube.models + +import kotlinx.serialization.Serializable + +/** + * watchPlaylistEndpoint: params, playlistId + * watchEndpoint: params, playlistId, videoId, index + * browseEndpoint: params, browseId + * searchEndpoint: params, query + */ + +@Serializable +data class NavigationEndpoint( + val watchEndpoint: Endpoint.Watch?, + val watchPlaylistEndpoint: Endpoint.WatchPlaylist?, + val browseEndpoint: Endpoint.Browse?, + val searchEndpoint: Endpoint.Search? +) { + val endpoint get() = watchEndpoint ?: browseEndpoint ?: watchPlaylistEndpoint ?: searchEndpoint + + @Serializable + sealed class Endpoint { + @Serializable + data class Watch( + val params: String? = null, + val playlistId: String? = null, + val videoId: String? = null, + val index: Int? = null, + val playlistSetVideoId: String? = null, + val watchEndpointMusicSupportedConfigs: WatchEndpointMusicSupportedConfigs? = null + ) : Endpoint() { + @Serializable + data class WatchEndpointMusicSupportedConfigs( + val watchEndpointMusicConfig: WatchEndpointMusicConfig? + ) { + @Serializable + data class WatchEndpointMusicConfig( + val musicVideoType: String? + ) + } + } + + @Serializable + data class WatchPlaylist( + val params: String?, + val playlistId: String? + ) : Endpoint() + + @Serializable + data class Browse( + val params: String? = null, + val browseId: String? = null, + val browseEndpointContextSupportedConfigs: BrowseEndpointContextSupportedConfigs? = null + ) : Endpoint() { + val type: String? + get() = browseEndpointContextSupportedConfigs + ?.browseEndpointContextMusicConfig + ?.pageType + + @Serializable + data class BrowseEndpointContextSupportedConfigs( + val browseEndpointContextMusicConfig: BrowseEndpointContextMusicConfig + ) { + @Serializable + data class BrowseEndpointContextMusicConfig( + val pageType: String + ) + } + } + + @Serializable + data class Search( + val params: String?, + val query: String + ) : Endpoint() + } +} diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/NextResponse.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/NextResponse.kt similarity index 96% rename from innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/NextResponse.kt rename to providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/NextResponse.kt index a165974..eca7a4e 100644 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/NextResponse.kt +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/NextResponse.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.innertube.models +package app.vimusic.providers.innertube.models import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable @@ -21,12 +21,12 @@ data class NextResponse( @Serializable data class PlaylistPanelRenderer( val contents: List?, - val continuations: List?, + val continuations: List? ) { @Serializable data class Content( val playlistPanelVideoRenderer: PlaylistPanelVideoRenderer?, - val automixPreviewVideoRenderer: AutomixPreviewVideoRenderer?, + val automixPreviewVideoRenderer: AutomixPreviewVideoRenderer? ) { @Serializable diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/PlayerResponse.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/PlayerResponse.kt similarity index 63% rename from innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/PlayerResponse.kt rename to providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/PlayerResponse.kt index d913698..264f5bd 100644 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/PlayerResponse.kt +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/PlayerResponse.kt @@ -1,6 +1,8 @@ -package it.vfsfitvnm.innertube.models +package app.vimusic.providers.innertube.models +import app.vimusic.providers.innertube.Innertube import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient @Serializable data class PlayerResponse( @@ -8,6 +10,8 @@ data class PlayerResponse( val playerConfig: PlayerConfig?, val streamingData: StreamingData?, val videoDetails: VideoDetails?, + @Transient + val cpn: String? = null ) { @Serializable data class PlayabilityStatus( @@ -20,7 +24,7 @@ data class PlayerResponse( ) { @Serializable data class AudioConfig( - private val loudnessDb: Double? + internal val loudnessDb: Double? ) { // For music clients only val normalizedLoudnessDb: Float? @@ -30,10 +34,14 @@ data class PlayerResponse( @Serializable data class StreamingData( - val adaptiveFormats: List? + val adaptiveFormats: List?, + val expiresInSeconds: Long? ) { val highestQualityFormat: AdaptiveFormat? - get() = adaptiveFormats?.findLast { it.itag == 251 || it.itag == 140 } + get() = adaptiveFormats?.filter { it.url != null || it.signatureCipher != null }?.let { formats -> + formats.findLast { it.itag == 251 || it.itag == 140 } + ?: formats.maxBy { it.bitrate ?: 0L } + } @Serializable data class AdaptiveFormat( @@ -48,7 +56,10 @@ data class PlayerResponse( val loudnessDb: Double?, val audioSampleRate: Int?, val url: String?, - ) + val signatureCipher: String? + ) { + suspend fun findUrl() = url ?: signatureCipher?.let { Innertube.decodeSignatureCipher(it) } + } } @Serializable diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/PlaylistPanelVideoRenderer.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/PlaylistPanelVideoRenderer.kt similarity index 81% rename from innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/PlaylistPanelVideoRenderer.kt rename to providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/PlaylistPanelVideoRenderer.kt index 6e0b497..968c456 100644 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/PlaylistPanelVideoRenderer.kt +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/PlaylistPanelVideoRenderer.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.innertube.models +package app.vimusic.providers.innertube.models import kotlinx.serialization.Serializable @@ -10,4 +10,5 @@ data class PlaylistPanelVideoRenderer( val lengthText: Runs?, val navigationEndpoint: NavigationEndpoint?, val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail?, + val badges: List? ) diff --git a/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/Runs.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/Runs.kt new file mode 100644 index 0000000..ef7f07d --- /dev/null +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/Runs.kt @@ -0,0 +1,52 @@ +package app.vimusic.providers.innertube.models + +import kotlinx.serialization.Serializable + +@Serializable +data class Runs( + val runs: List = listOf() +) { + companion object { + const val SEPARATOR = " • " + } + + val text: String + get() = runs.joinToString("") { it.text.orEmpty() } + + fun splitBySeparator(): List> { + return runs.flatMapIndexed { index, run -> + when { + index == 0 || index == runs.lastIndex -> listOf(index) + run.text == SEPARATOR -> listOf(index - 1, index + 1) + else -> emptyList() + } + }.windowed(size = 2, step = 2) { (from, to) -> runs.slice(from..to) }.let { + it.ifEmpty { + listOf(runs) + } + } + } + + @Serializable + data class Run( + val text: String?, + val navigationEndpoint: NavigationEndpoint? + ) +} + +fun List.splitBySeparator(): List> { + val res = mutableListOf>() + var tmp = mutableListOf() + forEach { run -> + if (run.text == " • ") { + res.add(tmp) + tmp = mutableListOf() + } else { + tmp.add(run) + } + } + res.add(tmp) + return res +} + +fun List.oddElements() = filterIndexed { index, _ -> index % 2 == 0 } diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/SearchResponse.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/SearchResponse.kt similarity index 59% rename from innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/SearchResponse.kt rename to providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/SearchResponse.kt index c6b6d96..e007899 100644 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/SearchResponse.kt +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/SearchResponse.kt @@ -1,11 +1,10 @@ -package it.vfsfitvnm.innertube.models +package app.vimusic.providers.innertube.models -import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable @Serializable data class SearchResponse( - val contents: Contents?, + val contents: Contents? ) { @Serializable data class Contents( diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/SearchSuggestionsResponse.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/SearchSuggestionsResponse.kt similarity index 85% rename from innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/SearchSuggestionsResponse.kt rename to providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/SearchSuggestionsResponse.kt index b61de04..232a2f3 100644 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/SearchSuggestionsResponse.kt +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/SearchSuggestionsResponse.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.innertube.models +package app.vimusic.providers.innertube.models import kotlinx.serialization.Serializable @@ -20,7 +20,7 @@ data class SearchSuggestionsResponse( ) { @Serializable data class SearchSuggestionRenderer( - val navigationEndpoint: NavigationEndpoint?, + val navigationEndpoint: NavigationEndpoint? ) } } diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/SectionListRenderer.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/SectionListRenderer.kt similarity index 62% rename from innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/SectionListRenderer.kt rename to providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/SectionListRenderer.kt index 2f934a3..d6b67d6 100644 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/SectionListRenderer.kt +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/SectionListRenderer.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.innertube.models +package app.vimusic.providers.innertube.models import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable @@ -16,16 +16,23 @@ data class SectionListRenderer( val musicCarouselShelfRenderer: MusicCarouselShelfRenderer?, @JsonNames("musicPlaylistShelfRenderer") val musicShelfRenderer: MusicShelfRenderer?, - val musicResponsiveHeaderRenderer: MusicShelfRenderer?, - val musicPlaylistShelfRenderer: MusicShelfRenderer?, val gridRenderer: GridRenderer?, val musicDescriptionShelfRenderer: MusicDescriptionShelfRenderer?, + val musicResponsiveHeaderRenderer: MusicResponsiveHeaderRenderer? ) { - @Serializable data class MusicDescriptionShelfRenderer( - val description: Runs?, + val description: Runs? ) - } + @Serializable + data class MusicResponsiveHeaderRenderer( + val title: Runs?, + val description: MusicDescriptionShelfRenderer?, + val subtitle: Runs?, + val secondSubtitle: Runs?, + val thumbnail: ThumbnailRenderer?, + val straplineTextOne: Runs? + ) + } } diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/Tabs.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/Tabs.kt similarity index 58% rename from innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/Tabs.kt rename to providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/Tabs.kt index ad4a3fb..814e578 100644 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/Tabs.kt +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/Tabs.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.innertube.models +package app.vimusic.providers.innertube.models import kotlinx.serialization.Serializable @@ -14,12 +14,18 @@ data class Tabs( data class TabRenderer( val content: Content?, val title: String?, - val tabIdentifier: String?, + val tabIdentifier: String? ) { @Serializable data class Content( - val sectionListRenderer: SectionListRenderer?, + val sectionListRenderer: SectionListRenderer? ) } } } + +@Serializable +data class TwoColumnBrowseResultsRenderer( + val tabs: List?, + val secondaryContents: Tabs.Tab.TabRenderer.Content? +) diff --git a/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/Thumbnail.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/Thumbnail.kt new file mode 100644 index 0000000..c322dde --- /dev/null +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/Thumbnail.kt @@ -0,0 +1,16 @@ +package app.vimusic.providers.innertube.models + +import kotlinx.serialization.Serializable + +@Serializable +data class Thumbnail( + val url: String, + val height: Int?, + val width: Int? +) { + fun size(size: Int) = when { + url.startsWith("https://lh3.googleusercontent.com") -> "$url-w$size-h$size" + url.startsWith("https://yt3.ggpht.com") -> "$url-s$size" + else -> url + } +} diff --git a/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/ThumbnailRenderer.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/ThumbnailRenderer.kt new file mode 100644 index 0000000..df0d575 --- /dev/null +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/ThumbnailRenderer.kt @@ -0,0 +1,42 @@ +package app.vimusic.providers.innertube.models + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonNames + +@OptIn(ExperimentalSerializationApi::class) +@Serializable +data class ThumbnailRenderer( + @JsonNames("croppedSquareThumbnailRenderer") + val musicThumbnailRenderer: MusicThumbnailRenderer? +) { + @Serializable + data class MusicThumbnailRenderer( + val thumbnail: Thumbnail? + ) { + @Serializable + data class Thumbnail( + val thumbnails: List? + ) + } +} + +@Serializable +data class ThumbnailOverlay( + val musicItemThumbnailOverlayRenderer: MusicItemThumbnailOverlayRenderer +) { + @Serializable + data class MusicItemThumbnailOverlayRenderer( + val content: Content + ) { + @Serializable + data class Content( + val musicPlayButtonRenderer: MusicPlayButtonRenderer + ) { + @Serializable + data class MusicPlayButtonRenderer( + val playNavigationEndpoint: NavigationEndpoint? + ) + } + } +} diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/bodies/BrowseBody.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/bodies/BrowseBody.kt similarity index 63% rename from innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/bodies/BrowseBody.kt rename to providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/bodies/BrowseBody.kt index 7c35633..e69b1ff 100644 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/bodies/BrowseBody.kt +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/bodies/BrowseBody.kt @@ -1,6 +1,6 @@ -package it.vfsfitvnm.innertube.models.bodies +package app.vimusic.providers.innertube.models.bodies -import it.vfsfitvnm.innertube.models.Context +import app.vimusic.providers.innertube.models.Context import kotlinx.serialization.Serializable @Serializable diff --git a/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/bodies/ContinuationBody.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/bodies/ContinuationBody.kt new file mode 100644 index 0000000..756eb01 --- /dev/null +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/bodies/ContinuationBody.kt @@ -0,0 +1,10 @@ +package app.vimusic.providers.innertube.models.bodies + +import app.vimusic.providers.innertube.models.Context +import kotlinx.serialization.Serializable + +@Serializable +data class ContinuationBody( + val context: Context = Context.DefaultWeb, + val continuation: String +) diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/bodies/NextBody.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/bodies/NextBody.kt similarity index 86% rename from innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/bodies/NextBody.kt rename to providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/bodies/NextBody.kt index d3f2bd8..9f15106 100644 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/bodies/NextBody.kt +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/bodies/NextBody.kt @@ -1,6 +1,6 @@ -package it.vfsfitvnm.innertube.models.bodies +package app.vimusic.providers.innertube.models.bodies -import it.vfsfitvnm.innertube.models.Context +import app.vimusic.providers.innertube.models.Context import kotlinx.serialization.Serializable @Serializable diff --git a/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/bodies/PlayerBody.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/bodies/PlayerBody.kt new file mode 100644 index 0000000..79b0f1d --- /dev/null +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/bodies/PlayerBody.kt @@ -0,0 +1,25 @@ +package app.vimusic.providers.innertube.models.bodies + +import app.vimusic.providers.innertube.models.Context +import kotlinx.serialization.Serializable + +@Serializable +data class PlayerBody( + val context: Context = Context.DefaultAndroidMusic, + val videoId: String, + val playlistId: String? = null, + val cpn: String? = null, + val contentCheckOk: String = "true", + val racyCheckOn: String = "true", + val playbackContext: PlaybackContext? = null +) { + @Serializable + data class PlaybackContext( + val contentPlaybackContext: ContentPlaybackContext? = null + ) { + @Serializable + data class ContentPlaybackContext( + val signatureTimestamp: String? = null + ) + } +} diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/bodies/QueueBody.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/bodies/QueueBody.kt similarity index 54% rename from innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/bodies/QueueBody.kt rename to providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/bodies/QueueBody.kt index 2f9288c..9aa9f81 100644 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/bodies/QueueBody.kt +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/bodies/QueueBody.kt @@ -1,11 +1,11 @@ -package it.vfsfitvnm.innertube.models.bodies +package app.vimusic.providers.innertube.models.bodies -import it.vfsfitvnm.innertube.models.Context +import app.vimusic.providers.innertube.models.Context import kotlinx.serialization.Serializable @Serializable data class QueueBody( val context: Context = Context.DefaultWeb, val videoIds: List? = null, - val playlistId: String? = null, + val playlistId: String? = null ) diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/bodies/SearchBody.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/bodies/SearchBody.kt similarity index 61% rename from innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/bodies/SearchBody.kt rename to providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/bodies/SearchBody.kt index d21af57..de5d70b 100644 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/bodies/SearchBody.kt +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/bodies/SearchBody.kt @@ -1,6 +1,6 @@ -package it.vfsfitvnm.innertube.models.bodies +package app.vimusic.providers.innertube.models.bodies -import it.vfsfitvnm.innertube.models.Context +import app.vimusic.providers.innertube.models.Context import kotlinx.serialization.Serializable @Serializable diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/bodies/SearchSuggestionsBody.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/bodies/SearchSuggestionsBody.kt similarity index 60% rename from innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/bodies/SearchSuggestionsBody.kt rename to providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/bodies/SearchSuggestionsBody.kt index c0115e9..e8856a5 100644 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/models/bodies/SearchSuggestionsBody.kt +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/models/bodies/SearchSuggestionsBody.kt @@ -1,6 +1,6 @@ -package it.vfsfitvnm.innertube.models.bodies +package app.vimusic.providers.innertube.models.bodies -import it.vfsfitvnm.innertube.models.Context +import app.vimusic.providers.innertube.models.Context import kotlinx.serialization.Serializable @Serializable diff --git a/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/AlbumPage.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/AlbumPage.kt new file mode 100644 index 0000000..35d3e87 --- /dev/null +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/AlbumPage.kt @@ -0,0 +1,35 @@ +package app.vimusic.providers.innertube.requests + +import app.vimusic.providers.innertube.Innertube +import app.vimusic.providers.innertube.models.NavigationEndpoint +import app.vimusic.providers.innertube.models.bodies.BrowseBody +import io.ktor.http.Url + +suspend fun Innertube.albumPage(body: BrowseBody) = + playlistPage(body) + ?.map { album -> + album.url?.let { + playlistPage(body = BrowseBody(browseId = "VL${Url(it).parameters["list"]}")) + ?.getOrNull() + ?.let { playlist -> album.copy(songsPage = playlist.songsPage) } + } ?: album + } + ?.map { album -> + album.copy( + songsPage = album.songsPage?.copy( + items = album.songsPage.items?.map { song -> + song.copy( + authors = song.authors ?: album.authors, + album = Innertube.Info( + name = album.title, + endpoint = NavigationEndpoint.Endpoint.Browse( + browseId = body.browseId, + params = body.params + ) + ), + thumbnail = album.thumbnail + ) + } + ) + ) + } diff --git a/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/ArtistPage.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/ArtistPage.kt new file mode 100644 index 0000000..980e7f8 --- /dev/null +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/ArtistPage.kt @@ -0,0 +1,134 @@ +package app.vimusic.providers.innertube.requests + +import app.vimusic.providers.innertube.Innertube +import app.vimusic.providers.innertube.models.BrowseResponse +import app.vimusic.providers.innertube.models.Context +import app.vimusic.providers.innertube.models.MusicCarouselShelfRenderer +import app.vimusic.providers.innertube.models.MusicShelfRenderer +import app.vimusic.providers.innertube.models.bodies.BrowseBody +import app.vimusic.providers.innertube.utils.findSectionByTitle +import app.vimusic.providers.innertube.utils.from +import app.vimusic.providers.utils.runCatchingCancellable +import io.ktor.client.call.body +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.currentCoroutineContext + +suspend fun Innertube.artistPage(body: BrowseBody) = runCatchingCancellable { + val ctx = currentCoroutineContext() + val response = client.post(BROWSE) { + setBody(body) + mask("contents,header") + }.body() + + val responseNoLang by lazy { + CoroutineScope(ctx).async(start = CoroutineStart.LAZY) { + client.post(BROWSE) { + setBody(body.copy(context = Context.DefaultWebNoLang)) + mask("contents,header") + }.body() + } + } + + suspend fun findSectionByTitle(text: String) = response + .contents + ?.singleColumnBrowseResultsRenderer + ?.tabs + ?.get(0) + ?.tabRenderer + ?.content + ?.sectionListRenderer + ?.findSectionByTitle(text) ?: responseNoLang.await() + .contents + ?.singleColumnBrowseResultsRenderer + ?.tabs + ?.get(0) + ?.tabRenderer + ?.content + ?.sectionListRenderer + ?.findSectionByTitle(text) + + val songsSection = findSectionByTitle("Songs")?.musicShelfRenderer + val albumsSection = findSectionByTitle("Albums")?.musicCarouselShelfRenderer + val singlesSection = findSectionByTitle("Singles")?.musicCarouselShelfRenderer + + Innertube.ArtistPage( + name = response + .header + ?.musicImmersiveHeaderRenderer + ?.title + ?.text, + description = response + .header + ?.musicImmersiveHeaderRenderer + ?.description + ?.text, + thumbnail = ( + response + .header + ?.musicImmersiveHeaderRenderer + ?.foregroundThumbnail + ?: response + .header + ?.musicImmersiveHeaderRenderer + ?.thumbnail + ) + ?.musicThumbnailRenderer + ?.thumbnail + ?.thumbnails + ?.getOrNull(0), + shuffleEndpoint = response + .header + ?.musicImmersiveHeaderRenderer + ?.playButton + ?.buttonRenderer + ?.navigationEndpoint + ?.watchEndpoint, + radioEndpoint = response + .header + ?.musicImmersiveHeaderRenderer + ?.startRadioButton + ?.buttonRenderer + ?.navigationEndpoint + ?.watchEndpoint, + songs = songsSection + ?.contents + ?.mapNotNull(MusicShelfRenderer.Content::musicResponsiveListItemRenderer) + ?.mapNotNull(Innertube.SongItem::from), + songsEndpoint = songsSection + ?.bottomEndpoint + ?.browseEndpoint, + albums = albumsSection + ?.contents + ?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer) + ?.mapNotNull(Innertube.AlbumItem::from), + albumsEndpoint = albumsSection + ?.header + ?.musicCarouselShelfBasicHeaderRenderer + ?.moreContentButton + ?.buttonRenderer + ?.navigationEndpoint + ?.browseEndpoint, + singles = singlesSection + ?.contents + ?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer) + ?.mapNotNull(Innertube.AlbumItem::from), + singlesEndpoint = singlesSection + ?.header + ?.musicCarouselShelfBasicHeaderRenderer + ?.moreContentButton + ?.buttonRenderer + ?.navigationEndpoint + ?.browseEndpoint, + subscribersCountText = response + .header + ?.musicImmersiveHeaderRenderer + ?.subscriptionButton + ?.subscribeButtonRenderer + ?.subscriberCountText + ?.text + ) +} diff --git a/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/Browse.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/Browse.kt new file mode 100644 index 0000000..b9aa243 --- /dev/null +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/Browse.kt @@ -0,0 +1,109 @@ +package app.vimusic.providers.innertube.requests + +import app.vimusic.providers.innertube.Innertube +import app.vimusic.providers.innertube.Innertube.toMood +import app.vimusic.providers.innertube.models.BrowseResponse +import app.vimusic.providers.innertube.models.GridRenderer +import app.vimusic.providers.innertube.models.MusicCarouselShelfRenderer +import app.vimusic.providers.innertube.models.MusicNavigationButtonRenderer +import app.vimusic.providers.innertube.models.MusicResponsiveListItemRenderer +import app.vimusic.providers.innertube.models.MusicTwoRowItemRenderer +import app.vimusic.providers.innertube.models.SectionListRenderer +import app.vimusic.providers.innertube.models.bodies.BrowseBody +import app.vimusic.providers.innertube.utils.from +import app.vimusic.providers.utils.runCatchingCancellable +import io.ktor.client.call.body +import io.ktor.client.request.post +import io.ktor.client.request.setBody + +suspend fun Innertube.browse(body: BrowseBody) = runCatchingCancellable { + val response = client.post(BROWSE) { + setBody(body) + }.body() + + BrowseResult( + title = response + .header + ?.musicImmersiveHeaderRenderer + ?.title + ?.text + ?: response + .header + ?.musicDetailHeaderRenderer + ?.title + ?.text, + items = response + .contents + ?.singleColumnBrowseResultsRenderer + ?.tabs + ?.firstOrNull() + ?.tabRenderer + ?.content + ?.sectionListRenderer + ?.toBrowseItems() + .orEmpty() + ) +} + +fun SectionListRenderer.toBrowseItems() = contents?.mapNotNull { content -> + when { + content.gridRenderer != null -> content.gridRenderer.toBrowseItem() + content.musicCarouselShelfRenderer != null -> content.musicCarouselShelfRenderer.toBrowseItem() + else -> null + } +} + +fun GridRenderer.toBrowseItem() = BrowseResult.Item( + title = header + ?.gridHeaderRenderer + ?.title + ?.runs + ?.firstOrNull() + ?.text, + items = items + ?.mapNotNull { + it.musicTwoRowItemRenderer?.toItem() ?: it.musicNavigationButtonRenderer?.toItem() + } + .orEmpty() +) + +fun MusicCarouselShelfRenderer.toBrowseItem( + fromResponsiveListItemRenderer: ((MusicResponsiveListItemRenderer) -> Innertube.Item?)? = null +) = BrowseResult.Item( + title = header + ?.musicCarouselShelfBasicHeaderRenderer + ?.title + ?.runs + ?.firstOrNull() + ?.text, + items = contents + ?.mapNotNull { + it.musicResponsiveListItemRenderer?.let { renderer -> + fromResponsiveListItemRenderer?.invoke(renderer) + } ?: it.musicTwoRowItemRenderer?.toItem() + ?: it.musicNavigationButtonRenderer?.toItem() + } + .orEmpty() +) + +data class BrowseResult( + val title: String?, + val items: List +) { + data class Item( + val title: String?, + val items: List + ) +} + +fun MusicTwoRowItemRenderer.toItem() = when { + isAlbum -> Innertube.AlbumItem.from(this) + isPlaylist -> Innertube.PlaylistItem.from(this) + isArtist -> Innertube.ArtistItem.from(this) + else -> null +} + +fun MusicNavigationButtonRenderer.toItem() = when { + isMood -> toMood() + else -> null +} diff --git a/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/DiscoverPage.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/DiscoverPage.kt new file mode 100644 index 0000000..28fc0d5 --- /dev/null +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/DiscoverPage.kt @@ -0,0 +1,108 @@ +package app.vimusic.providers.innertube.requests + +import app.vimusic.providers.innertube.Innertube +import app.vimusic.providers.innertube.models.BrowseResponse +import app.vimusic.providers.innertube.models.MusicTwoRowItemRenderer +import app.vimusic.providers.innertube.models.bodies.BrowseBody +import app.vimusic.providers.innertube.models.oddElements +import app.vimusic.providers.innertube.models.splitBySeparator +import app.vimusic.providers.innertube.utils.from +import app.vimusic.providers.utils.runCatchingCancellable +import io.ktor.client.call.body +import io.ktor.client.request.post +import io.ktor.client.request.setBody + +suspend fun Innertube.discoverPage() = runCatchingCancellable { + val response = client.post(BROWSE) { + setBody(BrowseBody(browseId = "FEmusic_explore")) + mask("contents") + }.body() + + val sections = response + .contents + ?.singleColumnBrowseResultsRenderer + ?.tabs + ?.firstOrNull() + ?.tabRenderer + ?.content + ?.sectionListRenderer + ?.contents + + Innertube.DiscoverPage( + newReleaseAlbums = sections?.find { + it.musicCarouselShelfRenderer + ?.header + ?.musicCarouselShelfBasicHeaderRenderer + ?.moreContentButton + ?.buttonRenderer + ?.navigationEndpoint + ?.browseEndpoint + ?.browseId == "FEmusic_new_releases_albums" + }?.musicCarouselShelfRenderer + ?.contents + ?.mapNotNull { it.musicTwoRowItemRenderer?.toNewReleaseAlbumPage() } + .orEmpty(), + moods = sections?.find { + it.musicCarouselShelfRenderer + ?.header + ?.musicCarouselShelfBasicHeaderRenderer + ?.moreContentButton + ?.buttonRenderer + ?.navigationEndpoint + ?.browseEndpoint + ?.browseId == "FEmusic_moods_and_genres" + }?.musicCarouselShelfRenderer + ?.contents + ?.mapNotNull { it.musicNavigationButtonRenderer?.toMood() } + .orEmpty(), + trending = run { + val renderer = sections?.find { + it.musicCarouselShelfRenderer + ?.header + ?.musicCarouselShelfBasicHeaderRenderer + ?.moreContentButton + ?.buttonRenderer + ?.navigationEndpoint + ?.browseEndpoint + ?.browseEndpointContextSupportedConfigs + ?.browseEndpointContextMusicConfig + ?.pageType == "MUSIC_PAGE_TYPE_PLAYLIST" + }?.musicCarouselShelfRenderer + + Innertube.DiscoverPage.Trending( + songs = renderer + ?.toBrowseItem(Innertube.SongItem::from) + ?.items + ?.filterIsInstance() + ?.map { song -> // Why, YouTube, why + song.copy( + authors = song.authors?.firstOrNull()?.let { listOf(it) } ?: emptyList() + ) + } + .orEmpty(), + endpoint = renderer + ?.header + ?.musicCarouselShelfBasicHeaderRenderer + ?.moreContentButton + ?.buttonRenderer + ?.navigationEndpoint + ?.browseEndpoint + ) + } + ) +} + +fun MusicTwoRowItemRenderer.toNewReleaseAlbumPage() = Innertube.AlbumItem( + info = Innertube.Info( + name = title?.text, + endpoint = navigationEndpoint?.browseEndpoint + ), + authors = subtitle?.runs?.splitBySeparator()?.getOrNull(1)?.oddElements()?.map { + Innertube.Info( + name = it.text, + endpoint = it.navigationEndpoint?.browseEndpoint + ) + }, + year = subtitle?.runs?.lastOrNull()?.text, + thumbnail = thumbnailRenderer?.musicThumbnailRenderer?.thumbnail?.thumbnails?.firstOrNull() +) diff --git a/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/ItemsPage.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/ItemsPage.kt new file mode 100644 index 0000000..8c98a63 --- /dev/null +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/ItemsPage.kt @@ -0,0 +1,93 @@ +package app.vimusic.providers.innertube.requests + +import app.vimusic.providers.innertube.Innertube +import app.vimusic.providers.innertube.models.BrowseResponse +import app.vimusic.providers.innertube.models.ContinuationResponse +import app.vimusic.providers.innertube.models.GridRenderer +import app.vimusic.providers.innertube.models.MusicResponsiveListItemRenderer +import app.vimusic.providers.innertube.models.MusicShelfRenderer +import app.vimusic.providers.innertube.models.MusicTwoRowItemRenderer +import app.vimusic.providers.innertube.models.bodies.BrowseBody +import app.vimusic.providers.innertube.models.bodies.ContinuationBody +import app.vimusic.providers.utils.runCatchingCancellable +import io.ktor.client.call.body +import io.ktor.client.request.post +import io.ktor.client.request.setBody + +suspend fun Innertube.itemsPage( + body: BrowseBody, + fromMusicResponsiveListItemRenderer: (MusicResponsiveListItemRenderer) -> T? = { null }, + fromMusicTwoRowItemRenderer: (MusicTwoRowItemRenderer) -> T? = { null } +) = runCatchingCancellable { + val response = client.post(BROWSE) { + setBody(body) + }.body() + + val sectionListRendererContent = response + .contents + ?.singleColumnBrowseResultsRenderer + ?.tabs + ?.firstOrNull() + ?.tabRenderer + ?.content + ?.sectionListRenderer + ?.contents + ?.firstOrNull() + + itemsPageFromMusicShelRendererOrGridRenderer( + musicShelfRenderer = sectionListRendererContent + ?.musicShelfRenderer, + gridRenderer = sectionListRendererContent + ?.gridRenderer, + fromMusicResponsiveListItemRenderer = fromMusicResponsiveListItemRenderer, + fromMusicTwoRowItemRenderer = fromMusicTwoRowItemRenderer + ) +} + +suspend fun Innertube.itemsPage( + body: ContinuationBody, + fromMusicResponsiveListItemRenderer: (MusicResponsiveListItemRenderer) -> T? = { null }, + fromMusicTwoRowItemRenderer: (MusicTwoRowItemRenderer) -> T? = { null } +) = runCatchingCancellable { + val response = client.post(BROWSE) { + setBody(body) + }.body() + + itemsPageFromMusicShelRendererOrGridRenderer( + musicShelfRenderer = response + .continuationContents + ?.musicShelfContinuation, + gridRenderer = null, + fromMusicResponsiveListItemRenderer = fromMusicResponsiveListItemRenderer, + fromMusicTwoRowItemRenderer = fromMusicTwoRowItemRenderer + ) +} + +private fun itemsPageFromMusicShelRendererOrGridRenderer( + musicShelfRenderer: MusicShelfRenderer?, + gridRenderer: GridRenderer?, + fromMusicResponsiveListItemRenderer: (MusicResponsiveListItemRenderer) -> T?, + fromMusicTwoRowItemRenderer: (MusicTwoRowItemRenderer) -> T? +) = when { + musicShelfRenderer != null -> Innertube.ItemsPage( + continuation = musicShelfRenderer + .continuations + ?.firstOrNull() + ?.nextContinuationData + ?.continuation, + items = musicShelfRenderer + .contents + ?.mapNotNull(MusicShelfRenderer.Content::musicResponsiveListItemRenderer) + ?.mapNotNull(fromMusicResponsiveListItemRenderer) + ) + + gridRenderer != null -> Innertube.ItemsPage( + continuation = null, + items = gridRenderer + .items + ?.mapNotNull(GridRenderer.Item::musicTwoRowItemRenderer) + ?.mapNotNull(fromMusicTwoRowItemRenderer) + ) + + else -> null +} diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/Lyrics.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/Lyrics.kt similarity index 50% rename from innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/Lyrics.kt rename to providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/Lyrics.kt index a4c0615..256c0e3 100644 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/Lyrics.kt +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/Lyrics.kt @@ -1,19 +1,22 @@ -package it.vfsfitvnm.innertube.requests +package app.vimusic.providers.innertube.requests +import app.vimusic.providers.innertube.Innertube +import app.vimusic.providers.innertube.models.BrowseResponse +import app.vimusic.providers.innertube.models.NextResponse +import app.vimusic.providers.innertube.models.bodies.BrowseBody +import app.vimusic.providers.innertube.models.bodies.NextBody +import app.vimusic.providers.utils.runCatchingCancellable import io.ktor.client.call.body import io.ktor.client.request.post import io.ktor.client.request.setBody -import it.vfsfitvnm.innertube.Innertube -import it.vfsfitvnm.innertube.models.BrowseResponse -import it.vfsfitvnm.innertube.models.NextResponse -import it.vfsfitvnm.innertube.models.bodies.BrowseBody -import it.vfsfitvnm.innertube.models.bodies.NextBody -import it.vfsfitvnm.innertube.utils.runCatchingNonCancellable -suspend fun Innertube.lyrics(body: NextBody): Result? = runCatchingNonCancellable { - val nextResponse = client.post(next) { +suspend fun Innertube.lyrics(body: NextBody) = runCatchingCancellable { + val nextResponse = client.post(NEXT) { setBody(body) - mask("contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer.watchNextTabbedResultsRenderer.tabs.tabRenderer(endpoint,title)") + @Suppress("all") + mask( + "contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer.watchNextTabbedResultsRenderer.tabs.tabRenderer(endpoint,title)" + ) }.body() val browseId = nextResponse @@ -27,9 +30,9 @@ suspend fun Innertube.lyrics(body: NextBody): Result? = runCatchingNonC ?.endpoint ?.browseEndpoint ?.browseId - ?: return@runCatchingNonCancellable null + ?: return@runCatchingCancellable null - val response = client.post(browse) { + val response = client.post(BROWSE) { setBody(BrowseBody(browseId = browseId)) mask("contents.sectionListRenderer.contents.musicDescriptionShelfRenderer.description") }.body() diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/NextPage.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/NextPage.kt similarity index 54% rename from innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/NextPage.kt rename to providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/NextPage.kt index edb84d0..84ee71f 100644 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/NextPage.kt +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/NextPage.kt @@ -1,23 +1,24 @@ -package it.vfsfitvnm.innertube.requests +package app.vimusic.providers.innertube.requests +import app.vimusic.providers.innertube.Innertube +import app.vimusic.providers.innertube.models.ContinuationResponse +import app.vimusic.providers.innertube.models.NextResponse +import app.vimusic.providers.innertube.models.bodies.ContinuationBody +import app.vimusic.providers.innertube.models.bodies.NextBody +import app.vimusic.providers.innertube.utils.from +import app.vimusic.providers.utils.runCatchingCancellable import io.ktor.client.call.body import io.ktor.client.request.post import io.ktor.client.request.setBody -import it.vfsfitvnm.innertube.Innertube -import it.vfsfitvnm.innertube.models.ContinuationResponse -import it.vfsfitvnm.innertube.models.NextResponse -import it.vfsfitvnm.innertube.models.bodies.ContinuationBody -import it.vfsfitvnm.innertube.models.bodies.NextBody -import it.vfsfitvnm.innertube.utils.from -import it.vfsfitvnm.innertube.utils.runCatchingNonCancellable - - suspend fun Innertube.nextPage(body: NextBody): Result? = - runCatchingNonCancellable { - val response = client.post(next) { + runCatchingCancellable { + val response = client.post(NEXT) { setBody(body) - mask("contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer.watchNextTabbedResultsRenderer.tabs.tabRenderer.content.musicQueueRenderer.content.playlistPanelRenderer(continuations,contents(automixPreviewVideoRenderer,$playlistPanelVideoRendererMask))") + @Suppress("all") + mask( + "contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer.watchNextTabbedResultsRenderer.tabs.tabRenderer.content.musicQueueRenderer.content.playlistPanelRenderer(continuations,contents(automixPreviewVideoRenderer,$PLAYLIST_PANEL_VIDEO_RENDERER_MASK))" + ) }.body() val tabs = response @@ -45,14 +46,12 @@ suspend fun Innertube.nextPage(body: NextBody): Result? = ?.navigationEndpoint ?.watchPlaylistEndpoint - if (endpoint != null) { - return nextPage( - body.copy( - playlistId = endpoint.playlistId, - params = endpoint.params - ) + if (endpoint != null) return nextPage( + body.copy( + playlistId = endpoint.playlistId, + params = endpoint.params ) - } + ) } Innertube.NextPage( @@ -64,10 +63,13 @@ suspend fun Innertube.nextPage(body: NextBody): Result? = ) } -suspend fun Innertube.nextPage(body: ContinuationBody) = runCatchingNonCancellable { - val response = client.post(next) { +suspend fun Innertube.nextPage(body: ContinuationBody) = runCatchingCancellable { + val response = client.post(NEXT) { setBody(body) - mask("continuationContents.playlistPanelContinuation(continuations,contents.$playlistPanelVideoRendererMask)") + @Suppress("all") + mask( + "continuationContents.playlistPanelContinuation(continuations,contents.$PLAYLIST_PANEL_VIDEO_RENDERER_MASK)" + ) }.body() response @@ -80,8 +82,10 @@ private fun NextResponse.MusicQueueRenderer.Content.PlaylistPanelRenderer?.toSon Innertube.ItemsPage( items = this ?.contents - ?.mapNotNull(NextResponse.MusicQueueRenderer.Content.PlaylistPanelRenderer.Content::playlistPanelVideoRenderer) - ?.mapNotNull(Innertube.SongItem::from), + ?.mapNotNull( + NextResponse.MusicQueueRenderer.Content.PlaylistPanelRenderer.Content + ::playlistPanelVideoRenderer + )?.mapNotNull(Innertube.SongItem::from), continuation = this ?.continuations ?.firstOrNull() diff --git a/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/Player.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/Player.kt new file mode 100644 index 0000000..ff3a29a --- /dev/null +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/Player.kt @@ -0,0 +1,101 @@ +package app.vimusic.providers.innertube.requests + +import app.vimusic.providers.innertube.Innertube +import app.vimusic.providers.innertube.models.Context +import app.vimusic.providers.innertube.models.PlayerResponse +import app.vimusic.providers.innertube.models.bodies.PlayerBody +import app.vimusic.providers.utils.runCatchingCancellable +import io.ktor.client.call.body +import io.ktor.client.request.header +import io.ktor.client.request.parameter +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.util.generateNonce +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.isActive + +private suspend fun Innertube.tryContexts( + body: PlayerBody, + music: Boolean, + vararg contexts: Context +): PlayerResponse? { + contexts.forEach { context -> + if (!currentCoroutineContext().isActive) return null + + logger.info("Trying ${context.client.clientName} ${context.client.clientVersion} ${context.client.platform}") + val cpn = + if (context.client.clientName == "IOS") generateNonce(16).decodeToString() else null + runCatchingCancellable { + client.post(if (music) PLAYER_MUSIC else PLAYER) { + setBody( + body.copy( + context = context, + cpn = cpn + ) + ) + + context.apply() + + if (cpn != null) { + parameter("t", generateNonce(12)) + header("X-Goog-Api-Format-Version", "2") + parameter("id", body.videoId) + } + }.body().also { logger.info("Got $it") } + } + ?.getOrNull() + ?.takeIf { it.isValid } + ?.let { return it.copy(cpn = cpn) } + } + + return null +} + +private val PlayerResponse.isValid + get() = playabilityStatus?.status == "OK" && + streamingData?.adaptiveFormats?.any { it.url != null || it.signatureCipher != null } == true + +suspend fun Innertube.player(body: PlayerBody): Result? = runCatchingCancellable { + tryContexts( + body = body, + music = false, + Context.DefaultIOS + )?.let { response -> + if (response.playerConfig?.audioConfig?.loudnessDb == null) { + // On non-music clients, the loudness doesn't get accounted for, resulting in really bland audio + // Try to recover from this or gracefully accept the user's ears' fate + tryContexts( + body = body, + music = true, + Context.DefaultWebNoLang + )?.playerConfig?.let { + response.copy(playerConfig = it) + } ?: response + } else response + } ?: client.post(PLAYER) { + setBody( + body.copy( + context = Context.DefaultAgeRestrictionBypass.copy( + thirdParty = Context.ThirdParty( + embedUrl = "https://www.youtube.com/watch?v=${body.videoId}" + ) + ), + playbackContext = PlayerBody.PlaybackContext( + contentPlaybackContext = PlayerBody.PlaybackContext.ContentPlaybackContext( + signatureTimestamp = getSignatureTimestamp() + ) + ) + ) + ) + }.body().takeIf { it.isValid } ?: tryContexts( + body = body, + music = false, + Context.DefaultWebOld, + Context.DefaultAndroid + ) ?: tryContexts( + body = body, + music = true, + Context.DefaultWeb, + Context.DefaultAndroidMusic + ) +} diff --git a/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/PlaylistPage.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/PlaylistPage.kt new file mode 100644 index 0000000..3971836 --- /dev/null +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/PlaylistPage.kt @@ -0,0 +1,179 @@ +package app.vimusic.providers.innertube.requests + +import app.vimusic.providers.innertube.Innertube +import app.vimusic.providers.innertube.models.BrowseResponse +import app.vimusic.providers.innertube.models.ContinuationResponse +import app.vimusic.providers.innertube.models.MusicCarouselShelfRenderer +import app.vimusic.providers.innertube.models.MusicShelfRenderer +import app.vimusic.providers.innertube.models.bodies.BrowseBody +import app.vimusic.providers.innertube.models.bodies.ContinuationBody +import app.vimusic.providers.innertube.utils.from +import app.vimusic.providers.utils.runCatchingCancellable +import io.ktor.client.call.body +import io.ktor.client.request.parameter +import io.ktor.client.request.post +import io.ktor.client.request.setBody + +suspend fun Innertube.playlistPage(body: BrowseBody) = runCatchingCancellable { + val response = client.post(BROWSE) { + setBody(body) + body.context.apply() + }.body() + + if (response.contents?.twoColumnBrowseResultsRenderer == null) { + val header = response + .header + ?.musicDetailHeaderRenderer + + val contents = response + .contents + ?.singleColumnBrowseResultsRenderer + ?.tabs + ?.firstOrNull() + ?.tabRenderer + ?.content + ?.sectionListRenderer + ?.contents + + val musicShelfRenderer = contents + ?.firstOrNull() + ?.musicShelfRenderer + + val musicCarouselShelfRenderer = contents + ?.getOrNull(1) + ?.musicCarouselShelfRenderer + + Innertube.PlaylistOrAlbumPage( + title = header + ?.title + ?.text, + description = header + ?.description + ?.text, + thumbnail = header + ?.thumbnail + ?.musicThumbnailRenderer + ?.thumbnail + ?.thumbnails + ?.maxByOrNull { (it.width ?: 0) * (it.height ?: 0) }, + authors = header + ?.subtitle + ?.splitBySeparator() + ?.getOrNull(1) + ?.map(Innertube::Info), + year = header + ?.subtitle + ?.splitBySeparator() + ?.getOrNull(2) + ?.firstOrNull() + ?.text, + url = response + .microformat + ?.microformatDataRenderer + ?.urlCanonical, + songsPage = musicShelfRenderer + ?.toSongsPage(), + otherVersions = musicCarouselShelfRenderer + ?.contents + ?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer) + ?.mapNotNull(Innertube.AlbumItem::from), + otherInfo = header + ?.secondSubtitle + ?.text + ) + } else { + val header = response + .contents + .twoColumnBrowseResultsRenderer + .tabs + ?.firstOrNull() + ?.tabRenderer + ?.content + ?.sectionListRenderer + ?.contents + ?.firstOrNull() + ?.musicResponsiveHeaderRenderer + + val contents = response + .contents + .twoColumnBrowseResultsRenderer + .secondaryContents + ?.sectionListRenderer + ?.contents + + val musicShelfRenderer = contents + ?.firstOrNull() + ?.musicShelfRenderer + + val musicCarouselShelfRenderer = contents + ?.getOrNull(1) + ?.musicCarouselShelfRenderer + + Innertube.PlaylistOrAlbumPage( + title = header + ?.title + ?.text, + description = header + ?.description + ?.description + ?.text, + thumbnail = header + ?.thumbnail + ?.musicThumbnailRenderer + ?.thumbnail + ?.thumbnails + ?.maxByOrNull { (it.width ?: 0) * (it.height ?: 0) }, + authors = header + ?.straplineTextOne + ?.splitBySeparator() + ?.getOrNull(0) + ?.map(Innertube::Info), + year = header + ?.subtitle + ?.splitBySeparator() + ?.getOrNull(1) + ?.firstOrNull() + ?.text, + url = response + .microformat + ?.microformatDataRenderer + ?.urlCanonical, + songsPage = musicShelfRenderer + ?.toSongsPage(), + otherVersions = musicCarouselShelfRenderer + ?.contents + ?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer) + ?.mapNotNull(Innertube.AlbumItem::from), + otherInfo = header + ?.secondSubtitle + ?.text + ) + } +} + +suspend fun Innertube.playlistPage(body: ContinuationBody) = runCatchingCancellable { + val response = client.post(BROWSE) { + setBody(body) + parameter("continuation", body.continuation) + parameter("ctoken", body.continuation) + parameter("type", "next") + body.context.apply() + }.body() + + response + .continuationContents + ?.musicShelfContinuation + ?.toSongsPage() +} + +private fun MusicShelfRenderer?.toSongsPage() = Innertube.ItemsPage( + items = this + ?.contents + ?.mapNotNull(MusicShelfRenderer.Content::musicResponsiveListItemRenderer) + ?.mapNotNull(Innertube.SongItem::from), + continuation = this + ?.continuations + ?.firstOrNull() + ?.nextContinuationData + ?.continuation +) diff --git a/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/Queue.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/Queue.kt new file mode 100644 index 0000000..fc04d4f --- /dev/null +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/Queue.kt @@ -0,0 +1,29 @@ +package app.vimusic.providers.innertube.requests + +import app.vimusic.providers.innertube.Innertube +import app.vimusic.providers.innertube.models.GetQueueResponse +import app.vimusic.providers.innertube.models.bodies.QueueBody +import app.vimusic.providers.innertube.utils.from +import app.vimusic.providers.utils.runCatchingCancellable +import io.ktor.client.call.body +import io.ktor.client.request.post +import io.ktor.client.request.setBody + +suspend fun Innertube.queue(body: QueueBody) = runCatchingCancellable { + val response = client.post(QUEUE) { + setBody(body) + mask("queueDatas.content.$PLAYLIST_PANEL_VIDEO_RENDERER_MASK") + }.body() + + response + .queueData + ?.mapNotNull { queueData -> + queueData + .content + ?.playlistPanelVideoRenderer + ?.let(Innertube.SongItem::from) + } +} + +suspend fun Innertube.song(videoId: String): Result? = + queue(QueueBody(videoIds = listOf(videoId)))?.map { it?.firstOrNull() } diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/RelatedPage.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/RelatedPage.kt similarity index 51% rename from innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/RelatedPage.kt rename to providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/RelatedPage.kt index 41d0b8e..fd0f978 100644 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/requests/RelatedPage.kt +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/RelatedPage.kt @@ -1,23 +1,27 @@ -package it.vfsfitvnm.innertube.requests +package app.vimusic.providers.innertube.requests +import app.vimusic.providers.innertube.Innertube +import app.vimusic.providers.innertube.models.BrowseResponse +import app.vimusic.providers.innertube.models.Context +import app.vimusic.providers.innertube.models.MusicCarouselShelfRenderer +import app.vimusic.providers.innertube.models.NextResponse +import app.vimusic.providers.innertube.models.bodies.BrowseBody +import app.vimusic.providers.innertube.models.bodies.NextBody +import app.vimusic.providers.innertube.utils.findSectionByStrapline +import app.vimusic.providers.innertube.utils.findSectionByTitle +import app.vimusic.providers.innertube.utils.from +import app.vimusic.providers.utils.runCatchingCancellable import io.ktor.client.call.body import io.ktor.client.request.post import io.ktor.client.request.setBody -import it.vfsfitvnm.innertube.Innertube -import it.vfsfitvnm.innertube.models.BrowseResponse -import it.vfsfitvnm.innertube.models.MusicCarouselShelfRenderer -import it.vfsfitvnm.innertube.models.NextResponse -import it.vfsfitvnm.innertube.models.bodies.BrowseBody -import it.vfsfitvnm.innertube.models.bodies.NextBody -import it.vfsfitvnm.innertube.utils.findSectionByStrapline -import it.vfsfitvnm.innertube.utils.findSectionByTitle -import it.vfsfitvnm.innertube.utils.from -import it.vfsfitvnm.innertube.utils.runCatchingNonCancellable -suspend fun Innertube.relatedPage(body: NextBody) = runCatchingNonCancellable { - val nextResponse = client.post(next) { - setBody(body) - mask("contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer.watchNextTabbedResultsRenderer.tabs.tabRenderer(endpoint,title)") +suspend fun Innertube.relatedPage(body: NextBody) = runCatchingCancellable { + val nextResponse = client.post(NEXT) { + setBody(body.copy(context = Context.DefaultWebNoLang)) + @Suppress("all") + mask( + "contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer.watchNextTabbedResultsRenderer.tabs.tabRenderer(endpoint,title)" + ) }.body() val browseId = nextResponse @@ -31,11 +35,19 @@ suspend fun Innertube.relatedPage(body: NextBody) = runCatchingNonCancellable { ?.endpoint ?.browseEndpoint ?.browseId - ?: return@runCatchingNonCancellable null + ?: return@runCatchingCancellable null - val response = client.post(browse) { - setBody(BrowseBody(browseId = browseId)) - mask("contents.sectionListRenderer.contents.musicCarouselShelfRenderer(header.musicCarouselShelfBasicHeaderRenderer(title,strapline),contents($musicResponsiveListItemRendererMask,$musicTwoRowItemRendererMask))") + val response = client.post(BROWSE) { + setBody( + BrowseBody( + browseId = browseId, + context = Context.DefaultWebNoLang + ) + ) + @Suppress("all") + mask( + "contents.sectionListRenderer.contents.musicCarouselShelfRenderer(header.musicCarouselShelfBasicHeaderRenderer(title,strapline),contents($MUSIC_RESPONSIVE_LIST_ITEM_RENDERER_MASK,$MUSIC_TWO_ROW_ITEM_RENDERER_MASK))" + ) }.body() val sectionListRenderer = response @@ -67,6 +79,6 @@ suspend fun Innertube.relatedPage(body: NextBody) = runCatchingNonCancellable { ?.musicCarouselShelfRenderer ?.contents ?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer) - ?.mapNotNull(Innertube.ArtistItem::from), + ?.mapNotNull(Innertube.ArtistItem::from) ) } diff --git a/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/SearchPage.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/SearchPage.kt new file mode 100644 index 0000000..7a51a1d --- /dev/null +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/SearchPage.kt @@ -0,0 +1,69 @@ +package app.vimusic.providers.innertube.requests + +import app.vimusic.providers.innertube.Innertube +import app.vimusic.providers.innertube.models.ContinuationResponse +import app.vimusic.providers.innertube.models.MusicShelfRenderer +import app.vimusic.providers.innertube.models.SearchResponse +import app.vimusic.providers.innertube.models.bodies.ContinuationBody +import app.vimusic.providers.innertube.models.bodies.SearchBody +import app.vimusic.providers.utils.runCatchingCancellable +import io.ktor.client.call.body +import io.ktor.client.request.post +import io.ktor.client.request.setBody + +suspend fun Innertube.searchPage( + body: SearchBody, + fromMusicShelfRendererContent: (MusicShelfRenderer.Content) -> T? +) = runCatchingCancellable { + val response = client.post(SEARCH) { + setBody(body) + @Suppress("all") + mask( + "contents.tabbedSearchResultsRenderer.tabs.tabRenderer.content.sectionListRenderer.contents.musicShelfRenderer(continuations,contents.$MUSIC_RESPONSIVE_LIST_ITEM_RENDERER_MASK)" + ) + }.body() + + response + .contents + ?.tabbedSearchResultsRenderer + ?.tabs + ?.firstOrNull() + ?.tabRenderer + ?.content + ?.sectionListRenderer + ?.contents + ?.lastOrNull() + ?.musicShelfRenderer + ?.toItemsPage(fromMusicShelfRendererContent) +} + +suspend fun Innertube.searchPage( + body: ContinuationBody, + fromMusicShelfRendererContent: (MusicShelfRenderer.Content) -> T? +) = runCatchingCancellable { + val response = client.post(SEARCH) { + setBody(body) + @Suppress("all") + mask( + "continuationContents.musicShelfContinuation(continuations,contents.$MUSIC_RESPONSIVE_LIST_ITEM_RENDERER_MASK)" + ) + }.body() + + response + .continuationContents + ?.musicShelfContinuation + ?.toItemsPage(fromMusicShelfRendererContent) +} + +private fun MusicShelfRenderer?.toItemsPage( + mapper: (MusicShelfRenderer.Content) -> T? +) = Innertube.ItemsPage( + items = this + ?.contents + ?.mapNotNull(mapper), + continuation = this + ?.continuations + ?.firstOrNull() + ?.nextContinuationData + ?.continuation +) diff --git a/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/SearchSuggestions.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/SearchSuggestions.kt new file mode 100644 index 0000000..a4d0e1d --- /dev/null +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/requests/SearchSuggestions.kt @@ -0,0 +1,32 @@ +package app.vimusic.providers.innertube.requests + +import app.vimusic.providers.innertube.Innertube +import app.vimusic.providers.innertube.models.SearchSuggestionsResponse +import app.vimusic.providers.innertube.models.bodies.SearchSuggestionsBody +import app.vimusic.providers.utils.runCatchingCancellable +import io.ktor.client.call.body +import io.ktor.client.request.post +import io.ktor.client.request.setBody + +suspend fun Innertube.searchSuggestions(body: SearchSuggestionsBody) = runCatchingCancellable { + val response = client.post(SEARCH_SUGGESTIONS) { + setBody(body) + @Suppress("all") + mask( + "contents.searchSuggestionsSectionRenderer.contents.searchSuggestionRenderer.navigationEndpoint.searchEndpoint.query" + ) + }.body() + + response + .contents + ?.firstOrNull() + ?.searchSuggestionsSectionRenderer + ?.contents + ?.mapNotNull { content -> + content + .searchSuggestionRenderer + ?.navigationEndpoint + ?.searchEndpoint + ?.query + } +} diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/utils/FromMusicResponsiveListItemRenderer.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/utils/FromMusicResponsiveListItemRenderer.kt similarity index 57% rename from innertube/src/main/kotlin/it/vfsfitvnm/innertube/utils/FromMusicResponsiveListItemRenderer.kt rename to providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/utils/FromMusicResponsiveListItemRenderer.kt index 7339f0c..1b89ce1 100644 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/utils/FromMusicResponsiveListItemRenderer.kt +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/utils/FromMusicResponsiveListItemRenderer.kt @@ -1,12 +1,12 @@ -package it.vfsfitvnm.innertube.utils +package app.vimusic.providers.innertube.utils -import it.vfsfitvnm.innertube.Innertube -import it.vfsfitvnm.innertube.models.MusicResponsiveListItemRenderer -import it.vfsfitvnm.innertube.models.NavigationEndpoint -import it.vfsfitvnm.innertube.models.Runs +import app.vimusic.providers.innertube.Innertube +import app.vimusic.providers.innertube.models.MusicResponsiveListItemRenderer +import app.vimusic.providers.innertube.models.NavigationEndpoint +import app.vimusic.providers.innertube.models.isExplicit -fun Innertube.SongItem.Companion.from(renderer: MusicResponsiveListItemRenderer): Innertube.SongItem? { - return Innertube.SongItem( +fun Innertube.SongItem.Companion.from(renderer: MusicResponsiveListItemRenderer) = + Innertube.SongItem( info = renderer .flexColumns .getOrNull(0) @@ -14,14 +14,20 @@ fun Innertube.SongItem.Companion.from(renderer: MusicResponsiveListItemRenderer) ?.text ?.runs ?.getOrNull(0) - ?.let(Innertube::Info), + ?.let { + if (it.navigationEndpoint?.endpoint is NavigationEndpoint.Endpoint.Watch) Innertube.Info( + name = it.text, + endpoint = it.navigationEndpoint.endpoint as NavigationEndpoint.Endpoint.Watch + ) else null + }, authors = renderer .flexColumns .getOrNull(1) ?.musicResponsiveListItemFlexColumnRenderer ?.text ?.runs - ?.map>(Innertube::Info) + ?.map { Innertube.Info(name = it.text, endpoint = it.navigationEndpoint?.endpoint) } + ?.filterIsInstance>() ?.takeIf(List::isNotEmpty), durationText = renderer .fixedColumns @@ -39,6 +45,7 @@ fun Innertube.SongItem.Companion.from(renderer: MusicResponsiveListItemRenderer) ?.runs ?.firstOrNull() ?.let(Innertube::Info), + explicit = renderer.badges.isExplicit, thumbnail = renderer .thumbnail ?.musicThumbnailRenderer @@ -46,4 +53,3 @@ fun Innertube.SongItem.Companion.from(renderer: MusicResponsiveListItemRenderer) ?.thumbnails ?.firstOrNull() ).takeIf { it.info?.endpoint?.videoId != null } -} diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/utils/FromMusicShelfRendererContent.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/utils/FromMusicShelfRendererContent.kt similarity index 72% rename from innertube/src/main/kotlin/it/vfsfitvnm/innertube/utils/FromMusicShelfRendererContent.kt rename to providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/utils/FromMusicShelfRendererContent.kt index 1cbcab6..b3e7ce9 100644 --- a/innertube/src/main/kotlin/it/vfsfitvnm/innertube/utils/FromMusicShelfRendererContent.kt +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/utils/FromMusicShelfRendererContent.kt @@ -1,17 +1,18 @@ -package it.vfsfitvnm.innertube.utils +package app.vimusic.providers.innertube.utils -import it.vfsfitvnm.innertube.Innertube -import it.vfsfitvnm.innertube.models.MusicShelfRenderer -import it.vfsfitvnm.innertube.models.NavigationEndpoint +import app.vimusic.providers.innertube.Innertube +import app.vimusic.providers.innertube.models.MusicShelfRenderer +import app.vimusic.providers.innertube.models.NavigationEndpoint +import app.vimusic.providers.innertube.models.isExplicit -fun Innertube.SongItem.Companion.from(content: MusicShelfRenderer.Content): Innertube.SongItem? { - val (mainRuns, otherRuns) = content.runs +// Possible configurations: +// "song" • author(s) • album • duration +// "song" • author(s) • duration +// author(s) • album • duration +// author(s) • duration - // Possible configurations: - // "song" • author(s) • album • duration - // "song" • author(s) • duration - // author(s) • album • duration - // author(s) • duration +fun Innertube.SongItem.Companion.from(content: MusicShelfRenderer.Content) = runCatching { + val (mainRuns, otherRuns) = content.runs val album: Innertube.Info? = otherRuns .getOrNull(otherRuns.lastIndex - 1) @@ -24,7 +25,7 @@ fun Innertube.SongItem.Companion.from(content: MusicShelfRenderer.Content): Inne } ?.let(Innertube::Info) - return Innertube.SongItem( + Innertube.SongItem( info = mainRuns .firstOrNull() ?.let(Innertube::Info), @@ -34,16 +35,22 @@ fun Innertube.SongItem.Companion.from(content: MusicShelfRenderer.Content): Inne album = album, durationText = otherRuns .lastOrNull() - ?.firstOrNull()?.text, - thumbnail = content - .thumbnail + ?.firstOrNull() + ?.text + ?.takeIf { ':' in it } + ?: otherRuns + .getOrNull(otherRuns.size - 2) + ?.firstOrNull() + ?.text, + explicit = content.musicResponsiveListItemRenderer?.badges.isExplicit, + thumbnail = content.thumbnail ).takeIf { it.info?.endpoint?.videoId != null } -} +}.getOrNull() -fun Innertube.VideoItem.Companion.from(content: MusicShelfRenderer.Content): Innertube.VideoItem? { +fun Innertube.VideoItem.Companion.from(content: MusicShelfRenderer.Content) = runCatching { val (mainRuns, otherRuns) = content.runs - return Innertube.VideoItem( + Innertube.VideoItem( info = mainRuns .firstOrNull() ?.let(Innertube::Info), @@ -58,15 +65,14 @@ fun Innertube.VideoItem.Companion.from(content: MusicShelfRenderer.Content): Inn .getOrNull(otherRuns.lastIndex) ?.firstOrNull() ?.text, - thumbnail = content - .thumbnail + thumbnail = content.thumbnail ).takeIf { it.info?.endpoint?.videoId != null } -} +}.getOrNull() -fun Innertube.AlbumItem.Companion.from(content: MusicShelfRenderer.Content): Innertube.AlbumItem? { +fun Innertube.AlbumItem.Companion.from(content: MusicShelfRenderer.Content) = runCatching { val (mainRuns, otherRuns) = content.runs - return Innertube.AlbumItem( + Innertube.AlbumItem( info = Innertube.Info( name = mainRuns .firstOrNull() @@ -83,15 +89,14 @@ fun Innertube.AlbumItem.Companion.from(content: MusicShelfRenderer.Content): Inn .getOrNull(otherRuns.lastIndex) ?.firstOrNull() ?.text, - thumbnail = content - .thumbnail + thumbnail = content.thumbnail ).takeIf { it.info?.endpoint?.browseId != null } -} +}.getOrNull() -fun Innertube.ArtistItem.Companion.from(content: MusicShelfRenderer.Content): Innertube.ArtistItem? { +fun Innertube.ArtistItem.Companion.from(content: MusicShelfRenderer.Content) = runCatching { val (mainRuns, otherRuns) = content.runs - return Innertube.ArtistItem( + Innertube.ArtistItem( info = Innertube.Info( name = mainRuns .firstOrNull() @@ -105,15 +110,14 @@ fun Innertube.ArtistItem.Companion.from(content: MusicShelfRenderer.Content): In .lastOrNull() ?.last() ?.text, - thumbnail = content - .thumbnail + thumbnail = content.thumbnail ).takeIf { it.info?.endpoint?.browseId != null } -} +}.getOrNull() -fun Innertube.PlaylistItem.Companion.from(content: MusicShelfRenderer.Content): Innertube.PlaylistItem? { +fun Innertube.PlaylistItem.Companion.from(content: MusicShelfRenderer.Content) = runCatching { val (mainRuns, otherRuns) = content.runs - return Innertube.PlaylistItem( + Innertube.PlaylistItem( info = Innertube.Info( name = mainRuns .firstOrNull() @@ -134,7 +138,6 @@ fun Innertube.PlaylistItem.Companion.from(content: MusicShelfRenderer.Content): ?.split(' ') ?.firstOrNull() ?.toIntOrNull(), - thumbnail = content - .thumbnail + thumbnail = content.thumbnail ).takeIf { it.info?.endpoint?.browseId != null } -} +}.getOrNull() diff --git a/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/utils/FromMusicTwoRowItemRenderer.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/utils/FromMusicTwoRowItemRenderer.kt new file mode 100644 index 0000000..c23a208 --- /dev/null +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/utils/FromMusicTwoRowItemRenderer.kt @@ -0,0 +1,71 @@ +package app.vimusic.providers.innertube.utils + +import app.vimusic.providers.innertube.Innertube +import app.vimusic.providers.innertube.models.MusicTwoRowItemRenderer + +fun Innertube.AlbumItem.Companion.from(renderer: MusicTwoRowItemRenderer) = Innertube.AlbumItem( + info = renderer + .title + ?.runs + ?.firstOrNull() + ?.let(Innertube::Info), + authors = null, + year = renderer + .subtitle + ?.runs + ?.lastOrNull() + ?.text, + thumbnail = renderer + .thumbnailRenderer + ?.musicThumbnailRenderer + ?.thumbnail + ?.thumbnails + ?.firstOrNull() +).takeIf { it.info?.endpoint?.browseId != null } + +fun Innertube.ArtistItem.Companion.from(renderer: MusicTwoRowItemRenderer) = Innertube.ArtistItem( + info = renderer + .title + ?.runs + ?.firstOrNull() + ?.let(Innertube::Info), + subscribersCountText = renderer + .subtitle + ?.runs + ?.firstOrNull() + ?.text, + thumbnail = renderer + .thumbnailRenderer + ?.musicThumbnailRenderer + ?.thumbnail + ?.thumbnails + ?.firstOrNull() +).takeIf { it.info?.endpoint?.browseId != null } + +fun Innertube.PlaylistItem.Companion.from(renderer: MusicTwoRowItemRenderer) = + Innertube.PlaylistItem( + info = renderer + .title + ?.runs + ?.firstOrNull() + ?.let(Innertube::Info), + channel = renderer + .subtitle + ?.runs + ?.getOrNull(2) + ?.let(Innertube::Info), + songCount = renderer + .subtitle + ?.runs + ?.getOrNull(4) + ?.text + ?.split(' ') + ?.firstOrNull() + ?.toIntOrNull(), + thumbnail = renderer + .thumbnailRenderer + ?.musicThumbnailRenderer + ?.thumbnail + ?.thumbnails + ?.firstOrNull() + ).takeIf { it.info?.endpoint?.browseId != null } diff --git a/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/utils/FromPlaylistPanelVideoRenderer.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/utils/FromPlaylistPanelVideoRenderer.kt new file mode 100644 index 0000000..469e68d --- /dev/null +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/utils/FromPlaylistPanelVideoRenderer.kt @@ -0,0 +1,35 @@ +package app.vimusic.providers.innertube.utils + +import app.vimusic.providers.innertube.Innertube +import app.vimusic.providers.innertube.models.PlaylistPanelVideoRenderer +import app.vimusic.providers.innertube.models.isExplicit + +fun Innertube.SongItem.Companion.from(renderer: PlaylistPanelVideoRenderer) = Innertube.SongItem( + info = Innertube.Info( + name = renderer + .title + ?.text, + endpoint = renderer + .navigationEndpoint + ?.watchEndpoint + ), + authors = renderer + .longBylineText + ?.splitBySeparator() + ?.getOrNull(0) + ?.map(Innertube::Info), + album = renderer + .longBylineText + ?.splitBySeparator() + ?.getOrNull(1) + ?.getOrNull(0) + ?.let(Innertube::Info), + thumbnail = renderer + .thumbnail + ?.thumbnails + ?.getOrNull(0), + durationText = renderer + .lengthText + ?.text, + explicit = renderer.badges.isExplicit +).takeIf { it.info?.endpoint?.videoId != null } diff --git a/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/utils/Utils.kt b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/utils/Utils.kt new file mode 100644 index 0000000..f32e0ec --- /dev/null +++ b/providers/innertube/src/main/kotlin/app/vimusic/providers/innertube/utils/Utils.kt @@ -0,0 +1,38 @@ +package app.vimusic.providers.innertube.utils + +import app.vimusic.providers.innertube.Innertube +import app.vimusic.providers.innertube.models.SectionListRenderer + +internal fun SectionListRenderer.findSectionByTitle(text: String) = contents?.find { + val title = it + .musicCarouselShelfRenderer + ?.header + ?.musicCarouselShelfBasicHeaderRenderer + ?.title + ?: it + .musicShelfRenderer + ?.title + + title + ?.runs + ?.firstOrNull() + ?.text == text +} + +internal fun SectionListRenderer.findSectionByStrapline(text: String) = contents?.find { + it + .musicCarouselShelfRenderer + ?.header + ?.musicCarouselShelfBasicHeaderRenderer + ?.strapline + ?.runs + ?.firstOrNull() + ?.text == text +} + +infix operator fun Innertube.ItemsPage?.plus(other: Innertube.ItemsPage) = + other.copy( + items = (this?.items?.plus(other.items ?: emptyList()) ?: other.items) + ?.distinctBy(Innertube.Item::key), + continuation = other.continuation ?: this?.continuation + ) diff --git a/providers/kugou/build.gradle.kts b/providers/kugou/build.gradle.kts new file mode 100644 index 0000000..8febb74 --- /dev/null +++ b/providers/kugou/build.gradle.kts @@ -0,0 +1,25 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.android.lint) +} + +dependencies { + implementation(projects.providers.common) + + implementation(libs.kotlin.coroutines) + + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.okhttp) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.client.encoding) + implementation(libs.ktor.client.serialization) + implementation(libs.ktor.serialization.json) + + detektPlugins(libs.detekt.compose) + detektPlugins(libs.detekt.formatting) +} + +kotlin { + jvmToolchain(libs.versions.jvm.get().toInt()) +} diff --git a/providers/kugou/src/main/kotlin/app/vimusic/providers/kugou/KuGou.kt b/providers/kugou/src/main/kotlin/app/vimusic/providers/kugou/KuGou.kt new file mode 100644 index 0000000..21eb7d2 --- /dev/null +++ b/providers/kugou/src/main/kotlin/app/vimusic/providers/kugou/KuGou.kt @@ -0,0 +1,183 @@ +package app.vimusic.providers.kugou + +import app.vimusic.providers.kugou.models.DownloadLyricsResponse +import app.vimusic.providers.kugou.models.SearchLyricsResponse +import app.vimusic.providers.kugou.models.SearchSongResponse +import app.vimusic.providers.utils.runCatchingCancellable +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.plugins.BrowserUserAgent +import io.ktor.client.plugins.compression.ContentEncoding +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.http.ContentType +import io.ktor.http.encodeURLParameter +import io.ktor.serialization.kotlinx.json.json +import io.ktor.util.decodeBase64String +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json + +object KuGou { + @OptIn(ExperimentalSerializationApi::class) + private val client by lazy { + HttpClient(OkHttp) { + BrowserUserAgent() + + expectSuccess = true + + install(ContentNegotiation) { + val feature = Json { + ignoreUnknownKeys = true + explicitNulls = false + encodeDefaults = true + } + + json(feature) + json(feature, ContentType.Text.Html) + json(feature, ContentType.Text.Plain) + } + + install(ContentEncoding) { + gzip() + deflate() + } + + defaultRequest { + url("https://krcs.kugou.com") + } + } + } + + suspend fun lyrics(artist: String, title: String, duration: Long) = runCatchingCancellable { + val keyword = keyword(artist, title) + val infoByKeyword = searchSong(keyword) + + if (infoByKeyword.isNotEmpty()) { + var tolerance = 0 + + while (tolerance <= 5) { + for (info in infoByKeyword) { + if (info.duration >= duration - tolerance && info.duration <= duration + tolerance) { + searchLyricsByHash(info.hash).firstOrNull()?.let { candidate -> + return@runCatchingCancellable downloadLyrics( + candidate.id, + candidate.accessKey + ).normalize() + } + } + } + + tolerance++ + } + } + + searchLyricsByKeyword(keyword).firstOrNull()?.let { candidate -> + return@runCatchingCancellable downloadLyrics( + candidate.id, + candidate.accessKey + ).normalize() + } + + null + } + + private suspend fun downloadLyrics(id: Long, accessKey: String) = client.get("/download") { + parameter("ver", 1) + parameter("man", "yes") + parameter("client", "pc") + parameter("fmt", "lrc") + parameter("id", id) + parameter("accesskey", accessKey) + }.body().content.decodeBase64String().let(KuGou::Lyrics) + + private suspend fun searchLyricsByHash(hash: String) = client.get("/search") { + parameter("ver", 1) + parameter("man", "yes") + parameter("client", "mobi") + parameter("hash", hash) + }.body().candidates + + private suspend fun searchLyricsByKeyword(keyword: String) = client.get("/search") { + parameter("ver", 1) + parameter("man", "yes") + parameter("client", "mobi") + url.encodedParameters.append("keyword", keyword.encodeURLParameter(spaceToPlus = false)) + }.body().candidates + + private suspend fun searchSong(keyword: String) = + client.get("https://mobileservice.kugou.com/api/v3/search/song") { + parameter("version", 9108) + parameter("plat", 0) + parameter("pagesize", 8) + parameter("showtype", 0) + url.encodedParameters.append("keyword", keyword.encodeURLParameter(spaceToPlus = false)) + }.body().data.info + + private fun keyword(artist: String, title: String): String { + val (newTitle, featuring) = title.extract(" (feat. ", ')') + + val newArtist = (if (featuring.isEmpty()) artist else "$artist, $featuring") + .replace(", ", "、") + .replace(" & ", "、") + .replace(".", "") + + return "$newArtist - $newTitle" + } + + private fun String.extract(startDelimiter: String, endDelimiter: Char): Pair { + val startIndex = indexOf(startDelimiter).takeIf { it != -1 } ?: return this to "" + val endIndex = indexOf(endDelimiter, startIndex).takeIf { it != -1 } ?: return this to "" + + return removeRange(startIndex, endIndex + 1) to substring(startIndex + startDelimiter.length, endIndex) + } + + @JvmInline + value class Lyrics(val value: String) { + @Suppress("CyclomaticComplexMethod") + fun normalize(): Lyrics { + var toDrop = 0 + var maybeToDrop = 0 + + val text = value.replace("\r\n", "\n").trim() + + for (line in text.lineSequence()) when { + line.startsWith("[ti:") || + line.startsWith("[ar:") || + line.startsWith("[al:") || + line.startsWith("[by:") || + line.startsWith("[hash:") || + line.startsWith("[sign:") || + line.startsWith("[qq:") || + line.startsWith("[total:") || + line.startsWith("[offset:") || + line.startsWith("[id:") || + line.containsAt("]Written by:", 9) || + line.containsAt("]Lyrics by:", 9) || + line.containsAt("]Composed by:", 9) || + line.containsAt("]Producer:", 9) || + line.containsAt("]作曲 : ", 9) || + line.containsAt("]作词 : ", 9) -> { + toDrop += line.length + 1 + maybeToDrop + maybeToDrop = 0 + } + + maybeToDrop == 0 -> maybeToDrop = line.length + 1 + + else -> { + maybeToDrop = 0 + break + } + } + + return Lyrics(text.drop(toDrop + maybeToDrop).removeHtmlEntities()) + } + + private fun String.containsAt(charSequence: CharSequence, startIndex: Int) = + regionMatches(startIndex, charSequence, 0, charSequence.length) + + private fun String.removeHtmlEntities() = replace("'", "'") + } +} diff --git a/kugou/src/main/kotlin/it/vfsfitvnm/kugou/models/DownloadLyricsResponse.kt b/providers/kugou/src/main/kotlin/app/vimusic/providers/kugou/models/DownloadLyricsResponse.kt similarity index 74% rename from kugou/src/main/kotlin/it/vfsfitvnm/kugou/models/DownloadLyricsResponse.kt rename to providers/kugou/src/main/kotlin/app/vimusic/providers/kugou/models/DownloadLyricsResponse.kt index 049b482..06a5b2b 100644 --- a/kugou/src/main/kotlin/it/vfsfitvnm/kugou/models/DownloadLyricsResponse.kt +++ b/providers/kugou/src/main/kotlin/app/vimusic/providers/kugou/models/DownloadLyricsResponse.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.kugou.models +package app.vimusic.providers.kugou.models import kotlinx.serialization.Serializable diff --git a/kugou/src/main/kotlin/it/vfsfitvnm/kugou/models/SearchLyricsResponse.kt b/providers/kugou/src/main/kotlin/app/vimusic/providers/kugou/models/SearchLyricsResponse.kt similarity index 88% rename from kugou/src/main/kotlin/it/vfsfitvnm/kugou/models/SearchLyricsResponse.kt rename to providers/kugou/src/main/kotlin/app/vimusic/providers/kugou/models/SearchLyricsResponse.kt index 342b867..c732021 100644 --- a/kugou/src/main/kotlin/it/vfsfitvnm/kugou/models/SearchLyricsResponse.kt +++ b/providers/kugou/src/main/kotlin/app/vimusic/providers/kugou/models/SearchLyricsResponse.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.kugou.models +package app.vimusic.providers.kugou.models import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/kugou/src/main/kotlin/it/vfsfitvnm/kugou/models/SearchSongResponse.kt b/providers/kugou/src/main/kotlin/app/vimusic/providers/kugou/models/SearchSongResponse.kt similarity index 80% rename from kugou/src/main/kotlin/it/vfsfitvnm/kugou/models/SearchSongResponse.kt rename to providers/kugou/src/main/kotlin/app/vimusic/providers/kugou/models/SearchSongResponse.kt index 97d9dbd..62e10af 100644 --- a/kugou/src/main/kotlin/it/vfsfitvnm/kugou/models/SearchSongResponse.kt +++ b/providers/kugou/src/main/kotlin/app/vimusic/providers/kugou/models/SearchSongResponse.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.kugou.models +package app.vimusic.providers.kugou.models import kotlinx.serialization.Serializable @@ -7,7 +7,7 @@ internal data class SearchSongResponse( val data: Data ) { @Serializable - internal data class Data( + internal data class Data( val info: List ) { @Serializable diff --git a/innertube/build.gradle.kts b/providers/lrclib/build.gradle.kts similarity index 51% rename from innertube/build.gradle.kts rename to providers/lrclib/build.gradle.kts index e2cf347..5eb4332 100644 --- a/innertube/build.gradle.kts +++ b/providers/lrclib/build.gradle.kts @@ -1,23 +1,24 @@ - plugins { - kotlin("jvm") - @Suppress("DSL_SCOPE_VIOLATION") + alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.serialization) -} - -sourceSets.all { - java.srcDir("src/$name/kotlin") + alias(libs.plugins.android.lint) } dependencies { - implementation(projects.ktorClientBrotli) + implementation(projects.providers.common) + + implementation(libs.kotlin.coroutines) implementation(libs.ktor.client.core) implementation(libs.ktor.client.cio) implementation(libs.ktor.client.content.negotiation) - implementation(libs.ktor.client.encoding) implementation(libs.ktor.client.serialization) implementation(libs.ktor.serialization.json) - testImplementation(testLibs.junit) + detektPlugins(libs.detekt.compose) + detektPlugins(libs.detekt.formatting) +} + +kotlin { + jvmToolchain(libs.versions.jvm.get().toInt()) } diff --git a/providers/lrclib/src/main/kotlin/app/vimusic/providers/lrclib/LrcLib.kt b/providers/lrclib/src/main/kotlin/app/vimusic/providers/lrclib/LrcLib.kt new file mode 100644 index 0000000..4aee946 --- /dev/null +++ b/providers/lrclib/src/main/kotlin/app/vimusic/providers/lrclib/LrcLib.kt @@ -0,0 +1,218 @@ +package app.vimusic.providers.lrclib + +import app.vimusic.providers.lrclib.models.Track +import app.vimusic.providers.lrclib.models.bestMatchingFor +import app.vimusic.providers.utils.runCatchingCancellable +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.UserAgent +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.parameter +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.json.Json +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +private const val AGENT = "ViMusic (https://github.com/haturatu/ViMusic)" + +object LrcLib { + private val client by lazy { + HttpClient(CIO) { + install(ContentNegotiation) { + json( + Json { + isLenient = true + ignoreUnknownKeys = true + } + ) + } + + defaultRequest { + url("https://lrclib.net") + header("Lrclib-Client", AGENT) + } + + install(UserAgent) { + agent = AGENT + } + + expectSuccess = true + } + } + + private suspend fun queryLyrics( + artist: String, + title: String, + album: String? = null + ) = client.get("/api/search") { + parameter("track_name", title) + parameter("artist_name", artist) + if (album != null) parameter("album_name", album) + }.body>() + + private suspend fun queryLyrics(query: String) = client.get("/api/search") { + parameter("q", query) + }.body>() + + suspend fun lyrics( + artist: String, + title: String, + album: String? = null, + synced: Boolean = true + ) = runCatchingCancellable { + queryLyrics( + artist = artist, + title = title, + album = album + ).let { list -> + list.filter { if (synced) it.syncedLyrics != null else it.plainLyrics != null } + } + } + + suspend fun lyrics( + query: String, + synced: Boolean = true + ) = runCatchingCancellable { + queryLyrics(query = query).let { list -> + list.filter { if (synced) it.syncedLyrics != null else it.plainLyrics != null } + } + } + + suspend fun bestLyrics( + artist: String, + title: String, + duration: Duration, + album: String? = null, + synced: Boolean = true + ) = lyrics( + artist = artist, + title = title, + album = album, + synced = synced + )?.mapCatching { tracks -> + tracks.bestMatchingFor(title, duration) + ?.let { if (synced) it.syncedLyrics else it.plainLyrics } + ?.let { + Lyrics( + text = it, + synced = synced + ) + } + } + + data class Lyrics( + val text: String, + val synced: Boolean + ) { + fun asLrc() = LrcParser.parse(text)?.toLrcFile() + } +} + +object LrcParser { + private val lyricRegex = "^\\[(\\d{2,}):(\\d{2}).(\\d{2,3})](.*)$".toRegex() + private val metadataRegex = "^\\[(.+?):(.*?)]$".toRegex() + + sealed interface Line { + val raw: String? + + data object Invalid : Line { + override val raw: String? = null + } + + data class Metadata( + val key: String, + val value: String, + override val raw: String + ) : Line + + data class Lyric( + val timestamp: Long, + val line: String, + override val raw: String + ) : Line + } + + private fun Result.handleError(logging: Boolean) = onFailure { + when { + it is CancellationException -> throw it + logging -> it.printStackTrace() + } + } + + fun parse( + raw: String, + logging: Boolean = false + ) = raw.lines().mapNotNull { line -> + line.substringBefore('#').trim().takeIf { it.isNotBlank() } + }.map { line -> + runCatching { + val results = lyricRegex.find(line)?.groups ?: error("Invalid lyric") + val (minutes, seconds, millis, lyric) = results.drop(1).take(4).mapNotNull { it?.value } + val duration = minutes.toInt().minutes + + seconds.toInt().seconds + + millis.padEnd(length = 3, padChar = '0').toInt().milliseconds + + Line.Lyric( + timestamp = duration.inWholeMilliseconds, + line = lyric.trim(), + raw = line + ) + }.handleError(logging).recoverCatching { + val results = metadataRegex.find(line)?.groups ?: error("Invalid metadata") + val (key, value) = results.drop(1).take(2).mapNotNull { it?.value } + + Line.Metadata( + key = key.trim(), + value = value.trim(), + raw = line + ) + }.handleError(logging).getOrDefault(Line.Invalid) + }.takeIf { lrc -> lrc.isNotEmpty() && !lrc.all { it == Line.Invalid } } + + data class LrcFile( + val metadata: Map, + val lines: Map, + val errors: Int + ) { + val title get() = metadata["ti"] + val artist get() = metadata["ar"] + val album get() = metadata["al"] + val author get() = metadata["au"] + val duration + get() = metadata["length"]?.runCatching { + val (minutes, seconds) = split(":", limit = 2) + minutes.toInt().minutes + seconds.toInt().seconds + }?.getOrNull() + val fileAuthor get() = metadata["by"] + val offset get() = metadata["offset"]?.removePrefix("+")?.toIntOrNull()?.milliseconds + val tool get() = metadata["re"] ?: metadata["tool"] + val version get() = metadata["ve"] + } +} + +fun List.toLrcFile(): LrcParser.LrcFile { + val metadata = mutableMapOf() + val lines = mutableMapOf(0L to "") + var errors = 0 + + forEach { + when (it) { + LrcParser.Line.Invalid -> errors++ + is LrcParser.Line.Lyric -> lines += it.timestamp to it.line + is LrcParser.Line.Metadata -> metadata += it.key to it.value + } + } + + return LrcParser.LrcFile( + metadata = metadata, + lines = lines, + errors = errors + ) +} diff --git a/providers/lrclib/src/main/kotlin/app/vimusic/providers/lrclib/models/Track.kt b/providers/lrclib/src/main/kotlin/app/vimusic/providers/lrclib/models/Track.kt new file mode 100644 index 0000000..4cc5084 --- /dev/null +++ b/providers/lrclib/src/main/kotlin/app/vimusic/providers/lrclib/models/Track.kt @@ -0,0 +1,23 @@ +package app.vimusic.providers.lrclib.models + +import app.vimusic.providers.lrclib.LrcParser +import app.vimusic.providers.lrclib.toLrcFile +import kotlinx.serialization.Serializable +import kotlin.math.abs +import kotlin.time.Duration + +@Serializable +data class Track( + val id: Int, + val trackName: String, + val artistName: String, + val duration: Double, + val plainLyrics: String?, + val syncedLyrics: String? +) { + val lrc by lazy { syncedLyrics?.let { LrcParser.parse(it)?.toLrcFile() } } +} + +internal fun List.bestMatchingFor(title: String, duration: Duration) = + firstOrNull { it.duration.toLong() == duration.inWholeSeconds } + ?: minByOrNull { abs(it.trackName.length - title.length) } diff --git a/providers/piped/build.gradle.kts b/providers/piped/build.gradle.kts new file mode 100644 index 0000000..2266424 --- /dev/null +++ b/providers/piped/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.android.lint) +} + +dependencies { + implementation(projects.providers.common) + + implementation(libs.kotlin.coroutines) + api(libs.kotlin.datetime) + + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.cio) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.client.serialization) + implementation(libs.ktor.serialization.json) + api(libs.ktor.http) + + detektPlugins(libs.detekt.compose) + detektPlugins(libs.detekt.formatting) +} + +kotlin { + jvmToolchain(libs.versions.jvm.get().toInt()) +} diff --git a/providers/piped/src/main/kotlin/app/vimusic/providers/piped/Piped.kt b/providers/piped/src/main/kotlin/app/vimusic/providers/piped/Piped.kt new file mode 100644 index 0000000..32f8f18 --- /dev/null +++ b/providers/piped/src/main/kotlin/app/vimusic/providers/piped/Piped.kt @@ -0,0 +1,169 @@ +package app.vimusic.providers.piped + +import app.vimusic.providers.piped.models.CreatedPlaylist +import app.vimusic.providers.piped.models.Instance +import app.vimusic.providers.piped.models.Playlist +import app.vimusic.providers.piped.models.PlaylistPreview +import app.vimusic.providers.piped.models.Session +import app.vimusic.providers.piped.models.authenticatedWith +import app.vimusic.providers.utils.runCatchingCancellable +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.HttpRequestRetry +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.accept +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.request +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.http.ContentType +import io.ktor.http.HttpMethod +import io.ktor.http.URLBuilder +import io.ktor.http.Url +import io.ktor.http.contentType +import io.ktor.http.path +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.util.UUID + +operator fun Url.div(path: String) = URLBuilder(this).apply { path(path) }.build() +operator fun JsonElement.div(key: String) = jsonObject[key]!! + +object Piped { + private val client by lazy { + HttpClient(CIO) { + install(ContentNegotiation) { + json( + Json { + isLenient = true + ignoreUnknownKeys = true + } + ) + } + + install(HttpRequestRetry) { + exponentialDelay() + maxRetries = 2 + } + + install(HttpTimeout) { + connectTimeoutMillis = 1000L + requestTimeoutMillis = 5000L + } + + expectSuccess = true + + defaultRequest { + accept(ContentType.Application.Json) + contentType(ContentType.Application.Json) + } + } + } + + private val mutex = Mutex() + + private suspend fun request( + session: Session, + endpoint: String, + block: HttpRequestBuilder.() -> Unit = { } + ) = mutex.withLock { + client.request(url = session.apiBaseUrl / endpoint) { + block() + header("Authorization", session.token) + } + } + + private suspend fun HttpResponse.isOk() = + (body() / "message").jsonPrimitive.content == "ok" + + suspend fun getInstances() = runCatchingCancellable { + client.get("https://piped-instances.kavin.rocks/").body>() + } + + suspend fun login(apiBaseUrl: Url, username: String, password: String) = + runCatchingCancellable { + apiBaseUrl authenticatedWith ( + client.post(apiBaseUrl / "login") { + setBody( + mapOf( + "username" to username, + "password" to password + ) + ) + }.body() / "token" + ).jsonPrimitive.content + } + + val playlist = Playlists() + + class Playlists internal constructor() { + suspend fun list(session: Session) = runCatchingCancellable { + request(session, "user/playlists").body>() + } + + suspend fun create(session: Session, name: String) = runCatchingCancellable { + request(session, "user/playlists/create") { + method = HttpMethod.Post + setBody(mapOf("name" to name)) + }.body() + } + + suspend fun rename(session: Session, id: UUID, name: String) = runCatchingCancellable { + request(session, "user/playlists/rename") { + method = HttpMethod.Post + setBody( + mapOf( + "playlistId" to id.toString(), + "newName" to name + ) + ) + }.isOk() + } + + suspend fun delete(session: Session, id: UUID) = runCatchingCancellable { + request(session, "user/playlists/delete") { + method = HttpMethod.Post + setBody(mapOf("playlistId" to id.toString())) + }.isOk() + } + + suspend fun add(session: Session, id: UUID, videos: List) = runCatchingCancellable { + request(session, "user/playlists/add") { + method = HttpMethod.Post + setBody( + mapOf( + "playlistId" to id.toString(), + "videoIds" to videos + ) + ) + }.isOk() + } + + suspend fun remove(session: Session, id: UUID, idx: Int) = runCatchingCancellable { + request(session, "user/playlists/remove") { + method = HttpMethod.Post + setBody( + mapOf( + "playlistId" to id.toString(), + "index" to idx + ) + ) + }.isOk() + } + + suspend fun songs(session: Session, id: UUID) = runCatchingCancellable { + request(session, "playlists/$id").body() + } + } +} diff --git a/providers/piped/src/main/kotlin/app/vimusic/providers/piped/models/Instance.kt b/providers/piped/src/main/kotlin/app/vimusic/providers/piped/models/Instance.kt new file mode 100644 index 0000000..a10858e --- /dev/null +++ b/providers/piped/src/main/kotlin/app/vimusic/providers/piped/models/Instance.kt @@ -0,0 +1,30 @@ +package app.vimusic.providers.piped.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Instance( + val name: String, + @SerialName("api_url") + val apiBaseUrl: UrlString, + @SerialName("locations") + val locationsFormatted: String, + val version: String, + @SerialName("up_to_date") + val upToDate: Boolean, + @SerialName("cdn") + val isCdn: Boolean, + @SerialName("registered") + val userCount: Long, + @SerialName("last_checked") + val lastChecked: DateTimeSeconds, + @SerialName("cache") + val hasCache: Boolean, + @SerialName("s3_enabled") + val usesS3: Boolean, + @SerialName("image_proxy_url") + val imageProxyBaseUrl: UrlString, + @SerialName("registration_disabled") + val registrationDisabled: Boolean +) diff --git a/providers/piped/src/main/kotlin/app/vimusic/providers/piped/models/PlaylistPreview.kt b/providers/piped/src/main/kotlin/app/vimusic/providers/piped/models/PlaylistPreview.kt new file mode 100644 index 0000000..21e0375 --- /dev/null +++ b/providers/piped/src/main/kotlin/app/vimusic/providers/piped/models/PlaylistPreview.kt @@ -0,0 +1,60 @@ +package app.vimusic.providers.piped.models + +import io.ktor.http.Url +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlin.time.Duration.Companion.seconds + +@Serializable +data class CreatedPlaylist( + @SerialName("playlistId") + val id: UUIDString +) + +@Serializable +data class PlaylistPreview( + val id: UUIDString, + val name: String, + @SerialName("shortDescription") + val description: String? = null, + @SerialName("thumbnail") + val thumbnailUrl: UrlString, + @SerialName("videos") + val videoCount: Int +) + +@Serializable +data class Playlist( + val name: String, + val thumbnailUrl: UrlString, + val description: String? = null, + val bannerUrl: UrlString? = null, + @SerialName("videos") + val videoCount: Int, + @SerialName("relatedStreams") + val videos: List